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:
@@ -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": {
|
||||
|
||||
@@ -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, ['копейка', 'копейки', 'копеек'])}`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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"}
|
||||
+17
-11
@@ -1,23 +1,32 @@
|
||||
FROM node:20-alpine
|
||||
WORKDIR /app
|
||||
RUN apk add --no-cache openssl tini
|
||||
|
||||
# Корневой манифест для npm workspaces
|
||||
# Chromium для Puppeteer (PDF рендер) + шрифты для кириллицы.
|
||||
# nss/freetype/harfbuzz нужны самому chromium для рендера, ttf-* — для текста.
|
||||
RUN apk add --no-cache \
|
||||
openssl \
|
||||
tini \
|
||||
chromium \
|
||||
nss \
|
||||
freetype \
|
||||
harfbuzz \
|
||||
ca-certificates \
|
||||
ttf-dejavu \
|
||||
ttf-liberation \
|
||||
font-noto-cjk
|
||||
|
||||
ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium-browser
|
||||
ENV PUPPETEER_SKIP_DOWNLOAD=true
|
||||
|
||||
COPY package.json package-lock.json* tsconfig.base.json ./
|
||||
|
||||
# Манифесты воркспейсов
|
||||
COPY apps/api/package.json apps/api/
|
||||
COPY packages/shared/package.json packages/shared/
|
||||
|
||||
# Все зависимости (включая dev — нужен tsx и prisma CLI). Образ на api ~250MB,
|
||||
# приемлемо для small-scale деплоя; оптимизируем многоэтапной сборкой когда понадобится.
|
||||
RUN npm install --include=dev
|
||||
|
||||
# Исходники
|
||||
COPY apps/api ./apps/api
|
||||
COPY packages/shared ./packages/shared
|
||||
|
||||
# Prisma client (без коннекта к БД)
|
||||
RUN cd apps/api && npx prisma generate
|
||||
|
||||
ENV NODE_ENV=production
|
||||
@@ -25,7 +34,4 @@ WORKDIR /app/apps/api
|
||||
EXPOSE 3030
|
||||
|
||||
ENTRYPOINT ["/sbin/tini", "--"]
|
||||
# `prisma migrate deploy` накатывает все миграции из prisma/migrations.
|
||||
# При первом деплое (миграций ещё нет) выполнит `db push` — но db push в проде
|
||||
# опасен; на продакшен-этапе всегда коммитим миграции в репо через `prisma migrate dev`.
|
||||
CMD ["sh", "-c", "npx prisma migrate deploy && npx tsx src/server.ts"]
|
||||
|
||||
Generated
+672
-25
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,4 @@
|
||||
export * from './blocks/schema.js';
|
||||
export * from './tochka/dto.js';
|
||||
export * from './auth/types.js';
|
||||
export * from './render/toHtml.js';
|
||||
|
||||
@@ -0,0 +1,344 @@
|
||||
import type { Block, DocBody, RichText, VatRate } from '../blocks/schema.js';
|
||||
import { VAT_LABEL, VAT_PERCENT } from '../blocks/schema.js';
|
||||
|
||||
// ========== Domain types для рендера ==========
|
||||
// shared не зависит от Prisma — нам нужны простые DTO.
|
||||
|
||||
export type RenderOrganization = {
|
||||
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 type RenderClient = {
|
||||
id: string;
|
||||
kind: 'ul' | 'ip' | 'fl';
|
||||
name: string;
|
||||
inn: string | null;
|
||||
kpp: string | null;
|
||||
address: string | null;
|
||||
email: string | null;
|
||||
phone: string | null;
|
||||
};
|
||||
|
||||
export type RenderLine = {
|
||||
id: string;
|
||||
position: number;
|
||||
name: string;
|
||||
qtyMilli: number;
|
||||
unit: string;
|
||||
priceCents: number;
|
||||
vat: VatRate;
|
||||
sumCents: number;
|
||||
};
|
||||
|
||||
export type RenderDocument = {
|
||||
number: string;
|
||||
docType: 'contract' | 'invoice' | 'act' | 'upd';
|
||||
issuedAt: Date | string | null;
|
||||
totalCents: number;
|
||||
vatCents: number;
|
||||
currency: string;
|
||||
};
|
||||
|
||||
export type RenderContext = {
|
||||
doc: RenderDocument;
|
||||
organization: RenderOrganization;
|
||||
client: RenderClient | null;
|
||||
lines: RenderLine[];
|
||||
vars: Record<string, unknown>;
|
||||
};
|
||||
|
||||
// ========== HTML escaping ==========
|
||||
|
||||
const ESC: Record<string, string> = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": ''',
|
||||
};
|
||||
function esc(s: string): string {
|
||||
return s.replace(/[&<>"']/g, (c) => ESC[c]!);
|
||||
}
|
||||
|
||||
// ========== Placeholder resolution ==========
|
||||
|
||||
function getByPath(obj: unknown, path: string): unknown {
|
||||
return path.split('.').reduce<unknown>((acc, key) => {
|
||||
if (acc && typeof acc === 'object' && key in (acc as Record<string, unknown>)) {
|
||||
return (acc as Record<string, unknown>)[key];
|
||||
}
|
||||
return undefined;
|
||||
}, obj);
|
||||
}
|
||||
|
||||
const PLACEHOLDER_RE = /\{\{\s*([a-zA-Z0-9_.]+)\s*\}\}/g;
|
||||
|
||||
function fillPlaceholders(text: string, ctx: RenderContext): string {
|
||||
const fullCtx: Record<string, unknown> = {
|
||||
today: new Date().toLocaleDateString('ru-RU'),
|
||||
contract: {
|
||||
number: ctx.doc.number,
|
||||
date: ctx.doc.issuedAt ? new Date(ctx.doc.issuedAt).toLocaleDateString('ru-RU') : '',
|
||||
},
|
||||
customer: ctx.client ?? {},
|
||||
executor: ctx.organization,
|
||||
self: ctx.organization,
|
||||
...ctx.vars,
|
||||
};
|
||||
return text.replace(PLACEHOLDER_RE, (_m, path: string) => {
|
||||
const v = getByPath(fullCtx, path);
|
||||
return v === undefined || v === null ? '' : esc(String(v));
|
||||
});
|
||||
}
|
||||
|
||||
// ========== RichText rendering ==========
|
||||
// На M3 в редакторе тексты вводятся как plain string и оборачиваются в простой
|
||||
// TipTap-совместимый JSON: { type: 'doc', content: [{ type: 'paragraph', content: [{ type: 'text', text: '...' }] }] }
|
||||
// Здесь поддерживаем минимум: рекурсивный обход, marks=bold/italic/underline, line breaks.
|
||||
|
||||
function renderRichText(node: RichText | undefined, ctx: RenderContext): string {
|
||||
if (!node) return '';
|
||||
return renderNode(node, ctx);
|
||||
}
|
||||
|
||||
function renderNode(n: unknown, ctx: RenderContext): string {
|
||||
if (!n || typeof n !== 'object') return '';
|
||||
const node = n as { type?: string; content?: unknown[]; text?: string; marks?: { type: string }[] };
|
||||
|
||||
if (node.type === 'text' && typeof node.text === 'string') {
|
||||
let out = esc(fillPlaceholders(node.text, ctx));
|
||||
for (const m of node.marks ?? []) {
|
||||
if (m.type === 'bold') out = `<strong>${out}</strong>`;
|
||||
else if (m.type === 'italic') out = `<em>${out}</em>`;
|
||||
else if (m.type === 'underline') out = `<u>${out}</u>`;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
if (node.type === 'hardBreak') return '<br/>';
|
||||
if (node.type === 'paragraph') return `<p>${(node.content ?? []).map((c) => renderNode(c, ctx)).join('')}</p>`;
|
||||
// doc / прочие контейнеры — просто содержимое.
|
||||
return (node.content ?? []).map((c) => renderNode(c, ctx)).join('');
|
||||
}
|
||||
|
||||
// ========== Block rendering ==========
|
||||
|
||||
function renderHeading(level: 1 | 2 | 3, text: RichText, ctx: RenderContext): string {
|
||||
return `<h${level} class="b-heading">${renderRichText(text, ctx) || ''}</h${level}>`;
|
||||
}
|
||||
|
||||
function renderParty(role: 'executor' | 'customer', clientLike: { kind?: string } | RenderOrganization, ctx: RenderContext): string {
|
||||
const isOrg = !('kind' in clientLike);
|
||||
const o = clientLike as RenderOrganization;
|
||||
const c = clientLike as RenderClient;
|
||||
const title = role === 'executor' ? 'Исполнитель' : 'Заказчик';
|
||||
const fields: [string, string | null | undefined][] = isOrg
|
||||
? [
|
||||
['', o.name],
|
||||
['ИНН', o.inn],
|
||||
['КПП', o.kpp],
|
||||
['ОГРН', o.ogrn],
|
||||
['Адрес', o.legalAddress],
|
||||
['Банк', o.bankName],
|
||||
['БИК', o.bankBik],
|
||||
['Р/с', o.bankAccount],
|
||||
]
|
||||
: [
|
||||
['', c.name],
|
||||
['ИНН', c.inn],
|
||||
['КПП', c.kpp],
|
||||
['Адрес', c.address],
|
||||
['Email', c.email],
|
||||
['Телефон', c.phone],
|
||||
];
|
||||
|
||||
const rows = fields
|
||||
.filter((f) => f[1])
|
||||
.map(([label, value]) =>
|
||||
label
|
||||
? `<tr><td class="party-k">${esc(label)}</td><td class="party-v">${esc(value!)}</td></tr>`
|
||||
: `<tr><td class="party-k"></td><td class="party-v"><b>${esc(value!)}</b></td></tr>`,
|
||||
)
|
||||
.join('');
|
||||
|
||||
return `
|
||||
<div class="b-party">
|
||||
<div class="party-title">${title}:</div>
|
||||
<table class="party-table">${rows}</table>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderServicesTable(blockLines: { lineId: string }[], ctx: RenderContext, columns: string[]): string {
|
||||
const wantedIds = new Set(blockLines.map((bl) => bl.lineId));
|
||||
const rows = ctx.lines
|
||||
.filter((l) => wantedIds.has(l.id) || blockLines.length === 0)
|
||||
.sort((a, b) => a.position - b.position);
|
||||
|
||||
const colDefs: Record<string, { th: string; th_class?: string; render: (l: RenderLine, idx: number) => string }> = {
|
||||
name: { th: 'Наименование', render: (l) => esc(l.name) },
|
||||
qty: { th: 'Кол-во', th_class: 'num', render: (l) => formatQty(l.qtyMilli) },
|
||||
unit: { th: 'Ед.', render: (l) => esc(l.unit) },
|
||||
price: { th: 'Цена', th_class: 'num', render: (l) => formatRub(l.priceCents) },
|
||||
vat: { th: 'НДС', render: (l) => VAT_LABEL[l.vat] },
|
||||
sum: { th: 'Сумма', th_class: 'num', render: (l) => formatRub(l.sumCents) },
|
||||
};
|
||||
|
||||
const cols = columns.length ? columns : ['name', 'qty', 'unit', 'price', 'vat', 'sum'];
|
||||
|
||||
const head = `<tr>${cols.map((c) => `<th class="${colDefs[c]?.th_class ?? ''}">${esc(colDefs[c]?.th ?? c)}</th>`).join('')}</tr>`;
|
||||
const body = rows
|
||||
.map((l, idx) =>
|
||||
`<tr>${cols
|
||||
.map((c) => `<td class="${colDefs[c]?.th_class ?? ''}">${colDefs[c]?.render(l, idx) ?? ''}</td>`)
|
||||
.join('')}</tr>`,
|
||||
)
|
||||
.join('');
|
||||
|
||||
return `<table class="b-services">${head}${body}</table>`;
|
||||
}
|
||||
|
||||
function renderTotals(showVat: boolean, showInWords: boolean, ctx: RenderContext, rubInWordsFn?: (cents: number) => string): string {
|
||||
const total = ctx.doc.totalCents;
|
||||
const vat = ctx.doc.vatCents;
|
||||
const rows: string[] = [];
|
||||
rows.push(`<tr><td class="totals-k">Итого:</td><td class="totals-v"><b>${formatRub(total)}</b></td></tr>`);
|
||||
if (showVat) {
|
||||
rows.push(
|
||||
`<tr><td class="totals-k">в т.ч. НДС:</td><td class="totals-v">${vat > 0 ? formatRub(vat) : 'Без НДС'}</td></tr>`,
|
||||
);
|
||||
}
|
||||
if (showInWords && rubInWordsFn) {
|
||||
rows.push(`<tr><td class="totals-k">Прописью:</td><td class="totals-v">${esc(rubInWordsFn(total))}</td></tr>`);
|
||||
}
|
||||
return `<table class="b-totals">${rows.join('')}</table>`;
|
||||
}
|
||||
|
||||
function renderSignatures(sides: ('executor' | 'customer')[], ctx: RenderContext): string {
|
||||
const cells = sides.map((side) => {
|
||||
const title = side === 'executor' ? 'Исполнитель' : 'Заказчик';
|
||||
let line2 = '';
|
||||
if (side === 'executor') {
|
||||
const pos = ctx.organization.signatoryPosition ?? '';
|
||||
const fio = ctx.organization.signatoryName ?? '';
|
||||
line2 = `${esc(pos)} ${esc(fio)}`;
|
||||
} else if (ctx.client) {
|
||||
line2 = esc(ctx.client.name);
|
||||
}
|
||||
return `
|
||||
<div class="sig-cell">
|
||||
<div class="sig-title">${title}</div>
|
||||
<div class="sig-line">_____________________ / ${line2} /</div>
|
||||
<div class="sig-stamp">М.П.</div>
|
||||
</div>`;
|
||||
});
|
||||
return `<div class="b-signatures">${cells.join('')}</div>`;
|
||||
}
|
||||
|
||||
function renderBlock(block: Block, ctx: RenderContext, rubInWordsFn?: (cents: number) => string): string {
|
||||
switch (block.type) {
|
||||
case 'heading':
|
||||
return renderHeading(block.level, block.text as RichText, ctx);
|
||||
case 'paragraph':
|
||||
return `<p class="b-paragraph">${renderRichText(block.text as RichText, ctx)}</p>`;
|
||||
case 'party': {
|
||||
if (block.bind.kind === 'self') return renderParty(block.role, ctx.organization, ctx);
|
||||
if (ctx.client) return renderParty(block.role, ctx.client, ctx);
|
||||
return `<div class="b-party"><i>Сторона не указана</i></div>`;
|
||||
}
|
||||
case 'services_table':
|
||||
return renderServicesTable(block.lines, ctx, block.columns);
|
||||
case 'totals':
|
||||
return renderTotals(block.showVat, block.showInWords, ctx, rubInWordsFn);
|
||||
case 'terms':
|
||||
return `<div class="b-terms">${renderRichText(block.text as RichText, ctx)}</div>`;
|
||||
case 'signatures':
|
||||
return renderSignatures(block.sides, ctx);
|
||||
case 'custom_text':
|
||||
return `<div class="b-custom">${renderRichText(block.text as RichText, ctx)}</div>`;
|
||||
case 'page_break':
|
||||
return `<div class="b-page-break"></div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function formatRub(cents: number): string {
|
||||
return (cents / 100).toLocaleString('ru-RU', {
|
||||
style: 'currency',
|
||||
currency: 'RUB',
|
||||
minimumFractionDigits: 2,
|
||||
});
|
||||
}
|
||||
function formatQty(qtyMilli: number): string {
|
||||
const n = qtyMilli / 1000;
|
||||
return n % 1 === 0 ? String(n) : n.toFixed(3).replace(/\.?0+$/, '');
|
||||
}
|
||||
|
||||
export function renderDocumentHtml(
|
||||
body: DocBody,
|
||||
ctx: RenderContext,
|
||||
opts: {
|
||||
title?: string;
|
||||
rubInWords?: (cents: number) => string;
|
||||
} = {},
|
||||
): string {
|
||||
const blocksHtml = body.blocks.map((b) => renderBlock(b, ctx, opts.rubInWords)).join('\n');
|
||||
const title = opts.title ?? `Документ ${ctx.doc.number}`;
|
||||
|
||||
return `<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>${esc(title)}</title>
|
||||
<style>
|
||||
@page { size: A4; margin: 18mm 16mm; }
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
font-family: 'PT Sans', 'DejaVu Sans', system-ui, sans-serif;
|
||||
font-size: 11pt; line-height: 1.4; color: #111; margin: 0;
|
||||
}
|
||||
h1.b-heading { font-size: 16pt; margin: 12px 0 6px; }
|
||||
h2.b-heading { font-size: 13pt; margin: 10px 0 4px; }
|
||||
h3.b-heading { font-size: 11.5pt; margin: 8px 0 4px; }
|
||||
p.b-paragraph { margin: 4px 0 8px; text-align: justify; }
|
||||
.b-party { margin: 8px 0 12px; page-break-inside: avoid; }
|
||||
.party-title { font-weight: bold; margin-bottom: 2px; }
|
||||
table.party-table { border-collapse: collapse; }
|
||||
.party-table .party-k { padding: 1px 8px 1px 0; opacity: 0.7; vertical-align: top; white-space: nowrap; }
|
||||
.party-table .party-v { padding: 1px 0; vertical-align: top; }
|
||||
table.b-services {
|
||||
width: 100%; border-collapse: collapse; margin: 8px 0 12px;
|
||||
page-break-inside: auto;
|
||||
}
|
||||
table.b-services th, table.b-services td {
|
||||
border: 1px solid #888; padding: 4px 8px; text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
table.b-services th { background: #f4f4f4; font-weight: 600; }
|
||||
table.b-services .num { text-align: right; }
|
||||
table.b-totals { margin-left: auto; margin-right: 0; margin-top: 8px; }
|
||||
.totals-k { padding: 2px 12px 2px 0; opacity: 0.7; text-align: right; }
|
||||
.totals-v { padding: 2px 0; text-align: right; }
|
||||
.b-terms { margin: 8px 0; text-align: justify; }
|
||||
.b-custom { margin: 8px 0; }
|
||||
.b-signatures {
|
||||
display: flex; gap: 32px; margin-top: 32px; page-break-inside: avoid;
|
||||
}
|
||||
.sig-cell { flex: 1; }
|
||||
.sig-title { font-weight: 600; margin-bottom: 28px; }
|
||||
.sig-line { font-size: 10pt; }
|
||||
.sig-stamp { margin-top: 16px; font-size: 9pt; opacity: 0.6; }
|
||||
.b-page-break { page-break-before: always; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
${blocksHtml}
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
Reference in New Issue
Block a user