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:
+10
-2
@@ -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
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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: [] }] };
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 @@
|
||||
{"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"}
|
||||
Reference in New Issue
Block a user