f95fa7c254
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>
249 lines
9.1 KiB
TypeScript
249 lines
9.1 KiB
TypeScript
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>
|
||
);
|
||
}
|