diff --git a/apps/api/prisma/migrations/1_multiorg/migration.sql b/apps/api/prisma/migrations/1_multiorg/migration.sql new file mode 100644 index 0000000..148b8ab --- /dev/null +++ b/apps/api/prisma/migrations/1_multiorg/migration.sql @@ -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; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 3ff8575..d59dd39 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -58,15 +58,19 @@ enum PaymentKind { model Organization { id String @id @default(uuid()) @db.Uuid name String + shortName String? // короткое имя для бейджей в UI / селектора inn String kpp String? ogrn String? legalAddress String? + // Поля bankName/bankBik/bankAccount устарели после введения BankAccount. + // Оставлены для обратной совместимости с уже сохранёнными данными. bankName String? bankBik String? bankAccount String? signatoryName String? signatoryPosition String? + archivedAt DateTime? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -77,6 +81,27 @@ model Organization { payments Payment[] tochkaCredentials TochkaCredential[] 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 { @@ -209,6 +234,8 @@ model TochkaCredential { id String @id @default(uuid()) @db.Uuid organizationId String @db.Uuid organization Organization @relation(fields: [organizationId], references: [id]) + bankAccountId String? @db.Uuid + bankAccount BankAccount? @relation(fields: [bankAccountId], references: [id]) environment TochkaEnv // AES-256-GCM ciphertext (iv|tag|ct), base64 jwtEncrypted String diff --git a/apps/api/src/lib/org.ts b/apps/api/src/lib/org.ts index eb81c80..510ec6c 100644 --- a/apps/api/src/lib/org.ts +++ b/apps/api/src/lib/org.ts @@ -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'; +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, в контексте которого работает запрос. - * Single-tenant v1: всегда DEFAULT_ORGANIZATION_ID. - * При переходе к multi-tenant заменим на маппинг из req.user.permissions / групп / отдельной таблицы membership. + * Возвращает organization_id для текущего запроса. + * Алгоритм: + * 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 { + 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; } + +/** + * Старая функция для роутов, которые ещё не переехали на 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: '/' }); +} diff --git a/apps/api/src/modules/bank-accounts/routes.ts b/apps/api/src/modules/bank-accounts/routes.ts new file mode 100644 index 0000000..6c0ec20 --- /dev/null +++ b/apps/api/src/modules/bank-accounts/routes.ts @@ -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(); + }, + ); +} diff --git a/apps/api/src/modules/organizations/routes.ts b/apps/api/src/modules/organizations/routes.ts index f06b1a3..7314516 100644 --- a/apps/api/src/modules/organizations/routes.ts +++ b/apps/api/src/modules/organizations/routes.ts @@ -1,54 +1,112 @@ import type { FastifyInstance } from 'fastify'; import { z } from 'zod'; 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'; -const OrgUpdate = z.object({ +const OrgUpsert = z.object({ name: z.string().min(1).max(500), + shortName: optionalText(100), inn: z.string().regex(/^\d{10}$|^\d{12}$/), kpp: optionalRegex(/^\d{9}$/), ogrn: optionalRegex(/^\d{13}$|^\d{15}$/), legalAddress: optionalText(1000), - bankName: optionalText(500), - bankBik: optionalRegex(/^\d{9}$/), - bankAccount: optionalRegex(/^\d{20}$/), signatoryName: optionalText(500), signatoryPosition: optionalText(500), }); +const SetActive = z.object({ id: z.string().uuid() }); + 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') }, async (req, reply) => { - const id = getOrganizationId(req); - 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); + const parsed = SetActive.safeParse(req.body); if (!parsed.success) { reply.code(400).send({ error: 'validation_error', issues: parsed.error.flatten() }); return; } - // upsert чтобы первое сохранение из UI создавало строку, если её ещё нет (вместо seed-only) - const org = await prisma.organization.upsert({ - where: { id }, - update: parsed.data, - create: { id, ...parsed.data }, + const org = await prisma.organization.findFirst({ + where: { id: parsed.data.id, archivedAt: null }, }); - 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(); + }); } diff --git a/apps/api/src/plugins/activeOrg.ts b/apps/api/src/plugins/activeOrg.ts new file mode 100644 index 0000000..951b0c2 --- /dev/null +++ b/apps/api/src/plugins/activeOrg.ts @@ -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); + }); +}); diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index 1e968eb..7b9cea0 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -8,12 +8,14 @@ import authPlugin from './plugins/auth.js'; import { healthRoutes } from './routes/health.js'; import { meRoutes } from './routes/me.js'; import { organizationsRoutes } from './modules/organizations/routes.js'; +import { bankAccountsRoutes } from './modules/bank-accounts/routes.js'; import { clientsRoutes } from './modules/clients/routes.js'; import { servicesRoutes } from './modules/services/routes.js'; import { documentsRoutes } from './modules/documents/routes.js'; import { documentsPdfRoutes } from './modules/documents/pdf.routes.js'; import { templatesRoutes } from './modules/templates/routes.js'; import { shutdownBrowser } from './modules/documents/pdf.js'; +import activeOrgPlugin from './plugins/activeOrg.js'; async function main() { const loggerOptions = @@ -36,10 +38,12 @@ async function main() { }); await app.register(cookie); await app.register(authPlugin); + await app.register(activeOrgPlugin); await app.register(healthRoutes); await app.register(meRoutes); await app.register(organizationsRoutes); + await app.register(bankAccountsRoutes); await app.register(clientsRoutes); await app.register(servicesRoutes); await app.register(documentsRoutes); diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index c084ca2..91dd5f6 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -3,11 +3,13 @@ import { Link, Route, Routes } from 'react-router-dom'; import { redirectToLogin, useAuth } from './auth.js'; import { ClientsPage } from './pages/Clients.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 { DocumentEditPage } from './pages/DocumentEdit.js'; import { TemplatesPage } from './pages/Templates.js'; import { TemplateEditPage } from './pages/TemplateEdit.js'; +import { OrgSwitcher } from './components/OrgSwitcher.js'; function Layout({ email }: { email: string }) { return ( @@ -18,9 +20,9 @@ function Layout({ email }: { email: string }) { Клиенты Услуги Шаблоны - Банк - Реквизиты + Компании + {email} ); @@ -71,8 +73,8 @@ export function App() { } /> } /> } /> - } /> - } /> + } /> + } /> } /> diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index 24eb51a..6d184be 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -103,6 +103,7 @@ export const api = { export type Organization = { id: string; name: string; + shortName: string | null; inn: string; kpp: string | null; ogrn: string | null; @@ -112,6 +113,24 @@ export type Organization = { bankAccount: string | null; signatoryName: 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 = { diff --git a/apps/web/src/components/OrgSwitcher.tsx b/apps/web/src/components/OrgSwitcher.tsx new file mode 100644 index 0000000..8c61390 --- /dev/null +++ b/apps/web/src/components/OrgSwitcher.tsx @@ -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(null); + const [items, setItems] = useState([]); + 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 ( + + Создать компанию + + ); + } + + return ( +
+ + {open ? ( +
setOpen(false)}> + {items.map((o) => ( + + ))} +
+ setOpen(false)}> + Управление компаниями… + +
+ ) : null} +
+ ); +} diff --git a/apps/web/src/pages/Companies.tsx b/apps/web/src/pages/Companies.tsx new file mode 100644 index 0000000..942b071 --- /dev/null +++ b/apps/web/src/pages/Companies.tsx @@ -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(null); + const [creating, setCreating] = useState<{ name: string; inn: string } | null>(null); + const [error, setError] = useState(null); + const [fieldErrors, setFieldErrors] = useState>({}); + 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('/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 ( +
+
+

Компании

+ +
+ + {error ?
{error}
: null} + + {items === null ? ( +

Загрузка…

+ ) : items.length === 0 ? ( + + Ещё не добавлено ни одной компании. Создайте первую — её данные будут подставляться в договоры и счета как сторона-исполнитель. + + ) : ( + + + + + + + + + + {items.map((o) => ( + + + + + + + ))} + +
НазваниеИННКПП +
+ {o.name} + {o.shortName ? · {o.shortName} : null} + {o.inn}{o.kpp ?? '—'} + + +
+ )} + + setCreating(null)} + footer={ + <> + + + + } + > + {creating ? ( +
+ setCreating({ ...creating, name: e.target.value })} + placeholder="ООО «Моя компания»" + error={fieldErrors.name} + /> + setCreating({ ...creating, inn: e.target.value })} + placeholder="10 или 12 цифр" + error={fieldErrors.inn} + /> +

