From f95fa7c254b05ff347991c5c58e10b74433a153c Mon Sep 17 00:00:00 2001 From: admin Date: Fri, 1 May 2026 14:11:27 +0300 Subject: [PATCH] feat(projects): explicit organization (executor) selector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- apps/api/src/modules/projects/routes.ts | 39 +++++++++----- apps/web/src/api.ts | 1 + apps/web/src/pages/ProjectEdit.tsx | 33 ++++++++++-- apps/web/src/pages/Projects.tsx | 71 ++++++++++++++++++++----- apps/web/src/styles.css | 13 +++++ 5 files changed, 126 insertions(+), 31 deletions(-) diff --git a/apps/api/src/modules/projects/routes.ts b/apps/api/src/modules/projects/routes.ts index 79634d5..bfcf2d5 100644 --- a/apps/api/src/modules/projects/routes.ts +++ b/apps/api/src/modules/projects/routes.ts @@ -15,6 +15,11 @@ const ProjectUpsert = z.object({ notes: optionalText(2000), }); +// При создании можно явно указать компанию-исполнителя; если не задана — берётся активная. +const ProjectCreate = ProjectUpsert.extend({ + organizationId: z.string().uuid().optional(), +}); + const ListQuery = z.object({ status: z.enum(STATUSES).optional(), q: z.string().optional(), @@ -23,7 +28,7 @@ const ListQuery = z.object({ }); export async function projectsRoutes(app: FastifyInstance) { - // ---- list ---- + // ---- list (по активной компании) ---- app.get('/api/projects', { preHandler: app.requireDocPermission('viewer') }, async (req, reply) => { const orgId = getOrganizationId(req); const parsed = ListQuery.safeParse(req.query); @@ -40,6 +45,7 @@ export async function projectsRoutes(app: FastifyInstance) { ...(q ? { name: { contains: q, mode: 'insensitive' as const } } : {}), }, include: { + organization: { select: { id: true, name: true, shortName: true } }, defaultClient: { select: { id: true, name: true, kind: true } }, defaultTemplate: { select: { id: true, name: true, docType: true } }, defaultBankAccount: { select: { id: true, name: true } }, @@ -52,12 +58,14 @@ export async function projectsRoutes(app: FastifyInstance) { }); // ---- get one ---- + // Не фильтруем по active org — позволяем открыть проект другой компании по прямой ссылке; + // фронт может предложить переключить активную, чтобы увидеть его в списке. 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 }, + where: { id }, include: { + organization: { select: { id: true, name: true, shortName: true } }, defaultClient: true, defaultTemplate: true, defaultBankAccount: true, @@ -72,15 +80,14 @@ export async function projectsRoutes(app: FastifyInstance) { // ---- 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 } }); + const project = await prisma.project.findFirst({ where: { id } }); if (!project) { reply.code(404).send({ error: 'not_found' }); return; } const items = await prisma.document.findMany({ - where: { projectId: id, organizationId: orgId }, + where: { projectId: id, organizationId: project.organizationId }, include: { client: { select: { id: true, name: true, kind: true } } }, orderBy: [{ issuedAt: { sort: 'desc', nulls: 'last' } }, { createdAt: 'desc' }], }); @@ -89,28 +96,35 @@ export async function projectsRoutes(app: FastifyInstance) { // ---- create ---- app.post('/api/projects', { preHandler: app.requireDocPermission('user') }, async (req, reply) => { - const orgId = getOrganizationId(req); - const parsed = ProjectUpsert.safeParse(req.body); + const activeOrgId = getOrganizationId(req); + const parsed = ProjectCreate.safeParse(req.body); if (!parsed.success) { reply.code(400).send({ error: 'validation_error', issues: parsed.error.flatten() }); return; } + const { organizationId: requestedOrgId, ...rest } = parsed.data; + const orgId = requestedOrgId ?? activeOrgId; + const org = await prisma.organization.findFirst({ where: { id: orgId, archivedAt: null } }); + if (!org) { + reply.code(400).send({ error: 'invalid_organization' }); + return; + } const created = await prisma.project.create({ - data: { ...parsed.data, organizationId: orgId }, + data: { ...rest, organizationId: orgId }, }); reply.code(201).send(created); }); // ---- update ---- + // organizationId менять не даём — иначе придётся переносить связанные документы и счета. 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 } }); + const existing = await prisma.project.findFirst({ where: { id } }); if (!existing) { reply.code(404).send({ error: 'not_found' }); return; @@ -120,9 +134,8 @@ export async function projectsRoutes(app: FastifyInstance) { // ---- 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 } }); + const existing = await prisma.project.findFirst({ where: { id } }); if (!existing) { reply.code(404).send({ error: 'not_found' }); return; diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index 40e56f5..a457467 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -264,6 +264,7 @@ export type ProjectStatus = 'active' | 'completed' | 'cancelled'; export type ProjectSummary = { id: string; organizationId: string; + organization: { id: string; name: string; shortName: string | null } | null; name: string; status: ProjectStatus; defaultClientId: string | null; diff --git a/apps/web/src/pages/ProjectEdit.tsx b/apps/web/src/pages/ProjectEdit.tsx index 9dccfe7..c28b498 100644 --- a/apps/web/src/pages/ProjectEdit.tsx +++ b/apps/web/src/pages/ProjectEdit.tsx @@ -37,14 +37,14 @@ export function ProjectEditPage() { const [saving, setSaving] = useState(false); const [savedAt, setSavedAt] = useState(null); const [error, setError] = useState(null); - const [orgId, setOrgId] = useState(null); + const [activeOrgId, setActiveOrgId] = useState(null); useEffect(() => { if (!id) return; void Promise.all([ api.get(`/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
{error}
; if (!project) return

