diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index e4c6613..40e56f5 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -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; + disabledBlockIds: string[]; }; export type LineHistoryItem = { diff --git a/apps/web/src/components/BlocksEditor.tsx b/apps/web/src/components/BlocksEditor.tsx index 137b960..d70194c 100644 --- a/apps/web/src/components/BlocksEditor.tsx +++ b/apps/web/src/components/BlocksEditor.tsx @@ -81,9 +81,12 @@ export function BlocksEditor({ <> add(t, 0)} /> {blocks.map((b, idx) => ( -
+
- {BLOCK_LABELS[b.type]} + + {BLOCK_LABELS[b.type]} + {b.optional ? · опциональный : null} +
@@ -91,6 +94,25 @@ export function BlocksEditor({
update(idx, patch)} /> +
+ + {b.optional ? ( + update(idx, { optionalLabel: e.target.value || undefined } as Partial)} + /> + ) : null} +
add(t, idx + 1)} />
))} diff --git a/apps/web/src/pages/DocumentEdit.tsx b/apps/web/src/pages/DocumentEdit.tsx index e23fb23..0be36ed 100644 --- a/apps/web/src/pages/DocumentEdit.tsx +++ b/apps/web/src/pages/DocumentEdit.tsx @@ -20,6 +20,18 @@ const DOC_TYPE_LABEL: Record = { contract: 'Договор', invoice: 'Счёт', act: 'Акт', upd: 'УПД', }; +const BLOCK_LABEL_RU: Record = { + 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(null); const [lines, setLines] = useState([]); const [tochkaLocked, setTochkaLocked] = useState(false); + const [advancedMode, setAdvancedMode] = useState(false); const [saving, setSaving] = useState(false); const [error, setError] = useState(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() {

{DOC_TYPE_LABEL[docType]} {number ? `№ ${number}` : '(новый)'}

-
+
+ diff --git a/apps/web/src/pages/Templates.tsx b/apps/web/src/pages/Templates.tsx index a2a56b3..5b96793 100644 --- a/apps/web/src/pages/Templates.tsx +++ b/apps/web/src/pages/Templates.tsx @@ -20,6 +20,7 @@ function emptyBody(): DocBody { { id: uid(), type: 'heading', level: 1, text: emptyRich() }, ], vars: {}, + disabledBlockIds: [], }; } diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index eeca75c..20341af 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -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; diff --git a/packages/shared/src/blocks/schema.ts b/packages/shared/src/blocks/schema.ts index a8336dc..8c59dee 100644 --- a/packages/shared/src/blocks/schema.ts +++ b/packages/shared/src/blocks/schema.ts @@ -36,7 +36,15 @@ export const VAT_PERCENT: Record = { 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; -export const emptyDocBody = (): DocBody => ({ version: 1, blocks: [], vars: {} }); +export const emptyDocBody = (): DocBody => ({ version: 1, blocks: [], vars: {}, disabledBlockIds: [] }); diff --git a/packages/shared/src/render/toHtml.ts b/packages/shared/src/render/toHtml.ts index 95d9f25..01e9112 100644 --- a/packages/shared/src/render/toHtml.ts +++ b/packages/shared/src/render/toHtml.ts @@ -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 `