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
+5
View File
@@ -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>
</>
+26
View File
@@ -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;
+12 -3
View File
@@ -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,
+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>
);
}