feat: Projects + stronger LLM placeholder substitution

Backend:
- Project model with defaults: defaultClient, defaultTemplate, defaultBankAccount
- Document gets optional projectId (FK + index)
- Migration 2_projects: enum ProjectStatus + Project table + Document.projectId
- API: /api/projects CRUD, GET /api/projects/:id/documents
- documents/routes filter by projectId

LLM prompt:
- Concrete preamble example: «ООО «...», в лице директора ... , ИП ..., ОГРНИП ...»
  → {{customer.name}}, {{customer.signatoryPosition}} {{customer.signatoryName}},
    {{executor.name}}, {{executor.ogrn}}
- Expanded placeholder list (signatoryName/Position для customer, ogrn etc)
- Side-role detection: «Заказчик» vs «Исполнитель» map to customer/executor

Frontend:
- /projects (list) and /projects/:id (defaults form + documents list under project)
- Nav: «Проекты» first
- DocumentEdit reads projectId from URL, presets default client from project,
  saves projectId with the document

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
admin
2026-05-01 13:17:07 +03:00
parent 973d85c1dd
commit b2c221e643
11 changed files with 661 additions and 17 deletions
@@ -0,0 +1,37 @@
-- CreateEnum
CREATE TYPE "ProjectStatus" AS ENUM ('active', 'completed', 'cancelled');
-- CreateTable
CREATE TABLE "Project" (
"id" UUID NOT NULL,
"organizationId" UUID NOT NULL,
"name" TEXT NOT NULL,
"status" "ProjectStatus" NOT NULL DEFAULT 'active',
"defaultClientId" UUID,
"defaultTemplateId" UUID,
"defaultBankAccountId" UUID,
"notes" TEXT,
"archivedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Project_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "Project_organizationId_archivedAt_idx" ON "Project"("organizationId", "archivedAt");
CREATE INDEX "Project_organizationId_status_idx" ON "Project"("organizationId", "status");
ALTER TABLE "Project" ADD CONSTRAINT "Project_organizationId_fkey"
FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "Project" ADD CONSTRAINT "Project_defaultClientId_fkey"
FOREIGN KEY ("defaultClientId") REFERENCES "Client"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "Project" ADD CONSTRAINT "Project_defaultTemplateId_fkey"
FOREIGN KEY ("defaultTemplateId") REFERENCES "DocumentTemplate"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "Project" ADD CONSTRAINT "Project_defaultBankAccountId_fkey"
FOREIGN KEY ("defaultBankAccountId") REFERENCES "BankAccount"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AlterTable: добавить projectId на Document
ALTER TABLE "Document" ADD COLUMN "projectId" UUID;
CREATE INDEX "Document_organizationId_projectId_idx" ON "Document"("organizationId", "projectId");
ALTER TABLE "Document" ADD CONSTRAINT "Document_projectId_fkey"
FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+38
View File
@@ -55,6 +55,12 @@ enum PaymentKind {
outgoing outgoing
} }
enum ProjectStatus {
active
completed
cancelled
}
model Organization { model Organization {
id String @id @default(uuid()) @db.Uuid id String @id @default(uuid()) @db.Uuid
name String name String
@@ -82,6 +88,31 @@ model Organization {
tochkaCredentials TochkaCredential[] tochkaCredentials TochkaCredential[]
auditLog AuditLog[] auditLog AuditLog[]
bankAccounts BankAccount[] bankAccounts BankAccount[]
projects Project[]
}
model Project {
id String @id @default(uuid()) @db.Uuid
organizationId String @db.Uuid
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
name String
status ProjectStatus @default(active)
// Дефолты — подставляются при создании документов внутри проекта
defaultClientId String? @db.Uuid
defaultClient Client? @relation(fields: [defaultClientId], references: [id])
defaultTemplateId String? @db.Uuid
defaultTemplate DocumentTemplate? @relation(fields: [defaultTemplateId], references: [id])
defaultBankAccountId String? @db.Uuid
defaultBankAccount BankAccount? @relation(fields: [defaultBankAccountId], references: [id])
notes String?
archivedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
documents Document[]
@@index([organizationId, archivedAt])
@@index([organizationId, status])
} }
model BankAccount { model BankAccount {
@@ -100,6 +131,7 @@ model BankAccount {
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
tochkaCredentials TochkaCredential[] tochkaCredentials TochkaCredential[]
defaultForProjects Project[]
@@index([organizationId]) @@index([organizationId])
} }
@@ -121,6 +153,7 @@ model Client {
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
documents Document[] documents Document[]
defaultForProjects Project[]
@@index([organizationId]) @@index([organizationId])
@@index([organizationId, name]) @@index([organizationId, name])
@@ -155,6 +188,8 @@ model DocumentTemplate {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
defaultForProjects Project[]
@@index([organizationId, docType]) @@index([organizationId, docType])
} }
@@ -162,6 +197,8 @@ model Document {
id String @id @default(uuid()) @db.Uuid id String @id @default(uuid()) @db.Uuid
organizationId String @db.Uuid organizationId String @db.Uuid
organization Organization @relation(fields: [organizationId], references: [id]) organization Organization @relation(fields: [organizationId], references: [id])
projectId String? @db.Uuid
project Project? @relation(fields: [projectId], references: [id])
docType DocType docType DocType
number String number String
issuedAt DateTime? issuedAt DateTime?
@@ -188,6 +225,7 @@ model Document {
@@unique([organizationId, docType, number]) @@unique([organizationId, docType, number])
@@index([organizationId, clientId, issuedAt(sort: Desc)]) @@index([organizationId, clientId, issuedAt(sort: Desc)])
@@index([organizationId, status]) @@index([organizationId, status])
@@index([organizationId, projectId])
@@index([tochkaDocumentId]) @@index([tochkaDocumentId])
} }
+9 -3
View File
@@ -25,6 +25,7 @@ const LineInput = z.object({
const DocumentCreate = z.object({ const DocumentCreate = z.object({
docType: z.enum(DOC_TYPES), docType: z.enum(DOC_TYPES),
clientId: z.string().uuid().nullable(), clientId: z.string().uuid().nullable(),
projectId: z.string().uuid().nullable(),
parentDocumentId: z.string().uuid().nullable(), parentDocumentId: z.string().uuid().nullable(),
body: DocBody, body: DocBody,
lines: z.array(LineInput).default([]), lines: z.array(LineInput).default([]),
@@ -35,6 +36,7 @@ const DocumentCreate = z.object({
const DocumentUpdate = z.object({ const DocumentUpdate = z.object({
clientId: z.string().uuid().nullable(), clientId: z.string().uuid().nullable(),
projectId: z.string().uuid().nullable(),
body: DocBody, body: DocBody,
lines: z.array(LineInput).default([]), lines: z.array(LineInput).default([]),
number: z.string().min(1).max(100), number: z.string().min(1).max(100),
@@ -48,6 +50,7 @@ const StatusChange = z.object({
const ListQuery = z.object({ const ListQuery = z.object({
docType: z.enum(DOC_TYPES).optional(), docType: z.enum(DOC_TYPES).optional(),
clientId: z.string().uuid().optional(), clientId: z.string().uuid().optional(),
projectId: z.string().uuid().optional(),
status: z.enum(DOC_STATUSES).optional(), status: z.enum(DOC_STATUSES).optional(),
q: z.string().optional(), q: z.string().optional(),
limit: z.coerce.number().int().min(1).max(500).default(100), limit: z.coerce.number().int().min(1).max(500).default(100),
@@ -71,12 +74,13 @@ export async function documentsRoutes(app: FastifyInstance) {
reply.code(400).send({ error: 'validation_error', issues: parsed.error.flatten() }); reply.code(400).send({ error: 'validation_error', issues: parsed.error.flatten() });
return; return;
} }
const { docType, clientId, status, q, limit } = parsed.data; const { docType, clientId, projectId, status, q, limit } = parsed.data;
const docs = await prisma.document.findMany({ const docs = await prisma.document.findMany({
where: { where: {
organizationId: orgId, organizationId: orgId,
...(docType ? { docType } : {}), ...(docType ? { docType } : {}),
...(clientId ? { clientId } : {}), ...(clientId ? { clientId } : {}),
...(projectId ? { projectId } : {}),
...(status ? { status } : {}), ...(status ? { status } : {}),
...(q ? { OR: [{ number: { contains: q, mode: 'insensitive' } }, { client: { name: { contains: q, mode: 'insensitive' } } }] } : {}), ...(q ? { OR: [{ number: { contains: q, mode: 'insensitive' } }, { client: { name: { contains: q, mode: 'insensitive' } } }] } : {}),
}, },
@@ -149,7 +153,7 @@ export async function documentsRoutes(app: FastifyInstance) {
reply.code(400).send({ error: 'validation_error', issues: parsed.error.flatten() }); reply.code(400).send({ error: 'validation_error', issues: parsed.error.flatten() });
return; return;
} }
const { docType, clientId, parentDocumentId, body, lines, number, issuedAt, currency } = parsed.data; const { docType, clientId, projectId, parentDocumentId, body, lines, number, issuedAt, currency } = parsed.data;
const calcLines = lines.map(lineCalc); const calcLines = lines.map(lineCalc);
const { totalCents, vatCents } = totals(calcLines); const { totalCents, vatCents } = totals(calcLines);
const sub = (req.user!.sub as string) || null; const sub = (req.user!.sub as string) || null;
@@ -165,6 +169,7 @@ export async function documentsRoutes(app: FastifyInstance) {
number: num, number: num,
issuedAt: issuedAt ? new Date(issuedAt) : null, issuedAt: issuedAt ? new Date(issuedAt) : null,
clientId, clientId,
projectId,
parentDocumentId, parentDocumentId,
body: body as Prisma.InputJsonValue, body: body as Prisma.InputJsonValue,
totalCents, totalCents,
@@ -213,7 +218,7 @@ export async function documentsRoutes(app: FastifyInstance) {
reply.code(409).send({ error: 'locked_by_bank', message: 'Документ выставлен через банк, редактировать нельзя.' }); reply.code(409).send({ error: 'locked_by_bank', message: 'Документ выставлен через банк, редактировать нельзя.' });
return; return;
} }
const { clientId, body, lines, number, issuedAt } = parsed.data; const { clientId, projectId, body, lines, number, issuedAt } = parsed.data;
const { totalCents, vatCents } = totals(lines.map(lineCalc)); const { totalCents, vatCents } = totals(lines.map(lineCalc));
const updated = await prisma.$transaction(async (tx) => { const updated = await prisma.$transaction(async (tx) => {
@@ -224,6 +229,7 @@ export async function documentsRoutes(app: FastifyInstance) {
data: { data: {
number, number,
clientId, clientId,
projectId,
issuedAt: issuedAt ? new Date(issuedAt) : null, issuedAt: issuedAt ? new Date(issuedAt) : null,
body: body as Prisma.InputJsonValue, body: body as Prisma.InputJsonValue,
totalCents, totalCents,
+133
View File
@@ -0,0 +1,133 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { prisma } from '../../db.js';
import { getOrganizationId } from '../../lib/org.js';
import { optionalText } from '../../lib/zod-utils.js';
const STATUSES = ['active', 'completed', 'cancelled'] as const;
const ProjectUpsert = z.object({
name: z.string().min(1).max(500),
status: z.enum(STATUSES).default('active'),
defaultClientId: z.string().uuid().nullable(),
defaultTemplateId: z.string().uuid().nullable(),
defaultBankAccountId: z.string().uuid().nullable(),
notes: optionalText(2000),
});
const ListQuery = z.object({
status: z.enum(STATUSES).optional(),
q: z.string().optional(),
includeArchived: z.coerce.boolean().default(false),
limit: z.coerce.number().int().min(1).max(500).default(200),
});
export async function projectsRoutes(app: FastifyInstance) {
// ---- list ----
app.get('/api/projects', { 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 { status, q, includeArchived, limit } = parsed.data;
const items = await prisma.project.findMany({
where: {
organizationId: orgId,
...(includeArchived ? {} : { archivedAt: null }),
...(status ? { status } : {}),
...(q ? { name: { contains: q, mode: 'insensitive' as const } } : {}),
},
include: {
defaultClient: { select: { id: true, name: true, kind: true } },
defaultTemplate: { select: { id: true, name: true, docType: true } },
defaultBankAccount: { select: { id: true, name: true } },
_count: { select: { documents: true } },
},
orderBy: [{ status: 'asc' }, { updatedAt: 'desc' }],
take: limit,
});
return { items };
});
// ---- get one ----
app.get('/api/projects/:id', { preHandler: app.requireDocPermission('viewer') }, async (req, reply) => {
const orgId = getOrganizationId(req);
const { id } = req.params as { id: string };
const project = await prisma.project.findFirst({
where: { id, organizationId: orgId },
include: {
defaultClient: true,
defaultTemplate: true,
defaultBankAccount: true,
},
});
if (!project) {
reply.code(404).send({ error: 'not_found' });
return;
}
return project;
});
// ---- documents under project ----
app.get('/api/projects/:id/documents', { preHandler: app.requireDocPermission('viewer') }, async (req, reply) => {
const orgId = getOrganizationId(req);
const { id } = req.params as { id: string };
const project = await prisma.project.findFirst({ where: { id, organizationId: orgId } });
if (!project) {
reply.code(404).send({ error: 'not_found' });
return;
}
const items = await prisma.document.findMany({
where: { projectId: id, organizationId: orgId },
include: { client: { select: { id: true, name: true, kind: true } } },
orderBy: [{ issuedAt: { sort: 'desc', nulls: 'last' } }, { createdAt: 'desc' }],
});
return { items };
});
// ---- create ----
app.post('/api/projects', { preHandler: app.requireDocPermission('user') }, async (req, reply) => {
const orgId = getOrganizationId(req);
const parsed = ProjectUpsert.safeParse(req.body);
if (!parsed.success) {
reply.code(400).send({ error: 'validation_error', issues: parsed.error.flatten() });
return;
}
const created = await prisma.project.create({
data: { ...parsed.data, organizationId: orgId },
});
reply.code(201).send(created);
});
// ---- update ----
app.put('/api/projects/:id', { preHandler: app.requireDocPermission('user') }, async (req, reply) => {
const orgId = getOrganizationId(req);
const { id } = req.params as { id: string };
const parsed = ProjectUpsert.safeParse(req.body);
if (!parsed.success) {
reply.code(400).send({ error: 'validation_error', issues: parsed.error.flatten() });
return;
}
const existing = await prisma.project.findFirst({ where: { id, organizationId: orgId } });
if (!existing) {
reply.code(404).send({ error: 'not_found' });
return;
}
return prisma.project.update({ where: { id }, data: parsed.data });
});
// ---- archive ----
app.delete('/api/projects/:id', { preHandler: app.requireDocPermission('user') }, async (req, reply) => {
const orgId = getOrganizationId(req);
const { id } = req.params as { id: string };
const existing = await prisma.project.findFirst({ where: { id, organizationId: orgId } });
if (!existing) {
reply.code(404).send({ error: 'not_found' });
return;
}
await prisma.project.update({ where: { id }, data: { archivedAt: new Date() } });
reply.code(204).send();
});
}
+22 -11
View File
@@ -24,19 +24,30 @@ type LlmResult = {
const SYSTEM_PROMPT = `Ты — парсер юридических документов на русском языке. Получаешь plain-text договора, счёта, акта или УПД и возвращаешь его структуру строго в JSON формате DocBody. const SYSTEM_PROMPT = `Ты — парсер юридических документов на русском языке. Получаешь plain-text договора, счёта, акта или УПД и возвращаешь его структуру строго в JSON формате DocBody.
Цель: создать ШАБЛОН, в котором конкретные данные сторон, номера, даты заменены на плейсхолдеры. Шаблон потом будет инстансироваться для конкретного клиента и документа. Цель: создать ШАБЛОН — переиспользуемый каркас, в котором ВСЕ конкретные значения сторон/номера/даты заменены на плейсхолдеры. Шаблон потом инстансируется автоматически: при создании документа конкретные поля придут из карточек клиента, нашей компании, проекта.
Правила замены на плейсхолдеры (заменяй ТОЛЬКО эти сущности, остальной текст оставь как есть): КРИТИЧНО: тщательно ищи и заменяй ВСЕ конкретные имена, номера и реквизиты в любом тексте, включая преамбулу.
- Конкретный номер договора/счёта → {{contract.number}}
- Конкретная дата документа → {{contract.date}} Полный список плейсхолдеров:
- Текущая дата (на момент рендера) → {{today}} - {{contract.number}} — номер документа
- Реквизиты исполнителя (наша компания): - {{contract.date}} — дата документа
{{executor.name}}, {{executor.inn}}, {{executor.kpp}}, {{executor.ogrn}}, {{executor.legalAddress}}, - {{today}} — сегодняшняя дата (для актов/УПД)
{{executor.signatoryName}}, {{executor.signatoryPosition}}, - Наша компания (Исполнитель):
{{executor.name}}, {{executor.shortName}}, {{executor.inn}}, {{executor.kpp}}, {{executor.ogrn}},
{{executor.legalAddress}}, {{executor.signatoryName}}, {{executor.signatoryPosition}},
{{executor.bankName}}, {{executor.bankBik}}, {{executor.bankAccount}} {{executor.bankName}}, {{executor.bankBik}}, {{executor.bankAccount}}
- Реквизиты заказчика (клиента): - Клиент (Заказчик):
{{customer.name}}, {{customer.inn}}, {{customer.kpp}}, {{customer.address}}, {{customer.name}}, {{customer.inn}}, {{customer.kpp}}, {{customer.ogrn}}, {{customer.address}},
{{customer.email}}, {{customer.phone}}, {{customer.contactPerson}} {{customer.email}}, {{customer.phone}}, {{customer.contactPerson}},
{{customer.signatoryName}}, {{customer.signatoryPosition}}
ПРИМЕР замены преамбулы:
ВХОД: «ООО «Джет Сервис», именуемое в дальнейшем "Заказчик", в лице генерального директора Ерохиной Екатерины Викторовны, действующего на основании Устава, с одной стороны, и ИП Морозов Владимир Владимирович, именуемый в дальнейшем "Исполнитель", действующий на основании регистрации в Едином государственном реестре индивидуальных предпринимателей (ОГРНИП 324665800184321), с другой стороны, совместно именуемые "Стороны", заключили настоящий договор о нижеследующем:»
ВЫХОД (text внутри блока paragraph):
«{{customer.name}}, именуемое в дальнейшем "Заказчик", в лице {{customer.signatoryPosition}} {{customer.signatoryName}}, действующего на основании Устава, с одной стороны, и {{executor.name}}, именуемый в дальнейшем "Исполнитель", действующий на основании регистрации в Едином государственном реестре индивидуальных предпринимателей (ОГРНИП {{executor.ogrn}}), с другой стороны, совместно именуемые "Стороны", заключили настоящий договор о нижеследующем:»
Тип сторон: понимай по контексту — кто Заказчик / Customer, кто Исполнитель / Executor / Подрядчик. Их роль определяет, какой плейсхолдер ({{customer.*}} или {{executor.*}}) подставлять. По умолчанию НАША компания = Исполнитель.
Структура ответа (JSON-объект): Структура ответа (JSON-объект):
{ {
+2
View File
@@ -16,6 +16,7 @@ import { documentsRoutes } from './modules/documents/routes.js';
import { documentsPdfRoutes } from './modules/documents/pdf.routes.js'; import { documentsPdfRoutes } from './modules/documents/pdf.routes.js';
import { templatesRoutes } from './modules/templates/routes.js'; import { templatesRoutes } from './modules/templates/routes.js';
import { templatesImportRoutes } from './modules/templates/import.routes.js'; import { templatesImportRoutes } from './modules/templates/import.routes.js';
import { projectsRoutes } from './modules/projects/routes.js';
import { dadataRoutes } from './modules/dadata/routes.js'; import { dadataRoutes } from './modules/dadata/routes.js';
import { shutdownBrowser } from './modules/documents/pdf.js'; import { shutdownBrowser } from './modules/documents/pdf.js';
import activeOrgPlugin from './plugins/activeOrg.js'; import activeOrgPlugin from './plugins/activeOrg.js';
@@ -54,6 +55,7 @@ async function main() {
await app.register(documentsPdfRoutes); await app.register(documentsPdfRoutes);
await app.register(templatesRoutes); await app.register(templatesRoutes);
await app.register(templatesImportRoutes); await app.register(templatesImportRoutes);
await app.register(projectsRoutes);
await app.register(dadataRoutes); await app.register(dadataRoutes);
app.addHook('onClose', async () => { app.addHook('onClose', async () => {
+5
View File
@@ -9,6 +9,8 @@ import { DocumentsPage } from './pages/Documents.js';
import { DocumentEditPage } from './pages/DocumentEdit.js'; import { DocumentEditPage } from './pages/DocumentEdit.js';
import { TemplatesPage } from './pages/Templates.js'; import { TemplatesPage } from './pages/Templates.js';
import { TemplateEditPage } from './pages/TemplateEdit.js'; import { TemplateEditPage } from './pages/TemplateEdit.js';
import { ProjectsPage } from './pages/Projects.js';
import { ProjectEditPage } from './pages/ProjectEdit.js';
import { OrgSwitcher } from './components/OrgSwitcher.js'; import { OrgSwitcher } from './components/OrgSwitcher.js';
function Layout({ email }: { email: string }) { function Layout({ email }: { email: string }) {
@@ -16,6 +18,7 @@ function Layout({ email }: { email: string }) {
<header className="topbar"> <header className="topbar">
<h1>Doc_manager</h1> <h1>Doc_manager</h1>
<nav> <nav>
<Link to="/projects">Проекты</Link>
<Link to="/">Документы</Link> <Link to="/">Документы</Link>
<Link to="/clients">Клиенты</Link> <Link to="/clients">Клиенты</Link>
<Link to="/services">Услуги</Link> <Link to="/services">Услуги</Link>
@@ -75,6 +78,8 @@ export function App() {
<Route path="/templates/:id" element={<TemplateEditPage />} /> <Route path="/templates/:id" element={<TemplateEditPage />} />
<Route path="/companies" element={<CompaniesPage />} /> <Route path="/companies" element={<CompaniesPage />} />
<Route path="/companies/:id" element={<CompanyEditPage />} /> <Route path="/companies/:id" element={<CompanyEditPage />} />
<Route path="/projects" element={<ProjectsPage />} />
<Route path="/projects/:id" element={<ProjectEditPage />} />
<Route path="*" element={<Placeholder title="Не найдено" />} /> <Route path="*" element={<Placeholder title="Не найдено" />} />
</Routes> </Routes>
</> </>
+26
View File
@@ -258,6 +258,32 @@ export type LineHistoryItem = {
useCount: number; useCount: number;
}; };
export type ProjectStatus = 'active' | 'completed' | 'cancelled';
export type ProjectSummary = {
id: string;
organizationId: string;
name: string;
status: ProjectStatus;
defaultClientId: string | null;
defaultClient: { id: string; name: string; kind: Client['kind'] } | null;
defaultTemplateId: string | null;
defaultTemplate: { id: string; name: string; docType: DocType } | null;
defaultBankAccountId: string | null;
defaultBankAccount: { id: string; name: string } | null;
notes: string | null;
archivedAt: string | null;
createdAt: string;
updatedAt: string;
_count?: { documents: number };
};
export type Project = ProjectSummary & {
defaultClient: Client | null;
defaultTemplate: DocumentTemplate | null;
defaultBankAccount: BankAccount | null;
};
export type DadataParty = { export type DadataParty = {
inn: string; inn: string;
kpp: string | null; kpp: string | null;
+12 -3
View File
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom'; 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 { api, ApiError, type Block, type DocBody, type DocStatus, type DocType, type Document, type DocumentTemplate, type Project } from '../api.js';
import { BlocksEditor } from '../components/BlocksEditor.js'; import { BlocksEditor } from '../components/BlocksEditor.js';
import { ClientPicker } from '../components/ClientPicker.js'; import { ClientPicker } from '../components/ClientPicker.js';
import { LinesEditor, type LineDraft } from '../components/LinesEditor.js'; import { LinesEditor, type LineDraft } from '../components/LinesEditor.js';
@@ -80,12 +80,14 @@ export function DocumentEditPage() {
const initialDocType = (searchParams.get('docType') as DocType) ?? 'contract'; const initialDocType = (searchParams.get('docType') as DocType) ?? 'contract';
const fromTemplateId = searchParams.get('fromTemplate'); const fromTemplateId = searchParams.get('fromTemplate');
const projectIdParam = searchParams.get('projectId');
const [docType] = useState<DocType>(initialDocType); const [docType] = useState<DocType>(initialDocType);
const [number, setNumber] = useState<string>(''); const [number, setNumber] = useState<string>('');
const [issuedAt, setIssuedAt] = useState<string>(new Date().toISOString().slice(0, 10)); const [issuedAt, setIssuedAt] = useState<string>(new Date().toISOString().slice(0, 10));
const [status, setStatus] = useState<DocStatus>('draft'); const [status, setStatus] = useState<DocStatus>('draft');
const [clientId, setClientId] = useState<string | null>(null); const [clientId, setClientId] = useState<string | null>(null);
const [projectId, setProjectId] = useState<string | null>(projectIdParam);
const [body, setBody] = useState<DocBody | null>(null); const [body, setBody] = useState<DocBody | null>(null);
const [lines, setLines] = useState<LineDraft[]>([]); const [lines, setLines] = useState<LineDraft[]>([]);
const [tochkaLocked, setTochkaLocked] = useState(false); const [tochkaLocked, setTochkaLocked] = useState(false);
@@ -98,7 +100,7 @@ export function DocumentEditPage() {
// Загрузить существующий документ // Загрузить существующий документ
useEffect(() => { useEffect(() => {
if (isNew) { if (isNew) {
// Шаблон или новый пустой // Шаблон или новый пустой; преселект клиента если пришёл projectId
if (fromTemplateId) { if (fromTemplateId) {
api.get<DocumentTemplate>(`/api/templates/${fromTemplateId}`).then((tpl) => { api.get<DocumentTemplate>(`/api/templates/${fromTemplateId}`).then((tpl) => {
setBody(tpl.body); setBody(tpl.body);
@@ -106,6 +108,11 @@ export function DocumentEditPage() {
} else { } else {
setBody(defaultBody(initialDocType)); setBody(defaultBody(initialDocType));
} }
if (projectIdParam) {
api.get<Project>(`/api/projects/${projectIdParam}`).then((p) => {
if (p.defaultClientId) setClientId(p.defaultClientId);
}).catch(() => {});
}
return; return;
} }
api.get<Document>(`/api/documents/${id}`).then((d) => { api.get<Document>(`/api/documents/${id}`).then((d) => {
@@ -113,12 +120,13 @@ export function DocumentEditPage() {
setIssuedAt(d.issuedAt ? d.issuedAt.slice(0, 10) : ''); setIssuedAt(d.issuedAt ? d.issuedAt.slice(0, 10) : '');
setStatus(d.status); setStatus(d.status);
setClientId(d.clientId); setClientId(d.clientId);
setProjectId((d as Document & { projectId?: string | null }).projectId ?? null);
setBody(d.body); setBody(d.body);
setLines(d.lines); setLines(d.lines);
setTochkaLocked(!!d.tochkaDocumentId); setTochkaLocked(!!d.tochkaDocumentId);
setSavedId(d.id); setSavedId(d.id);
}).catch((e) => setError(String(e))); }).catch((e) => setError(String(e)));
}, [id, isNew, fromTemplateId, initialDocType]); }, [id, isNew, fromTemplateId, initialDocType, projectIdParam]);
const totalCents = useMemo(() => lines.reduce((s, l) => s + l.sumCents, 0), [lines]); const totalCents = useMemo(() => lines.reduce((s, l) => s + l.sumCents, 0), [lines]);
@@ -129,6 +137,7 @@ export function DocumentEditPage() {
try { try {
const payload = { const payload = {
clientId: clientId, clientId: clientId,
projectId: projectId,
body, body,
lines: lines.map((l, i) => ({ lines: lines.map((l, i) => ({
position: i, position: i,
+223
View File
@@ -0,0 +1,223 @@
import { useEffect, useState } from 'react';
import { Link, useNavigate, useParams } from 'react-router-dom';
import {
api,
ApiError,
type BankAccount,
type Client,
type DocumentSummary,
type DocumentTemplate,
type Project,
type ProjectStatus,
} from '../api.js';
import { Button, EmptyState, Field, Select, Textarea, formatRub } from '../components/ui.js';
const STATUS_LABEL: Record<ProjectStatus, string> = {
active: 'Активный',
completed: 'Завершён',
cancelled: 'Отменён',
};
const DOC_TYPE_LABEL: Record<string, string> = {
contract: 'Договор',
invoice: 'Счёт',
act: 'Акт',
upd: 'УПД',
};
export function ProjectEditPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [project, setProject] = useState<Project | null>(null);
const [draft, setDraft] = useState<Partial<Project>>({});
const [docs, setDocs] = useState<DocumentSummary[]>([]);
const [clients, setClients] = useState<Client[]>([]);
const [templates, setTemplates] = useState<DocumentTemplate[]>([]);
const [bankAccounts, setBankAccounts] = useState<BankAccount[]>([]);
const [saving, setSaving] = useState(false);
const [savedAt, setSavedAt] = useState<Date | null>(null);
const [error, setError] = useState<string | null>(null);
const [orgId, setOrgId] = useState<string | null>(null);
useEffect(() => {
if (!id) return;
void Promise.all([
api.get<Project>(`/api/projects/${id}`),
api.get<{ items: DocumentSummary[] }>(`/api/projects/${id}/documents`),
api.get<{ items: Client[] }>('/api/clients?limit=500'),
api.get<{ items: DocumentTemplate[] }>('/api/templates'),
api.get<{ id: string }>('/api/active-organization'),
])
.then(async ([p, ds, cs, ts, ao]) => {
setProject(p);
setDraft(p);
setDocs(ds.items);
setClients(cs.items);
setTemplates(ts.items);
setOrgId(ao.id);
const ba = await api.get<{ items: BankAccount[] }>(`/api/organizations/${ao.id}/bank-accounts`);
setBankAccounts(ba.items);
})
.catch((e) => setError(String(e)));
}, [id]);
async function save() {
if (!id || !draft) return;
setSaving(true);
setError(null);
try {
const updated = await api.put<Project>(`/api/projects/${id}`, {
name: draft.name ?? project?.name ?? '',
status: draft.status ?? project?.status ?? 'active',
defaultClientId: draft.defaultClientId ?? null,
defaultTemplateId: draft.defaultTemplateId ?? null,
defaultBankAccountId: draft.defaultBankAccountId ?? null,
notes: draft.notes ?? null,
});
setProject(updated);
setDraft(updated);
setSavedAt(new Date());
} catch (e) {
setError(e instanceof ApiError ? e.prettyMessage() : String(e));
} finally {
setSaving(false);
}
}
function createDoc(docType: 'contract' | 'invoice' | 'act' | 'upd') {
if (!project) return;
const params = new URLSearchParams({ docType, projectId: project.id });
if (project.defaultTemplateId) params.set('fromTemplate', project.defaultTemplateId);
navigate(`/documents/new?${params.toString()}`);
}
if (error && !project) return <main className="content"><div className="error-text">{error}</div></main>;
if (!project) return <main className="content"><p className="hint">Загрузка</p></main>;
return (
<main className="content">
<header className="page-head">
<h2>{project.name}</h2>
<Button onClick={() => navigate('/projects')}> К проектам</Button>
</header>
<section className="form-grid">
<Field
label="Название"
value={draft.name ?? ''}
onChange={(e) => setDraft((d) => ({ ...d, name: e.target.value }))}
/>
<Select
label="Статус"
value={(draft.status ?? 'active') as ProjectStatus}
onChange={(v) => setDraft((d) => ({ ...d, status: v as ProjectStatus }))}
options={[
{ value: 'active', label: STATUS_LABEL.active },
{ value: 'completed', label: STATUS_LABEL.completed },
{ value: 'cancelled', label: STATUS_LABEL.cancelled },
]}
/>
<label className="field">
<span className="field__label">Клиент по умолчанию</span>
<select
className="field__input"
value={draft.defaultClientId ?? ''}
onChange={(e) => setDraft((d) => ({ ...d, defaultClientId: e.target.value || null }))}
>
<option value=""> не выбран </option>
{clients.map((c) => (
<option key={c.id} value={c.id}>
{c.name}{c.inn ? ` · ИНН ${c.inn}` : ''}
</option>
))}
</select>
</label>
<label className="field">
<span className="field__label">Шаблон по умолчанию</span>
<select
className="field__input"
value={draft.defaultTemplateId ?? ''}
onChange={(e) => setDraft((d) => ({ ...d, defaultTemplateId: e.target.value || null }))}
>
<option value=""> не выбран </option>
{templates.map((t) => (
<option key={t.id} value={t.id}>
{DOC_TYPE_LABEL[t.docType]}: {t.name}
</option>
))}
</select>
</label>
<label className="field">
<span className="field__label">Банк-счёт для оплаты</span>
<select
className="field__input"
value={draft.defaultBankAccountId ?? ''}
onChange={(e) => setDraft((d) => ({ ...d, defaultBankAccountId: e.target.value || null }))}
>
<option value=""> не выбран </option>
{bankAccounts.map((b) => (
<option key={b.id} value={b.id}>
{b.name}{b.bankName ? ` · ${b.bankName}` : ''}
</option>
))}
</select>
</label>
<Textarea
label="Заметки"
value={draft.notes ?? ''}
onChange={(e) => setDraft((d) => ({ ...d, notes: e.target.value }))}
rows={3}
/>
</section>
<div className="form-actions">
<Button variant="primary" onClick={save} disabled={saving}>
{saving ? 'Сохраняю…' : 'Сохранить'}
</Button>
{savedAt ? <span className="hint">Сохранено в {savedAt.toLocaleTimeString('ru-RU')}</span> : null}
{error ? <span className="error-text">{error}</span> : null}
</div>
<hr style={{ margin: '24px 0', border: 0, borderTop: '1px solid #e5e7eb' }} />
<header className="page-head">
<h3>Документы проекта ({docs.length})</h3>
<div style={{ display: 'flex', gap: 8 }}>
<Button onClick={() => createDoc('contract')}>+ Договор</Button>
<Button onClick={() => createDoc('invoice')}>+ Счёт</Button>
<Button onClick={() => createDoc('act')}>+ Акт</Button>
<Button onClick={() => createDoc('upd')}>+ УПД</Button>
</div>
</header>
{docs.length === 0 ? (
<EmptyState>В проекте пока нет документов. Жми кнопки выше.</EmptyState>
) : (
<table className="table">
<thead>
<tr>
<th></th>
<th>Тип</th>
<th>Дата</th>
<th>Клиент</th>
<th>Сумма</th>
<th>Статус</th>
</tr>
</thead>
<tbody>
{docs.map((d) => (
<tr key={d.id}>
<td><Link to={`/documents/${d.id}`}>{d.number}</Link></td>
<td>{DOC_TYPE_LABEL[d.docType] ?? d.docType}</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>{d.status}</td>
</tr>
))}
</tbody>
</table>
)}
</main>
);
}
+154
View File
@@ -0,0 +1,154 @@
import { useEffect, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { api, ApiError, type ProjectSummary, type ProjectStatus } from '../api.js';
import { Button, EmptyState, Field, Modal, Select } from '../components/ui.js';
const STATUS_LABEL: Record<ProjectStatus, string> = {
active: 'Активный',
completed: 'Завершён',
cancelled: 'Отменён',
};
export function ProjectsPage() {
const [items, setItems] = useState<ProjectSummary[] | null>(null);
const [status, setStatus] = useState<ProjectStatus | ''>('');
const [q, setQ] = useState('');
const [creating, setCreating] = useState<{ name: string } | null>(null);
const [error, setError] = useState<string | null>(null);
const navigate = useNavigate();
async function load() {
setError(null);
try {
const params = new URLSearchParams();
if (status) params.set('status', status);
if (q) params.set('q', q);
const r = await api.get<{ items: ProjectSummary[] }>(`/api/projects?${params.toString()}`);
setItems(r.items);
} catch (e) {
setError(String(e));
}
}
useEffect(() => { void load(); /* eslint-disable-next-line */ }, [status, q]);
async function create() {
if (!creating) return;
try {
const p = await api.post<ProjectSummary>('/api/projects', {
name: creating.name,
status: 'active',
defaultClientId: null,
defaultTemplateId: null,
defaultBankAccountId: null,
notes: null,
});
setCreating(null);
navigate(`/projects/${p.id}`);
} catch (e) {
setError(e instanceof ApiError ? e.prettyMessage() : String(e));
}
}
async function archive(id: string) {
if (!confirm('Архивировать проект? Документы внутри сохранятся.')) return;
try {
await api.del(`/api/projects/${id}`);
await load();
} catch (e) {
setError(String(e));
}
}
return (
<main className="content">
<header className="page-head">
<h2>Проекты</h2>
<Button variant="primary" onClick={() => setCreating({ name: '' })}>+ Новый проект</Button>
</header>
<div className="toolbar">
<Select
label=""
value={status}
onChange={(v) => setStatus(v as ProjectStatus | '')}
options={[
{ value: '', label: 'Все статусы' },
{ value: 'active', label: 'Активные' },
{ value: 'completed', 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>
Проектов пока нет. Создайте первый внутри проекта зададите дефолтного клиента, шаблон и банк-счёт, чтобы создавать документы быстро.
</EmptyState>
) : (
<table className="table">
<thead>
<tr>
<th>Название</th>
<th>Клиент по умолчанию</th>
<th>Шаблон</th>
<th>Документов</th>
<th>Статус</th>
<th />
</tr>
</thead>
<tbody>
{items.map((p) => (
<tr key={p.id}>
<td><Link to={`/projects/${p.id}`}>{p.name}</Link></td>
<td>{p.defaultClient?.name ?? '—'}</td>
<td>{p.defaultTemplate?.name ?? '—'}</td>
<td>{p._count?.documents ?? 0}</td>
<td>
<span className={`status status--${p.status === 'active' ? 'issued' : p.status === 'completed' ? 'paid' : 'cancelled'}`}>
{STATUS_LABEL[p.status]}
</span>
</td>
<td className="row-actions">
<Button variant="ghost" onClick={() => navigate(`/projects/${p.id}`)}>Открыть</Button>
<Button variant="danger" onClick={() => archive(p.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={create}>Создать</Button>
</>
}
>
{creating ? (
<Field
label="Название"
value={creating.name}
onChange={(e) => setCreating({ name: e.target.value })}
placeholder="напр. Свадьба Ивановых, июнь 2026"
/>
) : null}
</Modal>
</main>
);
}