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:
admin
2026-05-01 11:08:26 +03:00
parent b28c0463b3
commit 524789facc
15 changed files with 909 additions and 200 deletions
@@ -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;
+27
View File
@@ -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
View File
@@ -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();
},
);
}
+87 -29
View File
@@ -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();
});
} }
+20
View File
@@ -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);
});
});
+4
View File
@@ -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);
+7 -5
View File
@@ -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>
</> </>
+19
View File
@@ -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 = {
+72
View File
@@ -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>
);
}
+137
View File
@@ -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>
);
}
+270
View File
@@ -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>
);
}
-160
View File
@@ -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>
);
}
+48
View File
@@ -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
View File
@@ -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"}