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 }[];
|
||||
};
|
||||
|
||||
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 = {
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
<h3 style={{ marginTop: 24 }}>Содержимое документа</h3>
|
||||
<BlocksEditor blocks={body.blocks as Block[]} onChange={(blocks) => setBody({ ...body, blocks })} />
|
||||
{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>
|
||||
|
||||
@@ -20,6 +20,7 @@ function emptyBody(): DocBody {
|
||||
{ id: uid(), type: 'heading', level: 1, text: emptyRich() },
|
||||
],
|
||||
vars: {},
|
||||
disabledBlockIds: [],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user