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>
This commit is contained in:
admin
2026-05-01 14:11:27 +03:00
parent 8b7fe30b76
commit f95fa7c254
5 changed files with 126 additions and 31 deletions
+29 -4
View File
@@ -37,14 +37,14 @@ export function ProjectEditPage() {
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);
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=500'),
api.get<{ items: Client[] }>('/api/clients?limit=1000'),
api.get<{ items: DocumentTemplate[] }>('/api/templates'),
api.get<{ id: string }>('/api/active-organization'),
])
@@ -54,13 +54,26 @@ export function ProjectEditPage() {
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`);
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);
@@ -94,6 +107,8 @@ export function ProjectEditPage() {
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">
@@ -101,6 +116,16 @@ export function ProjectEditPage() {
<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="Название"