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 }[];
};
type BlockBase = { id: string; optional?: boolean; optionalLabel?: string };
export type Block =
| { id: string; type: 'heading'; level: 1 | 2 | 3; text: RichText }
| { id: string; type: 'paragraph'; text: RichText }
| {
id: string;
| (BlockBase & { type: 'heading'; level: 1 | 2 | 3; text: RichText })
| (BlockBase & { type: 'paragraph'; text: RichText })
| (BlockBase & {
type: 'party';
role: 'executor' | 'customer';
bind: { kind: 'self' } | { kind: 'client'; clientId?: string };
}
| {
id: string;
})
| (BlockBase & {
type: 'services_table';
columns: Array<'name' | 'qty' | 'unit' | 'price' | 'vat' | 'sum'>;
lines: { lineId: string }[];
}
| { id: string; type: 'totals'; showVat: boolean; showInWords: boolean }
| { id: string; type: 'terms'; text: RichText }
| { id: string; type: 'signatures'; sides: ('executor' | 'customer')[] }
| { id: string; type: 'custom_text'; text: RichText }
| { id: string; type: 'page_break' };
})
| (BlockBase & { type: 'totals'; showVat: boolean; showInWords: boolean })
| (BlockBase & { type: 'terms'; text: RichText })
| (BlockBase & { type: 'signatures'; sides: ('executor' | 'customer')[] })
| (BlockBase & { type: 'custom_text'; text: RichText })
| (BlockBase & { type: 'page_break' });
export type DocBody = {
version: 1;
blocks: Block[];
vars: Record<string, unknown>;
disabledBlockIds: string[];
};
export type LineHistoryItem = {
+24 -2
View File
@@ -81,9 +81,12 @@ export function BlocksEditor({
<>
<AddBlock onAdd={(t) => add(t, 0)} />
{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">
<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">
<Button variant="ghost" onClick={() => move(idx, -1)} disabled={idx === 0}></Button>
<Button variant="ghost" onClick={() => move(idx, 1)} disabled={idx === blocks.length - 1}></Button>
@@ -91,6 +94,25 @@ export function BlocksEditor({
</div>
</header>
<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)} />
</div>
))}
+64 -1
View File
@@ -20,6 +20,18 @@ const DOC_TYPE_LABEL: Record<DocType, string> = {
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 {
return Math.random().toString(36).slice(2, 11);
}
@@ -47,6 +59,7 @@ function defaultContractBody(): DocBody {
{ id: uid(), type: 'signatures', sides: ['executor', 'customer'] },
],
vars: {},
disabledBlockIds: [],
};
}
@@ -65,6 +78,7 @@ function defaultInvoiceBody(): DocBody {
{ id: uid(), type: 'signatures', sides: ['executor'] },
],
vars: {},
disabledBlockIds: [],
};
}
@@ -92,6 +106,7 @@ export function DocumentEditPage() {
const [body, setBody] = useState<DocBody | null>(null);
const [lines, setLines] = useState<LineDraft[]>([]);
const [tochkaLocked, setTochkaLocked] = useState(false);
const [advancedMode, setAdvancedMode] = useState(false);
const [saving, setSaving] = useState(false);
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 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 } = {}) {
if (!body) return;
setError(null);
@@ -221,7 +253,15 @@ export function DocumentEditPage() {
<h2>
{DOC_TYPE_LABEL[docType]} {number ? `${number}` : '(новый)'}
</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 variant="primary" onClick={() => save()} disabled={saving}>
{saving ? 'Сохраняю…' : 'Сохранить'}
@@ -293,8 +333,31 @@ export function DocumentEditPage() {
<LinesEditor lines={lines} onChange={setLines} clientId={clientId} />
{optionalBlocks.length > 0 ? (
<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' }}>
<Button onClick={saveAsTemplate}>Сохранить как шаблон</Button>
+1
View File
@@ -20,6 +20,7 @@ function emptyBody(): DocBody {
{ id: uid(), type: 'heading', level: 1, text: emptyRich() },
],
vars: {},
disabledBlockIds: [],
};
}
+10
View File
@@ -192,6 +192,16 @@ body {
background: #fff; border: 1px solid #e5e7eb; border-radius: 8px;
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 {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 8px;
+13 -2
View File
@@ -36,7 +36,15 @@ export const VAT_PERCENT: Record<VatRate, number> = {
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({
type: z.literal('heading'),
@@ -109,7 +117,10 @@ export const DocBody = z.object({
version: z.literal(1),
blocks: z.array(Block),
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 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;
} = {},
): 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}`;
return `<!doctype html>