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
+1
View File
@@ -24,6 +24,7 @@
"fastify": "^4.28.1",
"fastify-plugin": "^4.5.1",
"jose": "^5.9.6",
"puppeteer-core": "^24.42.0",
"zod": "^3.23.8"
},
"devDependencies": {
+113
View File
@@ -0,0 +1,113 @@
import type { VatRate } from '@prisma/client';
const VAT_PERCENT: Record<VatRate, number> = {
none: 0,
vat_0: 0,
vat_5: 5,
vat_7: 7,
vat_10: 10,
vat_20: 20,
};
export type LineCalc = {
qtyMilli: bigint;
priceCents: bigint;
vat: VatRate;
};
/**
* Сумма по строке с НДС-логикой:
* - В РФ цена в счёте ВКЛЮЧАЕТ НДС. Сумма = price * qty / 1000.
* - НДС-часть выделяется из суммы: vatPart = sum * pct / (100 + pct).
* - Для VatRate=none — налога нет, vatPart=0.
*/
export function computeLine(line: LineCalc): { sumCents: bigint; vatCents: bigint } {
const sumCents = (line.priceCents * line.qtyMilli) / 1000n;
const pct = VAT_PERCENT[line.vat];
if (pct === 0) {
return { sumCents, vatCents: 0n };
}
// целочисленное округление до копейки: round(sum * pct / (100 + pct))
const num = sumCents * BigInt(pct);
const den = BigInt(100 + pct);
const vatCents = (num + den / 2n) / den;
return { sumCents, vatCents };
}
export function totals(lines: LineCalc[]): { totalCents: bigint; vatCents: bigint } {
let total = 0n;
let vat = 0n;
for (const l of lines) {
const r = computeLine(l);
total += r.sumCents;
vat += r.vatCents;
}
return { totalCents: total, vatCents: vat };
}
export function formatRub(cents: bigint | number): string {
const c = typeof cents === 'bigint' ? Number(cents) : cents;
return (c / 100).toLocaleString('ru-RU', {
style: 'currency',
currency: 'RUB',
minimumFractionDigits: 2,
});
}
const ONES = ['', 'один', 'два', 'три', 'четыре', 'пять', 'шесть', 'семь', 'восемь', 'девять'];
const ONES_F = ['', 'одна', 'две', 'три', 'четыре', 'пять', 'шесть', 'семь', 'восемь', 'девять'];
const TEENS = [
'десять', 'одиннадцать', 'двенадцать', 'тринадцать', 'четырнадцать',
'пятнадцать', 'шестнадцать', 'семнадцать', 'восемнадцать', 'девятнадцать',
];
const TENS = ['', '', 'двадцать', 'тридцать', 'сорок', 'пятьдесят', 'шестьдесят', 'семьдесят', 'восемьдесят', 'девяносто'];
const HUNDREDS = ['', 'сто', 'двести', 'триста', 'четыреста', 'пятьсот', 'шестьсот', 'семьсот', 'восемьсот', 'девятьсот'];
function group(n: number, feminine: boolean): string {
const ones = feminine ? ONES_F : ONES;
const parts: string[] = [];
const h = Math.floor(n / 100);
const t = Math.floor((n % 100) / 10);
const u = n % 10;
if (h > 0) parts.push(HUNDREDS[h]!);
if (t === 1) {
parts.push(TEENS[u]!);
} else {
if (t > 0) parts.push(TENS[t]!);
if (u > 0) parts.push(ones[u]!);
}
return parts.join(' ');
}
function pluralRu(n: number, forms: [string, string, string]): string {
const mod10 = n % 10;
const mod100 = n % 100;
if (mod10 === 1 && mod100 !== 11) return forms[0];
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 10 || mod100 >= 20)) return forms[1];
return forms[2];
}
/** Сумма прописью на русском. cents — копейки. */
export function rubInWords(cents: bigint | number): string {
const total = typeof cents === 'bigint' ? Number(cents) : cents;
const rub = Math.floor(Math.abs(total) / 100);
const kop = Math.abs(total) % 100;
const billions = Math.floor(rub / 1_000_000_000);
const millions = Math.floor((rub % 1_000_000_000) / 1_000_000);
const thousands = Math.floor((rub % 1_000_000) / 1_000);
const ones = rub % 1_000;
const parts: string[] = [];
if (billions > 0) parts.push(`${group(billions, false)} ${pluralRu(billions, ['миллиард', 'миллиарда', 'миллиардов'])}`);
if (millions > 0) parts.push(`${group(millions, false)} ${pluralRu(millions, ['миллион', 'миллиона', 'миллионов'])}`);
if (thousands > 0) parts.push(`${group(thousands, true)} ${pluralRu(thousands, ['тысяча', 'тысячи', 'тысяч'])}`);
if (ones > 0 || (billions === 0 && millions === 0 && thousands === 0)) {
parts.push(group(ones || 0, false));
}
const rubText = parts.join(' ').replace(/\s+/g, ' ').trim() || 'ноль';
const kopText = String(kop).padStart(2, '0');
const sign = total < 0 ? 'минус ' : '';
return `${sign}${rubText} ${pluralRu(rub, ['рубль', 'рубля', 'рублей'])} ${kopText} ${pluralRu(kop, ['копейка', 'копейки', 'копеек'])}`;
}
+52
View File
@@ -0,0 +1,52 @@
import type { DocType } from '@prisma/client';
import { prisma } from '../db.js';
const PREFIX: Record<DocType, string> = {
contract: 'ДГ',
invoice: 'СЧ',
act: 'АКТ',
upd: 'УПД',
};
/**
* Подобрать следующий свободный номер для (organization, docType, year).
* Формат: «ДГ-2026/001». Возвращает строку — вставлять под `documents.number` нужно
* в той же транзакции с retry на P2002 (unique constraint), на случай concurrent insert.
*
* Решение без advisory locks выбрано осознанно: на текущем масштабе
* (single-tenant, ручной ввод документов) шанс race-conflict пренебрежимо мал.
*/
export async function nextDocumentNumber(
organizationId: string,
docType: DocType,
now: Date = new Date(),
): Promise<string> {
const year = now.getFullYear();
const prefix = PREFIX[docType];
const filter = `${prefix}-${year}/`;
const docs = await prisma.document.findMany({
where: { organizationId, docType, number: { startsWith: filter } },
select: { number: true },
});
const re = new RegExp(`^${prefix}-${year}/(\\d+)$`);
let max = 0;
for (const d of docs) {
const m = re.exec(d.number);
if (m && m[1]) {
const n = parseInt(m[1], 10);
if (n > max) max = n;
}
}
return `${prefix}-${year}/${String(max + 1).padStart(3, '0')}`;
}
export function isUniqueViolation(err: unknown): boolean {
return (
typeof err === 'object' &&
err !== null &&
'code' in err &&
(err as { code?: string }).code === 'P2002'
);
}
@@ -0,0 +1,69 @@
import type { FastifyInstance } from 'fastify';
import { prisma } from '../../db.js';
import { getOrganizationId } from '../../lib/org.js';
import { renderDocumentToHtml, renderDocumentToPdf } from './pdf.js';
export async function documentsPdfRoutes(app: FastifyInstance) {
// Превью HTML — для отладки и интерактивного просмотра до выгрузки в PDF.
app.get(
'/api/documents/:id/preview',
{ preHandler: app.requireDocPermission('viewer') },
async (req, reply) => {
const orgId = getOrganizationId(req);
const { id } = req.params as { id: string };
const doc = await prisma.document.findFirst({
where: { id, organizationId: orgId },
include: { client: true, lines: { orderBy: { position: 'asc' } } },
});
if (!doc) {
reply.code(404).send({ error: 'not_found' });
return;
}
const org = await prisma.organization.findUnique({ where: { id: orgId } });
if (!org) {
reply.code(404).send({ error: 'organization_not_found' });
return;
}
const html = renderDocumentToHtml(doc, org);
reply.type('text/html; charset=utf-8').send(html);
},
);
app.get(
'/api/documents/:id/pdf',
{ preHandler: app.requireDocPermission('viewer') },
async (req, reply) => {
const orgId = getOrganizationId(req);
const { id } = req.params as { id: string };
const doc = await prisma.document.findFirst({
where: { id, organizationId: orgId },
include: { client: true, lines: { orderBy: { position: 'asc' } } },
});
if (!doc) {
reply.code(404).send({ error: 'not_found' });
return;
}
const org = await prisma.organization.findUnique({ where: { id: orgId } });
if (!org) {
reply.code(404).send({ error: 'organization_not_found' });
return;
}
try {
const pdf = await renderDocumentToPdf(doc, org);
reply
.type('application/pdf')
.header('Content-Disposition', `inline; filename="${encodeURIComponent(doc.number)}.pdf"`)
.send(pdf);
} catch (e) {
if ((e as { code?: string }).code === 'NO_CHROMIUM') {
reply.code(503).send({
error: 'no_chromium',
message: (e as Error).message,
});
return;
}
throw e;
}
},
);
}
+182
View File
@@ -0,0 +1,182 @@
import { existsSync } from 'node:fs';
import puppeteer, { type Browser } from 'puppeteer-core';
import {
renderDocumentHtml,
type RenderContext,
type RenderClient,
type RenderOrganization,
type RenderLine,
type RenderDocument,
type DocBody,
} from '@doc-manager/shared';
import { rubInWords } from '../../lib/money.js';
// Автодетект Chromium / Chrome для разных платформ.
function detectExecutable(): string | null {
if (process.env.PUPPETEER_EXECUTABLE_PATH && existsSync(process.env.PUPPETEER_EXECUTABLE_PATH)) {
return process.env.PUPPETEER_EXECUTABLE_PATH;
}
const candidates =
process.platform === 'win32'
? [
'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
'C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe',
]
: process.platform === 'darwin'
? [
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
'/Applications/Chromium.app/Contents/MacOS/Chromium',
]
: [
'/usr/bin/chromium-browser',
'/usr/bin/chromium',
'/usr/bin/google-chrome',
'/usr/bin/google-chrome-stable',
];
for (const p of candidates) {
if (existsSync(p)) return p;
}
return null;
}
let browserPromise: Promise<Browser> | null = null;
async function getBrowser(): Promise<Browser> {
if (browserPromise) {
const b = await browserPromise;
if (b.connected) return b;
browserPromise = null;
}
const executablePath = detectExecutable();
if (!executablePath) {
throw Object.assign(
new Error(
'Chromium не найден. Установите PUPPETEER_EXECUTABLE_PATH в .env или поставьте Chrome/Chromium.',
),
{ code: 'NO_CHROMIUM' },
);
}
browserPromise = puppeteer.launch({
executablePath,
args: ['--no-sandbox', '--disable-dev-shm-usage', '--disable-gpu'],
});
return browserPromise;
}
export async function shutdownBrowser(): Promise<void> {
if (browserPromise) {
try {
const b = await browserPromise;
await b.close();
} catch {
/* ignore */
}
browserPromise = null;
}
}
// ========== Адаптеры из Prisma в RenderContext ==========
type PrismaDoc = {
number: string;
docType: 'contract' | 'invoice' | 'act' | 'upd';
issuedAt: Date | null;
totalCents: bigint;
vatCents: bigint;
currency: string;
body: unknown;
client: {
id: string;
kind: 'ul' | 'ip' | 'fl';
name: string;
inn: string | null;
kpp: string | null;
address: string | null;
email: string | null;
phone: string | null;
} | null;
lines: {
id: string;
position: number;
name: string;
qtyMilli: bigint;
unit: string;
priceCents: bigint;
vat: 'none' | 'vat_0' | 'vat_5' | 'vat_7' | 'vat_10' | 'vat_20';
sumCents: bigint;
}[];
};
type PrismaOrg = {
name: string;
inn: string;
kpp: string | null;
ogrn: string | null;
legalAddress: string | null;
bankName: string | null;
bankBik: string | null;
bankAccount: string | null;
signatoryName: string | null;
signatoryPosition: string | null;
};
export function buildRenderContext(doc: PrismaDoc, organization: PrismaOrg): RenderContext {
const renderDoc: RenderDocument = {
number: doc.number,
docType: doc.docType,
issuedAt: doc.issuedAt,
totalCents: Number(doc.totalCents),
vatCents: Number(doc.vatCents),
currency: doc.currency,
};
const renderOrg: RenderOrganization = { ...organization };
const renderClient: RenderClient | null = doc.client ? { ...doc.client } : null;
const renderLines: RenderLine[] = doc.lines.map((l) => ({
id: l.id,
position: l.position,
name: l.name,
qtyMilli: Number(l.qtyMilli),
unit: l.unit,
priceCents: Number(l.priceCents),
vat: l.vat,
sumCents: Number(l.sumCents),
}));
return {
doc: renderDoc,
organization: renderOrg,
client: renderClient,
lines: renderLines,
vars: {},
};
}
export async function renderDocumentToPdf(doc: PrismaDoc, organization: PrismaOrg): Promise<Buffer> {
const ctx = buildRenderContext(doc, organization);
const html = renderDocumentHtml(doc.body as DocBody, ctx, {
title: `${doc.docType} ${doc.number}`,
rubInWords: (cents) => rubInWords(cents),
});
const browser = await getBrowser();
const page = await browser.newPage();
try {
await page.setContent(html, { waitUntil: 'networkidle0' });
const pdf = await page.pdf({
format: 'A4',
printBackground: true,
margin: { top: '18mm', right: '16mm', bottom: '18mm', left: '16mm' },
});
return Buffer.from(pdf);
} finally {
await page.close();
}
}
export function renderDocumentToHtml(doc: PrismaDoc, organization: PrismaOrg): string {
const ctx = buildRenderContext(doc, organization);
return renderDocumentHtml(doc.body as DocBody, ctx, {
title: `${doc.docType} ${doc.number}`,
rubInWords: (cents) => rubInWords(cents),
});
}
+288
View File
@@ -0,0 +1,288 @@
import type { FastifyInstance } from 'fastify';
import { Prisma, type DocType, type DocStatus, type VatRate } from '@prisma/client';
import { z } from 'zod';
import { DocBody } from '@doc-manager/shared';
import { prisma } from '../../db.js';
import { getOrganizationId } from '../../lib/org.js';
import { isUniqueViolation, nextDocumentNumber } from '../../lib/numbers.js';
import { totals } from '../../lib/money.js';
const VAT_VALUES = ['none', 'vat_0', 'vat_5', 'vat_7', 'vat_10', 'vat_20'] as const;
const DOC_TYPES = ['contract', 'invoice', 'act', 'upd'] as const;
const DOC_STATUSES = ['draft', 'issued', 'sent', 'partially_paid', 'paid', 'cancelled', 'signed'] as const;
const LineInput = z.object({
position: z.coerce.number().int().nonnegative(),
serviceId: z.string().uuid().nullable(),
lineId: z.string().uuid().optional(), // если есть — обновляем существующую строку
name: z.string().min(1).max(500),
qtyMilli: z.coerce.number().int().positive(),
unit: z.string().min(1).max(50),
priceCents: z.coerce.number().int().nonnegative(),
vat: z.enum(VAT_VALUES),
});
const DocumentCreate = z.object({
docType: z.enum(DOC_TYPES),
clientId: z.string().uuid().nullable(),
parentDocumentId: z.string().uuid().nullable(),
body: DocBody,
lines: z.array(LineInput).default([]),
number: z.string().min(1).max(100).nullable(), // если null — генерим автоматически
issuedAt: z.string().datetime().nullable(),
currency: z.string().length(3).default('RUB'),
});
const DocumentUpdate = z.object({
clientId: z.string().uuid().nullable(),
body: DocBody,
lines: z.array(LineInput).default([]),
number: z.string().min(1).max(100),
issuedAt: z.string().datetime().nullable(),
});
const StatusChange = z.object({
status: z.enum(DOC_STATUSES),
});
const ListQuery = z.object({
docType: z.enum(DOC_TYPES).optional(),
clientId: z.string().uuid().optional(),
status: z.enum(DOC_STATUSES).optional(),
q: z.string().optional(),
limit: z.coerce.number().int().min(1).max(500).default(100),
});
const HistoryQuery = z.object({
clientId: z.string().uuid(),
limit: z.coerce.number().int().min(1).max(200).default(50),
});
function lineCalc(l: { qtyMilli: number; priceCents: number; vat: VatRate }) {
return { qtyMilli: BigInt(l.qtyMilli), priceCents: BigInt(l.priceCents), vat: l.vat };
}
export async function documentsRoutes(app: FastifyInstance) {
// -------- LIST --------
app.get('/api/documents', { preHandler: app.requireDocPermission('viewer') }, async (req, reply) => {
const orgId = getOrganizationId(req);
const parsed = ListQuery.safeParse(req.query);
if (!parsed.success) {
reply.code(400).send({ error: 'validation_error', issues: parsed.error.flatten() });
return;
}
const { docType, clientId, status, q, limit } = parsed.data;
const docs = await prisma.document.findMany({
where: {
organizationId: orgId,
...(docType ? { docType } : {}),
...(clientId ? { clientId } : {}),
...(status ? { status } : {}),
...(q ? { OR: [{ number: { contains: q, mode: 'insensitive' } }, { client: { name: { contains: q, mode: 'insensitive' } } }] } : {}),
},
include: { client: { select: { id: true, name: true, kind: true } } },
orderBy: [{ issuedAt: { sort: 'desc', nulls: 'last' } }, { createdAt: 'desc' }],
take: limit,
});
return { items: docs };
});
// -------- HISTORY (line autocomplete по клиенту) --------
app.get('/api/documents/_history', { preHandler: app.requireDocPermission('viewer') }, async (req, reply) => {
const orgId = getOrganizationId(req);
const parsed = HistoryQuery.safeParse(req.query);
if (!parsed.success) {
reply.code(400).send({ error: 'validation_error', issues: parsed.error.flatten() });
return;
}
const { clientId, limit } = parsed.data;
// Сводка использованных услуг по клиенту: имя/цена/НДС, последний раз и сколько использовалась.
const rows = await prisma.$queryRaw<
{ service_id: string | null; name: string; unit: string; price_cents: bigint; vat: VatRate; last_used: Date; use_count: bigint }[]
>`
select dl.service_id, dl.name, dl.unit, dl.price_cents, dl.vat,
max(d.created_at) as last_used,
count(*) as use_count
from "DocumentLine" dl
join "Document" d on d.id = dl.document_id
where d.organization_id = ${orgId}::uuid and d.client_id = ${clientId}::uuid
group by dl.service_id, dl.name, dl.unit, dl.price_cents, dl.vat
order by max(d.created_at) desc
limit ${limit}
`;
return {
items: rows.map((r) => ({
serviceId: r.service_id,
name: r.name,
unit: r.unit,
priceCents: Number(r.price_cents),
vat: r.vat,
lastUsed: r.last_used,
useCount: Number(r.use_count),
})),
};
});
// -------- GET --------
app.get('/api/documents/:id', { preHandler: app.requireDocPermission('viewer') }, async (req, reply) => {
const orgId = getOrganizationId(req);
const { id } = req.params as { id: string };
const doc = await prisma.document.findFirst({
where: { id, organizationId: orgId },
include: {
client: true,
lines: { orderBy: { position: 'asc' } },
},
});
if (!doc) {
reply.code(404).send({ error: 'not_found' });
return;
}
return doc;
});
// -------- CREATE --------
app.post('/api/documents', { preHandler: app.requireDocPermission('user') }, async (req, reply) => {
const orgId = getOrganizationId(req);
const parsed = DocumentCreate.safeParse(req.body);
if (!parsed.success) {
reply.code(400).send({ error: 'validation_error', issues: parsed.error.flatten() });
return;
}
const { docType, clientId, parentDocumentId, body, lines, number, issuedAt, currency } = parsed.data;
const calcLines = lines.map(lineCalc);
const { totalCents, vatCents } = totals(calcLines);
const sub = (req.user!.sub as string) || null;
// Retry на P2002 при auto-номере (на случай гонки concurrent insert).
for (let attempt = 0; attempt < 3; attempt++) {
const num = number ?? (await nextDocumentNumber(orgId, docType));
try {
const doc = await prisma.document.create({
data: {
organizationId: orgId,
docType,
number: num,
issuedAt: issuedAt ? new Date(issuedAt) : null,
clientId,
parentDocumentId,
body: body as Prisma.InputJsonValue,
totalCents,
vatCents,
currency,
createdBy: sub,
lines: {
create: lines.map((l) => ({
position: l.position,
serviceId: l.serviceId,
name: l.name,
qtyMilli: BigInt(l.qtyMilli),
unit: l.unit,
priceCents: BigInt(l.priceCents),
vat: l.vat,
sumCents: (BigInt(l.priceCents) * BigInt(l.qtyMilli)) / 1000n,
})),
},
},
include: { lines: { orderBy: { position: 'asc' } }, client: true },
});
reply.code(201).send(doc);
return;
} catch (e) {
if (number === null && isUniqueViolation(e) && attempt < 2) continue;
throw e;
}
}
});
// -------- UPDATE --------
app.put('/api/documents/:id', { preHandler: app.requireDocPermission('user') }, async (req, reply) => {
const orgId = getOrganizationId(req);
const { id } = req.params as { id: string };
const parsed = DocumentUpdate.safeParse(req.body);
if (!parsed.success) {
reply.code(400).send({ error: 'validation_error', issues: parsed.error.flatten() });
return;
}
const existing = await prisma.document.findFirst({ where: { id, organizationId: orgId } });
if (!existing) {
reply.code(404).send({ error: 'not_found' });
return;
}
if (existing.tochkaDocumentId) {
reply.code(409).send({ error: 'locked_by_bank', message: 'Документ выставлен через банк, редактировать нельзя.' });
return;
}
const { clientId, body, lines, number, issuedAt } = parsed.data;
const { totalCents, vatCents } = totals(lines.map(lineCalc));
const updated = await prisma.$transaction(async (tx) => {
// Bulk-replace строк (пересоздаём — проще чем merge, т.к. упорядочивание по position)
await tx.documentLine.deleteMany({ where: { documentId: id } });
await tx.document.update({
where: { id },
data: {
number,
clientId,
issuedAt: issuedAt ? new Date(issuedAt) : null,
body: body as Prisma.InputJsonValue,
totalCents,
vatCents,
lines: {
create: lines.map((l) => ({
position: l.position,
serviceId: l.serviceId,
name: l.name,
qtyMilli: BigInt(l.qtyMilli),
unit: l.unit,
priceCents: BigInt(l.priceCents),
vat: l.vat,
sumCents: (BigInt(l.priceCents) * BigInt(l.qtyMilli)) / 1000n,
})),
},
},
});
return tx.document.findUnique({
where: { id },
include: { lines: { orderBy: { position: 'asc' } }, client: true },
});
});
return updated;
});
// -------- STATUS CHANGE --------
app.post('/api/documents/:id/status', { preHandler: app.requireDocPermission('user') }, async (req, reply) => {
const orgId = getOrganizationId(req);
const { id } = req.params as { id: string };
const parsed = StatusChange.safeParse(req.body);
if (!parsed.success) {
reply.code(400).send({ error: 'validation_error', issues: parsed.error.flatten() });
return;
}
const existing = await prisma.document.findFirst({ where: { id, organizationId: orgId } });
if (!existing) {
reply.code(404).send({ error: 'not_found' });
return;
}
const next: DocStatus = parsed.data.status;
const updated = await prisma.document.update({ where: { id }, data: { status: next } });
return updated;
});
// -------- DELETE (только drafts) --------
app.delete('/api/documents/:id', { preHandler: app.requireDocPermission('admin') }, async (req, reply) => {
const orgId = getOrganizationId(req);
const { id } = req.params as { id: string };
const existing = await prisma.document.findFirst({ where: { id, organizationId: orgId } });
if (!existing) {
reply.code(404).send({ error: 'not_found' });
return;
}
if (existing.status !== 'draft') {
reply.code(409).send({ error: 'not_draft', message: 'Удалять можно только черновики.' });
return;
}
await prisma.document.delete({ where: { id } });
reply.code(204).send();
});
}
+168
View File
@@ -0,0 +1,168 @@
import type { FastifyInstance } from 'fastify';
import { Prisma, type DocType } from '@prisma/client';
import { z } from 'zod';
import { DocBody } from '@doc-manager/shared';
import { prisma } from '../../db.js';
import { getOrganizationId } from '../../lib/org.js';
import { nextDocumentNumber, isUniqueViolation } from '../../lib/numbers.js';
import { totals } from '../../lib/money.js';
const DOC_TYPES = ['contract', 'invoice', 'act', 'upd'] as const;
const VAT_VALUES = ['none', 'vat_0', 'vat_5', 'vat_7', 'vat_10', 'vat_20'] as const;
const TemplateUpsert = z.object({
docType: z.enum(DOC_TYPES),
name: z.string().min(1).max(500),
body: DocBody,
});
const InstantiateInput = z.object({
clientId: z.string().uuid().nullable(),
// Линии у шаблона мы не храним; но на инстанс пользователь может передать стартовый набор линий.
initialLines: z
.array(
z.object({
position: z.number().int().nonnegative(),
serviceId: z.string().uuid().nullable(),
name: z.string().min(1).max(500),
qtyMilli: z.number().int().positive(),
unit: z.string().min(1).max(50),
priceCents: z.number().int().nonnegative(),
vat: z.enum(VAT_VALUES),
}),
)
.default([]),
});
export async function templatesRoutes(app: FastifyInstance) {
app.get('/api/templates', { preHandler: app.requireDocPermission('viewer') }, async (req) => {
const orgId = getOrganizationId(req);
const docType = (req.query as { docType?: DocType }).docType;
const items = await prisma.documentTemplate.findMany({
where: {
organizationId: orgId,
...(docType ? { docType } : {}),
},
orderBy: { updatedAt: 'desc' },
});
return { items };
});
app.get('/api/templates/:id', { preHandler: app.requireDocPermission('viewer') }, async (req, reply) => {
const orgId = getOrganizationId(req);
const { id } = req.params as { id: string };
const tpl = await prisma.documentTemplate.findFirst({ where: { id, organizationId: orgId } });
if (!tpl) {
reply.code(404).send({ error: 'not_found' });
return;
}
return tpl;
});
app.post('/api/templates', { preHandler: app.requireDocPermission('user') }, async (req, reply) => {
const orgId = getOrganizationId(req);
const parsed = TemplateUpsert.safeParse(req.body);
if (!parsed.success) {
reply.code(400).send({ error: 'validation_error', issues: parsed.error.flatten() });
return;
}
const created = await prisma.documentTemplate.create({
data: {
organizationId: orgId,
docType: parsed.data.docType,
name: parsed.data.name,
body: parsed.data.body as Prisma.InputJsonValue,
},
});
reply.code(201).send(created);
});
app.put('/api/templates/:id', { preHandler: app.requireDocPermission('user') }, async (req, reply) => {
const orgId = getOrganizationId(req);
const { id } = req.params as { id: string };
const parsed = TemplateUpsert.safeParse(req.body);
if (!parsed.success) {
reply.code(400).send({ error: 'validation_error', issues: parsed.error.flatten() });
return;
}
const existing = await prisma.documentTemplate.findFirst({ where: { id, organizationId: orgId } });
if (!existing) {
reply.code(404).send({ error: 'not_found' });
return;
}
return prisma.documentTemplate.update({
where: { id },
data: {
docType: parsed.data.docType,
name: parsed.data.name,
body: parsed.data.body as Prisma.InputJsonValue,
},
});
});
app.delete('/api/templates/:id', { preHandler: app.requireDocPermission('user') }, async (req, reply) => {
const orgId = getOrganizationId(req);
const { id } = req.params as { id: string };
const existing = await prisma.documentTemplate.findFirst({ where: { id, organizationId: orgId } });
if (!existing) {
reply.code(404).send({ error: 'not_found' });
return;
}
await prisma.documentTemplate.delete({ where: { id } });
reply.code(204).send();
});
// Создать новый документ-черновик из шаблона.
app.post('/api/templates/:id/instantiate', { preHandler: app.requireDocPermission('user') }, async (req, reply) => {
const orgId = getOrganizationId(req);
const { id } = req.params as { id: string };
const parsed = InstantiateInput.safeParse(req.body);
if (!parsed.success) {
reply.code(400).send({ error: 'validation_error', issues: parsed.error.flatten() });
return;
}
const tpl = await prisma.documentTemplate.findFirst({ where: { id, organizationId: orgId } });
if (!tpl) {
reply.code(404).send({ error: 'not_found' });
return;
}
const lines = parsed.data.initialLines;
const calc = totals(lines.map((l) => ({ qtyMilli: BigInt(l.qtyMilli), priceCents: BigInt(l.priceCents), vat: l.vat })));
const sub = (req.user!.sub as string) || null;
for (let attempt = 0; attempt < 3; attempt++) {
const number = await nextDocumentNumber(orgId, tpl.docType);
try {
const doc = await prisma.document.create({
data: {
organizationId: orgId,
docType: tpl.docType,
number,
clientId: parsed.data.clientId,
body: tpl.body as Prisma.InputJsonValue,
totalCents: calc.totalCents,
vatCents: calc.vatCents,
createdBy: sub,
lines: {
create: lines.map((l) => ({
position: l.position,
serviceId: l.serviceId,
name: l.name,
qtyMilli: BigInt(l.qtyMilli),
unit: l.unit,
priceCents: BigInt(l.priceCents),
vat: l.vat,
sumCents: (BigInt(l.priceCents) * BigInt(l.qtyMilli)) / 1000n,
})),
},
},
include: { lines: { orderBy: { position: 'asc' } }, client: true },
});
reply.code(201).send(doc);
return;
} catch (e) {
if (isUniqueViolation(e) && attempt < 2) continue;
throw e;
}
}
});
}
+11
View File
@@ -10,6 +10,10 @@ import { meRoutes } from './routes/me.js';
import { organizationsRoutes } from './modules/organizations/routes.js';
import { clientsRoutes } from './modules/clients/routes.js';
import { servicesRoutes } from './modules/services/routes.js';
import { documentsRoutes } from './modules/documents/routes.js';
import { documentsPdfRoutes } from './modules/documents/pdf.routes.js';
import { templatesRoutes } from './modules/templates/routes.js';
import { shutdownBrowser } from './modules/documents/pdf.js';
async function main() {
const loggerOptions =
@@ -38,6 +42,13 @@ async function main() {
await app.register(organizationsRoutes);
await app.register(clientsRoutes);
await app.register(servicesRoutes);
await app.register(documentsRoutes);
await app.register(documentsPdfRoutes);
await app.register(templatesRoutes);
app.addHook('onClose', async () => {
await shutdownBrowser();
});
app.setErrorHandler((err, _req, reply) => {
app.log.error({ err }, 'unhandled error');
+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"}