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:
@@ -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;
|
||||
@@ -55,6 +55,12 @@ enum PaymentKind {
|
||||
outgoing
|
||||
}
|
||||
|
||||
enum ProjectStatus {
|
||||
active
|
||||
completed
|
||||
cancelled
|
||||
}
|
||||
|
||||
model Organization {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
name String
|
||||
@@ -82,6 +88,31 @@ model Organization {
|
||||
tochkaCredentials TochkaCredential[]
|
||||
auditLog AuditLog[]
|
||||
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 {
|
||||
@@ -100,6 +131,7 @@ model BankAccount {
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
tochkaCredentials TochkaCredential[]
|
||||
defaultForProjects Project[]
|
||||
|
||||
@@index([organizationId])
|
||||
}
|
||||
@@ -121,6 +153,7 @@ model Client {
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
documents Document[]
|
||||
defaultForProjects Project[]
|
||||
|
||||
@@index([organizationId])
|
||||
@@index([organizationId, name])
|
||||
@@ -155,6 +188,8 @@ model DocumentTemplate {
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
defaultForProjects Project[]
|
||||
|
||||
@@index([organizationId, docType])
|
||||
}
|
||||
|
||||
@@ -162,6 +197,8 @@ model Document {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
organizationId String @db.Uuid
|
||||
organization Organization @relation(fields: [organizationId], references: [id])
|
||||
projectId String? @db.Uuid
|
||||
project Project? @relation(fields: [projectId], references: [id])
|
||||
docType DocType
|
||||
number String
|
||||
issuedAt DateTime?
|
||||
@@ -188,6 +225,7 @@ model Document {
|
||||
@@unique([organizationId, docType, number])
|
||||
@@index([organizationId, clientId, issuedAt(sort: Desc)])
|
||||
@@index([organizationId, status])
|
||||
@@index([organizationId, projectId])
|
||||
@@index([tochkaDocumentId])
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ const LineInput = z.object({
|
||||
const DocumentCreate = z.object({
|
||||
docType: z.enum(DOC_TYPES),
|
||||
clientId: z.string().uuid().nullable(),
|
||||
projectId: z.string().uuid().nullable(),
|
||||
parentDocumentId: z.string().uuid().nullable(),
|
||||
body: DocBody,
|
||||
lines: z.array(LineInput).default([]),
|
||||
@@ -35,6 +36,7 @@ const DocumentCreate = z.object({
|
||||
|
||||
const DocumentUpdate = z.object({
|
||||
clientId: z.string().uuid().nullable(),
|
||||
projectId: z.string().uuid().nullable(),
|
||||
body: DocBody,
|
||||
lines: z.array(LineInput).default([]),
|
||||
number: z.string().min(1).max(100),
|
||||
@@ -48,6 +50,7 @@ const StatusChange = z.object({
|
||||
const ListQuery = z.object({
|
||||
docType: z.enum(DOC_TYPES).optional(),
|
||||
clientId: z.string().uuid().optional(),
|
||||
projectId: 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),
|
||||
@@ -71,12 +74,13 @@ export async function documentsRoutes(app: FastifyInstance) {
|
||||
reply.code(400).send({ error: 'validation_error', issues: parsed.error.flatten() });
|
||||
return;
|
||||
}
|
||||
const { docType, clientId, status, q, limit } = parsed.data;
|
||||
const { docType, clientId, projectId, status, q, limit } = parsed.data;
|
||||
const docs = await prisma.document.findMany({
|
||||
where: {
|
||||
organizationId: orgId,
|
||||
...(docType ? { docType } : {}),
|
||||
...(clientId ? { clientId } : {}),
|
||||
...(projectId ? { projectId } : {}),
|
||||
...(status ? { status } : {}),
|
||||
...(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() });
|
||||
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 { totalCents, vatCents } = totals(calcLines);
|
||||
const sub = (req.user!.sub as string) || null;
|
||||
@@ -165,6 +169,7 @@ export async function documentsRoutes(app: FastifyInstance) {
|
||||
number: num,
|
||||
issuedAt: issuedAt ? new Date(issuedAt) : null,
|
||||
clientId,
|
||||
projectId,
|
||||
parentDocumentId,
|
||||
body: body as Prisma.InputJsonValue,
|
||||
totalCents,
|
||||
@@ -213,7 +218,7 @@ export async function documentsRoutes(app: FastifyInstance) {
|
||||
reply.code(409).send({ error: 'locked_by_bank', message: 'Документ выставлен через банк, редактировать нельзя.' });
|
||||
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 updated = await prisma.$transaction(async (tx) => {
|
||||
@@ -224,6 +229,7 @@ export async function documentsRoutes(app: FastifyInstance) {
|
||||
data: {
|
||||
number,
|
||||
clientId,
|
||||
projectId,
|
||||
issuedAt: issuedAt ? new Date(issuedAt) : null,
|
||||
body: body as Prisma.InputJsonValue,
|
||||
totalCents,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
@@ -24,19 +24,30 @@ type LlmResult = {
|
||||
|
||||
const SYSTEM_PROMPT = `Ты — парсер юридических документов на русском языке. Получаешь plain-text договора, счёта, акта или УПД и возвращаешь его структуру строго в JSON формате DocBody.
|
||||
|
||||
Цель: создать ШАБЛОН, в котором конкретные данные сторон, номера, даты — заменены на плейсхолдеры. Шаблон потом будет инстансироваться для конкретного клиента и документа.
|
||||
Цель: создать ШАБЛОН — переиспользуемый каркас, в котором ВСЕ конкретные значения сторон/номера/даты заменены на плейсхолдеры. Шаблон потом инстансируется автоматически: при создании документа конкретные поля придут из карточек клиента, нашей компании, проекта.
|
||||
|
||||
Правила замены на плейсхолдеры (заменяй ТОЛЬКО эти сущности, остальной текст оставь как есть):
|
||||
- Конкретный номер договора/счёта → {{contract.number}}
|
||||
- Конкретная дата документа → {{contract.date}}
|
||||
- Текущая дата (на момент рендера) → {{today}}
|
||||
- Реквизиты исполнителя (наша компания):
|
||||
{{executor.name}}, {{executor.inn}}, {{executor.kpp}}, {{executor.ogrn}}, {{executor.legalAddress}},
|
||||
{{executor.signatoryName}}, {{executor.signatoryPosition}},
|
||||
КРИТИЧНО: тщательно ищи и заменяй ВСЕ конкретные имена, номера и реквизиты в любом тексте, включая преамбулу.
|
||||
|
||||
Полный список плейсхолдеров:
|
||||
- {{contract.number}} — номер документа
|
||||
- {{contract.date}} — дата документа
|
||||
- {{today}} — сегодняшняя дата (для актов/УПД)
|
||||
- Наша компания (Исполнитель):
|
||||
{{executor.name}}, {{executor.shortName}}, {{executor.inn}}, {{executor.kpp}}, {{executor.ogrn}},
|
||||
{{executor.legalAddress}}, {{executor.signatoryName}}, {{executor.signatoryPosition}},
|
||||
{{executor.bankName}}, {{executor.bankBik}}, {{executor.bankAccount}}
|
||||
- Реквизиты заказчика (клиента):
|
||||
{{customer.name}}, {{customer.inn}}, {{customer.kpp}}, {{customer.address}},
|
||||
{{customer.email}}, {{customer.phone}}, {{customer.contactPerson}}
|
||||
- Клиент (Заказчик):
|
||||
{{customer.name}}, {{customer.inn}}, {{customer.kpp}}, {{customer.ogrn}}, {{customer.address}},
|
||||
{{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-объект):
|
||||
{
|
||||
|
||||
@@ -16,6 +16,7 @@ import { documentsRoutes } from './modules/documents/routes.js';
|
||||
import { documentsPdfRoutes } from './modules/documents/pdf.routes.js';
|
||||
import { templatesRoutes } from './modules/templates/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 { shutdownBrowser } from './modules/documents/pdf.js';
|
||||
import activeOrgPlugin from './plugins/activeOrg.js';
|
||||
@@ -54,6 +55,7 @@ async function main() {
|
||||
await app.register(documentsPdfRoutes);
|
||||
await app.register(templatesRoutes);
|
||||
await app.register(templatesImportRoutes);
|
||||
await app.register(projectsRoutes);
|
||||
await app.register(dadataRoutes);
|
||||
|
||||
app.addHook('onClose', async () => {
|
||||
|
||||
@@ -9,6 +9,8 @@ import { DocumentsPage } from './pages/Documents.js';
|
||||
import { DocumentEditPage } from './pages/DocumentEdit.js';
|
||||
import { TemplatesPage } from './pages/Templates.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';
|
||||
|
||||
function Layout({ email }: { email: string }) {
|
||||
@@ -16,6 +18,7 @@ function Layout({ email }: { email: string }) {
|
||||
<header className="topbar">
|
||||
<h1>Doc_manager</h1>
|
||||
<nav>
|
||||
<Link to="/projects">Проекты</Link>
|
||||
<Link to="/">Документы</Link>
|
||||
<Link to="/clients">Клиенты</Link>
|
||||
<Link to="/services">Услуги</Link>
|
||||
@@ -75,6 +78,8 @@ export function App() {
|
||||
<Route path="/templates/:id" element={<TemplateEditPage />} />
|
||||
<Route path="/companies" element={<CompaniesPage />} />
|
||||
<Route path="/companies/:id" element={<CompanyEditPage />} />
|
||||
<Route path="/projects" element={<ProjectsPage />} />
|
||||
<Route path="/projects/:id" element={<ProjectEditPage />} />
|
||||
<Route path="*" element={<Placeholder title="Не найдено" />} />
|
||||
</Routes>
|
||||
</>
|
||||
|
||||
@@ -258,6 +258,32 @@ export type LineHistoryItem = {
|
||||
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 = {
|
||||
inn: string;
|
||||
kpp: string | null;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
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 { 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 { ClientPicker } from '../components/ClientPicker.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 fromTemplateId = searchParams.get('fromTemplate');
|
||||
const projectIdParam = searchParams.get('projectId');
|
||||
|
||||
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 [projectId, setProjectId] = useState<string | null>(projectIdParam);
|
||||
const [body, setBody] = useState<DocBody | null>(null);
|
||||
const [lines, setLines] = useState<LineDraft[]>([]);
|
||||
const [tochkaLocked, setTochkaLocked] = useState(false);
|
||||
@@ -98,7 +100,7 @@ export function DocumentEditPage() {
|
||||
// Загрузить существующий документ
|
||||
useEffect(() => {
|
||||
if (isNew) {
|
||||
// Шаблон или новый пустой
|
||||
// Шаблон или новый пустой; преселект клиента если пришёл projectId
|
||||
if (fromTemplateId) {
|
||||
api.get<DocumentTemplate>(`/api/templates/${fromTemplateId}`).then((tpl) => {
|
||||
setBody(tpl.body);
|
||||
@@ -106,6 +108,11 @@ export function DocumentEditPage() {
|
||||
} else {
|
||||
setBody(defaultBody(initialDocType));
|
||||
}
|
||||
if (projectIdParam) {
|
||||
api.get<Project>(`/api/projects/${projectIdParam}`).then((p) => {
|
||||
if (p.defaultClientId) setClientId(p.defaultClientId);
|
||||
}).catch(() => {});
|
||||
}
|
||||
return;
|
||||
}
|
||||
api.get<Document>(`/api/documents/${id}`).then((d) => {
|
||||
@@ -113,12 +120,13 @@ export function DocumentEditPage() {
|
||||
setIssuedAt(d.issuedAt ? d.issuedAt.slice(0, 10) : '');
|
||||
setStatus(d.status);
|
||||
setClientId(d.clientId);
|
||||
setProjectId((d as Document & { projectId?: string | null }).projectId ?? null);
|
||||
setBody(d.body);
|
||||
setLines(d.lines);
|
||||
setTochkaLocked(!!d.tochkaDocumentId);
|
||||
setSavedId(d.id);
|
||||
}).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]);
|
||||
|
||||
@@ -129,6 +137,7 @@ export function DocumentEditPage() {
|
||||
try {
|
||||
const payload = {
|
||||
clientId: clientId,
|
||||
projectId: projectId,
|
||||
body,
|
||||
lines: lines.map((l, i) => ({
|
||||
position: i,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user