Files
doc-manager/apps/web/src/pages/ProjectEdit.tsx
T
admin f95fa7c254 feat(projects): explicit organization (executor) selector
Backend:
- POST /api/projects accepts optional organizationId; default = active org
- GET /api/projects (list) and GET /:id include organization {id,name,shortName}
- /:id and /:id/documents no longer filter by active org — direct link works
  across organizations; UI offers to switch active to project's org

Frontend:
- Project create modal: «Фирма (исполнитель)» dropdown, default = active
- Projects list shows «Фирма» column when user has >1 organization
- ProjectEdit shows org-banner with project's organization;
  if active org differs, banner has «переключить активную на эту →» button
- ProjectEdit fetches BankAccounts from project's org (not active)
- Bumped clients fetch limit to 1000 to match API max

Org of an existing project cannot be changed (would orphan documents/lines);
to switch fenced — create new project under desired org.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 14:11:27 +03:00

249 lines
9.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 [activeOrgId, setActiveOrgId] = 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=1000'),
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);
setActiveOrgId(ao.id);
// Банк-счета и шаблоны проекта берём из ФИРМЫ ПРОЕКТА, а не из активной.
const ba = await api.get<{ items: BankAccount[] }>(
`/api/organizations/${p.organizationId}/bank-accounts`,
);
setBankAccounts(ba.items);
})
.catch((e) => setError(String(e)));
}, [id]);
async function switchToProjectOrg() {
if (!project) return;
try {
await api.post('/api/active-organization', { id: project.organizationId });
window.location.reload();
} catch (e) {
setError(String(e));
}
}
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>;
const isCrossOrg = activeOrgId && project.organizationId && activeOrgId !== project.organizationId;
return (
<main className="content">
<header className="page-head">
<h2>{project.name}</h2>
<Button onClick={() => navigate('/projects')}> К проектам</Button>
</header>
<div className="org-banner">
<span className="hint">Фирма-исполнитель:</span>
<b>{project.organization?.shortName || project.organization?.name || '—'}</b>
{isCrossOrg ? (
<Button variant="ghost" onClick={switchToProjectOrg}>
переключить активную на эту
</Button>
) : null}
</div>
<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>
);
}