feat(M3): contracts editor, templates, PDF render via Puppeteer/Chromium

Backend:
- numbers.ts — per-org per-doctype per-year sequential numbering (ДГ-2026/001, СЧ-2026/001…)
- money.ts — line totals + Russian rubInWords helper
- documents/routes.ts — CRUD with transactional lines bulk-replace, status changes, history endpoint for client-line autocomplete
- templates/routes.ts — CRUD + instantiate (clones template body into new draft document)
- shared/render/toHtml.ts — block→HTML renderer with placeholder substitution ({{customer.inn}}, {{contract.number}}, {{today}}…)
- documents/pdf.ts — Puppeteer-based PDF rendering with auto-detected Chromium executable
- documents/pdf.routes.ts — GET /:id/preview (HTML) and GET /:id/pdf
- Dockerfile.api — added apk chromium + cyrillic fonts

Web:
- api.ts — Document, DocumentTemplate, Block, LineHistoryItem types
- BlocksEditor — generic block list with reorder/add/remove and per-block forms (heading, party, services_table, totals, terms, signatures, custom_text, page_break)
- LinesEditor — services rows with auto sumCents, "from catalog" picker, "from history by client" panel
- ClientPicker — reusable client dropdown
- pages: Documents list, DocumentEdit (new+existing), Templates list, TemplateEdit
- richtext.ts — plain↔TipTap-JSON conversion (no TipTap yet, just keeps the format compatible)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
admin
2026-05-01 08:29:44 +03:00
parent 0722a25845
commit 9807d47c8d
24 changed files with 3428 additions and 40 deletions
+10 -2
View File
@@ -4,6 +4,10 @@ import { redirectToLogin, useAuth } from './auth.js';
import { ClientsPage } from './pages/Clients.js';
import { ServicesPage } from './pages/Services.js';
import { OrganizationPage } from './pages/Organization.js';
import { DocumentsPage } from './pages/Documents.js';
import { DocumentEditPage } from './pages/DocumentEdit.js';
import { TemplatesPage } from './pages/Templates.js';
import { TemplateEditPage } from './pages/TemplateEdit.js';
function Layout({ email }: { email: string }) {
return (
@@ -59,10 +63,14 @@ export function App() {
<>
<Layout email={auth.me.email} />
<Routes>
<Route path="/" element={<Placeholder title="Документы" />} />
<Route path="/" element={<DocumentsPage />} />
<Route path="/documents" element={<DocumentsPage />} />
<Route path="/documents/new" element={<DocumentEditPage />} />
<Route path="/documents/:id" element={<DocumentEditPage />} />
<Route path="/clients" element={<ClientsPage />} />
<Route path="/services" element={<ServicesPage />} />
<Route path="/templates" element={<Placeholder title="Шаблоны договоров" />} />
<Route path="/templates" element={<TemplatesPage />} />
<Route path="/templates/:id" element={<TemplateEditPage />} />
<Route path="/bank" element={<Placeholder title="Банк" />} />
<Route path="/organization" element={<OrganizationPage />} />
<Route path="*" element={<Placeholder title="Не найдено" />} />
+99 -1
View File
@@ -68,7 +68,105 @@ export type Service = {
name: string;
unit: string;
defaultPriceCents: number; // BigInt сериализуется в number (см. apps/api/src/lib/bigint.ts)
defaultVat: 'none' | 'vat_0' | 'vat_5' | 'vat_7' | 'vat_10' | 'vat_20';
defaultVat: VatRate;
notes: string | null;
archivedAt: string | null;
};
export type VatRate = 'none' | 'vat_0' | 'vat_5' | 'vat_7' | 'vat_10' | 'vat_20';
export type DocType = 'contract' | 'invoice' | 'act' | 'upd';
export type DocStatus = 'draft' | 'issued' | 'sent' | 'partially_paid' | 'paid' | 'cancelled' | 'signed';
export type DocumentLine = {
id: string;
documentId: string;
position: number;
serviceId: string | null;
name: string;
qtyMilli: number;
unit: string;
priceCents: number;
vat: VatRate;
sumCents: number;
};
export type DocumentSummary = {
id: string;
organizationId: string;
docType: DocType;
number: string;
issuedAt: string | null;
status: DocStatus;
clientId: string | null;
client: { id: string; name: string; kind: Client['kind'] } | null;
parentDocumentId: string | null;
totalCents: number;
vatCents: number;
currency: string;
tochkaDocumentId: string | null;
pdfPath: string | null;
createdAt: string;
updatedAt: string;
};
export type Document = DocumentSummary & {
body: DocBody;
client: Client | null;
lines: DocumentLine[];
};
export type DocumentTemplate = {
id: string;
organizationId: string;
docType: DocType;
name: string;
body: DocBody;
createdAt: string;
updatedAt: string;
};
// === Block schema (mirrors packages/shared) ===
export type RichText = {
type: string;
content?: RichText[];
text?: string;
marks?: { type: string }[];
};
export type Block =
| { id: string; type: 'heading'; level: 1 | 2 | 3; text: RichText }
| { id: string; type: 'paragraph'; text: RichText }
| {
id: string;
type: 'party';
role: 'executor' | 'customer';
bind: { kind: 'self' } | { kind: 'client'; clientId?: string };
}
| {
id: string;
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' };
export type DocBody = {
version: 1;
blocks: Block[];
vars: Record<string, unknown>;
};
export type LineHistoryItem = {
serviceId: string | null;
name: string;
unit: string;
priceCents: number;
vat: VatRate;
lastUsed: string;
useCount: number;
};
+295
View File
@@ -0,0 +1,295 @@
import { useState } from 'react';
import type { Block } from '../api.js';
import { Button, Select } from './ui.js';
import { plainToRich, richToPlain, emptyRich } from '../lib/richtext.js';
const BLOCK_LABELS: 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);
}
function defaultBlock(type: Block['type']): Block {
switch (type) {
case 'heading':
return { id: uid(), type: 'heading', level: 1, text: emptyRich() };
case 'paragraph':
return { id: uid(), type: 'paragraph', text: emptyRich() };
case 'party':
return { id: uid(), type: 'party', role: 'executor', bind: { kind: 'self' } };
case 'services_table':
return {
id: uid(),
type: 'services_table',
columns: ['name', 'qty', 'unit', 'price', 'vat', 'sum'],
lines: [],
};
case 'totals':
return { id: uid(), type: 'totals', showVat: true, showInWords: true };
case 'terms':
return { id: uid(), type: 'terms', text: emptyRich() };
case 'signatures':
return { id: uid(), type: 'signatures', sides: ['executor', 'customer'] };
case 'custom_text':
return { id: uid(), type: 'custom_text', text: emptyRich() };
case 'page_break':
return { id: uid(), type: 'page_break' };
}
}
export function BlocksEditor({
blocks,
onChange,
}: {
blocks: Block[];
onChange: (next: Block[]) => void;
}) {
function add(type: Block['type'], idx: number) {
const next = [...blocks];
next.splice(idx, 0, defaultBlock(type));
onChange(next);
}
function remove(idx: number) {
onChange(blocks.filter((_, i) => i !== idx));
}
function move(idx: number, dir: -1 | 1) {
const j = idx + dir;
if (j < 0 || j >= blocks.length) return;
const next = [...blocks];
[next[idx], next[j]] = [next[j]!, next[idx]!];
onChange(next);
}
function update(idx: number, patch: Partial<Block>) {
onChange(blocks.map((b, i) => (i === idx ? ({ ...b, ...patch } as Block) : b)));
}
return (
<section className="blocks-editor">
{blocks.length === 0 ? (
<AddBlock onAdd={(t) => add(t, 0)} />
) : (
<>
<AddBlock onAdd={(t) => add(t, 0)} />
{blocks.map((b, idx) => (
<div key={b.id} className="block-card">
<header className="block-head">
<span className="block-type">{BLOCK_LABELS[b.type]}</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>
<Button variant="danger" onClick={() => remove(idx)}>×</Button>
</div>
</header>
<BlockForm block={b} onChange={(patch) => update(idx, patch)} />
<AddBlock onAdd={(t) => add(t, idx + 1)} />
</div>
))}
</>
)}
</section>
);
}
function AddBlock({ onAdd }: { onAdd: (type: Block['type']) => void }) {
const [open, setOpen] = useState(false);
const types: Block['type'][] = [
'heading', 'paragraph', 'party', 'services_table',
'totals', 'terms', 'signatures', 'custom_text', 'page_break',
];
if (!open) {
return (
<button className="add-block" onClick={() => setOpen(true)}>
+ Добавить блок
</button>
);
}
return (
<div className="add-block-menu">
{types.map((t) => (
<button
key={t}
className="add-block-item"
onClick={() => {
onAdd(t);
setOpen(false);
}}
>
{BLOCK_LABELS[t]}
</button>
))}
<button className="add-block-item add-block-cancel" onClick={() => setOpen(false)}>
Отмена
</button>
</div>
);
}
function BlockForm({ block, onChange }: { block: Block; onChange: (patch: Partial<Block>) => void }) {
switch (block.type) {
case 'heading':
return (
<div className="form-grid">
<Select
label="Уровень"
value={String(block.level)}
onChange={(v) => onChange({ level: Number(v) as 1 | 2 | 3 } as Partial<Block>)}
options={[
{ value: '1', label: 'H1 — Заголовок' },
{ value: '2', label: 'H2 — Подзаголовок' },
{ value: '3', label: 'H3 — Подзаголовок 2-го уровня' },
]}
/>
<label className="field" style={{ gridColumn: 'span 2' }}>
<span className="field__label">Текст</span>
<input
className="field__input"
value={richToPlain(block.text)}
onChange={(e) => onChange({ text: plainToRich(e.target.value) } as Partial<Block>)}
/>
</label>
</div>
);
case 'paragraph':
case 'terms':
case 'custom_text':
return (
<label className="field">
<span className="field__label">Текст (плейсхолдеры: {'{{customer.name}}'}, {'{{contract.number}}'}, {'{{today}}'} и т.д.)</span>
<textarea
className="field__input field__input--area"
rows={4}
value={richToPlain(block.text)}
onChange={(e) => onChange({ text: plainToRich(e.target.value) } as Partial<Block>)}
/>
</label>
);
case 'party':
return (
<div className="form-grid">
<Select
label="Роль"
value={block.role}
onChange={(v) => onChange({ role: v as 'executor' | 'customer' } as Partial<Block>)}
options={[
{ value: 'executor', label: 'Исполнитель' },
{ value: 'customer', label: 'Заказчик' },
]}
/>
<Select
label="Источник"
value={block.bind.kind}
onChange={(v) =>
onChange({
bind: v === 'self' ? { kind: 'self' } : { kind: 'client' },
} as Partial<Block>)
}
options={[
{ value: 'self', label: 'Наша организация' },
{ value: 'client', label: 'Клиент документа' },
]}
/>
<div className="hint" style={{ gridColumn: '1 / -1' }}>
При рендере подставятся реквизиты: для «нашей организации» со страницы Реквизиты; для клиента из карточки клиента документа.
</div>
</div>
);
case 'services_table': {
const all: Array<'name' | 'qty' | 'unit' | 'price' | 'vat' | 'sum'> = ['name', 'qty', 'unit', 'price', 'vat', 'sum'];
const labels: Record<string, string> = {
name: 'Наименование', qty: 'Кол-во', unit: 'Ед.', price: 'Цена', vat: 'НДС', sum: 'Сумма',
};
return (
<div>
<div className="hint">Колонки таблицы:</div>
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
{all.map((col) => {
const checked = block.columns.includes(col);
return (
<label key={col} className="checkbox">
<input
type="checkbox"
checked={checked}
onChange={(e) => {
const next = e.target.checked
? [...block.columns, col]
: block.columns.filter((c) => c !== col);
onChange({ columns: all.filter((c) => next.includes(c)) } as Partial<Block>);
}}
/>
{labels[col]}
</label>
);
})}
</div>
<div className="hint" style={{ marginTop: 8 }}>
В таблице будут показаны все строки услуг документа в порядке их добавления.
</div>
</div>
);
}
case 'totals':
return (
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap' }}>
<label className="checkbox">
<input
type="checkbox"
checked={block.showVat}
onChange={(e) => onChange({ showVat: e.target.checked } as Partial<Block>)}
/>
Показать НДС
</label>
<label className="checkbox">
<input
type="checkbox"
checked={block.showInWords}
onChange={(e) => onChange({ showInWords: e.target.checked } as Partial<Block>)}
/>
Сумма прописью
</label>
</div>
);
case 'signatures':
return (
<div style={{ display: 'flex', gap: 16 }}>
<label className="checkbox">
<input
type="checkbox"
checked={block.sides.includes('executor')}
onChange={(e) => {
const next = e.target.checked
? Array.from(new Set([...block.sides, 'executor' as const]))
: block.sides.filter((s) => s !== 'executor');
onChange({ sides: next } as Partial<Block>);
}}
/>
Исполнитель
</label>
<label className="checkbox">
<input
type="checkbox"
checked={block.sides.includes('customer')}
onChange={(e) => {
const next = e.target.checked
? Array.from(new Set([...block.sides, 'customer' as const]))
: block.sides.filter((s) => s !== 'customer');
onChange({ sides: next } as Partial<Block>);
}}
/>
Заказчик
</label>
</div>
);
case 'page_break':
return <div className="hint">Принудительный разрыв страницы при печати.</div>;
}
}
+37
View File
@@ -0,0 +1,37 @@
import { useEffect, useState } from 'react';
import { api, type Client } from '../api.js';
export function ClientPicker({
value,
onChange,
allowEmpty = true,
placeholder = 'Выберите клиента…',
}: {
value: string | null;
onChange: (id: string | null) => void;
allowEmpty?: boolean;
placeholder?: string;
}) {
const [clients, setClients] = useState<Client[]>([]);
useEffect(() => {
api
.get<{ items: Client[] }>('/api/clients?limit=200')
.then((r) => setClients(r.items))
.catch(() => setClients([]));
}, []);
return (
<select
className="field__input"
value={value ?? ''}
onChange={(e) => onChange(e.target.value || null)}
>
{allowEmpty ? <option value="">{placeholder}</option> : null}
{clients.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
{c.inn ? ` · ИНН ${c.inn}` : ''}
</option>
))}
</select>
);
}
+277
View File
@@ -0,0 +1,277 @@
import { useEffect, useState } from 'react';
import { api, type DocumentLine, type LineHistoryItem, type Service, type VatRate } from '../api.js';
import { Button, Select, formatRub } from './ui.js';
export type LineDraft = Omit<DocumentLine, 'id' | 'documentId'> & { id?: string };
const VAT_OPTIONS: { value: VatRate; label: string }[] = [
{ value: 'none', label: 'Без НДС' },
{ value: 'vat_0', label: '0%' },
{ value: 'vat_5', label: '5%' },
{ value: 'vat_7', label: '7%' },
{ value: 'vat_10', label: '10%' },
{ value: 'vat_20', label: '20%' },
];
const VAT_PCT: Record<VatRate, number> = {
none: 0, vat_0: 0, vat_5: 5, vat_7: 7, vat_10: 10, vat_20: 20,
};
function calcSumCents(qtyMilli: number, priceCents: number): number {
return Math.round((qtyMilli * priceCents) / 1000);
}
function calcVatCents(sumCents: number, vat: VatRate): number {
const pct = VAT_PCT[vat];
if (pct === 0) return 0;
return Math.round((sumCents * pct) / (100 + pct));
}
export function LinesEditor({
lines,
onChange,
clientId,
}: {
lines: LineDraft[];
onChange: (next: LineDraft[]) => void;
clientId: string | null;
}) {
const [services, setServices] = useState<Service[]>([]);
const [history, setHistory] = useState<LineHistoryItem[]>([]);
const [showHistory, setShowHistory] = useState(false);
useEffect(() => {
api.get<{ items: Service[] }>('/api/services?limit=500').then((r) => setServices(r.items)).catch(() => {});
}, []);
useEffect(() => {
if (!clientId) {
setHistory([]);
return;
}
api
.get<{ items: LineHistoryItem[] }>(`/api/documents/_history?clientId=${clientId}&limit=50`)
.then((r) => setHistory(r.items))
.catch(() => setHistory([]));
}, [clientId]);
function addEmpty() {
onChange([
...lines,
{
position: lines.length,
serviceId: null,
name: '',
qtyMilli: 1000,
unit: 'шт',
priceCents: 0,
vat: 'none',
sumCents: 0,
},
]);
}
function addFromService(s: Service) {
onChange([
...lines,
{
position: lines.length,
serviceId: s.id,
name: s.name,
qtyMilli: 1000,
unit: s.unit,
priceCents: s.defaultPriceCents,
vat: s.defaultVat,
sumCents: calcSumCents(1000, s.defaultPriceCents),
},
]);
}
function addFromHistory(h: LineHistoryItem) {
onChange([
...lines,
{
position: lines.length,
serviceId: h.serviceId,
name: h.name,
qtyMilli: 1000,
unit: h.unit,
priceCents: h.priceCents,
vat: h.vat,
sumCents: calcSumCents(1000, h.priceCents),
},
]);
setShowHistory(false);
}
function update(idx: number, patch: Partial<LineDraft>) {
const next = lines.map((l, i) => (i === idx ? { ...l, ...patch } : l));
// пересчёт sumCents если изменили qty/price
const cur = next[idx]!;
if ('qtyMilli' in patch || 'priceCents' in patch) {
cur.sumCents = calcSumCents(cur.qtyMilli, cur.priceCents);
}
onChange(next);
}
function remove(idx: number) {
const next = lines.filter((_, i) => i !== idx).map((l, i) => ({ ...l, position: i }));
onChange(next);
}
function move(idx: number, dir: -1 | 1) {
const j = idx + dir;
if (j < 0 || j >= lines.length) return;
const next = [...lines];
[next[idx], next[j]] = [next[j]!, next[idx]!];
onChange(next.map((l, i) => ({ ...l, position: i })));
}
const total = lines.reduce((s, l) => s + l.sumCents, 0);
const totalVat = lines.reduce((s, l) => s + calcVatCents(l.sumCents, l.vat), 0);
return (
<section className="lines-editor">
<header className="lines-head">
<h3>Услуги ({lines.length})</h3>
<div className="lines-actions">
<Button onClick={addEmpty}>+ Строка</Button>
{services.length > 0 ? (
<ServiceDropdown services={services} onPick={addFromService} />
) : null}
{clientId ? (
<Button variant="ghost" onClick={() => setShowHistory((s) => !s)}>
{showHistory ? 'Скрыть историю' : `История (${history.length})`}
</Button>
) : null}
</div>
</header>
{showHistory && history.length > 0 ? (
<div className="history-panel">
<div className="hint">Услуги, ранее использованные с этим клиентом:</div>
{history.map((h, i) => (
<button key={i} className="history-item" onClick={() => addFromHistory(h)}>
<div>
<b>{h.name}</b> {formatRub(h.priceCents)} / {h.unit}
</div>
<div className="hint">
использовано {h.useCount} раз, последний: {new Date(h.lastUsed).toLocaleDateString('ru-RU')}
</div>
</button>
))}
</div>
) : null}
{lines.length === 0 ? (
<div className="empty">Добавьте строки для услуг.</div>
) : (
<table className="table lines-table">
<thead>
<tr>
<th style={{ width: 28 }}>#</th>
<th>Наименование</th>
<th style={{ width: 100 }}>Кол-во</th>
<th style={{ width: 80 }}>Ед.</th>
<th style={{ width: 120 }}>Цена </th>
<th style={{ width: 100 }}>НДС</th>
<th style={{ width: 130 }}>Сумма</th>
<th style={{ width: 110 }} />
</tr>
</thead>
<tbody>
{lines.map((l, idx) => (
<tr key={idx}>
<td>{idx + 1}</td>
<td>
<input
className="field__input"
value={l.name}
onChange={(e) => update(idx, { name: e.target.value })}
/>
</td>
<td>
<input
className="field__input"
type="number"
step="0.001"
value={l.qtyMilli / 1000}
onChange={(e) => update(idx, { qtyMilli: Math.max(1, Math.round(parseFloat(e.target.value || '0') * 1000)) })}
/>
</td>
<td>
<input
className="field__input"
value={l.unit}
onChange={(e) => update(idx, { unit: e.target.value })}
/>
</td>
<td>
<input
className="field__input"
type="number"
step="0.01"
value={(l.priceCents / 100).toFixed(2)}
onChange={(e) => update(idx, { priceCents: Math.max(0, Math.round(parseFloat(e.target.value || '0') * 100)) })}
/>
</td>
<td>
<Select
label=""
value={l.vat}
onChange={(v) => update(idx, { vat: v as VatRate })}
options={VAT_OPTIONS}
/>
</td>
<td className="num">{formatRub(l.sumCents)}</td>
<td className="row-actions">
<Button variant="ghost" onClick={() => move(idx, -1)} disabled={idx === 0}></Button>
<Button variant="ghost" onClick={() => move(idx, 1)} disabled={idx === lines.length - 1}></Button>
<Button variant="danger" onClick={() => remove(idx)}>×</Button>
</td>
</tr>
))}
<tr>
<td />
<td colSpan={5} style={{ textAlign: 'right' }}><b>Итого:</b></td>
<td className="num"><b>{formatRub(total)}</b></td>
<td />
</tr>
{totalVat > 0 ? (
<tr>
<td />
<td colSpan={5} style={{ textAlign: 'right' }}>в т.ч. НДС:</td>
<td className="num">{formatRub(totalVat)}</td>
<td />
</tr>
) : null}
</tbody>
</table>
)}
</section>
);
}
function ServiceDropdown({ services, onPick }: { services: Service[]; onPick: (s: Service) => void }) {
return (
<select
className="field__input"
style={{ maxWidth: 240 }}
defaultValue=""
onChange={(e) => {
const s = services.find((x) => x.id === e.target.value);
if (s) onPick(s);
e.target.value = '';
}}
>
<option value="">+ Из каталога</option>
{services
.filter((s) => !s.archivedAt)
.map((s) => (
<option key={s.id} value={s.id}>
{s.name} {formatRub(s.defaultPriceCents)}
</option>
))}
</select>
);
}
+34
View File
@@ -0,0 +1,34 @@
import type { RichText } from '../api.js';
/**
* На M3 редактор не использует TipTap — он принимает plain text.
* Эти утилиты сохраняют TipTap-совместимую JSON-структуру:
* { type: 'doc', content: [{ type: 'paragraph', content: [{ type: 'text', text: '...' }] }] }
* — чтобы потом без миграции данных подключить TipTap.
*/
export function plainToRich(text: string): RichText {
const lines = text.split(/\r?\n/);
return {
type: 'doc',
content: lines.map((line) => ({
type: 'paragraph',
content: line ? [{ type: 'text', text: line }] : [],
})),
};
}
export function richToPlain(node: RichText | undefined): string {
if (!node) return '';
if (node.type === 'text') return node.text ?? '';
const children = node.content ?? [];
if (node.type === 'paragraph' || node.type === 'heading') {
return children.map((c) => richToPlain(c)).join('');
}
// для doc и прочих контейнеров — параграфы через \n
return children.map((c) => richToPlain(c)).join(node.type === 'doc' ? '\n' : '');
}
export function emptyRich(): RichText {
return { type: 'doc', content: [{ type: 'paragraph', content: [] }] };
}
+287
View File
@@ -0,0 +1,287 @@
import { useEffect, useMemo, useState } from 'react';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { api, ApiError, type Block, type DocBody, type DocStatus, type DocType, type Document, type DocumentTemplate } from '../api.js';
import { BlocksEditor } from '../components/BlocksEditor.js';
import { ClientPicker } from '../components/ClientPicker.js';
import { LinesEditor, type LineDraft } from '../components/LinesEditor.js';
import { Button, Field, Select } from '../components/ui.js';
import { emptyRich } from '../lib/richtext.js';
const STATUS_OPTIONS: { value: DocStatus; label: string }[] = [
{ value: 'draft', label: 'Черновик' },
{ value: 'issued', label: 'Выставлен' },
{ value: 'sent', label: 'Отправлен' },
{ value: 'signed', label: 'Подписан' },
{ value: 'cancelled', label: 'Отменён' },
];
const DOC_TYPE_LABEL: Record<DocType, string> = {
contract: 'Договор', invoice: 'Счёт', act: 'Акт', upd: 'УПД',
};
function uid(): string {
return Math.random().toString(36).slice(2, 11);
}
function defaultContractBody(): DocBody {
return {
version: 1,
blocks: [
{
id: uid(), type: 'heading', level: 1,
text: { type: 'doc', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Договор оказания услуг № {{contract.number}} от {{contract.date}}' }] }] },
},
{ id: uid(), type: 'party', role: 'executor', bind: { kind: 'self' } },
{ id: uid(), type: 'party', role: 'customer', bind: { kind: 'client' } },
{
id: uid(), type: 'paragraph',
text: { type: 'doc', content: [{ type: 'paragraph', content: [{ type: 'text', text: '1. Предмет договора. Исполнитель обязуется оказать Заказчику услуги, указанные ниже.' }] }] },
},
{ id: uid(), type: 'services_table', columns: ['name', 'qty', 'unit', 'price', 'vat', 'sum'], lines: [] },
{ id: uid(), type: 'totals', showVat: true, showInWords: true },
{
id: uid(), type: 'terms',
text: { type: 'doc', content: [{ type: 'paragraph', content: [{ type: 'text', text: '2. Условия оплаты: 100% по факту оказания услуг.' }] }] },
},
{ id: uid(), type: 'signatures', sides: ['executor', 'customer'] },
],
vars: {},
};
}
function defaultInvoiceBody(): DocBody {
return {
version: 1,
blocks: [
{
id: uid(), type: 'heading', level: 1,
text: { type: 'doc', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Счёт № {{contract.number}} от {{contract.date}}' }] }] },
},
{ id: uid(), type: 'party', role: 'executor', bind: { kind: 'self' } },
{ id: uid(), type: 'party', role: 'customer', bind: { kind: 'client' } },
{ id: uid(), type: 'services_table', columns: ['name', 'qty', 'unit', 'price', 'vat', 'sum'], lines: [] },
{ id: uid(), type: 'totals', showVat: true, showInWords: true },
{ id: uid(), type: 'signatures', sides: ['executor'] },
],
vars: {},
};
}
function defaultBody(docType: DocType): DocBody {
if (docType === 'invoice') return defaultInvoiceBody();
return defaultContractBody();
}
export function DocumentEditPage() {
const { id } = useParams<{ id: string }>();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const isNew = id === 'new' || !id;
const initialDocType = (searchParams.get('docType') as DocType) ?? 'contract';
const fromTemplateId = searchParams.get('fromTemplate');
const [docType] = useState<DocType>(initialDocType);
const [number, setNumber] = useState<string>('');
const [issuedAt, setIssuedAt] = useState<string>(new Date().toISOString().slice(0, 10));
const [status, setStatus] = useState<DocStatus>('draft');
const [clientId, setClientId] = useState<string | null>(null);
const [body, setBody] = useState<DocBody | null>(null);
const [lines, setLines] = useState<LineDraft[]>([]);
const [tochkaLocked, setTochkaLocked] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [savedAt, setSavedAt] = useState<Date | null>(null);
const [savedId, setSavedId] = useState<string | null>(isNew ? null : (id ?? null));
// Загрузить существующий документ
useEffect(() => {
if (isNew) {
// Шаблон или новый пустой
if (fromTemplateId) {
api.get<DocumentTemplate>(`/api/templates/${fromTemplateId}`).then((tpl) => {
setBody(tpl.body);
}).catch((e) => setError(String(e)));
} else {
setBody(defaultBody(initialDocType));
}
return;
}
api.get<Document>(`/api/documents/${id}`).then((d) => {
setNumber(d.number);
setIssuedAt(d.issuedAt ? d.issuedAt.slice(0, 10) : '');
setStatus(d.status);
setClientId(d.clientId);
setBody(d.body);
setLines(d.lines);
setTochkaLocked(!!d.tochkaDocumentId);
setSavedId(d.id);
}).catch((e) => setError(String(e)));
}, [id, isNew, fromTemplateId, initialDocType]);
const totalCents = useMemo(() => lines.reduce((s, l) => s + l.sumCents, 0), [lines]);
async function save(opts: { andStay?: boolean } = {}) {
if (!body) return;
setError(null);
setSaving(true);
try {
const payload = {
clientId: clientId,
body,
lines: lines.map((l, i) => ({
position: i,
serviceId: l.serviceId,
name: l.name,
qtyMilli: l.qtyMilli,
unit: l.unit,
priceCents: l.priceCents,
vat: l.vat,
})),
number: number || undefined,
issuedAt: issuedAt ? new Date(issuedAt).toISOString() : null,
};
if (isNew && !savedId) {
const created = await api.post<Document>('/api/documents', {
...payload,
docType,
parentDocumentId: null,
number: number || null,
});
setSavedId(created.id);
setNumber(created.number);
setStatus(created.status);
setLines(created.lines);
setSavedAt(new Date());
if (!opts.andStay) navigate(`/documents/${created.id}`, { replace: true });
} else if (savedId) {
const updated = await api.put<Document>(`/api/documents/${savedId}`, payload);
setNumber(updated.number);
setLines(updated.lines);
setSavedAt(new Date());
}
} catch (e) {
setError(e instanceof ApiError ? `${e.code} (${e.status})` : String(e));
} finally {
setSaving(false);
}
}
async function changeStatus(next: DocStatus) {
if (!savedId) return;
try {
await api.post(`/api/documents/${savedId}/status`, { status: next });
setStatus(next);
} catch (e) {
setError(String(e));
}
}
async function remove() {
if (!savedId) return;
if (!confirm('Удалить черновик?')) return;
try {
await api.del(`/api/documents/${savedId}`);
navigate('/documents');
} catch (e) {
setError(e instanceof ApiError ? `${e.code} (${e.status})` : String(e));
}
}
async function saveAsTemplate() {
if (!body) return;
const name = prompt('Название шаблона?', `Шаблон ${DOC_TYPE_LABEL[docType].toLowerCase()}`);
if (!name) return;
try {
await api.post('/api/templates', { docType, name, body });
alert('Шаблон сохранён.');
} catch (e) {
setError(String(e));
}
}
if (!body) {
return <main className="content"><p className="hint">Загрузка</p></main>;
}
return (
<main className="content">
<header className="page-head">
<h2>
{DOC_TYPE_LABEL[docType]} {number ? `${number}` : '(новый)'}
</h2>
<div style={{ display: 'flex', gap: 8 }}>
<Button onClick={() => navigate('/documents')}> К списку</Button>
<Button variant="primary" onClick={() => save()} disabled={saving}>
{saving ? 'Сохраняю…' : 'Сохранить'}
</Button>
{savedId ? (
<>
<Button onClick={() => window.open(`/api/documents/${savedId}/preview`, '_blank')}>
Превью
</Button>
<Button onClick={() => window.open(`/api/documents/${savedId}/pdf`, '_blank')}>
PDF
</Button>
</>
) : null}
</div>
</header>
{error ? <div className="error-text">{error}</div> : null}
{savedAt ? <div className="hint">Сохранено в {savedAt.toLocaleTimeString('ru-RU')}</div> : null}
{tochkaLocked ? (
<div className="error-text">Документ выставлен через банк редактирование строк/блоков заморожено в API.</div>
) : null}
<section className="form-grid" style={{ marginBottom: 16 }}>
<Field
label="Номер документа (оставить пустым для авто)"
value={number}
onChange={(e) => setNumber(e.target.value)}
placeholder={isNew ? 'будет сгенерирован' : ''}
/>
<Field
label="Дата"
type="date"
value={issuedAt}
onChange={(e) => setIssuedAt(e.target.value)}
/>
<label className="field">
<span className="field__label">Клиент</span>
<ClientPicker value={clientId} onChange={setClientId} />
</label>
{savedId ? (
<label className="field">
<span className="field__label">Статус</span>
<select
className="field__input"
value={status}
onChange={(e) => changeStatus(e.target.value as DocStatus)}
>
{STATUS_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
</label>
) : null}
</section>
<LinesEditor lines={lines} onChange={setLines} clientId={clientId} />
<h3 style={{ marginTop: 24 }}>Содержимое документа</h3>
<BlocksEditor blocks={body.blocks as Block[]} onChange={(blocks) => setBody({ ...body, blocks })} />
<footer style={{ marginTop: 24, display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<Button onClick={saveAsTemplate}>Сохранить как шаблон</Button>
{savedId && status === 'draft' ? (
<Button variant="danger" onClick={remove}>Удалить черновик</Button>
) : null}
<span className="hint" style={{ marginLeft: 'auto' }}>
Итого по строкам: <b>{(totalCents / 100).toLocaleString('ru-RU', { style: 'currency', currency: 'RUB' })}</b>
</span>
</footer>
</main>
);
}
+141
View File
@@ -0,0 +1,141 @@
import { useEffect, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { api, type DocStatus, type DocType, type DocumentSummary } from '../api.js';
import { Button, EmptyState, Select, formatRub } from '../components/ui.js';
const DOC_TYPE_LABEL: Record<DocType, string> = {
contract: 'Договоры',
invoice: 'Счета',
act: 'Акты',
upd: 'УПД',
};
const DOC_TYPE_ONE: Record<DocType, string> = {
contract: 'Договор',
invoice: 'Счёт',
act: 'Акт',
upd: 'УПД',
};
const STATUS_LABEL: Record<DocStatus, string> = {
draft: 'Черновик',
issued: 'Выставлен',
sent: 'Отправлен',
partially_paid: 'Частично оплачен',
paid: 'Оплачен',
cancelled: 'Отменён',
signed: 'Подписан',
};
export function DocumentsPage() {
const [docType, setDocType] = useState<DocType>('contract');
const [status, setStatus] = useState<DocStatus | ''>('');
const [q, setQ] = useState('');
const [items, setItems] = useState<DocumentSummary[] | null>(null);
const [error, setError] = useState<string | null>(null);
const navigate = useNavigate();
async function load() {
setError(null);
try {
const params = new URLSearchParams({ docType });
if (status) params.set('status', status);
if (q) params.set('q', q);
const r = await api.get<{ items: DocumentSummary[] }>(`/api/documents?${params.toString()}`);
setItems(r.items);
} catch (e) {
setError(String(e));
}
}
useEffect(() => {
void load();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [docType, status, q]);
return (
<main className="content">
<header className="page-head">
<h2>Документы</h2>
<Button variant="primary" onClick={() => navigate(`/documents/new?docType=${docType}`)}>
+ Новый {DOC_TYPE_ONE[docType].toLowerCase()}
</Button>
</header>
<div className="toolbar">
<Select
label=""
value={docType}
onChange={(v) => setDocType(v as DocType)}
options={[
{ value: 'contract', label: 'Договоры' },
{ value: 'invoice', label: 'Счета' },
{ value: 'act', label: 'Акты' },
{ value: 'upd', label: 'УПД' },
]}
/>
<Select
label=""
value={status}
onChange={(v) => setStatus(v as DocStatus | '')}
options={[
{ value: '', label: 'Все статусы' },
{ value: 'draft', label: 'Черновики' },
{ value: 'issued', label: 'Выставленные' },
{ value: 'sent', label: 'Отправленные' },
{ value: 'partially_paid', label: 'Частично оплачены' },
{ value: 'paid', label: 'Оплаченные' },
{ value: 'signed', label: 'Подписанные' },
{ value: 'cancelled', label: 'Отменённые' },
]}
/>
<input
className="search"
placeholder="Поиск по номеру или клиенту…"
value={q}
onChange={(e) => setQ(e.target.value)}
/>
</div>
{error ? <div className="error-text">{error}</div> : null}
{items === null ? (
<p className="hint">Загрузка</p>
) : items.length === 0 ? (
<EmptyState>
{q || status
? 'Ничего не найдено.'
: `Пока нет ${DOC_TYPE_LABEL[docType].toLowerCase()}. Создайте первый — кнопка справа сверху.`}
</EmptyState>
) : (
<table className="table">
<thead>
<tr>
<th>Номер</th>
<th>Дата</th>
<th>Клиент</th>
<th>Сумма</th>
<th>Статус</th>
<th aria-label="actions" />
</tr>
</thead>
<tbody>
{items.map((d) => (
<tr key={d.id}>
<td><Link to={`/documents/${d.id}`}>{d.number}</Link></td>
<td>{d.issuedAt ? new Date(d.issuedAt).toLocaleDateString('ru-RU') : '—'}</td>
<td>{d.client?.name ?? '—'}</td>
<td className="num">{formatRub(d.totalCents)}</td>
<td>
<span className={`status status--${d.status}`}>{STATUS_LABEL[d.status]}</span>
</td>
<td className="row-actions">
<Link to={`/documents/${d.id}`}><Button variant="ghost">Открыть</Button></Link>
</td>
</tr>
))}
</tbody>
</table>
)}
</main>
);
}
+97
View File
@@ -0,0 +1,97 @@
import { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { api, ApiError, type Block, type DocBody, type DocType, type DocumentTemplate } from '../api.js';
import { BlocksEditor } from '../components/BlocksEditor.js';
import { Button, Field, Select } from '../components/ui.js';
const DOC_TYPE_LABEL: Record<DocType, string> = {
contract: 'Договор', invoice: 'Счёт', act: 'Акт', upd: 'УПД',
};
export function TemplateEditPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [name, setName] = useState('');
const [docType, setDocType] = useState<DocType>('contract');
const [body, setBody] = useState<DocBody | null>(null);
const [saving, setSaving] = useState(false);
const [savedAt, setSavedAt] = useState<Date | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!id) return;
api.get<DocumentTemplate>(`/api/templates/${id}`).then((t) => {
setName(t.name);
setDocType(t.docType);
setBody(t.body);
}).catch((e) => setError(String(e)));
}, [id]);
async function save() {
if (!body || !id) return;
setError(null);
setSaving(true);
try {
await api.put(`/api/templates/${id}`, { name, docType, body });
setSavedAt(new Date());
} catch (e) {
setError(e instanceof ApiError ? `${e.code} (${e.status})` : String(e));
} finally {
setSaving(false);
}
}
async function instantiate() {
if (!id) return;
try {
const doc = await api.post<{ id: string }>(`/api/templates/${id}/instantiate`, {
clientId: null,
initialLines: [],
});
navigate(`/documents/${doc.id}`);
} catch (e) {
setError(String(e));
}
}
if (!body) return <main className="content"><p className="hint">Загрузка</p></main>;
return (
<main className="content">
<header className="page-head">
<h2>Шаблон: {name || '(без названия)'}</h2>
<div style={{ display: 'flex', gap: 8 }}>
<Button onClick={() => navigate('/templates')}> К списку</Button>
<Button variant="primary" onClick={save} disabled={saving}>
{saving ? 'Сохраняю…' : 'Сохранить'}
</Button>
<Button onClick={instantiate}>Создать документ из шаблона</Button>
</div>
</header>
{error ? <div className="error-text">{error}</div> : null}
{savedAt ? <div className="hint">Сохранено в {savedAt.toLocaleTimeString('ru-RU')}</div> : null}
<section className="form-grid" style={{ marginBottom: 16 }}>
<Field label="Название" value={name} onChange={(e) => setName(e.target.value)} />
<Select
label="Тип документа"
value={docType}
onChange={(v) => setDocType(v as DocType)}
options={[
{ value: 'contract', label: 'Договор' },
{ value: 'invoice', label: 'Счёт' },
{ value: 'act', label: 'Акт' },
{ value: 'upd', label: 'УПД' },
]}
/>
</section>
<p className="hint">
Шаблон содержит структуру документа без строк услуг и реквизитов клиента они подставляются при создании конкретного документа.
</p>
<BlocksEditor blocks={body.blocks as Block[]} onChange={(blocks) => setBody({ ...body, blocks })} />
</main>
);
}
+157
View File
@@ -0,0 +1,157 @@
import { useEffect, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { api, type DocBody, type DocType, type DocumentTemplate } from '../api.js';
import { Button, EmptyState, Field, Modal, Select } from '../components/ui.js';
import { emptyRich } from '../lib/richtext.js';
const DOC_TYPE_LABEL: Record<DocType, string> = {
contract: 'Договор', invoice: 'Счёт', act: 'Акт', upd: 'УПД',
};
function uid(): string {
return Math.random().toString(36).slice(2, 11);
}
function emptyBody(): DocBody {
return {
version: 1,
blocks: [
{ id: uid(), type: 'heading', level: 1, text: emptyRich() },
],
vars: {},
};
}
export function TemplatesPage() {
const [items, setItems] = useState<DocumentTemplate[] | null>(null);
const [creating, setCreating] = useState<{ name: string; docType: DocType } | null>(null);
const [error, setError] = useState<string | null>(null);
const navigate = useNavigate();
async function load() {
setError(null);
try {
const r = await api.get<{ items: DocumentTemplate[] }>('/api/templates');
setItems(r.items);
} catch (e) {
setError(String(e));
}
}
useEffect(() => { void load(); }, []);
async function createBlank() {
if (!creating) return;
try {
const tpl = await api.post<DocumentTemplate>('/api/templates', {
docType: creating.docType,
name: creating.name,
body: emptyBody(),
});
setCreating(null);
navigate(`/templates/${tpl.id}`);
} catch (e) {
setError(String(e));
}
}
async function instantiate(tpl: DocumentTemplate) {
try {
const doc = await api.post<{ id: string }>(`/api/templates/${tpl.id}/instantiate`, {
clientId: null,
initialLines: [],
});
navigate(`/documents/${doc.id}`);
} catch (e) {
setError(String(e));
}
}
async function remove(id: string) {
if (!confirm('Удалить шаблон?')) return;
try {
await api.del(`/api/templates/${id}`);
await load();
} catch (e) {
setError(String(e));
}
}
return (
<main className="content">
<header className="page-head">
<h2>Шаблоны</h2>
<Button variant="primary" onClick={() => setCreating({ name: '', docType: 'contract' })}>
+ Новый шаблон
</Button>
</header>
{error ? <div className="error-text">{error}</div> : null}
{items === null ? (
<p className="hint">Загрузка</p>
) : items.length === 0 ? (
<EmptyState>
Шаблонов пока нет. Создайте первый или сохраните существующий документ как шаблон.
</EmptyState>
) : (
<table className="table">
<thead>
<tr>
<th>Название</th>
<th>Тип</th>
<th>Обновлён</th>
<th />
</tr>
</thead>
<tbody>
{items.map((t) => (
<tr key={t.id}>
<td><Link to={`/templates/${t.id}`}>{t.name}</Link></td>
<td>{DOC_TYPE_LABEL[t.docType]}</td>
<td>{new Date(t.updatedAt).toLocaleDateString('ru-RU')}</td>
<td className="row-actions">
<Button variant="ghost" onClick={() => instantiate(t)}>Создать документ</Button>
<Button variant="ghost" onClick={() => navigate(`/templates/${t.id}`)}>Изменить</Button>
<Button variant="danger" onClick={() => remove(t.id)}>×</Button>
</td>
</tr>
))}
</tbody>
</table>
)}
<Modal
open={creating !== null}
title="Новый шаблон"
onClose={() => setCreating(null)}
footer={
<>
<Button variant="ghost" onClick={() => setCreating(null)}>Отмена</Button>
<Button variant="primary" onClick={createBlank}>Создать</Button>
</>
}
>
{creating ? (
<div className="form-grid">
<Field
label="Название"
value={creating.name}
onChange={(e) => setCreating({ ...creating, name: e.target.value })}
/>
<Select
label="Тип документа"
value={creating.docType}
onChange={(v) => setCreating({ ...creating, docType: v as DocType })}
options={[
{ value: 'contract', label: 'Договор' },
{ value: 'invoice', label: 'Счёт' },
{ value: 'act', label: 'Акт' },
{ value: 'upd', label: 'УПД' },
]}
/>
</div>
) : null}
</Modal>
</main>
);
}
+75
View File
@@ -164,3 +164,78 @@ body {
.modal { background: #1c1f24; }
.modal__header, .modal__footer { border-color: #2a2e35; }
}
/* === blocks editor === */
.blocks-editor { display: flex; flex-direction: column; gap: 4px; }
.add-block {
background: transparent; border: 1px dashed #c9cbcf; color: inherit; opacity: 0.55;
padding: 6px 12px; border-radius: 6px; cursor: pointer; font-size: 13px;
align-self: flex-start;
}
.add-block:hover { opacity: 1; border-color: #2563eb; color: #2563eb; }
.add-block-menu {
display: flex; flex-wrap: wrap; gap: 6px; padding: 8px;
background: #f1f2f5; border-radius: 6px;
}
.add-block-item {
background: #fff; border: 1px solid #d6d8dd; padding: 6px 12px; border-radius: 4px;
cursor: pointer; font-size: 13px; color: inherit;
}
.add-block-item:hover { border-color: #2563eb; color: #2563eb; }
.add-block-cancel { opacity: 0.6; }
@media (prefers-color-scheme: dark) {
.add-block-menu { background: #25282e; }
.add-block-item { background: #1c1f24; border-color: #2a2e35; }
}
.block-card {
background: #fff; border: 1px solid #e5e7eb; border-radius: 8px;
padding: 12px; margin: 4px 0;
}
.block-head {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 8px;
}
.block-type {
font-size: 11px; text-transform: uppercase; letter-spacing: 0.06em;
font-weight: 600; opacity: 0.5;
}
.block-actions { display: flex; gap: 4px; }
@media (prefers-color-scheme: dark) {
.block-card { background: #1c1f24; border-color: #2a2e35; }
}
/* === lines editor === */
.lines-editor { margin: 16px 0; }
.lines-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
.lines-head h3 { margin: 0; }
.lines-actions { display: flex; gap: 8px; align-items: center; }
.lines-table td { padding: 4px 6px; }
.lines-table .field__input { padding: 4px 8px; font-size: 13px; }
.lines-table .num { text-align: right; }
.history-panel {
margin: 8px 0; padding: 12px; border: 1px solid #e5e7eb; border-radius: 8px;
background: #f9fafb; display: flex; flex-direction: column; gap: 6px;
}
.history-item {
text-align: left; background: #fff; border: 1px solid #d6d8dd; padding: 8px 12px;
border-radius: 6px; cursor: pointer; color: inherit; font-size: 13px;
}
.history-item:hover { border-color: #2563eb; }
@media (prefers-color-scheme: dark) {
.history-panel { background: #14161a; border-color: #2a2e35; }
.history-item { background: #1c1f24; border-color: #2a2e35; }
}
/* === document status pills === */
.status {
display: inline-block; padding: 2px 8px; border-radius: 999px; font-size: 12px;
background: #eef0f3; color: #4b5563;
}
.status--draft { background: #f1f2f5; color: #6b7280; }
.status--issued { background: #dbeafe; color: #1e40af; }
.status--sent { background: #e0e7ff; color: #3730a3; }
.status--partially_paid { background: #fef3c7; color: #92400e; }
.status--paid { background: #d1fae5; color: #065f46; }
.status--cancelled { background: #fee2e2; color: #991b1b; }
.status--signed { background: #d1fae5; color: #065f46; }
+1 -1
View File
@@ -1 +1 @@
{"root":["./src/app.tsx","./src/api.ts","./src/auth.ts","./src/main.tsx","./src/components/ui.tsx","./src/pages/clients.tsx","./src/pages/organization.tsx","./src/pages/services.tsx"],"version":"5.9.3"}
{"root":["./src/app.tsx","./src/api.ts","./src/auth.ts","./src/main.tsx","./src/components/blockseditor.tsx","./src/components/clientpicker.tsx","./src/components/lineseditor.tsx","./src/components/ui.tsx","./src/lib/richtext.ts","./src/pages/clients.tsx","./src/pages/documentedit.tsx","./src/pages/documents.tsx","./src/pages/organization.tsx","./src/pages/services.tsx","./src/pages/templateedit.tsx","./src/pages/templates.tsx"],"version":"5.9.3"}