+ Остальные реквизиты заполните на странице компании. +

+
+ ) : null} +
+
+ ); +} diff --git a/apps/web/src/pages/CompanyEdit.tsx b/apps/web/src/pages/CompanyEdit.tsx new file mode 100644 index 0000000..f643141 --- /dev/null +++ b/apps/web/src/pages/CompanyEdit.tsx @@ -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(null); + const [tab, setTab] = useState('requisites'); + const [error, setError] = useState(null); + + useEffect(() => { + if (!id) return; + api.get(`/api/organizations/${id}`).then(setOrg).catch((e) => setError(String(e))); + }, [id]); + + if (!id) return null; + if (error) return
{error}
; + if (!org) return

Загрузка…

; + + return ( +
+
+

{org.name}

+ +
+ +
+ + + +
+ + {tab === 'requisites' ? : null} + {tab === 'banks' ? : null} + {tab === 'integrations' ? : null} +
+ ); +} + +function RequisitesTab({ org, onChange }: { org: Organization; onChange: (o: Organization) => void }) { + const [draft, setDraft] = useState(org); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const [savedAt, setSavedAt] = useState(null); + const [fieldErrors, setFieldErrors] = useState>({}); + + const set = (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(`/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 ( + <> +
+ set('name', e.target.value)} error={fieldErrors.name} /> + set('shortName', e.target.value)} placeholder='напр. "Моя компания"' error={fieldErrors.shortName} /> + set('inn', e.target.value)} error={fieldErrors.inn} /> + set('kpp', e.target.value)} error={fieldErrors.kpp} /> + set('ogrn', e.target.value)} error={fieldErrors.ogrn} /> + set('legalAddress', e.target.value)} error={fieldErrors.legalAddress} /> + set('signatoryName', e.target.value)} error={fieldErrors.signatoryName} /> + set('signatoryPosition', e.target.value)} error={fieldErrors.signatoryPosition} /> +
+ +
+ + {savedAt ? Сохранено в {savedAt.toLocaleTimeString('ru-RU')} : null} + {error ? {error} : null} +
+ + ); +} + +const emptyAccount = (): Partial => ({ + name: '', bankName: '', bankBik: '', accountNumber: '', corrAccount: '', currency: 'RUB', isPrimary: false, +}); + +function BanksTab({ orgId }: { orgId: string }) { + const [items, setItems] = useState(null); + const [editing, setEditing] = useState | null>(null); + const [error, setError] = useState(null); + const [fieldErrors, setFieldErrors] = useState>({}); + + 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: 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 ( + <> +
+

Банки и счета

+ +
+ + {error ?
{error}
: null} + + {items === null ?

Загрузка…

+ : items.length === 0 ? ( + + Счетов пока нет. Добавьте — будут подставляться в счета (через интеграцию с банком). + + ) : ( + + + + + + + + + + + {items.map((a) => ( + + + + + + + + ))} + +
ИмяБанк / БИКРасчётный счётВалюта +
+ {a.name} + {a.isPrimary ? · основной : null} + {a.bankName ?? '—'}{a.bankBik ? ` / ${a.bankBik}` : ''}{a.accountNumber ?? '—'}{a.currency} + + +
+ )} + + setEditing(null)} + footer={ + <> + + + + } + > + {editing ? ( +
+ set('name', e.target.value)} placeholder="Точка — основной" error={fieldErrors.name} /> + set('bankName', e.target.value)} error={fieldErrors.bankName} /> + set('bankBik', e.target.value)} placeholder="9 цифр" error={fieldErrors.bankBik} /> + set('accountNumber', e.target.value)} placeholder="20 цифр" error={fieldErrors.accountNumber} /> + set('corrAccount', e.target.value)} placeholder="20 цифр" error={fieldErrors.corrAccount} /> + set('currency', e.target.value)} placeholder="RUB" error={fieldErrors.currency} /> + +
+ ) : null} +
+ + ); +} + +function IntegrationsTab() { + return ( +
+

Интеграции

+

+ В разработке (этап M4): подключение API банка Точка для выставления счетов и приёма webhook-ов о платежах. +

+
+ ); +} diff --git a/apps/web/src/pages/Organization.tsx b/apps/web/src/pages/Organization.tsx deleted file mode 100644 index 646275d..0000000 --- a/apps/web/src/pages/Organization.tsx +++ /dev/null @@ -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(null); - const [draft, setDraft] = useState>({}); - const [saving, setSaving] = useState(false); - const [error, setError] = useState(null); - const [savedAt, setSavedAt] = useState(null); - const [fieldErrors, setFieldErrors] = useState>({}); - - useEffect(() => { - api - .get('/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('/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: 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 ( -
-

Реквизиты организации

-

Будут подставляться в договоры и счета как сторона-исполнитель.

- -
- set('name', e.target.value)} - placeholder="ООО «Моя компания»" - error={fieldErrors.name} - /> - set('inn', e.target.value)} - placeholder="10 или 12 цифр" - error={fieldErrors.inn} - /> - set('kpp', e.target.value)} - placeholder="9 цифр" - error={fieldErrors.kpp} - /> - set('ogrn', e.target.value)} - placeholder="13 или 15 цифр" - error={fieldErrors.ogrn} - /> - set('legalAddress', e.target.value)} - error={fieldErrors.legalAddress} - /> - set('bankName', e.target.value)} - placeholder="Точка ПАО Банка ФК Открытие" - error={fieldErrors.bankName} - /> - set('bankBik', e.target.value)} - placeholder="9 цифр" - error={fieldErrors.bankBik} - /> - set('bankAccount', e.target.value)} - placeholder="20 цифр" - error={fieldErrors.bankAccount} - /> - set('signatoryName', e.target.value)} - error={fieldErrors.signatoryName} - /> - set('signatoryPosition', e.target.value)} - placeholder="Генеральный директор" - error={fieldErrors.signatoryPosition} - /> -
- -
- - {savedAt ? Сохранено в {savedAt.toLocaleTimeString('ru-RU')} : null} - {error ? {error} : null} - {org === null && !error ? null : null} -
-
- ); -} diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index 4bedc86..ee7a88e 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -227,6 +227,54 @@ body { .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 === */ .status { display: inline-block; padding: 2px 8px; border-radius: 999px; font-size: 12px; diff --git a/apps/web/tsconfig.tsbuildinfo b/apps/web/tsconfig.tsbuildinfo index 51c17d1..d13b9ad 100644 --- a/apps/web/tsconfig.tsbuildinfo +++ b/apps/web/tsconfig.tsbuildinfo @@ -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"} \ No newline at end of file +{"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"} \ No newline at end of file