Загрузка…

; + const isCrossOrg = activeOrgId && project.organizationId && activeOrgId !== project.organizationId; + return (
@@ -101,6 +116,16 @@ export function ProjectEditPage() {
+
+ Фирма-исполнитель: + {project.organization?.shortName || project.organization?.name || '—'} + {isCrossOrg ? ( + + ) : null} +
+
= { @@ -11,9 +11,11 @@ const STATUS_LABEL: Record = { export function ProjectsPage() { const [items, setItems] = useState(null); + const [orgs, setOrgs] = useState([]); + const [activeOrgId, setActiveOrgId] = useState(null); const [status, setStatus] = useState(''); const [q, setQ] = useState(''); - const [creating, setCreating] = useState<{ name: string } | null>(null); + const [creating, setCreating] = useState<{ name: string; organizationId: string } | null>(null); const [error, setError] = useState(null); const [fieldErrors, setFieldErrors] = useState>({}); const navigate = useNavigate(); @@ -33,6 +35,18 @@ export function ProjectsPage() { useEffect(() => { void load(); /* eslint-disable-next-line */ }, [status, q]); + useEffect(() => { + void Promise.all([ + api.get<{ items: Organization[] }>('/api/organizations'), + api.get<{ id: string }>('/api/active-organization'), + ]) + .then(([list, active]) => { + setOrgs(list.items); + setActiveOrgId(active.id); + }) + .catch(() => {}); + }, []); + async function create() { if (!creating) return; if (!creating.name.trim()) { @@ -43,6 +57,7 @@ export function ProjectsPage() { try { const p = await api.post('/api/projects', { name: creating.name.trim(), + organizationId: creating.organizationId, status: 'active', defaultClientId: null, defaultTemplateId: null, @@ -72,11 +87,18 @@ export function ProjectsPage() { } } + const showOrgColumn = orgs.length > 1; + return (

Проекты

- +
@@ -105,13 +127,14 @@ export function ProjectsPage() {

Загрузка…

) : items.length === 0 ? ( - Проектов пока нет. Создайте первый — внутри проекта зададите дефолтного клиента, шаблон и банк-счёт, чтобы создавать документы быстро. + Проектов пока нет в активной фирме. Создай первый — внутри зададишь клиента по умолчанию, шаблон, банк-счёт. ) : ( + {showOrgColumn ? : null} @@ -123,6 +146,7 @@ export function ProjectsPage() { {items.map((p) => ( + {showOrgColumn ? : null} @@ -153,16 +177,35 @@ export function ProjectsPage() { } > {creating ? ( - { - setCreating({ name: e.target.value }); - if (fieldErrors.name) setFieldErrors((fe) => { const n = { ...fe }; delete n.name; return n; }); - }} - placeholder="напр. Свадьба Ивановых, июнь 2026" - error={fieldErrors.name} - /> +
+ { + setCreating({ ...creating, name: e.target.value }); + if (fieldErrors.name) setFieldErrors((fe) => { const n = { ...fe }; delete n.name; return n; }); + }} + placeholder="напр. Свадьба Ивановых, июнь 2026" + error={fieldErrors.name} + /> + +

+ Документы внутри проекта будут выставляться от этой компании. После создания фирму поменять нельзя — заведи новый проект, если нужно из другой фирмы. +

+
) : null} diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index 20341af..7a01693 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -302,6 +302,19 @@ body { .import-banner { background: #14213d; border-color: #1e3a8a; color: #93c5fd; } } +/* === org banner (project page) === */ +.org-banner { + display: flex; align-items: center; gap: 10px; flex-wrap: wrap; + padding: 8px 12px; margin-bottom: 16px; + background: #f1f5f9; border: 1px solid #cbd5e1; border-radius: 6px; + font-size: 14px; +} +.org-banner b { color: #1e3a8a; } +@media (prefers-color-scheme: dark) { + .org-banner { background: #1c1f24; border-color: #2a2e35; } + .org-banner b { color: #93c5fd; } +} + /* === inn lookup === */ .inn-lookup { margin-top: 6px; display: flex; flex-direction: column; gap: 4px; } .inn-lookup__error { font-size: 12px; color: #c0392b; }
НазваниеФирмаКлиент по умолчанию Шаблон Документов
{p.name}{p.organization?.shortName || p.organization?.name || '—'}{p.defaultClient?.name ?? '—'} {p.defaultTemplate?.name ?? '—'} {p._count?.documents ?? 0}