feat: simplified document editor + optional blocks

Schema:
- Block: new optional fields `optional` (boolean) and `optionalLabel` (string)
- DocBody: new field `disabledBlockIds` — IDs of optional blocks turned off on this instance
- Renderer skips blocks whose id is in disabledBlockIds

Template authoring (BlocksEditor):
- Each block card has «Опциональный» checkbox + custom label input
- Optional blocks visually highlighted (amber border)

Document editing (DocumentEdit):
- New «Расширенный режим» toggle in header (default off → simple mode)
- Simple mode: only document header, lines, and «Дополнительные пункты»
  section listing optional blocks as checkboxes
- Advanced mode: also shows full BlocksEditor (previous behavior)
- Toggling optional block updates body.disabledBlockIds; renderer/PDF reflects it

Workflow: автор шаблона помечает спорные блоки как опциональные → пользователь
при создании документа просто отмечает галочки, не залезая в контент.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
admin
2026-05-01 14:01:57 +03:00
parent a5330fd46e
commit 8b7fe30b76
7 changed files with 133 additions and 21 deletions
+14 -13
View File
@@ -221,31 +221,32 @@ export type RichText = {
marks?: { type: string }[]; marks?: { type: string }[];
}; };
type BlockBase = { id: string; optional?: boolean; optionalLabel?: string };
export type Block = export type Block =
| { id: string; type: 'heading'; level: 1 | 2 | 3; text: RichText } | (BlockBase & { type: 'heading'; level: 1 | 2 | 3; text: RichText })
| { id: string; type: 'paragraph'; text: RichText } | (BlockBase & { type: 'paragraph'; text: RichText })
| { | (BlockBase & {
id: string;
type: 'party'; type: 'party';
role: 'executor' | 'customer'; role: 'executor' | 'customer';
bind: { kind: 'self' } | { kind: 'client'; clientId?: string }; bind: { kind: 'self' } | { kind: 'client'; clientId?: string };
} })
| { | (BlockBase & {
id: string;
type: 'services_table'; type: 'services_table';
columns: Array<'name' | 'qty' | 'unit' | 'price' | 'vat' | 'sum'>; columns: Array<'name' | 'qty' | 'unit' | 'price' | 'vat' | 'sum'>;
lines: { lineId: string }[]; lines: { lineId: string }[];
} })
| { id: string; type: 'totals'; showVat: boolean; showInWords: boolean } | (BlockBase & { type: 'totals'; showVat: boolean; showInWords: boolean })
| { id: string; type: 'terms'; text: RichText } | (BlockBase & { type: 'terms'; text: RichText })
| { id: string; type: 'signatures'; sides: ('executor' | 'customer')[] } | (BlockBase & { type: 'signatures'; sides: ('executor' | 'customer')[] })
| { id: string; type: 'custom_text'; text: RichText } | (BlockBase & { type: 'custom_text'; text: RichText })
| { id: string; type: 'page_break' }; | (BlockBase & { type: 'page_break' });
export type DocBody = { export type DocBody = {
version: 1; version: 1;
blocks: Block[]; blocks: Block[];
vars: Record<string, unknown>; vars: Record<string, unknown>;
disabledBlockIds: string[];
}; };
export type LineHistoryItem = { export type LineHistoryItem = {
+24 -2
View File
@@ -81,9 +81,12 @@ export function BlocksEditor({
<> <>
<AddBlock onAdd={(t) => add(t, 0)} /> <AddBlock onAdd={(t) => add(t, 0)} />
{blocks.map((b, idx) => ( {blocks.map((b, idx) => (
<div key={b.id} className="block-card"> <div key={b.id} className={`block-card ${b.optional ? 'block-card--optional' : ''}`}>
<header className="block-head"> <header className="block-head">
<span className="block-type">{BLOCK_LABELS[b.type]}</span> <span className="block-type">
{BLOCK_LABELS[b.type]}
{b.optional ? <span className="block-optional-badge"> · опциональный</span> : null}
</span>
<div className="block-actions"> <div className="block-actions">
<Button variant="ghost" onClick={() => move(idx, -1)} disabled={idx === 0}></Button> <Button variant="ghost" onClick={() => move(idx, -1)} disabled={idx === 0}></Button>
<Button variant="ghost" onClick={() => move(idx, 1)} disabled={idx === blocks.length - 1}></Button> <Button variant="ghost" onClick={() => move(idx, 1)} disabled={idx === blocks.length - 1}></Button>
@@ -91,6 +94,25 @@ export function BlocksEditor({
</div> </div>
</header> </header>
<BlockForm block={b} onChange={(patch) => update(idx, patch)} /> <BlockForm block={b} onChange={(patch) => update(idx, patch)} />
<div className="block-optional-controls">
<label className="checkbox">
<input
type="checkbox"
checked={!!b.optional}
onChange={(e) => update(idx, { optional: e.target.checked || undefined } as Partial<Block>)}
/>
Опциональный (можно отключить при создании документа)
</label>
{b.optional ? (
<input
className="field__input"
style={{ marginTop: 6, fontSize: 13 }}
placeholder="Подпись для галочки (необязательно)"
value={b.optionalLabel ?? ''}
onChange={(e) => update(idx, { optionalLabel: e.target.value || undefined } as Partial<Block>)}
/>
) : null}
</div>
<AddBlock onAdd={(t) => add(t, idx + 1)} /> <AddBlock onAdd={(t) => add(t, idx + 1)} />
</div> </div>
))} ))}
+66 -3
View File
@@ -20,6 +20,18 @@ const DOC_TYPE_LABEL: Record<DocType, string> = {
contract: 'Договор', invoice: 'Счёт', act: 'Акт', upd: 'УПД', contract: 'Договор', invoice: 'Счёт', act: 'Акт', upd: 'УПД',
}; };
const BLOCK_LABEL_RU: Record<Block['type'], string> = {
heading: 'Заголовок',
paragraph: 'Параграф',
party: 'Реквизиты стороны',
services_table: 'Таблица услуг',
totals: 'Итоги',
terms: 'Условия',
signatures: 'Подписи',
custom_text: 'Свободный текст',
page_break: 'Разрыв страницы',
};
function uid(): string { function uid(): string {
return Math.random().toString(36).slice(2, 11); return Math.random().toString(36).slice(2, 11);
} }
@@ -47,6 +59,7 @@ function defaultContractBody(): DocBody {
{ id: uid(), type: 'signatures', sides: ['executor', 'customer'] }, { id: uid(), type: 'signatures', sides: ['executor', 'customer'] },
], ],
vars: {}, vars: {},
disabledBlockIds: [],
}; };
} }
@@ -65,6 +78,7 @@ function defaultInvoiceBody(): DocBody {
{ id: uid(), type: 'signatures', sides: ['executor'] }, { id: uid(), type: 'signatures', sides: ['executor'] },
], ],
vars: {}, vars: {},
disabledBlockIds: [],
}; };
} }
@@ -92,6 +106,7 @@ export function DocumentEditPage() {
const [body, setBody] = useState<DocBody | null>(null); const [body, setBody] = useState<DocBody | null>(null);
const [lines, setLines] = useState<LineDraft[]>([]); const [lines, setLines] = useState<LineDraft[]>([]);
const [tochkaLocked, setTochkaLocked] = useState(false); const [tochkaLocked, setTochkaLocked] = useState(false);
const [advancedMode, setAdvancedMode] = useState(false);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -131,6 +146,23 @@ export function DocumentEditPage() {
const totalCents = useMemo(() => lines.reduce((s, l) => s + l.sumCents, 0), [lines]); const totalCents = useMemo(() => lines.reduce((s, l) => s + l.sumCents, 0), [lines]);
// Опциональные блоки шаблона (видны как чекбоксы в простом режиме)
const optionalBlocks = useMemo(
() => (body?.blocks ?? []).filter((b) => b.optional),
[body],
);
const disabledIds = useMemo(() => new Set(body?.disabledBlockIds ?? []), [body]);
function toggleOptional(blockId: string, enabled: boolean) {
setBody((b) => {
if (!b) return b;
const set = new Set(b.disabledBlockIds ?? []);
if (enabled) set.delete(blockId);
else set.add(blockId);
return { ...b, disabledBlockIds: Array.from(set) };
});
}
async function save(opts: { andStay?: boolean } = {}) { async function save(opts: { andStay?: boolean } = {}) {
if (!body) return; if (!body) return;
setError(null); setError(null);
@@ -221,7 +253,15 @@ export function DocumentEditPage() {
<h2> <h2>
{DOC_TYPE_LABEL[docType]} {number ? `${number}` : '(новый)'} {DOC_TYPE_LABEL[docType]} {number ? `${number}` : '(новый)'}
</h2> </h2>
<div style={{ display: 'flex', gap: 8 }}> <div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<label className="checkbox" title="Показать редактор контентных блоков">
<input
type="checkbox"
checked={advancedMode}
onChange={(e) => setAdvancedMode(e.target.checked)}
/>
Расширенный режим
</label>
<Button onClick={() => navigate('/documents')}> К списку</Button> <Button onClick={() => navigate('/documents')}> К списку</Button>
<Button variant="primary" onClick={() => save()} disabled={saving}> <Button variant="primary" onClick={() => save()} disabled={saving}>
{saving ? 'Сохраняю…' : 'Сохранить'} {saving ? 'Сохраняю…' : 'Сохранить'}
@@ -293,8 +333,31 @@ export function DocumentEditPage() {
<LinesEditor lines={lines} onChange={setLines} clientId={clientId} /> <LinesEditor lines={lines} onChange={setLines} clientId={clientId} />
<h3 style={{ marginTop: 24 }}>Содержимое документа</h3> {optionalBlocks.length > 0 ? (
<BlocksEditor blocks={body.blocks as Block[]} onChange={(blocks) => setBody({ ...body, blocks })} /> <section style={{ marginTop: 24 }}>
<h3>Дополнительные пункты</h3>
<p className="hint">Включи пункты, которые нужно добавить в этот документ.</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginTop: 8 }}>
{optionalBlocks.map((b) => (
<label key={b.id} className="checkbox">
<input
type="checkbox"
checked={!disabledIds.has(b.id)}
onChange={(e) => toggleOptional(b.id, e.target.checked)}
/>
{b.optionalLabel || `${BLOCK_LABEL_RU[b.type]} (${b.id.slice(0, 4)})`}
</label>
))}
</div>
</section>
) : null}
{advancedMode ? (
<>
<h3 style={{ marginTop: 24 }}>Содержимое документа</h3>
<BlocksEditor blocks={body.blocks as Block[]} onChange={(blocks) => setBody({ ...body, blocks })} />
</>
) : null}
<footer style={{ marginTop: 24, display: 'flex', gap: 8, flexWrap: 'wrap' }}> <footer style={{ marginTop: 24, display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<Button onClick={saveAsTemplate}>Сохранить как шаблон</Button> <Button onClick={saveAsTemplate}>Сохранить как шаблон</Button>
+1
View File
@@ -20,6 +20,7 @@ function emptyBody(): DocBody {
{ id: uid(), type: 'heading', level: 1, text: emptyRich() }, { id: uid(), type: 'heading', level: 1, text: emptyRich() },
], ],
vars: {}, vars: {},
disabledBlockIds: [],
}; };
} }
+10
View File
@@ -192,6 +192,16 @@ body {
background: #fff; border: 1px solid #e5e7eb; border-radius: 8px; background: #fff; border: 1px solid #e5e7eb; border-radius: 8px;
padding: 12px; margin: 4px 0; padding: 12px; margin: 4px 0;
} }
.block-card--optional { border-color: #fde68a; background: #fffbeb; }
.block-optional-badge { color: #b45309; font-weight: 500; }
.block-optional-controls {
margin-top: 8px; padding-top: 8px;
border-top: 1px dashed #e5e7eb; font-size: 13px;
}
@media (prefers-color-scheme: dark) {
.block-card--optional { border-color: #7c5e10; background: #1f1a0e; }
.block-optional-controls { border-top-color: #2a2e35; }
}
.block-head { .block-head {
display: flex; align-items: center; justify-content: space-between; display: flex; align-items: center; justify-content: space-between;
margin-bottom: 8px; margin-bottom: 8px;
+13 -2
View File
@@ -36,7 +36,15 @@ export const VAT_PERCENT: Record<VatRate, number> = {
vat_20: 20, vat_20: 20,
}; };
const blockBase = z.object({ id: z.string().min(1) }); // Базовые поля для всех блоков:
// - optional: блок помечается как «выбираемый при создании документа»;
// автор шаблона ставит флаг, пользователь при инстансировании видит его как checkbox.
// - optionalLabel: пользовательский лейбл для checkbox (если не задан — берётся стандартное имя типа).
const blockBase = z.object({
id: z.string().min(1),
optional: z.boolean().optional(),
optionalLabel: z.string().max(200).optional(),
});
export const HeadingBlock = blockBase.extend({ export const HeadingBlock = blockBase.extend({
type: z.literal('heading'), type: z.literal('heading'),
@@ -109,7 +117,10 @@ export const DocBody = z.object({
version: z.literal(1), version: z.literal(1),
blocks: z.array(Block), blocks: z.array(Block),
vars: z.record(z.unknown()).default({}), vars: z.record(z.unknown()).default({}),
// ID блоков, которые на этом инстансе документа выключены пользователем (galочка снята).
// Применимо только к блокам с optional=true; они физически остаются в body, но не попадают в рендер.
disabledBlockIds: z.array(z.string()).default([]),
}); });
export type DocBody = z.infer<typeof DocBody>; export type DocBody = z.infer<typeof DocBody>;
export const emptyDocBody = (): DocBody => ({ version: 1, blocks: [], vars: {} }); export const emptyDocBody = (): DocBody => ({ version: 1, blocks: [], vars: {}, disabledBlockIds: [] });
+5 -1
View File
@@ -288,7 +288,11 @@ export function renderDocumentHtml(
rubInWords?: (cents: number) => string; rubInWords?: (cents: number) => string;
} = {}, } = {},
): string { ): string {
const blocksHtml = body.blocks.map((b) => renderBlock(b, ctx, opts.rubInWords)).join('\n'); const disabled = new Set(body.disabledBlockIds ?? []);
const blocksHtml = body.blocks
.filter((b) => !disabled.has(b.id))
.map((b) => renderBlock(b, ctx, opts.rubInWords))
.join('\n');
const title = opts.title ?? `Документ ${ctx.doc.number}`; const title = opts.title ?? `Документ ${ctx.doc.number}`;
return `<!doctype html> return `<!doctype html>