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:
+14
-13
@@ -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 = {
|
||||||
|
|||||||
@@ -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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|
||||||
|
{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>
|
<h3 style={{ marginTop: 24 }}>Содержимое документа</h3>
|
||||||
<BlocksEditor blocks={body.blocks as Block[]} onChange={(blocks) => setBody({ ...body, blocks })} />
|
<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>
|
||||||
|
|||||||
@@ -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: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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: [] });
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user