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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user