feat: multi-organization support + bank accounts
- Renamed singular /api/organization → CRUD /api/organizations - New BankAccount table with CRUD under /api/organizations/:orgId/bank-accounts - TochkaCredential gets optional bankAccountId for future per-account bank API config - Active organization stored in cookie dm_org, /api/active-organization GET/POST - activeOrgPlugin resolves req._orgId from cookie (or first non-archived as fallback) - Migration 1_multiorg: BankAccount table + data backfill from legacy Organization.bank* fields - Web: new /companies list + /companies/:id with tabs (Реквизиты, Банки и счета, Интеграции stub) - Web: OrgSwitcher dropdown in header (active org + management link) - Removed nav "Реквизиты", "Банк" — replaced by "Компании" - Per-field error highlighting wired up on new forms Existing organization data backfills cleanly: legacy bank* fields stay readable, but new BankAccount becomes the source of truth going forward. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,49 @@
|
|||||||
|
-- AlterTable: add shortName, archivedAt to Organization
|
||||||
|
ALTER TABLE "Organization" ADD COLUMN "shortName" TEXT;
|
||||||
|
ALTER TABLE "Organization" ADD COLUMN "archivedAt" TIMESTAMP(3);
|
||||||
|
|
||||||
|
-- CreateTable: BankAccount
|
||||||
|
CREATE TABLE "BankAccount" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"organizationId" UUID NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"bankName" TEXT,
|
||||||
|
"bankBik" TEXT,
|
||||||
|
"accountNumber" TEXT,
|
||||||
|
"corrAccount" TEXT,
|
||||||
|
"currency" TEXT NOT NULL DEFAULT 'RUB',
|
||||||
|
"isPrimary" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"archivedAt" TIMESTAMP(3),
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "BankAccount_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX "BankAccount_organizationId_idx" ON "BankAccount"("organizationId");
|
||||||
|
|
||||||
|
ALTER TABLE "BankAccount" ADD CONSTRAINT "BankAccount_organizationId_fkey"
|
||||||
|
FOREIGN KEY ("organizationId") REFERENCES "Organization"("id")
|
||||||
|
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AlterTable: add bankAccountId on TochkaCredential
|
||||||
|
ALTER TABLE "TochkaCredential" ADD COLUMN "bankAccountId" UUID;
|
||||||
|
ALTER TABLE "TochkaCredential" ADD CONSTRAINT "TochkaCredential_bankAccountId_fkey"
|
||||||
|
FOREIGN KEY ("bankAccountId") REFERENCES "BankAccount"("id")
|
||||||
|
ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- Data migration: для каждой организации с заполненным bank* — создать запись BankAccount(isPrimary=true)
|
||||||
|
INSERT INTO "BankAccount" ("id", "organizationId", "name", "bankName", "bankBik", "accountNumber", "currency", "isPrimary", "createdAt", "updatedAt")
|
||||||
|
SELECT
|
||||||
|
gen_random_uuid(),
|
||||||
|
o."id",
|
||||||
|
'Основной счёт',
|
||||||
|
o."bankName",
|
||||||
|
o."bankBik",
|
||||||
|
o."bankAccount",
|
||||||
|
'RUB',
|
||||||
|
true,
|
||||||
|
NOW(),
|
||||||
|
NOW()
|
||||||
|
FROM "Organization" o
|
||||||
|
WHERE o."bankName" IS NOT NULL OR o."bankBik" IS NOT NULL OR o."bankAccount" IS NOT NULL;
|
||||||
@@ -58,15 +58,19 @@ enum PaymentKind {
|
|||||||
model Organization {
|
model Organization {
|
||||||
id String @id @default(uuid()) @db.Uuid
|
id String @id @default(uuid()) @db.Uuid
|
||||||
name String
|
name String
|
||||||
|
shortName String? // короткое имя для бейджей в UI / селектора
|
||||||
inn String
|
inn String
|
||||||
kpp String?
|
kpp String?
|
||||||
ogrn String?
|
ogrn String?
|
||||||
legalAddress String?
|
legalAddress String?
|
||||||
|
// Поля bankName/bankBik/bankAccount устарели после введения BankAccount.
|
||||||
|
// Оставлены для обратной совместимости с уже сохранёнными данными.
|
||||||
bankName String?
|
bankName String?
|
||||||
bankBik String?
|
bankBik String?
|
||||||
bankAccount String?
|
bankAccount String?
|
||||||
signatoryName String?
|
signatoryName String?
|
||||||
signatoryPosition String?
|
signatoryPosition String?
|
||||||
|
archivedAt DateTime?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@ -77,6 +81,27 @@ model Organization {
|
|||||||
payments Payment[]
|
payments Payment[]
|
||||||
tochkaCredentials TochkaCredential[]
|
tochkaCredentials TochkaCredential[]
|
||||||
auditLog AuditLog[]
|
auditLog AuditLog[]
|
||||||
|
bankAccounts BankAccount[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model BankAccount {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
organizationId String @db.Uuid
|
||||||
|
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||||
|
name String // отображаемое имя, напр. "Точка — основной"
|
||||||
|
bankName String?
|
||||||
|
bankBik String?
|
||||||
|
accountNumber String? // р/счёт (20 цифр)
|
||||||
|
corrAccount String? // к/счёт (20 цифр)
|
||||||
|
currency String @default("RUB")
|
||||||
|
isPrimary Boolean @default(false)
|
||||||
|
archivedAt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
tochkaCredentials TochkaCredential[]
|
||||||
|
|
||||||
|
@@index([organizationId])
|
||||||
}
|
}
|
||||||
|
|
||||||
model Client {
|
model Client {
|
||||||
@@ -209,6 +234,8 @@ model TochkaCredential {
|
|||||||
id String @id @default(uuid()) @db.Uuid
|
id String @id @default(uuid()) @db.Uuid
|
||||||
organizationId String @db.Uuid
|
organizationId String @db.Uuid
|
||||||
organization Organization @relation(fields: [organizationId], references: [id])
|
organization Organization @relation(fields: [organizationId], references: [id])
|
||||||
|
bankAccountId String? @db.Uuid
|
||||||
|
bankAccount BankAccount? @relation(fields: [bankAccountId], references: [id])
|
||||||
environment TochkaEnv
|
environment TochkaEnv
|
||||||
// AES-256-GCM ciphertext (iv|tag|ct), base64
|
// AES-256-GCM ciphertext (iv|tag|ct), base64
|
||||||
jwtEncrypted String
|
jwtEncrypted String
|
||||||
|
|||||||
+54
-5
@@ -1,11 +1,60 @@
|
|||||||
import type { FastifyRequest } from 'fastify';
|
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||||
|
import { prisma } from '../db.js';
|
||||||
import { env } from '../env.js';
|
import { env } from '../env.js';
|
||||||
|
|
||||||
|
export const ACTIVE_ORG_COOKIE = 'dm_org';
|
||||||
|
|
||||||
|
const COOKIE_OPTS = {
|
||||||
|
path: '/',
|
||||||
|
sameSite: 'lax' as const,
|
||||||
|
httpOnly: false,
|
||||||
|
secure: process.env.NODE_ENV === 'production',
|
||||||
|
maxAge: 60 * 60 * 24 * 365,
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Возвращает organization_id, в контексте которого работает запрос.
|
* Возвращает organization_id для текущего запроса.
|
||||||
* Single-tenant v1: всегда DEFAULT_ORGANIZATION_ID.
|
* Алгоритм:
|
||||||
* При переходе к multi-tenant заменим на маппинг из req.user.permissions / групп / отдельной таблицы membership.
|
* 1. Читаем cookie dm_org. Если есть — проверяем, что такая организация ЕСТЬ и не архивная.
|
||||||
|
* 2. Иначе берём первую неархивную из БД.
|
||||||
|
* 3. Иначе — DEFAULT_ORGANIZATION_ID из env (для новых установок без записей).
|
||||||
|
*
|
||||||
|
* Кэшировать на запросе (декорирован в server.ts) — иначе хождение в БД на каждый роут.
|
||||||
*/
|
*/
|
||||||
export function getOrganizationId(_req: FastifyRequest): string {
|
export async function resolveOrganizationId(req: FastifyRequest): Promise<string> {
|
||||||
|
const cookieId = req.cookies?.[ACTIVE_ORG_COOKIE];
|
||||||
|
if (cookieId) {
|
||||||
|
const found = await prisma.organization.findFirst({
|
||||||
|
where: { id: cookieId, archivedAt: null },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (found) return found.id;
|
||||||
|
}
|
||||||
|
const first = await prisma.organization.findFirst({
|
||||||
|
where: { archivedAt: null },
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (first) return first.id;
|
||||||
return env.DEFAULT_ORGANIZATION_ID;
|
return env.DEFAULT_ORGANIZATION_ID;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Старая функция для роутов, которые ещё не переехали на async.
|
||||||
|
* Использовать только если из контекста уже известно, что cookie проверена выше.
|
||||||
|
* Безопасно после регистрации `resolveOrgPlugin` ниже.
|
||||||
|
*/
|
||||||
|
export function getOrganizationId(req: FastifyRequest): string {
|
||||||
|
const cached = (req as { _orgId?: string })._orgId;
|
||||||
|
if (cached) return cached;
|
||||||
|
// На случай раннего вызова без middleware — fallback (не должно происходить).
|
||||||
|
return env.DEFAULT_ORGANIZATION_ID;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setActiveOrgCookie(reply: FastifyReply, id: string) {
|
||||||
|
reply.setCookie(ACTIVE_ORG_COOKIE, id, COOKIE_OPTS);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearActiveOrgCookie(reply: FastifyReply) {
|
||||||
|
reply.clearCookie(ACTIVE_ORG_COOKIE, { path: '/' });
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { prisma } from '../../db.js';
|
||||||
|
import { optionalRegex, optionalText } from '../../lib/zod-utils.js';
|
||||||
|
|
||||||
|
const BankAccountUpsert = z.object({
|
||||||
|
name: z.string().min(1).max(200),
|
||||||
|
bankName: optionalText(500),
|
||||||
|
bankBik: optionalRegex(/^\d{9}$/),
|
||||||
|
accountNumber: optionalRegex(/^\d{20}$/),
|
||||||
|
corrAccount: optionalRegex(/^\d{20}$/),
|
||||||
|
currency: z.string().length(3).default('RUB'),
|
||||||
|
isPrimary: z.boolean().default(false),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function bankAccountsRoutes(app: FastifyInstance) {
|
||||||
|
// ---- список под organization ----
|
||||||
|
app.get(
|
||||||
|
'/api/organizations/:orgId/bank-accounts',
|
||||||
|
{ preHandler: app.requireDocPermission('viewer') },
|
||||||
|
async (req, reply) => {
|
||||||
|
const { orgId } = req.params as { orgId: string };
|
||||||
|
const org = await prisma.organization.findFirst({ where: { id: orgId, archivedAt: null } });
|
||||||
|
if (!org) {
|
||||||
|
reply.code(404).send({ error: 'organization_not_found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const items = await prisma.bankAccount.findMany({
|
||||||
|
where: { organizationId: orgId, archivedAt: null },
|
||||||
|
orderBy: [{ isPrimary: 'desc' }, { createdAt: 'asc' }],
|
||||||
|
});
|
||||||
|
return { items };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---- create ----
|
||||||
|
app.post(
|
||||||
|
'/api/organizations/:orgId/bank-accounts',
|
||||||
|
{ preHandler: app.requireDocPermission('user') },
|
||||||
|
async (req, reply) => {
|
||||||
|
const { orgId } = req.params as { orgId: string };
|
||||||
|
const parsed = BankAccountUpsert.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
reply.code(400).send({ error: 'validation_error', issues: parsed.error.flatten() });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const org = await prisma.organization.findFirst({ where: { id: orgId, archivedAt: null } });
|
||||||
|
if (!org) {
|
||||||
|
reply.code(404).send({ error: 'organization_not_found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Только один primary на организацию
|
||||||
|
const created = await prisma.$transaction(async (tx) => {
|
||||||
|
if (parsed.data.isPrimary) {
|
||||||
|
await tx.bankAccount.updateMany({
|
||||||
|
where: { organizationId: orgId, isPrimary: true },
|
||||||
|
data: { isPrimary: false },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return tx.bankAccount.create({ data: { ...parsed.data, organizationId: orgId } });
|
||||||
|
});
|
||||||
|
reply.code(201).send(created);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---- update ----
|
||||||
|
app.put(
|
||||||
|
'/api/organizations/:orgId/bank-accounts/:id',
|
||||||
|
{ preHandler: app.requireDocPermission('user') },
|
||||||
|
async (req, reply) => {
|
||||||
|
const { orgId, id } = req.params as { orgId: string; id: string };
|
||||||
|
const parsed = BankAccountUpsert.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
reply.code(400).send({ error: 'validation_error', issues: parsed.error.flatten() });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const existing = await prisma.bankAccount.findFirst({
|
||||||
|
where: { id, organizationId: orgId, archivedAt: null },
|
||||||
|
});
|
||||||
|
if (!existing) {
|
||||||
|
reply.code(404).send({ error: 'not_found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const updated = await prisma.$transaction(async (tx) => {
|
||||||
|
if (parsed.data.isPrimary) {
|
||||||
|
await tx.bankAccount.updateMany({
|
||||||
|
where: { organizationId: orgId, isPrimary: true, id: { not: id } },
|
||||||
|
data: { isPrimary: false },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return tx.bankAccount.update({ where: { id }, data: parsed.data });
|
||||||
|
});
|
||||||
|
return updated;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---- archive ----
|
||||||
|
app.delete(
|
||||||
|
'/api/organizations/:orgId/bank-accounts/:id',
|
||||||
|
{ preHandler: app.requireDocPermission('user') },
|
||||||
|
async (req, reply) => {
|
||||||
|
const { orgId, id } = req.params as { orgId: string; id: string };
|
||||||
|
const existing = await prisma.bankAccount.findFirst({
|
||||||
|
where: { id, organizationId: orgId, archivedAt: null },
|
||||||
|
});
|
||||||
|
if (!existing) {
|
||||||
|
reply.code(404).send({ error: 'not_found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await prisma.bankAccount.update({ where: { id }, data: { archivedAt: new Date() } });
|
||||||
|
reply.code(204).send();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,54 +1,112 @@
|
|||||||
import type { FastifyInstance } from 'fastify';
|
import type { FastifyInstance } from 'fastify';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { prisma } from '../../db.js';
|
import { prisma } from '../../db.js';
|
||||||
import { getOrganizationId } from '../../lib/org.js';
|
import { ACTIVE_ORG_COOKIE, resolveOrganizationId, setActiveOrgCookie } from '../../lib/org.js';
|
||||||
import { optionalRegex, optionalText } from '../../lib/zod-utils.js';
|
import { optionalRegex, optionalText } from '../../lib/zod-utils.js';
|
||||||
|
|
||||||
const OrgUpdate = z.object({
|
const OrgUpsert = z.object({
|
||||||
name: z.string().min(1).max(500),
|
name: z.string().min(1).max(500),
|
||||||
|
shortName: optionalText(100),
|
||||||
inn: z.string().regex(/^\d{10}$|^\d{12}$/),
|
inn: z.string().regex(/^\d{10}$|^\d{12}$/),
|
||||||
kpp: optionalRegex(/^\d{9}$/),
|
kpp: optionalRegex(/^\d{9}$/),
|
||||||
ogrn: optionalRegex(/^\d{13}$|^\d{15}$/),
|
ogrn: optionalRegex(/^\d{13}$|^\d{15}$/),
|
||||||
legalAddress: optionalText(1000),
|
legalAddress: optionalText(1000),
|
||||||
bankName: optionalText(500),
|
|
||||||
bankBik: optionalRegex(/^\d{9}$/),
|
|
||||||
bankAccount: optionalRegex(/^\d{20}$/),
|
|
||||||
signatoryName: optionalText(500),
|
signatoryName: optionalText(500),
|
||||||
signatoryPosition: optionalText(500),
|
signatoryPosition: optionalText(500),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const SetActive = z.object({ id: z.string().uuid() });
|
||||||
|
|
||||||
export async function organizationsRoutes(app: FastifyInstance) {
|
export async function organizationsRoutes(app: FastifyInstance) {
|
||||||
app.get(
|
// ---- список ----
|
||||||
'/api/organization',
|
app.get('/api/organizations', { preHandler: app.requireDocPermission('viewer') }, async () => {
|
||||||
|
const items = await prisma.organization.findMany({
|
||||||
|
where: { archivedAt: null },
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
});
|
||||||
|
return { items };
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- активная (id + объект) ----
|
||||||
|
app.get('/api/active-organization', { preHandler: app.requireDocPermission('viewer') }, async (req) => {
|
||||||
|
const id = await resolveOrganizationId(req);
|
||||||
|
const org = await prisma.organization.findUnique({ where: { id } });
|
||||||
|
return { id, organization: org };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post(
|
||||||
|
'/api/active-organization',
|
||||||
{ preHandler: app.requireDocPermission('viewer') },
|
{ preHandler: app.requireDocPermission('viewer') },
|
||||||
async (req, reply) => {
|
async (req, reply) => {
|
||||||
const id = getOrganizationId(req);
|
const parsed = SetActive.safeParse(req.body);
|
||||||
const org = await prisma.organization.findUnique({ where: { id } });
|
|
||||||
if (!org) {
|
|
||||||
reply.code(404).send({ error: 'organization_not_found' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
return org;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
app.put(
|
|
||||||
'/api/organization',
|
|
||||||
{ preHandler: app.requireDocPermission('admin') },
|
|
||||||
async (req, reply) => {
|
|
||||||
const id = getOrganizationId(req);
|
|
||||||
const parsed = OrgUpdate.safeParse(req.body);
|
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
reply.code(400).send({ error: 'validation_error', issues: parsed.error.flatten() });
|
reply.code(400).send({ error: 'validation_error', issues: parsed.error.flatten() });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// upsert чтобы первое сохранение из UI создавало строку, если её ещё нет (вместо seed-only)
|
const org = await prisma.organization.findFirst({
|
||||||
const org = await prisma.organization.upsert({
|
where: { id: parsed.data.id, archivedAt: null },
|
||||||
where: { id },
|
|
||||||
update: parsed.data,
|
|
||||||
create: { id, ...parsed.data },
|
|
||||||
});
|
});
|
||||||
return org;
|
if (!org) {
|
||||||
|
reply.code(404).send({ error: 'not_found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setActiveOrgCookie(reply, org.id);
|
||||||
|
return { id: org.id, organization: org };
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ---- одна организация ----
|
||||||
|
app.get('/api/organizations/:id', { preHandler: app.requireDocPermission('viewer') }, async (req, reply) => {
|
||||||
|
const { id } = req.params as { id: string };
|
||||||
|
const org = await prisma.organization.findFirst({ where: { id, archivedAt: null } });
|
||||||
|
if (!org) {
|
||||||
|
reply.code(404).send({ error: 'not_found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return org;
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- create ----
|
||||||
|
app.post('/api/organizations', { preHandler: app.requireDocPermission('admin') }, async (req, reply) => {
|
||||||
|
const parsed = OrgUpsert.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
reply.code(400).send({ error: 'validation_error', issues: parsed.error.flatten() });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const created = await prisma.organization.create({ data: parsed.data });
|
||||||
|
// если активной не было — сделать новую активной
|
||||||
|
if (!req.cookies?.[ACTIVE_ORG_COOKIE]) {
|
||||||
|
setActiveOrgCookie(reply, created.id);
|
||||||
|
}
|
||||||
|
reply.code(201).send(created);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- update ----
|
||||||
|
app.put('/api/organizations/:id', { preHandler: app.requireDocPermission('admin') }, async (req, reply) => {
|
||||||
|
const { id } = req.params as { id: string };
|
||||||
|
const parsed = OrgUpsert.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
reply.code(400).send({ error: 'validation_error', issues: parsed.error.flatten() });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const existing = await prisma.organization.findFirst({ where: { id, archivedAt: null } });
|
||||||
|
if (!existing) {
|
||||||
|
reply.code(404).send({ error: 'not_found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return prisma.organization.update({ where: { id }, data: parsed.data });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- archive ----
|
||||||
|
app.delete('/api/organizations/:id', { preHandler: app.requireDocPermission('admin') }, async (req, reply) => {
|
||||||
|
const { id } = req.params as { id: string };
|
||||||
|
const existing = await prisma.organization.findFirst({ where: { id, archivedAt: null } });
|
||||||
|
if (!existing) {
|
||||||
|
reply.code(404).send({ error: 'not_found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Не удаляем физически — есть FK от документов, клиентов, услуг; делаем soft-delete.
|
||||||
|
await prisma.organization.update({ where: { id }, data: { archivedAt: new Date() } });
|
||||||
|
reply.code(204).send();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import fp from 'fastify-plugin';
|
||||||
|
import { resolveOrganizationId } from '../lib/org.js';
|
||||||
|
|
||||||
|
declare module 'fastify' {
|
||||||
|
interface FastifyRequest {
|
||||||
|
_orgId?: string;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Резолвит активную организацию для каждого запроса и кладёт в req._orgId.
|
||||||
|
* Запускается ПОСЛЕ requireAuth, поэтому сидит в onRequest hook only-after-auth.
|
||||||
|
*/
|
||||||
|
export default fp(async function activeOrgPlugin(app) {
|
||||||
|
app.addHook('preHandler', async (req) => {
|
||||||
|
if (!req.user) return; // не аутентифицирован — оставляем как есть
|
||||||
|
if (req._orgId) return;
|
||||||
|
req._orgId = await resolveOrganizationId(req);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -8,12 +8,14 @@ import authPlugin from './plugins/auth.js';
|
|||||||
import { healthRoutes } from './routes/health.js';
|
import { healthRoutes } from './routes/health.js';
|
||||||
import { meRoutes } from './routes/me.js';
|
import { meRoutes } from './routes/me.js';
|
||||||
import { organizationsRoutes } from './modules/organizations/routes.js';
|
import { organizationsRoutes } from './modules/organizations/routes.js';
|
||||||
|
import { bankAccountsRoutes } from './modules/bank-accounts/routes.js';
|
||||||
import { clientsRoutes } from './modules/clients/routes.js';
|
import { clientsRoutes } from './modules/clients/routes.js';
|
||||||
import { servicesRoutes } from './modules/services/routes.js';
|
import { servicesRoutes } from './modules/services/routes.js';
|
||||||
import { documentsRoutes } from './modules/documents/routes.js';
|
import { documentsRoutes } from './modules/documents/routes.js';
|
||||||
import { documentsPdfRoutes } from './modules/documents/pdf.routes.js';
|
import { documentsPdfRoutes } from './modules/documents/pdf.routes.js';
|
||||||
import { templatesRoutes } from './modules/templates/routes.js';
|
import { templatesRoutes } from './modules/templates/routes.js';
|
||||||
import { shutdownBrowser } from './modules/documents/pdf.js';
|
import { shutdownBrowser } from './modules/documents/pdf.js';
|
||||||
|
import activeOrgPlugin from './plugins/activeOrg.js';
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
const loggerOptions =
|
const loggerOptions =
|
||||||
@@ -36,10 +38,12 @@ async function main() {
|
|||||||
});
|
});
|
||||||
await app.register(cookie);
|
await app.register(cookie);
|
||||||
await app.register(authPlugin);
|
await app.register(authPlugin);
|
||||||
|
await app.register(activeOrgPlugin);
|
||||||
|
|
||||||
await app.register(healthRoutes);
|
await app.register(healthRoutes);
|
||||||
await app.register(meRoutes);
|
await app.register(meRoutes);
|
||||||
await app.register(organizationsRoutes);
|
await app.register(organizationsRoutes);
|
||||||
|
await app.register(bankAccountsRoutes);
|
||||||
await app.register(clientsRoutes);
|
await app.register(clientsRoutes);
|
||||||
await app.register(servicesRoutes);
|
await app.register(servicesRoutes);
|
||||||
await app.register(documentsRoutes);
|
await app.register(documentsRoutes);
|
||||||
|
|||||||
@@ -3,11 +3,13 @@ import { Link, Route, Routes } from 'react-router-dom';
|
|||||||
import { redirectToLogin, useAuth } from './auth.js';
|
import { redirectToLogin, useAuth } from './auth.js';
|
||||||
import { ClientsPage } from './pages/Clients.js';
|
import { ClientsPage } from './pages/Clients.js';
|
||||||
import { ServicesPage } from './pages/Services.js';
|
import { ServicesPage } from './pages/Services.js';
|
||||||
import { OrganizationPage } from './pages/Organization.js';
|
import { CompaniesPage } from './pages/Companies.js';
|
||||||
|
import { CompanyEditPage } from './pages/CompanyEdit.js';
|
||||||
import { DocumentsPage } from './pages/Documents.js';
|
import { DocumentsPage } from './pages/Documents.js';
|
||||||
import { DocumentEditPage } from './pages/DocumentEdit.js';
|
import { DocumentEditPage } from './pages/DocumentEdit.js';
|
||||||
import { TemplatesPage } from './pages/Templates.js';
|
import { TemplatesPage } from './pages/Templates.js';
|
||||||
import { TemplateEditPage } from './pages/TemplateEdit.js';
|
import { TemplateEditPage } from './pages/TemplateEdit.js';
|
||||||
|
import { OrgSwitcher } from './components/OrgSwitcher.js';
|
||||||
|
|
||||||
function Layout({ email }: { email: string }) {
|
function Layout({ email }: { email: string }) {
|
||||||
return (
|
return (
|
||||||
@@ -18,9 +20,9 @@ function Layout({ email }: { email: string }) {
|
|||||||
<Link to="/clients">Клиенты</Link>
|
<Link to="/clients">Клиенты</Link>
|
||||||
<Link to="/services">Услуги</Link>
|
<Link to="/services">Услуги</Link>
|
||||||
<Link to="/templates">Шаблоны</Link>
|
<Link to="/templates">Шаблоны</Link>
|
||||||
<Link to="/bank">Банк</Link>
|
<Link to="/companies">Компании</Link>
|
||||||
<Link to="/organization">Реквизиты</Link>
|
|
||||||
</nav>
|
</nav>
|
||||||
|
<OrgSwitcher />
|
||||||
<span className="user">{email}</span>
|
<span className="user">{email}</span>
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
@@ -71,8 +73,8 @@ export function App() {
|
|||||||
<Route path="/services" element={<ServicesPage />} />
|
<Route path="/services" element={<ServicesPage />} />
|
||||||
<Route path="/templates" element={<TemplatesPage />} />
|
<Route path="/templates" element={<TemplatesPage />} />
|
||||||
<Route path="/templates/:id" element={<TemplateEditPage />} />
|
<Route path="/templates/:id" element={<TemplateEditPage />} />
|
||||||
<Route path="/bank" element={<Placeholder title="Банк" />} />
|
<Route path="/companies" element={<CompaniesPage />} />
|
||||||
<Route path="/organization" element={<OrganizationPage />} />
|
<Route path="/companies/:id" element={<CompanyEditPage />} />
|
||||||
<Route path="*" element={<Placeholder title="Не найдено" />} />
|
<Route path="*" element={<Placeholder title="Не найдено" />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -103,6 +103,7 @@ export const api = {
|
|||||||
export type Organization = {
|
export type Organization = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
shortName: string | null;
|
||||||
inn: string;
|
inn: string;
|
||||||
kpp: string | null;
|
kpp: string | null;
|
||||||
ogrn: string | null;
|
ogrn: string | null;
|
||||||
@@ -112,6 +113,24 @@ export type Organization = {
|
|||||||
bankAccount: string | null;
|
bankAccount: string | null;
|
||||||
signatoryName: string | null;
|
signatoryName: string | null;
|
||||||
signatoryPosition: string | null;
|
signatoryPosition: string | null;
|
||||||
|
archivedAt: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BankAccount = {
|
||||||
|
id: string;
|
||||||
|
organizationId: string;
|
||||||
|
name: string;
|
||||||
|
bankName: string | null;
|
||||||
|
bankBik: string | null;
|
||||||
|
accountNumber: string | null;
|
||||||
|
corrAccount: string | null;
|
||||||
|
currency: string;
|
||||||
|
isPrimary: boolean;
|
||||||
|
archivedAt: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Client = {
|
export type Client = {
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { api, type Organization } from '../api.js';
|
||||||
|
|
||||||
|
export function OrgSwitcher() {
|
||||||
|
const [active, setActive] = useState<Organization | null>(null);
|
||||||
|
const [items, setItems] = useState<Organization[]>([]);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
const [a, list] = await Promise.all([
|
||||||
|
api.get<{ id: string; organization: Organization | null }>('/api/active-organization'),
|
||||||
|
api.get<{ items: Organization[] }>('/api/organizations'),
|
||||||
|
]);
|
||||||
|
setActive(a.organization);
|
||||||
|
setItems(list.items);
|
||||||
|
} catch {
|
||||||
|
/* проглатываем — баннер не критичен */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
useEffect(() => { void load(); }, []);
|
||||||
|
|
||||||
|
async function switchTo(id: string) {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const r = await api.post<{ organization: Organization }>('/api/active-organization', { id });
|
||||||
|
setActive(r.organization);
|
||||||
|
setOpen(false);
|
||||||
|
// Перезагружаем страницу — данные на странице зависят от активной организации.
|
||||||
|
window.location.reload();
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!active && items.length === 0) {
|
||||||
|
return (
|
||||||
|
<span className="org-switcher org-switcher--empty">
|
||||||
|
<Link to="/companies">Создать компанию</Link>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="org-switcher">
|
||||||
|
<button className="org-switcher__btn" onClick={() => setOpen((o) => !o)} disabled={loading}>
|
||||||
|
{active?.shortName || active?.name || '— выбрать —'}
|
||||||
|
<span className="org-switcher__chev">▾</span>
|
||||||
|
</button>
|
||||||
|
{open ? (
|
||||||
|
<div className="org-switcher__menu" onMouseLeave={() => setOpen(false)}>
|
||||||
|
{items.map((o) => (
|
||||||
|
<button
|
||||||
|
key={o.id}
|
||||||
|
className={`org-switcher__item ${o.id === active?.id ? 'org-switcher__item--active' : ''}`}
|
||||||
|
onClick={() => switchTo(o.id)}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{o.shortName || o.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<div className="org-switcher__divider" />
|
||||||
|
<Link to="/companies" className="org-switcher__item" onClick={() => setOpen(false)}>
|
||||||
|
Управление компаниями…
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
|
import { api, ApiError, type Organization } from '../api.js';
|
||||||
|
import { Button, EmptyState, Field, Modal } from '../components/ui.js';
|
||||||
|
|
||||||
|
export function CompaniesPage() {
|
||||||
|
const [items, setItems] = useState<Organization[] | null>(null);
|
||||||
|
const [creating, setCreating] = useState<{ name: string; inn: string } | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
const r = await api.get<{ items: Organization[] }>('/api/organizations');
|
||||||
|
setItems(r.items);
|
||||||
|
} catch (e) {
|
||||||
|
setError(String(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
useEffect(() => { void load(); }, []);
|
||||||
|
|
||||||
|
async function create() {
|
||||||
|
if (!creating) return;
|
||||||
|
setError(null);
|
||||||
|
setFieldErrors({});
|
||||||
|
try {
|
||||||
|
const created = await api.post<Organization>('/api/organizations', {
|
||||||
|
name: creating.name,
|
||||||
|
inn: creating.inn,
|
||||||
|
shortName: null,
|
||||||
|
kpp: null, ogrn: null, legalAddress: null,
|
||||||
|
signatoryName: null, signatoryPosition: null,
|
||||||
|
});
|
||||||
|
setCreating(null);
|
||||||
|
navigate(`/companies/${created.id}`);
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof ApiError) {
|
||||||
|
const fe = e.fieldErrors();
|
||||||
|
setFieldErrors(fe);
|
||||||
|
setError(Object.keys(fe).length ? 'Проверьте подсвеченные поля.' : e.prettyMessage());
|
||||||
|
} else {
|
||||||
|
setError(String(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function archive(id: string) {
|
||||||
|
if (!confirm('Архивировать компанию? Документы и клиенты сохранятся.')) return;
|
||||||
|
try {
|
||||||
|
await api.del(`/api/organizations/${id}`);
|
||||||
|
await load();
|
||||||
|
} catch (e) {
|
||||||
|
setError(String(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="content">
|
||||||
|
<header className="page-head">
|
||||||
|
<h2>Компании</h2>
|
||||||
|
<Button variant="primary" onClick={() => setCreating({ name: '', inn: '' })}>+ Добавить</Button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{error ? <div className="error-text">{error}</div> : null}
|
||||||
|
|
||||||
|
{items === null ? (
|
||||||
|
<p className="hint">Загрузка…</p>
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<EmptyState>
|
||||||
|
Ещё не добавлено ни одной компании. Создайте первую — её данные будут подставляться в договоры и счета как сторона-исполнитель.
|
||||||
|
</EmptyState>
|
||||||
|
) : (
|
||||||
|
<table className="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Название</th>
|
||||||
|
<th>ИНН</th>
|
||||||
|
<th>КПП</th>
|
||||||
|
<th aria-label="actions" />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{items.map((o) => (
|
||||||
|
<tr key={o.id}>
|
||||||
|
<td>
|
||||||
|
<Link to={`/companies/${o.id}`}>{o.name}</Link>
|
||||||
|
{o.shortName ? <span className="hint"> · {o.shortName}</span> : null}
|
||||||
|
</td>
|
||||||
|
<td>{o.inn}</td>
|
||||||
|
<td>{o.kpp ?? '—'}</td>
|
||||||
|
<td className="row-actions">
|
||||||
|
<Button variant="ghost" onClick={() => navigate(`/companies/${o.id}`)}>Открыть</Button>
|
||||||
|
<Button variant="danger" onClick={() => archive(o.id)}>В архив</Button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
open={creating !== null}
|
||||||
|
title="Новая компания"
|
||||||
|
onClose={() => setCreating(null)}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="ghost" onClick={() => setCreating(null)}>Отмена</Button>
|
||||||
|
<Button variant="primary" onClick={create}>Создать</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{creating ? (
|
||||||
|
<div className="form-grid">
|
||||||
|
<Field
|
||||||
|
label="Название"
|
||||||
|
value={creating.name}
|
||||||
|
onChange={(e) => setCreating({ ...creating, name: e.target.value })}
|
||||||
|
placeholder="ООО «Моя компания»"
|
||||||
|
error={fieldErrors.name}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label="ИНН"
|
||||||
|
value={creating.inn}
|
||||||
|
onChange={(e) => setCreating({ ...creating, inn: e.target.value })}
|
||||||
|
placeholder="10 или 12 цифр"
|
||||||
|
error={fieldErrors.inn}
|
||||||
|
/>
|
||||||
|
<p className="hint" style={{ gridColumn: '1 / -1' }}>
|
||||||
|
Остальные реквизиты заполните на странице компании.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</Modal>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
|
import { api, ApiError, type BankAccount, type Organization } from '../api.js';
|
||||||
|
import { Button, EmptyState, Field, Modal } from '../components/ui.js';
|
||||||
|
|
||||||
|
type Tab = 'requisites' | 'banks' | 'integrations';
|
||||||
|
|
||||||
|
export function CompanyEditPage() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [org, setOrg] = useState<Organization | null>(null);
|
||||||
|
const [tab, setTab] = useState<Tab>('requisites');
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!id) return;
|
||||||
|
api.get<Organization>(`/api/organizations/${id}`).then(setOrg).catch((e) => setError(String(e)));
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
if (!id) return null;
|
||||||
|
if (error) return <main className="content"><div className="error-text">{error}</div></main>;
|
||||||
|
if (!org) return <main className="content"><p className="hint">Загрузка…</p></main>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="content">
|
||||||
|
<header className="page-head">
|
||||||
|
<h2>{org.name}</h2>
|
||||||
|
<Button onClick={() => navigate('/companies')}>← К списку</Button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="tabs">
|
||||||
|
<button className={`tab ${tab === 'requisites' ? 'tab--active' : ''}`} onClick={() => setTab('requisites')}>Реквизиты</button>
|
||||||
|
<button className={`tab ${tab === 'banks' ? 'tab--active' : ''}`} onClick={() => setTab('banks')}>Банки и счета</button>
|
||||||
|
<button className={`tab ${tab === 'integrations' ? 'tab--active' : ''}`} onClick={() => setTab('integrations')}>Интеграции</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tab === 'requisites' ? <RequisitesTab org={org} onChange={setOrg} /> : null}
|
||||||
|
{tab === 'banks' ? <BanksTab orgId={org.id} /> : null}
|
||||||
|
{tab === 'integrations' ? <IntegrationsTab /> : null}
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RequisitesTab({ org, onChange }: { org: Organization; onChange: (o: Organization) => void }) {
|
||||||
|
const [draft, setDraft] = useState<Organization>(org);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [savedAt, setSavedAt] = useState<Date | null>(null);
|
||||||
|
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
|
const set = <K extends keyof Organization>(k: K, v: Organization[K] | string) => {
|
||||||
|
setDraft((d) => ({ ...d, [k]: v as Organization[K] }));
|
||||||
|
if (fieldErrors[k as string]) {
|
||||||
|
setFieldErrors((fe) => {
|
||||||
|
const next = { ...fe };
|
||||||
|
delete next[k as string];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
setFieldErrors({});
|
||||||
|
try {
|
||||||
|
const saved = await api.put<Organization>(`/api/organizations/${org.id}`, {
|
||||||
|
name: draft.name,
|
||||||
|
shortName: draft.shortName || null,
|
||||||
|
inn: draft.inn,
|
||||||
|
kpp: draft.kpp || null,
|
||||||
|
ogrn: draft.ogrn || null,
|
||||||
|
legalAddress: draft.legalAddress || null,
|
||||||
|
signatoryName: draft.signatoryName || null,
|
||||||
|
signatoryPosition: draft.signatoryPosition || null,
|
||||||
|
});
|
||||||
|
setDraft(saved);
|
||||||
|
onChange(saved);
|
||||||
|
setSavedAt(new Date());
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof ApiError) {
|
||||||
|
const fe = e.fieldErrors();
|
||||||
|
setFieldErrors(fe);
|
||||||
|
setError(Object.keys(fe).length ? 'Проверьте подсвеченные поля.' : e.prettyMessage());
|
||||||
|
} else {
|
||||||
|
setError(String(e));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<section className="form-grid">
|
||||||
|
<Field label="Название" value={draft.name} onChange={(e) => set('name', e.target.value)} error={fieldErrors.name} />
|
||||||
|
<Field label="Короткое имя (для шапки)" value={draft.shortName ?? ''} onChange={(e) => set('shortName', e.target.value)} placeholder='напр. "Моя компания"' error={fieldErrors.shortName} />
|
||||||
|
<Field label="ИНН" value={draft.inn} onChange={(e) => set('inn', e.target.value)} error={fieldErrors.inn} />
|
||||||
|
<Field label="КПП" value={draft.kpp ?? ''} onChange={(e) => set('kpp', e.target.value)} error={fieldErrors.kpp} />
|
||||||
|
<Field label="ОГРН/ОГРНИП" value={draft.ogrn ?? ''} onChange={(e) => set('ogrn', e.target.value)} error={fieldErrors.ogrn} />
|
||||||
|
<Field label="Юр. адрес" value={draft.legalAddress ?? ''} onChange={(e) => set('legalAddress', e.target.value)} error={fieldErrors.legalAddress} />
|
||||||
|
<Field label="Подписант ФИО" value={draft.signatoryName ?? ''} onChange={(e) => set('signatoryName', e.target.value)} error={fieldErrors.signatoryName} />
|
||||||
|
<Field label="Должность подписанта" value={draft.signatoryPosition ?? ''} onChange={(e) => set('signatoryPosition', e.target.value)} error={fieldErrors.signatoryPosition} />
|
||||||
|
</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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyAccount = (): Partial<BankAccount> => ({
|
||||||
|
name: '', bankName: '', bankBik: '', accountNumber: '', corrAccount: '', currency: 'RUB', isPrimary: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
function BanksTab({ orgId }: { orgId: string }) {
|
||||||
|
const [items, setItems] = useState<BankAccount[] | null>(null);
|
||||||
|
const [editing, setEditing] = useState<Partial<BankAccount> | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
const r = await api.get<{ items: BankAccount[] }>(`/api/organizations/${orgId}/bank-accounts`);
|
||||||
|
setItems(r.items);
|
||||||
|
} catch (e) {
|
||||||
|
setError(String(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
useEffect(() => { void load(); }, [orgId]);
|
||||||
|
|
||||||
|
const set = <K extends keyof BankAccount>(k: K, v: BankAccount[K] | string | boolean) => {
|
||||||
|
setEditing((d) => (d ? { ...d, [k]: v as BankAccount[K] } : d));
|
||||||
|
if (fieldErrors[k as string]) {
|
||||||
|
setFieldErrors((fe) => { const n = { ...fe }; delete n[k as string]; return n; });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
if (!editing) return;
|
||||||
|
setError(null);
|
||||||
|
setFieldErrors({});
|
||||||
|
const payload = {
|
||||||
|
name: editing.name ?? '',
|
||||||
|
bankName: editing.bankName || null,
|
||||||
|
bankBik: editing.bankBik || null,
|
||||||
|
accountNumber: editing.accountNumber || null,
|
||||||
|
corrAccount: editing.corrAccount || null,
|
||||||
|
currency: editing.currency || 'RUB',
|
||||||
|
isPrimary: !!editing.isPrimary,
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
if (editing.id) {
|
||||||
|
await api.put(`/api/organizations/${orgId}/bank-accounts/${editing.id}`, payload);
|
||||||
|
} else {
|
||||||
|
await api.post(`/api/organizations/${orgId}/bank-accounts`, payload);
|
||||||
|
}
|
||||||
|
setEditing(null);
|
||||||
|
await load();
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof ApiError) {
|
||||||
|
const fe = e.fieldErrors();
|
||||||
|
setFieldErrors(fe);
|
||||||
|
setError(Object.keys(fe).length ? 'Проверьте подсвеченные поля.' : e.prettyMessage());
|
||||||
|
} else {
|
||||||
|
setError(String(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function archive(a: BankAccount) {
|
||||||
|
if (!confirm(`Архивировать счёт «${a.name}»?`)) return;
|
||||||
|
try {
|
||||||
|
await api.del(`/api/organizations/${orgId}/bank-accounts/${a.id}`);
|
||||||
|
await load();
|
||||||
|
} catch (e) {
|
||||||
|
setError(String(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<header className="page-head">
|
||||||
|
<h3>Банки и счета</h3>
|
||||||
|
<Button variant="primary" onClick={() => setEditing(emptyAccount())}>+ Добавить счёт</Button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{error ? <div className="error-text">{error}</div> : null}
|
||||||
|
|
||||||
|
{items === null ? <p className="hint">Загрузка…</p>
|
||||||
|
: items.length === 0 ? (
|
||||||
|
<EmptyState>
|
||||||
|
Счетов пока нет. Добавьте — будут подставляться в счета (через интеграцию с банком).
|
||||||
|
</EmptyState>
|
||||||
|
) : (
|
||||||
|
<table className="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Имя</th>
|
||||||
|
<th>Банк / БИК</th>
|
||||||
|
<th>Расчётный счёт</th>
|
||||||
|
<th>Валюта</th>
|
||||||
|
<th />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{items.map((a) => (
|
||||||
|
<tr key={a.id}>
|
||||||
|
<td>
|
||||||
|
{a.name}
|
||||||
|
{a.isPrimary ? <span className="hint"> · основной</span> : null}
|
||||||
|
</td>
|
||||||
|
<td>{a.bankName ?? '—'}{a.bankBik ? ` / ${a.bankBik}` : ''}</td>
|
||||||
|
<td>{a.accountNumber ?? '—'}</td>
|
||||||
|
<td>{a.currency}</td>
|
||||||
|
<td className="row-actions">
|
||||||
|
<Button variant="ghost" onClick={() => setEditing(a)}>Изменить</Button>
|
||||||
|
<Button variant="danger" onClick={() => archive(a)}>В архив</Button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
open={editing !== null}
|
||||||
|
title={editing?.id ? 'Изменить счёт' : 'Новый счёт'}
|
||||||
|
onClose={() => setEditing(null)}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="ghost" onClick={() => setEditing(null)}>Отмена</Button>
|
||||||
|
<Button variant="primary" onClick={save}>Сохранить</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{editing ? (
|
||||||
|
<div className="form-grid">
|
||||||
|
<Field label="Имя счёта" value={editing.name ?? ''} onChange={(e) => set('name', e.target.value)} placeholder="Точка — основной" error={fieldErrors.name} />
|
||||||
|
<Field label="Банк" value={editing.bankName ?? ''} onChange={(e) => set('bankName', e.target.value)} error={fieldErrors.bankName} />
|
||||||
|
<Field label="БИК" value={editing.bankBik ?? ''} onChange={(e) => set('bankBik', e.target.value)} placeholder="9 цифр" error={fieldErrors.bankBik} />
|
||||||
|
<Field label="Расчётный счёт" value={editing.accountNumber ?? ''} onChange={(e) => set('accountNumber', e.target.value)} placeholder="20 цифр" error={fieldErrors.accountNumber} />
|
||||||
|
<Field label="Корр. счёт" value={editing.corrAccount ?? ''} onChange={(e) => set('corrAccount', e.target.value)} placeholder="20 цифр" error={fieldErrors.corrAccount} />
|
||||||
|
<Field label="Валюта" value={editing.currency ?? 'RUB'} onChange={(e) => set('currency', e.target.value)} placeholder="RUB" error={fieldErrors.currency} />
|
||||||
|
<label className="checkbox" style={{ gridColumn: '1 / -1' }}>
|
||||||
|
<input type="checkbox" checked={!!editing.isPrimary} onChange={(e) => set('isPrimary', e.target.checked)} />
|
||||||
|
Основной счёт компании
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function IntegrationsTab() {
|
||||||
|
return (
|
||||||
|
<section>
|
||||||
|
<h3>Интеграции</h3>
|
||||||
|
<p className="hint">
|
||||||
|
В разработке (этап M4): подключение API банка Точка для выставления счетов и приёма webhook-ов о платежах.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,160 +0,0 @@
|
|||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { api, ApiError, type Organization } from '../api.js';
|
|
||||||
import { Button, Field } from '../components/ui.js';
|
|
||||||
|
|
||||||
export function OrganizationPage() {
|
|
||||||
const [org, setOrg] = useState<Organization | null>(null);
|
|
||||||
const [draft, setDraft] = useState<Partial<Organization>>({});
|
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [savedAt, setSavedAt] = useState<Date | null>(null);
|
|
||||||
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
api
|
|
||||||
.get<Organization>('/api/organization')
|
|
||||||
.then((o) => {
|
|
||||||
setOrg(o);
|
|
||||||
setDraft(o);
|
|
||||||
})
|
|
||||||
.catch((e) => {
|
|
||||||
if (e instanceof ApiError && e.status === 404) {
|
|
||||||
// Первый запуск — БД сидится пустой записью; пользователь заполняет с нуля.
|
|
||||||
setDraft({ name: '', inn: '' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setError(String(e));
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
async function save() {
|
|
||||||
setSaving(true);
|
|
||||||
setError(null);
|
|
||||||
setFieldErrors({});
|
|
||||||
try {
|
|
||||||
const saved = await api.put<Organization>('/api/organization', {
|
|
||||||
name: draft.name ?? '',
|
|
||||||
inn: draft.inn ?? '',
|
|
||||||
kpp: draft.kpp || null,
|
|
||||||
ogrn: draft.ogrn || null,
|
|
||||||
legalAddress: draft.legalAddress || null,
|
|
||||||
bankName: draft.bankName || null,
|
|
||||||
bankBik: draft.bankBik || null,
|
|
||||||
bankAccount: draft.bankAccount || null,
|
|
||||||
signatoryName: draft.signatoryName || null,
|
|
||||||
signatoryPosition: draft.signatoryPosition || null,
|
|
||||||
});
|
|
||||||
setOrg(saved);
|
|
||||||
setDraft(saved);
|
|
||||||
setSavedAt(new Date());
|
|
||||||
} catch (e) {
|
|
||||||
if (e instanceof ApiError) {
|
|
||||||
const fe = e.fieldErrors();
|
|
||||||
setFieldErrors(fe);
|
|
||||||
setError(Object.keys(fe).length ? 'Проверьте подсвеченные поля.' : e.prettyMessage());
|
|
||||||
} else {
|
|
||||||
setError(String(e));
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const set = <K extends keyof Organization>(k: K, v: Organization[K] | string) => {
|
|
||||||
setDraft((d) => ({ ...d, [k]: v as Organization[K] }));
|
|
||||||
if (fieldErrors[k as string]) {
|
|
||||||
setFieldErrors((fe) => {
|
|
||||||
const next = { ...fe };
|
|
||||||
delete next[k as string];
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main className="content">
|
|
||||||
<h2>Реквизиты организации</h2>
|
|
||||||
<p className="hint">Будут подставляться в договоры и счета как сторона-исполнитель.</p>
|
|
||||||
|
|
||||||
<section className="form-grid">
|
|
||||||
<Field
|
|
||||||
label="Название"
|
|
||||||
value={draft.name ?? ''}
|
|
||||||
onChange={(e) => set('name', e.target.value)}
|
|
||||||
placeholder="ООО «Моя компания»"
|
|
||||||
error={fieldErrors.name}
|
|
||||||
/>
|
|
||||||
<Field
|
|
||||||
label="ИНН"
|
|
||||||
value={draft.inn ?? ''}
|
|
||||||
onChange={(e) => set('inn', e.target.value)}
|
|
||||||
placeholder="10 или 12 цифр"
|
|
||||||
error={fieldErrors.inn}
|
|
||||||
/>
|
|
||||||
<Field
|
|
||||||
label="КПП"
|
|
||||||
value={draft.kpp ?? ''}
|
|
||||||
onChange={(e) => set('kpp', e.target.value)}
|
|
||||||
placeholder="9 цифр"
|
|
||||||
error={fieldErrors.kpp}
|
|
||||||
/>
|
|
||||||
<Field
|
|
||||||
label="ОГРН/ОГРНИП"
|
|
||||||
value={draft.ogrn ?? ''}
|
|
||||||
onChange={(e) => set('ogrn', e.target.value)}
|
|
||||||
placeholder="13 или 15 цифр"
|
|
||||||
error={fieldErrors.ogrn}
|
|
||||||
/>
|
|
||||||
<Field
|
|
||||||
label="Юр. адрес"
|
|
||||||
value={draft.legalAddress ?? ''}
|
|
||||||
onChange={(e) => set('legalAddress', e.target.value)}
|
|
||||||
error={fieldErrors.legalAddress}
|
|
||||||
/>
|
|
||||||
<Field
|
|
||||||
label="Банк"
|
|
||||||
value={draft.bankName ?? ''}
|
|
||||||
onChange={(e) => set('bankName', e.target.value)}
|
|
||||||
placeholder="Точка ПАО Банка ФК Открытие"
|
|
||||||
error={fieldErrors.bankName}
|
|
||||||
/>
|
|
||||||
<Field
|
|
||||||
label="БИК"
|
|
||||||
value={draft.bankBik ?? ''}
|
|
||||||
onChange={(e) => set('bankBik', e.target.value)}
|
|
||||||
placeholder="9 цифр"
|
|
||||||
error={fieldErrors.bankBik}
|
|
||||||
/>
|
|
||||||
<Field
|
|
||||||
label="Расчётный счёт"
|
|
||||||
value={draft.bankAccount ?? ''}
|
|
||||||
onChange={(e) => set('bankAccount', e.target.value)}
|
|
||||||
placeholder="20 цифр"
|
|
||||||
error={fieldErrors.bankAccount}
|
|
||||||
/>
|
|
||||||
<Field
|
|
||||||
label="Подписант ФИО"
|
|
||||||
value={draft.signatoryName ?? ''}
|
|
||||||
onChange={(e) => set('signatoryName', e.target.value)}
|
|
||||||
error={fieldErrors.signatoryName}
|
|
||||||
/>
|
|
||||||
<Field
|
|
||||||
label="Должность подписанта"
|
|
||||||
value={draft.signatoryPosition ?? ''}
|
|
||||||
onChange={(e) => set('signatoryPosition', e.target.value)}
|
|
||||||
placeholder="Генеральный директор"
|
|
||||||
error={fieldErrors.signatoryPosition}
|
|
||||||
/>
|
|
||||||
</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}
|
|
||||||
{org === null && !error ? null : null}
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -227,6 +227,54 @@ body {
|
|||||||
.history-item { background: #1c1f24; border-color: #2a2e35; }
|
.history-item { background: #1c1f24; border-color: #2a2e35; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* === org switcher === */
|
||||||
|
.org-switcher { position: relative; }
|
||||||
|
.org-switcher__btn {
|
||||||
|
background: rgba(255,255,255,0.08); color: #f6f7f9;
|
||||||
|
border: 1px solid rgba(255,255,255,0.12); border-radius: 6px;
|
||||||
|
padding: 6px 12px; cursor: pointer; font-size: 13px;
|
||||||
|
display: inline-flex; align-items: center; gap: 6px;
|
||||||
|
}
|
||||||
|
.org-switcher__btn:hover { background: rgba(255,255,255,0.14); }
|
||||||
|
.org-switcher__chev { font-size: 10px; opacity: 0.7; }
|
||||||
|
.org-switcher__menu {
|
||||||
|
position: absolute; right: 0; top: calc(100% + 6px);
|
||||||
|
min-width: 220px; background: #fff; color: #1c1f24;
|
||||||
|
border: 1px solid #d6d8dd; border-radius: 8px;
|
||||||
|
box-shadow: 0 8px 24px rgba(0,0,0,0.12);
|
||||||
|
display: flex; flex-direction: column; padding: 4px; z-index: 50;
|
||||||
|
}
|
||||||
|
.org-switcher__item {
|
||||||
|
background: transparent; border: none; text-align: left; cursor: pointer;
|
||||||
|
padding: 8px 12px; border-radius: 4px; font-size: 13px;
|
||||||
|
color: inherit; text-decoration: none;
|
||||||
|
}
|
||||||
|
.org-switcher__item:hover:not(:disabled) { background: #f1f2f5; }
|
||||||
|
.org-switcher__item--active { background: #dbeafe; color: #1e40af; }
|
||||||
|
.org-switcher__divider { height: 1px; background: #eef0f3; margin: 4px 0; }
|
||||||
|
.org-switcher--empty a { color: #fde68a; }
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.org-switcher__menu { background: #1c1f24; color: #e7e8eb; border-color: #2a2e35; }
|
||||||
|
.org-switcher__item:hover:not(:disabled) { background: #25282e; }
|
||||||
|
.org-switcher__divider { background: #2a2e35; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === tabs === */
|
||||||
|
.tabs {
|
||||||
|
display: flex; gap: 4px; border-bottom: 1px solid #d6d8dd;
|
||||||
|
margin: 8px 0 16px;
|
||||||
|
}
|
||||||
|
.tab {
|
||||||
|
background: transparent; border: none; padding: 8px 16px; cursor: pointer;
|
||||||
|
color: inherit; font-size: 14px; border-bottom: 2px solid transparent;
|
||||||
|
margin-bottom: -1px;
|
||||||
|
}
|
||||||
|
.tab:hover { background: rgba(127,127,127,0.08); }
|
||||||
|
.tab--active { border-bottom-color: #2563eb; color: #2563eb; font-weight: 600; }
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.tabs { border-bottom-color: #2a2e35; }
|
||||||
|
}
|
||||||
|
|
||||||
/* === document status pills === */
|
/* === document status pills === */
|
||||||
.status {
|
.status {
|
||||||
display: inline-block; padding: 2px 8px; border-radius: 999px; font-size: 12px;
|
display: inline-block; padding: 2px 8px; border-radius: 999px; font-size: 12px;
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
{"root":["./src/app.tsx","./src/api.ts","./src/auth.ts","./src/main.tsx","./src/components/blockseditor.tsx","./src/components/clientpicker.tsx","./src/components/lineseditor.tsx","./src/components/ui.tsx","./src/lib/richtext.ts","./src/pages/clients.tsx","./src/pages/documentedit.tsx","./src/pages/documents.tsx","./src/pages/organization.tsx","./src/pages/services.tsx","./src/pages/templateedit.tsx","./src/pages/templates.tsx"],"version":"5.9.3"}
|
{"root":["./src/app.tsx","./src/api.ts","./src/auth.ts","./src/main.tsx","./src/components/blockseditor.tsx","./src/components/clientpicker.tsx","./src/components/lineseditor.tsx","./src/components/orgswitcher.tsx","./src/components/ui.tsx","./src/lib/richtext.ts","./src/pages/clients.tsx","./src/pages/companies.tsx","./src/pages/companyedit.tsx","./src/pages/documentedit.tsx","./src/pages/documents.tsx","./src/pages/services.tsx","./src/pages/templateedit.tsx","./src/pages/templates.tsx"],"version":"5.9.3"}
|
||||||
Reference in New Issue
Block a user