init: M1 scaffolding + M2 organization/clients/services CRUD
- monorepo (npm workspaces): apps/api (Fastify+Prisma+TS), apps/web (Vite+React+TS), packages/shared (zod schemas) - SSO via auth.queo.ru: jose+JWKS plugin, requireDocPermission(viewer|user|admin) - DEV_BYPASS_AUTH for local development (hard-checked off in production) - M2: organization upsert, clients CRUD with search, services catalog with soft-delete - BigInt -> Number serializer for Prisma money columns - Embedded Postgres + npm run dev:demo for one-command local boot - Docker compose for queoserver: postgres + api + web (nginx as ingress proxying /api -> api:3030) - First migration 0_init committed (prisma migrate diff) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
node_modules/
|
||||
dist/
|
||||
build/
|
||||
.next/
|
||||
.cache/
|
||||
.turbo/
|
||||
coverage/
|
||||
|
||||
# env
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
!.env.example
|
||||
|
||||
# logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
# editors
|
||||
.vscode/
|
||||
.idea/
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# prisma generated
|
||||
apps/api/prisma/migrations/dev.db*
|
||||
|
||||
# generated PDFs/uploads
|
||||
storage/
|
||||
data/
|
||||
|
||||
|
||||
# OS
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# Claude Code local
|
||||
.claude/settings.local.json
|
||||
@@ -0,0 +1,106 @@
|
||||
# Doc_manager
|
||||
|
||||
Сервис создания договоров, счетов и актов/УПД с интеграцией банка Точка. Часть экосистемы Queo.
|
||||
|
||||
## Стек
|
||||
|
||||
- **Backend** — Node.js 20+ / Fastify / Prisma / PostgreSQL (TypeScript, ESM).
|
||||
- **Frontend** — React + Vite + TipTap (TypeScript).
|
||||
- **Auth** — SSO через `auth.queo.ru` (RS256 JWT в cookie `q_at`, JWKS-валидация).
|
||||
- **Bank** — Точка API: счета и акты/УПД создаются на стороне банка, договоры — наши.
|
||||
|
||||
## Структура
|
||||
|
||||
```
|
||||
apps/
|
||||
api/ # Fastify + Prisma
|
||||
web/ # React + Vite
|
||||
packages/
|
||||
shared/ # zod-схемы и типы (DocBody, Tochka DTOs)
|
||||
docker/
|
||||
docker-compose.yml
|
||||
Caddyfile
|
||||
```
|
||||
|
||||
## Подготовка перед разработкой
|
||||
|
||||
1. **Node.js ≥ 20** — установлен.
|
||||
2. **PostgreSQL** — локально или через Docker (`docker compose up postgres`).
|
||||
3. **Auth_server** должен принимать редиректы на `doc.queo.ru` — добавить в `C:\project\Auth_server\.env`:
|
||||
```
|
||||
ALLOWED_RETURN_TO_HOSTS=auth.queo.ru,hall.queo.ru,cloud.queo.ru,doc.queo.ru
|
||||
```
|
||||
|
||||
## Быстрый старт «пощупать прямо сейчас»
|
||||
|
||||
Нет Postgres / Docker / доступа к auth.queo.ru — всё равно работает:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev:demo
|
||||
```
|
||||
|
||||
Что делает `dev:demo`:
|
||||
- Запускает встроенный Postgres из `node_modules` (первый раз скачает ~80MB binaries в `data/embedded-pg/`).
|
||||
- Накатывает схему через `prisma db push`.
|
||||
- Сидит организацию `DEFAULT_ORGANIZATION_ID`.
|
||||
- Поднимает API на `:3030` и web на `:5173` параллельно.
|
||||
- В API включает `DEV_BYPASS_AUTH=1` — фейковый admin без auth.queo.ru.
|
||||
|
||||
Открыть `http://localhost:5173/` — попадаешь сразу в шелл (без логина), можно ходить:
|
||||
- `/organization` — реквизиты компании,
|
||||
- `/clients` — клиенты,
|
||||
- `/services` — каталог услуг,
|
||||
- остальное — заглушки до M3+.
|
||||
|
||||
Данные между перезапусками **сохраняются** в `data/embedded-pg/`. Чтобы начать с нуля — удалить эту папку.
|
||||
|
||||
⚠️ **Безопасность:** `DEV_BYPASS_AUTH=1` обходит JWT. API hard-checks: при `NODE_ENV=production` с этим флагом процесс отказывается стартовать.
|
||||
|
||||
## Полная установка (с реальным auth.queo.ru)
|
||||
|
||||
```bash
|
||||
npm install
|
||||
cp apps/api/.env.example apps/api/.env
|
||||
# отредактировать DATABASE_URL, оставить DEV_BYPASS_AUTH=0
|
||||
npm run prisma:migrate
|
||||
npm run -w @doc-manager/api prisma:seed
|
||||
npm run dev # api+web с настоящей SSO-авторизацией
|
||||
```
|
||||
|
||||
API на `http://localhost:3030`, web — на `http://localhost:5173`. Перед этим добавить `doc.queo.ru` в `ALLOWED_RETURN_TO_HOSTS` в `C:\project\Auth_server\.env`.
|
||||
|
||||
## Тесты M1 (smoke)
|
||||
|
||||
- `GET http://localhost:3030/health` → `{ ok: true }`
|
||||
- Открыть `http://localhost:5173` без cookie `q_at` → редирект на `https://auth.queo.ru/auth/login?return_to=...`.
|
||||
- С валидной cookie от auth.queo.ru — `/api/me` отдаёт payload JWT.
|
||||
|
||||
## Тесты M2 (организация, клиенты, услуги)
|
||||
|
||||
С Postgres и применённой миграцией:
|
||||
|
||||
1. Поднять БД и применить миграцию:
|
||||
```bash
|
||||
docker compose -f docker/docker-compose.yml up -d postgres
|
||||
npm run prisma:migrate # создаст первую миграцию
|
||||
npm run -w @doc-manager/api prisma:seed # сидит DEFAULT_ORGANIZATION_ID
|
||||
```
|
||||
2. `GET /api/organization` → возвращает запись организации (после сида).
|
||||
3. `GET /api/clients?q=...` — поиск по name/inn/email.
|
||||
4. `GET /api/services?includeArchived=1` — каталог + архив.
|
||||
5. В UI: `/organization` — заполнить реквизиты; `/clients` — добавить клиента; `/services` — добавить услугу с ценой и НДС.
|
||||
|
||||
SQL-предпросмотр миграции без БД: `apps/api/prisma/init.sql.preview` (генерируется через `prisma migrate diff`).
|
||||
|
||||
## План реализации
|
||||
|
||||
См. `C:\Users\VVMedia\.claude\plans\floofy-baking-jellyfish.md`. Этапы M1–M7.
|
||||
|
||||
## Деплой (домашний сервер)
|
||||
|
||||
```
|
||||
ssh -i ~/.ssh/id_sat vmv@192.168.0.158
|
||||
cd ~/Doc-manager
|
||||
git pull && docker compose build && docker compose up -d
|
||||
```
|
||||
@@ -0,0 +1,36 @@
|
||||
# --- API server ---
|
||||
NODE_ENV=development
|
||||
PORT=3030
|
||||
HOST=127.0.0.1
|
||||
|
||||
# --- Database ---
|
||||
DATABASE_URL=postgresql://docmanager:docmanager@localhost:5432/docmanager?schema=public
|
||||
|
||||
# --- SSO via auth.queo.ru ---
|
||||
AUTH_ISSUER=https://auth.queo.ru
|
||||
AUTH_AUDIENCE=queo.ru
|
||||
AUTH_JWKS_URL=https://auth.queo.ru/.well-known/jwks.json
|
||||
AUTH_COOKIE_NAME=q_at
|
||||
AUTH_LOGIN_URL=https://auth.queo.ru/auth/login
|
||||
|
||||
# --- CORS ---
|
||||
# Допустимые источники для браузера (запятая). На проде: https://doc.queo.ru
|
||||
CORS_ORIGINS=http://localhost:5173
|
||||
|
||||
# --- Tochka API ---
|
||||
# 32 случайных байта в base64 — ключ AES-256-GCM для шифрования JWT-токенов Точки в БД.
|
||||
# Генерация: node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"
|
||||
TOCHKA_JWT_KEY=
|
||||
|
||||
# --- Webhook secret для Точки ---
|
||||
# Случайная строка, попадает в URL: /webhooks/tochka/<TOCHKA_WEBHOOK_SECRET>
|
||||
TOCHKA_WEBHOOK_SECRET=
|
||||
|
||||
# --- Default organization (single-tenant v1) ---
|
||||
# UUID единственной организации — сидится в M2.
|
||||
DEFAULT_ORGANIZATION_ID=00000000-0000-0000-0000-000000000001
|
||||
|
||||
# --- Dev-only ---
|
||||
# Если 1 — пропускает проверку JWT и подсовывает фейкового admin'а.
|
||||
# В production отказывается стартовать с этой переменной.
|
||||
DEV_BYPASS_AUTH=0
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "@doc-manager/api",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "dist/server.js",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/server.ts",
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"start": "node dist/server.js",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"prisma:generate": "prisma generate",
|
||||
"prisma:migrate": "prisma migrate dev",
|
||||
"prisma:deploy": "prisma migrate deploy",
|
||||
"prisma:seed": "tsx prisma/seed.ts",
|
||||
"dev:demo": "tsx scripts/dev-server.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@doc-manager/shared": "*",
|
||||
"@fastify/cookie": "^9.4.0",
|
||||
"@fastify/cors": "^9.0.1",
|
||||
"@fastify/helmet": "^11.1.1",
|
||||
"@prisma/client": "^5.22.0",
|
||||
"fastify": "^4.28.1",
|
||||
"fastify-plugin": "^4.5.1",
|
||||
"jose": "^5.9.6",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.16.0",
|
||||
"embedded-postgres": "^18.3.0-beta.17",
|
||||
"pino-pretty": "^11.3.0",
|
||||
"prisma": "^5.22.0",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.6.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "DocType" AS ENUM ('contract', 'invoice', 'act', 'upd');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "DocStatus" AS ENUM ('draft', 'issued', 'sent', 'partially_paid', 'paid', 'cancelled', 'signed');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "VatRate" AS ENUM ('none', '0', '5', '7', '10', '20');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ClientKind" AS ENUM ('ul', 'ip', 'fl');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "TochkaEnv" AS ENUM ('sandbox', 'prod');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "PaymentKind" AS ENUM ('incoming', 'incoming_sbp', 'incoming_sbp_b2b', 'outgoing');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Organization" (
|
||||
"id" UUID NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"inn" TEXT NOT NULL,
|
||||
"kpp" TEXT,
|
||||
"ogrn" TEXT,
|
||||
"legalAddress" TEXT,
|
||||
"bankName" TEXT,
|
||||
"bankBik" TEXT,
|
||||
"bankAccount" TEXT,
|
||||
"signatoryName" TEXT,
|
||||
"signatoryPosition" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Organization_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Client" (
|
||||
"id" UUID NOT NULL,
|
||||
"organizationId" UUID NOT NULL,
|
||||
"kind" "ClientKind" NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"inn" TEXT,
|
||||
"kpp" TEXT,
|
||||
"address" TEXT,
|
||||
"email" TEXT,
|
||||
"phone" TEXT,
|
||||
"contactPerson" TEXT,
|
||||
"requisitesJson" JSONB,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Client_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ServiceCatalog" (
|
||||
"id" UUID NOT NULL,
|
||||
"organizationId" UUID NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"unit" TEXT NOT NULL,
|
||||
"defaultPriceCents" BIGINT NOT NULL DEFAULT 0,
|
||||
"defaultVat" "VatRate" NOT NULL DEFAULT 'none',
|
||||
"notes" TEXT,
|
||||
"archivedAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "ServiceCatalog_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "DocumentTemplate" (
|
||||
"id" UUID NOT NULL,
|
||||
"organizationId" UUID NOT NULL,
|
||||
"docType" "DocType" NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"body" JSONB NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "DocumentTemplate_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Document" (
|
||||
"id" UUID NOT NULL,
|
||||
"organizationId" UUID NOT NULL,
|
||||
"docType" "DocType" NOT NULL,
|
||||
"number" TEXT NOT NULL,
|
||||
"issuedAt" TIMESTAMP(3),
|
||||
"status" "DocStatus" NOT NULL DEFAULT 'draft',
|
||||
"clientId" UUID,
|
||||
"parentDocumentId" UUID,
|
||||
"body" JSONB NOT NULL,
|
||||
"totalCents" BIGINT NOT NULL DEFAULT 0,
|
||||
"vatCents" BIGINT NOT NULL DEFAULT 0,
|
||||
"currency" TEXT NOT NULL DEFAULT 'RUB',
|
||||
"tochkaDocumentId" TEXT,
|
||||
"tochkaEnvironment" "TochkaEnv",
|
||||
"pdfPath" TEXT,
|
||||
"createdBy" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Document_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "DocumentLine" (
|
||||
"id" UUID NOT NULL,
|
||||
"documentId" UUID NOT NULL,
|
||||
"position" INTEGER NOT NULL,
|
||||
"serviceId" UUID,
|
||||
"name" TEXT NOT NULL,
|
||||
"qtyMilli" BIGINT NOT NULL DEFAULT 1000,
|
||||
"unit" TEXT NOT NULL,
|
||||
"priceCents" BIGINT NOT NULL,
|
||||
"vat" "VatRate" NOT NULL DEFAULT 'none',
|
||||
"sumCents" BIGINT NOT NULL,
|
||||
|
||||
CONSTRAINT "DocumentLine_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Payment" (
|
||||
"id" UUID NOT NULL,
|
||||
"organizationId" UUID NOT NULL,
|
||||
"documentId" UUID,
|
||||
"tochkaPaymentId" TEXT NOT NULL,
|
||||
"kind" "PaymentKind" NOT NULL,
|
||||
"amountCents" BIGINT NOT NULL,
|
||||
"payerInn" TEXT,
|
||||
"payerName" TEXT,
|
||||
"purpose" TEXT,
|
||||
"paidAt" TIMESTAMP(3),
|
||||
"raw" JSONB NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "Payment_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "TochkaCredential" (
|
||||
"id" UUID NOT NULL,
|
||||
"organizationId" UUID NOT NULL,
|
||||
"environment" "TochkaEnv" NOT NULL,
|
||||
"jwtEncrypted" TEXT NOT NULL,
|
||||
"customerCode" TEXT NOT NULL,
|
||||
"accountCode" TEXT,
|
||||
"expiresAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "TochkaCredential_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "WebhookEvent" (
|
||||
"id" UUID NOT NULL,
|
||||
"receivedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"source" TEXT NOT NULL,
|
||||
"eventType" TEXT NOT NULL,
|
||||
"raw" JSONB NOT NULL,
|
||||
"processedAt" TIMESTAMP(3),
|
||||
"error" TEXT,
|
||||
"dedupeKey" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "WebhookEvent_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "AuditLog" (
|
||||
"id" UUID NOT NULL,
|
||||
"organizationId" UUID NOT NULL,
|
||||
"actorSub" TEXT,
|
||||
"action" TEXT NOT NULL,
|
||||
"entity" TEXT NOT NULL,
|
||||
"entityId" TEXT NOT NULL,
|
||||
"diff" JSONB,
|
||||
"at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "AuditLog_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Client_organizationId_idx" ON "Client"("organizationId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Client_organizationId_name_idx" ON "Client"("organizationId", "name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ServiceCatalog_organizationId_idx" ON "ServiceCatalog"("organizationId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ServiceCatalog_organizationId_archivedAt_idx" ON "ServiceCatalog"("organizationId", "archivedAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "DocumentTemplate_organizationId_docType_idx" ON "DocumentTemplate"("organizationId", "docType");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Document_organizationId_clientId_issuedAt_idx" ON "Document"("organizationId", "clientId", "issuedAt" DESC);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Document_organizationId_status_idx" ON "Document"("organizationId", "status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Document_tochkaDocumentId_idx" ON "Document"("tochkaDocumentId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Document_organizationId_docType_number_key" ON "Document"("organizationId", "docType", "number");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "DocumentLine_documentId_idx" ON "DocumentLine"("documentId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "DocumentLine_serviceId_idx" ON "DocumentLine"("serviceId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Payment_tochkaPaymentId_key" ON "Payment"("tochkaPaymentId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Payment_organizationId_paidAt_idx" ON "Payment"("organizationId", "paidAt" DESC);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Payment_documentId_idx" ON "Payment"("documentId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "TochkaCredential_organizationId_environment_key" ON "TochkaCredential"("organizationId", "environment");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "WebhookEvent_dedupeKey_key" ON "WebhookEvent"("dedupeKey");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "WebhookEvent_source_eventType_receivedAt_idx" ON "WebhookEvent"("source", "eventType", "receivedAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AuditLog_organizationId_at_idx" ON "AuditLog"("organizationId", "at" DESC);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AuditLog_entity_entityId_idx" ON "AuditLog"("entity", "entityId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Client" ADD CONSTRAINT "Client_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ServiceCatalog" ADD CONSTRAINT "ServiceCatalog_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "DocumentTemplate" ADD CONSTRAINT "DocumentTemplate_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Document" ADD CONSTRAINT "Document_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Document" ADD CONSTRAINT "Document_clientId_fkey" FOREIGN KEY ("clientId") REFERENCES "Client"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Document" ADD CONSTRAINT "Document_parentDocumentId_fkey" FOREIGN KEY ("parentDocumentId") REFERENCES "Document"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "DocumentLine" ADD CONSTRAINT "DocumentLine_documentId_fkey" FOREIGN KEY ("documentId") REFERENCES "Document"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "DocumentLine" ADD CONSTRAINT "DocumentLine_serviceId_fkey" FOREIGN KEY ("serviceId") REFERENCES "ServiceCatalog"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Payment" ADD CONSTRAINT "Payment_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Payment" ADD CONSTRAINT "Payment_documentId_fkey" FOREIGN KEY ("documentId") REFERENCES "Document"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TochkaCredential" ADD CONSTRAINT "TochkaCredential_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "AuditLog" ADD CONSTRAINT "AuditLog_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (e.g., Git)
|
||||
provider = "postgresql"
|
||||
@@ -0,0 +1,250 @@
|
||||
// Doc_manager — модель данных
|
||||
// Полиморфная таблица documents для contract/invoice/act/upd.
|
||||
// organization_id присутствует на всех owner-scoped таблицах (single-tenant v1, готово к multi-tenant).
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
enum DocType {
|
||||
contract
|
||||
invoice
|
||||
act
|
||||
upd
|
||||
}
|
||||
|
||||
enum DocStatus {
|
||||
draft
|
||||
issued
|
||||
sent
|
||||
partially_paid
|
||||
paid
|
||||
cancelled
|
||||
signed
|
||||
}
|
||||
|
||||
enum VatRate {
|
||||
none
|
||||
vat_0 @map("0")
|
||||
vat_5 @map("5")
|
||||
vat_7 @map("7")
|
||||
vat_10 @map("10")
|
||||
vat_20 @map("20")
|
||||
}
|
||||
|
||||
enum ClientKind {
|
||||
ul // юр.лицо
|
||||
ip // ИП
|
||||
fl // физ.лицо
|
||||
}
|
||||
|
||||
enum TochkaEnv {
|
||||
sandbox
|
||||
prod
|
||||
}
|
||||
|
||||
enum PaymentKind {
|
||||
incoming
|
||||
incoming_sbp
|
||||
incoming_sbp_b2b
|
||||
outgoing
|
||||
}
|
||||
|
||||
model Organization {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
name String
|
||||
inn String
|
||||
kpp String?
|
||||
ogrn String?
|
||||
legalAddress String?
|
||||
bankName String?
|
||||
bankBik String?
|
||||
bankAccount String?
|
||||
signatoryName String?
|
||||
signatoryPosition String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
clients Client[]
|
||||
servicesCatalog ServiceCatalog[]
|
||||
templates DocumentTemplate[]
|
||||
documents Document[]
|
||||
payments Payment[]
|
||||
tochkaCredentials TochkaCredential[]
|
||||
auditLog AuditLog[]
|
||||
}
|
||||
|
||||
model Client {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
organizationId String @db.Uuid
|
||||
organization Organization @relation(fields: [organizationId], references: [id])
|
||||
kind ClientKind
|
||||
name String
|
||||
inn String?
|
||||
kpp String?
|
||||
address String?
|
||||
email String?
|
||||
phone String?
|
||||
contactPerson String?
|
||||
requisitesJson Json?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
documents Document[]
|
||||
|
||||
@@index([organizationId])
|
||||
@@index([organizationId, name])
|
||||
}
|
||||
|
||||
model ServiceCatalog {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
organizationId String @db.Uuid
|
||||
organization Organization @relation(fields: [organizationId], references: [id])
|
||||
name String
|
||||
unit String // "шт", "час", "мес"
|
||||
defaultPriceCents BigInt @default(0)
|
||||
defaultVat VatRate @default(none)
|
||||
notes String?
|
||||
archivedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
lines DocumentLine[]
|
||||
|
||||
@@index([organizationId])
|
||||
@@index([organizationId, archivedAt])
|
||||
}
|
||||
|
||||
model DocumentTemplate {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
organizationId String @db.Uuid
|
||||
organization Organization @relation(fields: [organizationId], references: [id])
|
||||
docType DocType
|
||||
name String
|
||||
body Json
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([organizationId, docType])
|
||||
}
|
||||
|
||||
model Document {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
organizationId String @db.Uuid
|
||||
organization Organization @relation(fields: [organizationId], references: [id])
|
||||
docType DocType
|
||||
number String
|
||||
issuedAt DateTime?
|
||||
status DocStatus @default(draft)
|
||||
clientId String? @db.Uuid
|
||||
client Client? @relation(fields: [clientId], references: [id])
|
||||
parentDocumentId String? @db.Uuid
|
||||
parent Document? @relation("DocumentChildren", fields: [parentDocumentId], references: [id])
|
||||
children Document[] @relation("DocumentChildren")
|
||||
body Json
|
||||
totalCents BigInt @default(0)
|
||||
vatCents BigInt @default(0)
|
||||
currency String @default("RUB")
|
||||
tochkaDocumentId String?
|
||||
tochkaEnvironment TochkaEnv?
|
||||
pdfPath String?
|
||||
createdBy String? // sub из JWT auth.queo.ru
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
lines DocumentLine[]
|
||||
payments Payment[]
|
||||
|
||||
@@unique([organizationId, docType, number])
|
||||
@@index([organizationId, clientId, issuedAt(sort: Desc)])
|
||||
@@index([organizationId, status])
|
||||
@@index([tochkaDocumentId])
|
||||
}
|
||||
|
||||
model DocumentLine {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
documentId String @db.Uuid
|
||||
document Document @relation(fields: [documentId], references: [id], onDelete: Cascade)
|
||||
position Int
|
||||
serviceId String? @db.Uuid
|
||||
service ServiceCatalog? @relation(fields: [serviceId], references: [id])
|
||||
name String
|
||||
// Количество хранится как milli-units (тысячные), чтобы поддержать дробные количества без float.
|
||||
qtyMilli BigInt @default(1000)
|
||||
unit String
|
||||
priceCents BigInt
|
||||
vat VatRate @default(none)
|
||||
sumCents BigInt
|
||||
|
||||
@@index([documentId])
|
||||
@@index([serviceId])
|
||||
}
|
||||
|
||||
model Payment {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
organizationId String @db.Uuid
|
||||
organization Organization @relation(fields: [organizationId], references: [id])
|
||||
documentId String? @db.Uuid
|
||||
document Document? @relation(fields: [documentId], references: [id])
|
||||
tochkaPaymentId String @unique
|
||||
kind PaymentKind
|
||||
amountCents BigInt
|
||||
payerInn String?
|
||||
payerName String?
|
||||
purpose String?
|
||||
paidAt DateTime?
|
||||
raw Json
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([organizationId, paidAt(sort: Desc)])
|
||||
@@index([documentId])
|
||||
}
|
||||
|
||||
model TochkaCredential {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
organizationId String @db.Uuid
|
||||
organization Organization @relation(fields: [organizationId], references: [id])
|
||||
environment TochkaEnv
|
||||
// AES-256-GCM ciphertext (iv|tag|ct), base64
|
||||
jwtEncrypted String
|
||||
customerCode String
|
||||
accountCode String?
|
||||
expiresAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([organizationId, environment])
|
||||
}
|
||||
|
||||
model WebhookEvent {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
receivedAt DateTime @default(now())
|
||||
source String // 'tochka'
|
||||
eventType String
|
||||
raw Json
|
||||
processedAt DateTime?
|
||||
error String?
|
||||
dedupeKey String @unique
|
||||
|
||||
@@index([source, eventType, receivedAt])
|
||||
}
|
||||
|
||||
model AuditLog {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
organizationId String @db.Uuid
|
||||
organization Organization @relation(fields: [organizationId], references: [id])
|
||||
actorSub String?
|
||||
action String
|
||||
entity String
|
||||
entityId String
|
||||
diff Json?
|
||||
at DateTime @default(now())
|
||||
|
||||
@@index([organizationId, at(sort: Desc)])
|
||||
@@index([entity, entityId])
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function main() {
|
||||
const id = process.env.DEFAULT_ORGANIZATION_ID ?? '00000000-0000-0000-0000-000000000001';
|
||||
|
||||
const existing = await prisma.organization.findUnique({ where: { id } });
|
||||
if (existing) {
|
||||
console.log(`organization ${id} already exists — skipping seed`);
|
||||
return;
|
||||
}
|
||||
|
||||
await prisma.organization.create({
|
||||
data: {
|
||||
id,
|
||||
name: 'ООО «Моя компания»',
|
||||
inn: '0000000000',
|
||||
// Остальные реквизиты пользователь заполнит через UI на странице «Реквизиты».
|
||||
},
|
||||
});
|
||||
console.log(`organization ${id} created (заполните реквизиты в UI: /organization)`);
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* Dev orchestrator: embedded Postgres → prisma db push → seed → API server.
|
||||
* Запуск: npm run dev:demo (в корне) или tsx scripts/dev-server.ts (в apps/api).
|
||||
*
|
||||
* Использовать ТОЛЬКО для локальной разработки. Защита: процесс выходит,
|
||||
* если NODE_ENV=production или DEV_BYPASS_AUTH не включён (см. ниже).
|
||||
*/
|
||||
import { spawn } from 'node:child_process';
|
||||
import { existsSync, mkdirSync } from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import EmbeddedPostgres from 'embedded-postgres';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const apiRoot = resolve(__dirname, '..');
|
||||
|
||||
const PG_PORT = 5433;
|
||||
const PG_USER = 'postgres';
|
||||
const PG_PASSWORD = 'postgres';
|
||||
const PG_DB = 'docmanager';
|
||||
const PG_DATA = resolve(apiRoot, '../../data/embedded-pg');
|
||||
const DATABASE_URL = `postgresql://${PG_USER}:${PG_PASSWORD}@localhost:${PG_PORT}/${PG_DB}?schema=public`;
|
||||
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
console.error('FATAL: dev-server.ts запрещён в production');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
mkdirSync(dirname(PG_DATA), { recursive: true });
|
||||
|
||||
const pg = new EmbeddedPostgres({
|
||||
databaseDir: PG_DATA,
|
||||
user: PG_USER,
|
||||
password: PG_PASSWORD,
|
||||
port: PG_PORT,
|
||||
persistent: true,
|
||||
});
|
||||
|
||||
async function exec(cmd: string, args: string[], env: NodeJS.ProcessEnv): Promise<void> {
|
||||
return new Promise((res, rej) => {
|
||||
const child = spawn(cmd, args, {
|
||||
stdio: 'inherit',
|
||||
cwd: apiRoot,
|
||||
env: { ...process.env, ...env },
|
||||
shell: process.platform === 'win32',
|
||||
});
|
||||
child.on('exit', (code) => (code === 0 ? res() : rej(new Error(`${cmd} ${args.join(' ')} exited ${code}`))));
|
||||
child.on('error', rej);
|
||||
});
|
||||
}
|
||||
|
||||
let serverChild: ReturnType<typeof spawn> | null = null;
|
||||
let stopping = false;
|
||||
|
||||
async function shutdown(reason: string) {
|
||||
if (stopping) return;
|
||||
stopping = true;
|
||||
console.log(`\n[dev-server] shutdown: ${reason}`);
|
||||
try {
|
||||
if (serverChild && !serverChild.killed) {
|
||||
serverChild.kill('SIGTERM');
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[dev-server] error stopping API:', e);
|
||||
}
|
||||
try {
|
||||
await pg.stop();
|
||||
} catch (e) {
|
||||
console.warn('[dev-server] error stopping pg:', e);
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
process.on('SIGINT', () => void shutdown('SIGINT'));
|
||||
process.on('SIGTERM', () => void shutdown('SIGTERM'));
|
||||
|
||||
async function main() {
|
||||
const dataInitialised = existsSync(resolve(PG_DATA, 'PG_VERSION'));
|
||||
|
||||
if (!dataInitialised) {
|
||||
console.log('[dev-server] initialising embedded Postgres (~80MB binaries on first run)…');
|
||||
await pg.initialise();
|
||||
}
|
||||
|
||||
console.log(`[dev-server] starting Postgres on :${PG_PORT}…`);
|
||||
await pg.start();
|
||||
|
||||
if (!dataInitialised) {
|
||||
console.log(`[dev-server] creating database "${PG_DB}"…`);
|
||||
try {
|
||||
await pg.createDatabase(PG_DB);
|
||||
} catch (e) {
|
||||
console.warn('[dev-server] createDatabase warning:', (e as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[dev-server] applying schema (prisma db push)…');
|
||||
await exec('npx', ['prisma', 'db', 'push', '--skip-generate', '--accept-data-loss'], { DATABASE_URL });
|
||||
|
||||
console.log('[dev-server] seeding default organization…');
|
||||
await exec('npx', ['tsx', 'prisma/seed.ts'], { DATABASE_URL });
|
||||
|
||||
console.log('[dev-server] starting API server…');
|
||||
serverChild = spawn('npx', ['tsx', 'watch', 'src/server.ts'], {
|
||||
stdio: 'inherit',
|
||||
cwd: apiRoot,
|
||||
env: {
|
||||
...process.env,
|
||||
DATABASE_URL,
|
||||
DEV_BYPASS_AUTH: '1',
|
||||
PORT: '3030',
|
||||
HOST: '127.0.0.1',
|
||||
NODE_ENV: 'development',
|
||||
},
|
||||
shell: process.platform === 'win32',
|
||||
});
|
||||
serverChild.on('exit', (code) => {
|
||||
if (!stopping) {
|
||||
console.error(`[dev-server] API exited unexpectedly (${code})`);
|
||||
void shutdown('api-exit');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('[dev-server] fatal:', err);
|
||||
void shutdown('fatal');
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
export const prisma = new PrismaClient({
|
||||
log: process.env.NODE_ENV === 'development' ? ['warn', 'error'] : ['error'],
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const EnvSchema = z.object({
|
||||
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
|
||||
PORT: z.coerce.number().int().positive().default(3030),
|
||||
HOST: z.string().default('127.0.0.1'),
|
||||
|
||||
DATABASE_URL: z.string().url(),
|
||||
|
||||
AUTH_ISSUER: z.string().url().default('https://auth.queo.ru'),
|
||||
AUTH_AUDIENCE: z.string().default('queo.ru'),
|
||||
AUTH_JWKS_URL: z.string().url().default('https://auth.queo.ru/.well-known/jwks.json'),
|
||||
AUTH_COOKIE_NAME: z.string().default('q_at'),
|
||||
AUTH_LOGIN_URL: z.string().url().default('https://auth.queo.ru/auth/login'),
|
||||
|
||||
CORS_ORIGINS: z
|
||||
.string()
|
||||
.default('http://localhost:5173')
|
||||
.transform((v) => v.split(',').map((s) => s.trim()).filter(Boolean)),
|
||||
|
||||
TOCHKA_JWT_KEY: z.string().optional(),
|
||||
TOCHKA_WEBHOOK_SECRET: z.string().optional(),
|
||||
|
||||
DEFAULT_ORGANIZATION_ID: z.string().uuid().default('00000000-0000-0000-0000-000000000001'),
|
||||
|
||||
// Только для локальной разработки. В проде — НИКОГДА. Hard-check ниже.
|
||||
DEV_BYPASS_AUTH: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((v) => v === '1' || v === 'true'),
|
||||
});
|
||||
|
||||
export type Env = z.infer<typeof EnvSchema>;
|
||||
|
||||
export const env: Env = EnvSchema.parse(process.env);
|
||||
|
||||
if (env.NODE_ENV === 'production' && env.DEV_BYPASS_AUTH) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('FATAL: DEV_BYPASS_AUTH=1 в production. Это обход аутентификации, отказываюсь стартовать.');
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// Fastify сериализует ответы через JSON.stringify, который ругается на BigInt.
|
||||
// В Doc_manager все денежные/количественные поля копеек/milliQty влезают в JS Number
|
||||
// (2^53-1 ≈ 9 квадриллионов копеек) — конвертируем BigInt → Number один раз глобально.
|
||||
//
|
||||
// Если когда-нибудь понадобится точность выше Number.MAX_SAFE_INTEGER — переключим
|
||||
// на сериализацию в строку и type-marshalling на фронте.
|
||||
|
||||
declare global {
|
||||
interface BigInt {
|
||||
toJSON(): number;
|
||||
}
|
||||
}
|
||||
|
||||
(BigInt.prototype as { toJSON?: () => number }).toJSON = function () {
|
||||
return Number(this);
|
||||
};
|
||||
|
||||
export {};
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { FastifyRequest } from 'fastify';
|
||||
import { env } from '../env.js';
|
||||
|
||||
/**
|
||||
* Возвращает organization_id, в контексте которого работает запрос.
|
||||
* Single-tenant v1: всегда DEFAULT_ORGANIZATION_ID.
|
||||
* При переходе к multi-tenant заменим на маппинг из req.user.permissions / групп / отдельной таблицы membership.
|
||||
*/
|
||||
export function getOrganizationId(_req: FastifyRequest): string {
|
||||
return env.DEFAULT_ORGANIZATION_ID;
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { prisma } from '../../db.js';
|
||||
import { getOrganizationId } from '../../lib/org.js';
|
||||
|
||||
const ClientUpsert = z.object({
|
||||
kind: z.enum(['ul', 'ip', 'fl']),
|
||||
name: z.string().min(1).max(500),
|
||||
inn: z
|
||||
.string()
|
||||
.regex(/^\d{10}$|^\d{12}$/)
|
||||
.nullable(),
|
||||
kpp: z.string().regex(/^\d{9}$/).nullable(),
|
||||
address: z.string().max(1000).nullable(),
|
||||
email: z.string().email().nullable(),
|
||||
phone: z.string().max(50).nullable(),
|
||||
contactPerson: z.string().max(200).nullable(),
|
||||
});
|
||||
|
||||
const ListQuery = z.object({
|
||||
q: z.string().optional(),
|
||||
limit: z.coerce.number().int().min(1).max(200).default(100),
|
||||
});
|
||||
|
||||
export async function clientsRoutes(app: FastifyInstance) {
|
||||
app.get('/api/clients', { preHandler: app.requireDocPermission('viewer') }, async (req, reply) => {
|
||||
const orgId = getOrganizationId(req);
|
||||
const parsed = ListQuery.safeParse(req.query);
|
||||
if (!parsed.success) {
|
||||
reply.code(400).send({ error: 'validation_error', issues: parsed.error.flatten() });
|
||||
return;
|
||||
}
|
||||
const { q, limit } = parsed.data;
|
||||
const clients = await prisma.client.findMany({
|
||||
where: {
|
||||
organizationId: orgId,
|
||||
...(q
|
||||
? {
|
||||
OR: [
|
||||
{ name: { contains: q, mode: 'insensitive' } },
|
||||
{ inn: { contains: q } },
|
||||
{ email: { contains: q, mode: 'insensitive' } },
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
orderBy: { name: 'asc' },
|
||||
take: limit,
|
||||
});
|
||||
return { items: clients };
|
||||
});
|
||||
|
||||
app.get('/api/clients/:id', { preHandler: app.requireDocPermission('viewer') }, async (req, reply) => {
|
||||
const orgId = getOrganizationId(req);
|
||||
const { id } = req.params as { id: string };
|
||||
const client = await prisma.client.findFirst({ where: { id, organizationId: orgId } });
|
||||
if (!client) {
|
||||
reply.code(404).send({ error: 'not_found' });
|
||||
return;
|
||||
}
|
||||
return client;
|
||||
});
|
||||
|
||||
app.post('/api/clients', { preHandler: app.requireDocPermission('user') }, async (req, reply) => {
|
||||
const orgId = getOrganizationId(req);
|
||||
const parsed = ClientUpsert.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
reply.code(400).send({ error: 'validation_error', issues: parsed.error.flatten() });
|
||||
return;
|
||||
}
|
||||
const created = await prisma.client.create({
|
||||
data: { ...parsed.data, organizationId: orgId },
|
||||
});
|
||||
reply.code(201).send(created);
|
||||
});
|
||||
|
||||
app.put('/api/clients/:id', { preHandler: app.requireDocPermission('user') }, async (req, reply) => {
|
||||
const orgId = getOrganizationId(req);
|
||||
const { id } = req.params as { id: string };
|
||||
const parsed = ClientUpsert.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
reply.code(400).send({ error: 'validation_error', issues: parsed.error.flatten() });
|
||||
return;
|
||||
}
|
||||
const existing = await prisma.client.findFirst({ where: { id, organizationId: orgId } });
|
||||
if (!existing) {
|
||||
reply.code(404).send({ error: 'not_found' });
|
||||
return;
|
||||
}
|
||||
const updated = await prisma.client.update({ where: { id }, data: parsed.data });
|
||||
return updated;
|
||||
});
|
||||
|
||||
app.delete('/api/clients/:id', { preHandler: app.requireDocPermission('admin') }, async (req, reply) => {
|
||||
const orgId = getOrganizationId(req);
|
||||
const { id } = req.params as { id: string };
|
||||
const existing = await prisma.client.findFirst({ where: { id, organizationId: orgId } });
|
||||
if (!existing) {
|
||||
reply.code(404).send({ error: 'not_found' });
|
||||
return;
|
||||
}
|
||||
// Не используем onDelete: Cascade на documents.clientId — клиента с документами лучше архивировать.
|
||||
const docCount = await prisma.document.count({ where: { clientId: id } });
|
||||
if (docCount > 0) {
|
||||
reply.code(409).send({ error: 'has_documents', count: docCount });
|
||||
return;
|
||||
}
|
||||
await prisma.client.delete({ where: { id } });
|
||||
reply.code(204).send();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { prisma } from '../../db.js';
|
||||
import { getOrganizationId } from '../../lib/org.js';
|
||||
|
||||
const OrgUpdate = z.object({
|
||||
name: z.string().min(1).max(500),
|
||||
inn: z.string().regex(/^\d{10}$|^\d{12}$/),
|
||||
kpp: z.string().regex(/^\d{9}$/).nullable(),
|
||||
ogrn: z.string().regex(/^\d{13}$|^\d{15}$/).nullable(),
|
||||
legalAddress: z.string().max(1000).nullable(),
|
||||
bankName: z.string().max(500).nullable(),
|
||||
bankBik: z.string().regex(/^\d{9}$/).nullable(),
|
||||
bankAccount: z.string().regex(/^\d{20}$/).nullable(),
|
||||
signatoryName: z.string().max(500).nullable(),
|
||||
signatoryPosition: z.string().max(500).nullable(),
|
||||
});
|
||||
|
||||
export async function organizationsRoutes(app: FastifyInstance) {
|
||||
app.get(
|
||||
'/api/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);
|
||||
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 },
|
||||
});
|
||||
return org;
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { prisma } from '../../db.js';
|
||||
import { getOrganizationId } from '../../lib/org.js';
|
||||
|
||||
const VatRate = z.enum(['none', 'vat_0', 'vat_5', 'vat_7', 'vat_10', 'vat_20']);
|
||||
|
||||
const ServiceUpsert = z.object({
|
||||
name: z.string().min(1).max(500),
|
||||
unit: z.string().min(1).max(50),
|
||||
defaultPriceCents: z.coerce.number().int().nonnegative(),
|
||||
defaultVat: VatRate.default('none'),
|
||||
notes: z.string().max(2000).nullable(),
|
||||
});
|
||||
|
||||
const ListQuery = z.object({
|
||||
q: z.string().optional(),
|
||||
includeArchived: z.coerce.boolean().default(false),
|
||||
limit: z.coerce.number().int().min(1).max(500).default(200),
|
||||
});
|
||||
|
||||
export async function servicesRoutes(app: FastifyInstance) {
|
||||
app.get('/api/services', { preHandler: app.requireDocPermission('viewer') }, async (req, reply) => {
|
||||
const orgId = getOrganizationId(req);
|
||||
const parsed = ListQuery.safeParse(req.query);
|
||||
if (!parsed.success) {
|
||||
reply.code(400).send({ error: 'validation_error', issues: parsed.error.flatten() });
|
||||
return;
|
||||
}
|
||||
const { q, includeArchived, limit } = parsed.data;
|
||||
const services = await prisma.serviceCatalog.findMany({
|
||||
where: {
|
||||
organizationId: orgId,
|
||||
...(includeArchived ? {} : { archivedAt: null }),
|
||||
...(q
|
||||
? {
|
||||
OR: [
|
||||
{ name: { contains: q, mode: 'insensitive' } },
|
||||
{ notes: { contains: q, mode: 'insensitive' } },
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
orderBy: { name: 'asc' },
|
||||
take: limit,
|
||||
});
|
||||
return { items: services };
|
||||
});
|
||||
|
||||
app.post('/api/services', { preHandler: app.requireDocPermission('user') }, async (req, reply) => {
|
||||
const orgId = getOrganizationId(req);
|
||||
const parsed = ServiceUpsert.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
reply.code(400).send({ error: 'validation_error', issues: parsed.error.flatten() });
|
||||
return;
|
||||
}
|
||||
const created = await prisma.serviceCatalog.create({
|
||||
data: {
|
||||
organizationId: orgId,
|
||||
name: parsed.data.name,
|
||||
unit: parsed.data.unit,
|
||||
defaultPriceCents: BigInt(parsed.data.defaultPriceCents),
|
||||
defaultVat: parsed.data.defaultVat,
|
||||
notes: parsed.data.notes ?? null,
|
||||
},
|
||||
});
|
||||
reply.code(201).send(created);
|
||||
});
|
||||
|
||||
app.put('/api/services/:id', { preHandler: app.requireDocPermission('user') }, async (req, reply) => {
|
||||
const orgId = getOrganizationId(req);
|
||||
const { id } = req.params as { id: string };
|
||||
const parsed = ServiceUpsert.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
reply.code(400).send({ error: 'validation_error', issues: parsed.error.flatten() });
|
||||
return;
|
||||
}
|
||||
const existing = await prisma.serviceCatalog.findFirst({ where: { id, organizationId: orgId } });
|
||||
if (!existing) {
|
||||
reply.code(404).send({ error: 'not_found' });
|
||||
return;
|
||||
}
|
||||
const updated = await prisma.serviceCatalog.update({
|
||||
where: { id },
|
||||
data: {
|
||||
name: parsed.data.name,
|
||||
unit: parsed.data.unit,
|
||||
defaultPriceCents: BigInt(parsed.data.defaultPriceCents),
|
||||
defaultVat: parsed.data.defaultVat,
|
||||
notes: parsed.data.notes ?? null,
|
||||
},
|
||||
});
|
||||
return updated;
|
||||
});
|
||||
|
||||
// Архивация — soft delete. Жёстко удалять нельзя: на услугу могут ссылаться document_lines.
|
||||
app.post('/api/services/:id/archive', { preHandler: app.requireDocPermission('user') }, async (req, reply) => {
|
||||
const orgId = getOrganizationId(req);
|
||||
const { id } = req.params as { id: string };
|
||||
const existing = await prisma.serviceCatalog.findFirst({ where: { id, organizationId: orgId } });
|
||||
if (!existing) {
|
||||
reply.code(404).send({ error: 'not_found' });
|
||||
return;
|
||||
}
|
||||
return prisma.serviceCatalog.update({
|
||||
where: { id },
|
||||
data: { archivedAt: existing.archivedAt ?? new Date() },
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/api/services/:id/unarchive', { preHandler: app.requireDocPermission('user') }, async (req, reply) => {
|
||||
const orgId = getOrganizationId(req);
|
||||
const { id } = req.params as { id: string };
|
||||
const existing = await prisma.serviceCatalog.findFirst({ where: { id, organizationId: orgId } });
|
||||
if (!existing) {
|
||||
reply.code(404).send({ error: 'not_found' });
|
||||
return;
|
||||
}
|
||||
return prisma.serviceCatalog.update({ where: { id }, data: { archivedAt: null } });
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import fp from 'fastify-plugin';
|
||||
import { createRemoteJWKSet, jwtVerify, type JWTPayload } from 'jose';
|
||||
import {
|
||||
AuthPayload,
|
||||
hasDocPermission,
|
||||
type AuthPayload as AuthPayloadT,
|
||||
type PermissionRole,
|
||||
} from '@doc-manager/shared';
|
||||
import { env } from '../env.js';
|
||||
|
||||
declare module 'fastify' {
|
||||
interface FastifyRequest {
|
||||
user: AuthPayloadT | null;
|
||||
}
|
||||
interface FastifyInstance {
|
||||
requireAuth: (req: import('fastify').FastifyRequest, reply: import('fastify').FastifyReply) => Promise<void>;
|
||||
requireDocPermission: (
|
||||
level: PermissionRole,
|
||||
) => (req: import('fastify').FastifyRequest, reply: import('fastify').FastifyReply) => Promise<void>;
|
||||
}
|
||||
}
|
||||
|
||||
// Один JWKS-резолвер на процесс, jose сам кэширует ключи.
|
||||
// Не создаём, если включён dev-bypass — лишний DNS на boot.
|
||||
const jwks = env.DEV_BYPASS_AUTH ? null : createRemoteJWKSet(new URL(env.AUTH_JWKS_URL));
|
||||
|
||||
const DEV_FAKE_USER: AuthPayloadT = {
|
||||
sub: '11111111-1111-1111-1111-111111111111',
|
||||
email: 'dev@local',
|
||||
groups: ['dev'],
|
||||
permissions: { doc_manager: 'admin' },
|
||||
isSuperuser: true,
|
||||
};
|
||||
|
||||
export default fp(async function authPlugin(app) {
|
||||
app.decorateRequest('user', null);
|
||||
|
||||
if (env.DEV_BYPASS_AUTH) {
|
||||
app.log.warn('!!! DEV_BYPASS_AUTH ON — auth fully bypassed, fake admin injected !!!');
|
||||
}
|
||||
|
||||
app.decorate('requireAuth', async function requireAuth(req, reply) {
|
||||
if (env.DEV_BYPASS_AUTH) {
|
||||
req.user = DEV_FAKE_USER;
|
||||
return;
|
||||
}
|
||||
const token = req.cookies?.[env.AUTH_COOKIE_NAME];
|
||||
if (!token) {
|
||||
reply.code(401).send({ error: 'no_token' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { payload } = await jwtVerify<JWTPayload>(token, jwks!, {
|
||||
issuer: env.AUTH_ISSUER,
|
||||
audience: env.AUTH_AUDIENCE,
|
||||
});
|
||||
const parsed = AuthPayload.safeParse(payload);
|
||||
if (!parsed.success) {
|
||||
app.log.warn({ err: parsed.error.flatten() }, 'auth: payload schema mismatch');
|
||||
reply.code(401).send({ error: 'invalid_payload' });
|
||||
return;
|
||||
}
|
||||
req.user = parsed.data;
|
||||
} catch (e) {
|
||||
const code =
|
||||
(e as { code?: string } | null)?.code === 'ERR_JWT_EXPIRED'
|
||||
? 'token_expired'
|
||||
: 'invalid_token';
|
||||
reply.code(401).send({ error: code });
|
||||
}
|
||||
});
|
||||
|
||||
app.decorate('requireDocPermission', function requireDocPermission(level: PermissionRole) {
|
||||
return async (req, reply) => {
|
||||
if (!req.user) {
|
||||
await app.requireAuth(req, reply);
|
||||
if (reply.sent) return;
|
||||
}
|
||||
if (!req.user || !hasDocPermission(req.user, level)) {
|
||||
reply.code(403).send({ error: 'forbidden' });
|
||||
}
|
||||
};
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { prisma } from '../db.js';
|
||||
|
||||
export async function healthRoutes(app: FastifyInstance) {
|
||||
app.get('/health', async () => ({ ok: true, ts: new Date().toISOString() }));
|
||||
|
||||
app.get('/health/db', async (_req, reply) => {
|
||||
try {
|
||||
await prisma.$queryRaw`SELECT 1`;
|
||||
return { ok: true };
|
||||
} catch (e) {
|
||||
app.log.error({ err: e }, 'db health check failed');
|
||||
reply.code(503).send({ ok: false, error: 'db_unavailable' });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { DOC_MANAGER_RESOURCE } from '@doc-manager/shared';
|
||||
|
||||
export async function meRoutes(app: FastifyInstance) {
|
||||
app.get('/api/me', { preHandler: app.requireAuth }, async (req) => {
|
||||
const u = req.user!;
|
||||
return {
|
||||
sub: u.sub,
|
||||
email: u.email,
|
||||
groups: u.groups,
|
||||
isSuperuser: u.isSuperuser,
|
||||
docPermission: u.permissions[DOC_MANAGER_RESOURCE] ?? null,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import './lib/bigint.js'; // глобальный BigInt → number в JSON.stringify
|
||||
import Fastify from 'fastify';
|
||||
import cookie from '@fastify/cookie';
|
||||
import cors from '@fastify/cors';
|
||||
import helmet from '@fastify/helmet';
|
||||
import { env } from './env.js';
|
||||
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 { clientsRoutes } from './modules/clients/routes.js';
|
||||
import { servicesRoutes } from './modules/services/routes.js';
|
||||
|
||||
async function main() {
|
||||
const loggerOptions =
|
||||
env.NODE_ENV === 'development'
|
||||
? {
|
||||
level: 'debug',
|
||||
transport: {
|
||||
target: 'pino-pretty',
|
||||
options: { translateTime: 'HH:MM:ss', ignore: 'pid,hostname' },
|
||||
},
|
||||
}
|
||||
: { level: 'info' };
|
||||
|
||||
const app = Fastify({ logger: loggerOptions, trustProxy: true });
|
||||
|
||||
await app.register(helmet, { contentSecurityPolicy: false });
|
||||
await app.register(cors, {
|
||||
origin: env.CORS_ORIGINS,
|
||||
credentials: true,
|
||||
});
|
||||
await app.register(cookie);
|
||||
await app.register(authPlugin);
|
||||
|
||||
await app.register(healthRoutes);
|
||||
await app.register(meRoutes);
|
||||
await app.register(organizationsRoutes);
|
||||
await app.register(clientsRoutes);
|
||||
await app.register(servicesRoutes);
|
||||
|
||||
app.setErrorHandler((err, _req, reply) => {
|
||||
app.log.error({ err }, 'unhandled error');
|
||||
if (reply.sent) return;
|
||||
reply.code(err.statusCode ?? 500).send({ error: err.code ?? 'internal_error' });
|
||||
});
|
||||
|
||||
await app.listen({ port: env.PORT, host: env.HOST });
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('fatal:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"target": "ES2022",
|
||||
"types": ["node"],
|
||||
"declaration": false,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
# URL центра аутентификации Queo
|
||||
VITE_AUTH_LOGIN_URL=https://auth.queo.ru/auth/login
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Doc_manager — Queo</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "@doc-manager/web",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@doc-manager/shared": "*",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.27.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^5.4.10"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { useEffect } from 'react';
|
||||
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';
|
||||
|
||||
function Layout({ email }: { email: string }) {
|
||||
return (
|
||||
<header className="topbar">
|
||||
<h1>Doc_manager</h1>
|
||||
<nav>
|
||||
<Link to="/">Документы</Link>
|
||||
<Link to="/clients">Клиенты</Link>
|
||||
<Link to="/services">Услуги</Link>
|
||||
<Link to="/templates">Шаблоны</Link>
|
||||
<Link to="/bank">Банк</Link>
|
||||
<Link to="/organization">Реквизиты</Link>
|
||||
</nav>
|
||||
<span className="user">{email}</span>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
function Placeholder({ title }: { title: string }) {
|
||||
return (
|
||||
<main className="content">
|
||||
<h2>{title}</h2>
|
||||
<p>В разработке. См. план: M2–M7.</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function Forbidden({ email }: { email: string }) {
|
||||
return (
|
||||
<main className="content">
|
||||
<h2>Нет доступа</h2>
|
||||
<p>
|
||||
Аккаунт <b>{email}</b> авторизован в Queo, но не имеет роли в Doc_manager. Попросите
|
||||
администратора выдать <code>doc_manager</code> permission в auth.queo.ru.
|
||||
</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export function App() {
|
||||
const auth = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
if (auth.status === 'unauthenticated') redirectToLogin();
|
||||
}, [auth.status]);
|
||||
|
||||
if (auth.status === 'loading' || auth.status === 'unauthenticated') {
|
||||
return <div className="loading">Проверка доступа…</div>;
|
||||
}
|
||||
if (auth.status === 'forbidden') return <Forbidden email={auth.me.email} />;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Layout email={auth.me.email} />
|
||||
<Routes>
|
||||
<Route path="/" element={<Placeholder title="Документы" />} />
|
||||
<Route path="/clients" element={<ClientsPage />} />
|
||||
<Route path="/services" element={<ServicesPage />} />
|
||||
<Route path="/templates" element={<Placeholder title="Шаблоны договоров" />} />
|
||||
<Route path="/bank" element={<Placeholder title="Банк" />} />
|
||||
<Route path="/organization" element={<OrganizationPage />} />
|
||||
<Route path="*" element={<Placeholder title="Не найдено" />} />
|
||||
</Routes>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { redirectToLogin } from './auth.js';
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(public status: number, public code: string, public details?: unknown) {
|
||||
super(`${status} ${code}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function request<T>(method: string, path: string, body?: unknown): Promise<T> {
|
||||
const init: RequestInit = { method, credentials: 'include' };
|
||||
if (body !== undefined) {
|
||||
init.headers = { 'Content-Type': 'application/json' };
|
||||
init.body = JSON.stringify(body);
|
||||
}
|
||||
const res = await fetch(path, init);
|
||||
if (res.status === 401) {
|
||||
redirectToLogin();
|
||||
}
|
||||
if (res.status === 204) return undefined as T;
|
||||
const text = await res.text();
|
||||
const data = text ? JSON.parse(text) : undefined;
|
||||
if (!res.ok) {
|
||||
throw new ApiError(res.status, (data as { error?: string })?.error ?? 'http_error', data);
|
||||
}
|
||||
return data as T;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: <T>(p: string) => request<T>('GET', p),
|
||||
post: <T>(p: string, body: unknown) => request<T>('POST', p, body),
|
||||
put: <T>(p: string, body: unknown) => request<T>('PUT', p, body),
|
||||
del: <T = void>(p: string) => request<T>('DELETE', p),
|
||||
};
|
||||
|
||||
export type Organization = {
|
||||
id: string;
|
||||
name: string;
|
||||
inn: string;
|
||||
kpp: string | null;
|
||||
ogrn: string | null;
|
||||
legalAddress: string | null;
|
||||
bankName: string | null;
|
||||
bankBik: string | null;
|
||||
bankAccount: string | null;
|
||||
signatoryName: string | null;
|
||||
signatoryPosition: string | null;
|
||||
};
|
||||
|
||||
export type Client = {
|
||||
id: string;
|
||||
organizationId: string;
|
||||
kind: 'ul' | 'ip' | 'fl';
|
||||
name: string;
|
||||
inn: string | null;
|
||||
kpp: string | null;
|
||||
address: string | null;
|
||||
email: string | null;
|
||||
phone: string | null;
|
||||
contactPerson: string | null;
|
||||
requisitesJson: Record<string, unknown> | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type Service = {
|
||||
id: string;
|
||||
organizationId: string;
|
||||
name: string;
|
||||
unit: string;
|
||||
defaultPriceCents: number; // BigInt сериализуется в number (см. apps/api/src/lib/bigint.ts)
|
||||
defaultVat: 'none' | 'vat_0' | 'vat_5' | 'vat_7' | 'vat_10' | 'vat_20';
|
||||
notes: string | null;
|
||||
archivedAt: string | null;
|
||||
};
|
||||
@@ -0,0 +1,58 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { PermissionRole } from '@doc-manager/shared';
|
||||
|
||||
export type Me = {
|
||||
sub: string;
|
||||
email: string;
|
||||
groups: string[];
|
||||
isSuperuser: boolean;
|
||||
docPermission: PermissionRole | null;
|
||||
};
|
||||
|
||||
const AUTH_LOGIN_URL = import.meta.env.VITE_AUTH_LOGIN_URL ?? 'https://auth.queo.ru/auth/login';
|
||||
|
||||
export function redirectToLogin(): never {
|
||||
const returnTo = encodeURIComponent(window.location.href);
|
||||
window.location.href = `${AUTH_LOGIN_URL}?return_to=${returnTo}`;
|
||||
throw new Error('redirecting');
|
||||
}
|
||||
|
||||
export type AuthState =
|
||||
| { status: 'loading' }
|
||||
| { status: 'authenticated'; me: Me }
|
||||
| { status: 'unauthenticated' }
|
||||
| { status: 'forbidden'; me: Me };
|
||||
|
||||
export function useAuth(): AuthState {
|
||||
const [state, setState] = useState<AuthState>({ status: 'loading' });
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
fetch('/api/me', { credentials: 'include' })
|
||||
.then(async (r) => {
|
||||
if (cancelled) return;
|
||||
if (r.status === 401) {
|
||||
setState({ status: 'unauthenticated' });
|
||||
return;
|
||||
}
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||
const me = (await r.json()) as Me;
|
||||
// Для M1 любой залогиненный пользователь видит шелл; запрет отдельных страниц — позже.
|
||||
if (me.docPermission == null && !me.isSuperuser) {
|
||||
setState({ status: 'forbidden', me });
|
||||
return;
|
||||
}
|
||||
setState({ status: 'authenticated', me });
|
||||
})
|
||||
.catch((e) => {
|
||||
if (cancelled) return;
|
||||
console.error('auth fetch failed', e);
|
||||
setState({ status: 'unauthenticated' });
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return state;
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { type ButtonHTMLAttributes, type InputHTMLAttributes, type ReactNode, type SelectHTMLAttributes, type TextareaHTMLAttributes, useEffect } from 'react';
|
||||
|
||||
export function Button({
|
||||
variant = 'default',
|
||||
...props
|
||||
}: ButtonHTMLAttributes<HTMLButtonElement> & { variant?: 'default' | 'primary' | 'danger' | 'ghost' }) {
|
||||
return <button {...props} className={`btn btn--${variant} ${props.className ?? ''}`} />;
|
||||
}
|
||||
|
||||
export function Field(
|
||||
props: InputHTMLAttributes<HTMLInputElement> & { label: string; error?: string },
|
||||
) {
|
||||
const { label, error, ...input } = props;
|
||||
return (
|
||||
<label className="field">
|
||||
<span className="field__label">{label}</span>
|
||||
<input {...input} className={`field__input ${error ? 'field__input--err' : ''}`} />
|
||||
{error ? <span className="field__error">{error}</span> : null}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function Textarea(props: TextareaHTMLAttributes<HTMLTextAreaElement> & { label: string }) {
|
||||
const { label, ...textarea } = props;
|
||||
return (
|
||||
<label className="field">
|
||||
<span className="field__label">{label}</span>
|
||||
<textarea {...textarea} className="field__input field__input--area" />
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function Select<T extends string>(
|
||||
props: Omit<SelectHTMLAttributes<HTMLSelectElement>, 'value' | 'onChange'> & {
|
||||
label: string;
|
||||
value: T;
|
||||
onChange: (v: T) => void;
|
||||
options: ReadonlyArray<{ value: T; label: string }>;
|
||||
},
|
||||
) {
|
||||
const { label, value, onChange, options, ...sel } = props;
|
||||
return (
|
||||
<label className="field">
|
||||
<span className="field__label">{label}</span>
|
||||
<select
|
||||
{...sel}
|
||||
className="field__input"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value as T)}
|
||||
>
|
||||
{options.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function Modal({
|
||||
open,
|
||||
title,
|
||||
onClose,
|
||||
children,
|
||||
footer,
|
||||
}: {
|
||||
open: boolean;
|
||||
title: string;
|
||||
onClose: () => void;
|
||||
children: ReactNode;
|
||||
footer?: ReactNode;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [open, onClose]);
|
||||
if (!open) return null;
|
||||
return (
|
||||
<div className="modal__backdrop" onClick={onClose}>
|
||||
<div className="modal" onClick={(e) => e.stopPropagation()}>
|
||||
<header className="modal__header">
|
||||
<h3>{title}</h3>
|
||||
<button className="modal__close" onClick={onClose} aria-label="Закрыть">
|
||||
×
|
||||
</button>
|
||||
</header>
|
||||
<div className="modal__body">{children}</div>
|
||||
{footer ? <footer className="modal__footer">{footer}</footer> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function EmptyState({ children }: { children: ReactNode }) {
|
||||
return <div className="empty">{children}</div>;
|
||||
}
|
||||
|
||||
export function formatRub(cents: number): string {
|
||||
return (cents / 100).toLocaleString('ru-RU', {
|
||||
style: 'currency',
|
||||
currency: 'RUB',
|
||||
minimumFractionDigits: 2,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { App } from './App.js';
|
||||
import './styles.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,189 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { api, ApiError, type Client } from '../api.js';
|
||||
import { Button, EmptyState, Field, Modal, Select } from '../components/ui.js';
|
||||
|
||||
const KIND_LABEL: Record<Client['kind'], string> = {
|
||||
ul: 'Юр. лицо',
|
||||
ip: 'ИП',
|
||||
fl: 'Физ. лицо',
|
||||
};
|
||||
|
||||
const emptyDraft = (): Partial<Client> => ({
|
||||
kind: 'ul',
|
||||
name: '',
|
||||
inn: '',
|
||||
kpp: '',
|
||||
address: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
contactPerson: '',
|
||||
});
|
||||
|
||||
export function ClientsPage() {
|
||||
const [items, setItems] = useState<Client[] | null>(null);
|
||||
const [q, setQ] = useState('');
|
||||
const [editing, setEditing] = useState<Partial<Client> | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function load() {
|
||||
setError(null);
|
||||
try {
|
||||
const r = await api.get<{ items: Client[] }>(
|
||||
`/api/clients${q ? `?q=${encodeURIComponent(q)}` : ''}`,
|
||||
);
|
||||
setItems(r.items);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [q]);
|
||||
|
||||
async function save() {
|
||||
if (!editing) return;
|
||||
setError(null);
|
||||
try {
|
||||
const payload = {
|
||||
kind: editing.kind ?? 'ul',
|
||||
name: editing.name ?? '',
|
||||
inn: editing.inn || null,
|
||||
kpp: editing.kpp || null,
|
||||
address: editing.address || null,
|
||||
email: editing.email || null,
|
||||
phone: editing.phone || null,
|
||||
contactPerson: editing.contactPerson || null,
|
||||
};
|
||||
if (editing.id) {
|
||||
await api.put<Client>(`/api/clients/${editing.id}`, payload);
|
||||
} else {
|
||||
await api.post<Client>('/api/clients', payload);
|
||||
}
|
||||
setEditing(null);
|
||||
await load();
|
||||
} catch (e) {
|
||||
setError(e instanceof ApiError ? `${e.code} (${e.status})` : String(e));
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(id: string) {
|
||||
if (!confirm('Удалить клиента?')) return;
|
||||
try {
|
||||
await api.del(`/api/clients/${id}`);
|
||||
await load();
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError && e.code === 'has_documents') {
|
||||
alert(`Нельзя удалить — есть ${(e.details as { count?: number })?.count ?? 0} документов. Архивацию добавим позже.`);
|
||||
return;
|
||||
}
|
||||
setError(String(e));
|
||||
}
|
||||
}
|
||||
|
||||
const set = <K extends keyof Client>(k: K, v: Client[K] | string) =>
|
||||
setEditing((d) => (d ? { ...d, [k]: v as Client[K] } : d));
|
||||
|
||||
return (
|
||||
<main className="content">
|
||||
<header className="page-head">
|
||||
<h2>Клиенты</h2>
|
||||
<Button variant="primary" onClick={() => setEditing(emptyDraft())}>
|
||||
+ Добавить
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
<div className="toolbar">
|
||||
<input
|
||||
className="search"
|
||||
placeholder="Поиск по названию, ИНН, email…"
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error ? <div className="error-text">{error}</div> : null}
|
||||
|
||||
{items === null ? (
|
||||
<p className="hint">Загрузка…</p>
|
||||
) : items.length === 0 ? (
|
||||
<EmptyState>
|
||||
{q ? 'Ничего не найдено.' : 'Пока нет клиентов. Добавьте первого, чтобы выставлять документы.'}
|
||||
</EmptyState>
|
||||
) : (
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Тип</th>
|
||||
<th>Название</th>
|
||||
<th>ИНН</th>
|
||||
<th>Email</th>
|
||||
<th>Телефон</th>
|
||||
<th aria-label="actions" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((c) => (
|
||||
<tr key={c.id}>
|
||||
<td>{KIND_LABEL[c.kind]}</td>
|
||||
<td>{c.name}</td>
|
||||
<td>{c.inn ?? '—'}</td>
|
||||
<td>{c.email ?? '—'}</td>
|
||||
<td>{c.phone ?? '—'}</td>
|
||||
<td className="row-actions">
|
||||
<Button variant="ghost" onClick={() => setEditing(c)}>
|
||||
Изменить
|
||||
</Button>
|
||||
<Button variant="danger" onClick={() => remove(c.id)}>
|
||||
Удалить
|
||||
</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>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="form-grid">
|
||||
<Select
|
||||
label="Тип"
|
||||
value={(editing?.kind ?? 'ul') as Client['kind']}
|
||||
onChange={(v) => set('kind', v)}
|
||||
options={[
|
||||
{ value: 'ul' as const, label: 'Юр. лицо' },
|
||||
{ value: 'ip' as const, label: 'ИП' },
|
||||
{ value: 'fl' as const, label: 'Физ. лицо' },
|
||||
]}
|
||||
/>
|
||||
<Field label="Название" value={editing?.name ?? ''} onChange={(e) => set('name', e.target.value)} />
|
||||
<Field label="ИНН" value={editing?.inn ?? ''} onChange={(e) => set('inn', e.target.value)} />
|
||||
<Field label="КПП" value={editing?.kpp ?? ''} onChange={(e) => set('kpp', e.target.value)} />
|
||||
<Field label="Адрес" value={editing?.address ?? ''} onChange={(e) => set('address', e.target.value)} />
|
||||
<Field label="Email" type="email" value={editing?.email ?? ''} onChange={(e) => set('email', e.target.value)} />
|
||||
<Field label="Телефон" value={editing?.phone ?? ''} onChange={(e) => set('phone', e.target.value)} />
|
||||
<Field
|
||||
label="Контактное лицо"
|
||||
value={editing?.contactPerson ?? ''}
|
||||
onChange={(e) => set('contactPerson', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
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);
|
||||
|
||||
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);
|
||||
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) {
|
||||
setError(e instanceof ApiError ? `${e.code} (${e.status})` : 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] }));
|
||||
|
||||
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="ООО «Моя компания»"
|
||||
/>
|
||||
<Field
|
||||
label="ИНН"
|
||||
value={draft.inn ?? ''}
|
||||
onChange={(e) => set('inn', e.target.value)}
|
||||
placeholder="10 или 12 цифр"
|
||||
/>
|
||||
<Field
|
||||
label="КПП"
|
||||
value={draft.kpp ?? ''}
|
||||
onChange={(e) => set('kpp', e.target.value)}
|
||||
placeholder="9 цифр"
|
||||
/>
|
||||
<Field
|
||||
label="ОГРН/ОГРНИП"
|
||||
value={draft.ogrn ?? ''}
|
||||
onChange={(e) => set('ogrn', e.target.value)}
|
||||
placeholder="13 или 15 цифр"
|
||||
/>
|
||||
<Field
|
||||
label="Юр. адрес"
|
||||
value={draft.legalAddress ?? ''}
|
||||
onChange={(e) => set('legalAddress', e.target.value)}
|
||||
/>
|
||||
<Field
|
||||
label="Банк"
|
||||
value={draft.bankName ?? ''}
|
||||
onChange={(e) => set('bankName', e.target.value)}
|
||||
placeholder="Точка ПАО Банка ФК Открытие"
|
||||
/>
|
||||
<Field
|
||||
label="БИК"
|
||||
value={draft.bankBik ?? ''}
|
||||
onChange={(e) => set('bankBik', e.target.value)}
|
||||
placeholder="9 цифр"
|
||||
/>
|
||||
<Field
|
||||
label="Расчётный счёт"
|
||||
value={draft.bankAccount ?? ''}
|
||||
onChange={(e) => set('bankAccount', e.target.value)}
|
||||
placeholder="20 цифр"
|
||||
/>
|
||||
<Field
|
||||
label="Подписант ФИО"
|
||||
value={draft.signatoryName ?? ''}
|
||||
onChange={(e) => set('signatoryName', e.target.value)}
|
||||
/>
|
||||
<Field
|
||||
label="Должность подписанта"
|
||||
value={draft.signatoryPosition ?? ''}
|
||||
onChange={(e) => set('signatoryPosition', e.target.value)}
|
||||
placeholder="Генеральный директор"
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { api, ApiError, type Service } from '../api.js';
|
||||
import { Button, EmptyState, Field, Modal, Select, Textarea, formatRub } from '../components/ui.js';
|
||||
|
||||
const VAT_OPTIONS = [
|
||||
{ value: 'none' as const, label: 'Без НДС' },
|
||||
{ value: 'vat_0' as const, label: '0%' },
|
||||
{ value: 'vat_5' as const, label: '5%' },
|
||||
{ value: 'vat_7' as const, label: '7%' },
|
||||
{ value: 'vat_10' as const, label: '10%' },
|
||||
{ value: 'vat_20' as const, label: '20%' },
|
||||
];
|
||||
|
||||
const VAT_LABEL: Record<Service['defaultVat'], string> = {
|
||||
none: 'Без НДС',
|
||||
vat_0: '0%',
|
||||
vat_5: '5%',
|
||||
vat_7: '7%',
|
||||
vat_10: '10%',
|
||||
vat_20: '20%',
|
||||
};
|
||||
|
||||
type Draft = {
|
||||
id?: string;
|
||||
name: string;
|
||||
unit: string;
|
||||
priceRub: string; // строка для контрол-инпута, конвертим в копейки при отправке
|
||||
defaultVat: Service['defaultVat'];
|
||||
notes: string;
|
||||
};
|
||||
|
||||
const emptyDraft = (): Draft => ({
|
||||
name: '',
|
||||
unit: 'шт',
|
||||
priceRub: '',
|
||||
defaultVat: 'none',
|
||||
notes: '',
|
||||
});
|
||||
|
||||
const toDraft = (s: Service): Draft => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
unit: s.unit,
|
||||
priceRub: (s.defaultPriceCents / 100).toFixed(2),
|
||||
defaultVat: s.defaultVat,
|
||||
notes: s.notes ?? '',
|
||||
});
|
||||
|
||||
export function ServicesPage() {
|
||||
const [items, setItems] = useState<Service[] | null>(null);
|
||||
const [q, setQ] = useState('');
|
||||
const [includeArchived, setIncludeArchived] = useState(false);
|
||||
const [editing, setEditing] = useState<Draft | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function load() {
|
||||
setError(null);
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (q) params.set('q', q);
|
||||
if (includeArchived) params.set('includeArchived', '1');
|
||||
const r = await api.get<{ items: Service[] }>(`/api/services?${params.toString()}`);
|
||||
setItems(r.items);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [q, includeArchived]);
|
||||
|
||||
async function save() {
|
||||
if (!editing) return;
|
||||
setError(null);
|
||||
const priceCents = Math.round(parseFloat(editing.priceRub.replace(',', '.') || '0') * 100);
|
||||
if (Number.isNaN(priceCents) || priceCents < 0) {
|
||||
setError('Некорректная цена');
|
||||
return;
|
||||
}
|
||||
const payload = {
|
||||
name: editing.name,
|
||||
unit: editing.unit,
|
||||
defaultPriceCents: priceCents,
|
||||
defaultVat: editing.defaultVat,
|
||||
notes: editing.notes || null,
|
||||
};
|
||||
try {
|
||||
if (editing.id) {
|
||||
await api.put<Service>(`/api/services/${editing.id}`, payload);
|
||||
} else {
|
||||
await api.post<Service>('/api/services', payload);
|
||||
}
|
||||
setEditing(null);
|
||||
await load();
|
||||
} catch (e) {
|
||||
setError(e instanceof ApiError ? `${e.code} (${e.status})` : String(e));
|
||||
}
|
||||
}
|
||||
|
||||
async function archive(s: Service) {
|
||||
try {
|
||||
await api.post(`/api/services/${s.id}/archive`, {});
|
||||
await load();
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
}
|
||||
}
|
||||
async function unarchive(s: Service) {
|
||||
try {
|
||||
await api.post(`/api/services/${s.id}/unarchive`, {});
|
||||
await load();
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
}
|
||||
}
|
||||
|
||||
const set = <K extends keyof Draft>(k: K, v: Draft[K]) => setEditing((d) => (d ? { ...d, [k]: v } : d));
|
||||
|
||||
return (
|
||||
<main className="content">
|
||||
<header className="page-head">
|
||||
<h2>Каталог услуг</h2>
|
||||
<Button variant="primary" onClick={() => setEditing(emptyDraft())}>
|
||||
+ Добавить
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
<div className="toolbar">
|
||||
<input
|
||||
className="search"
|
||||
placeholder="Поиск по названию или примечаниям…"
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
/>
|
||||
<label className="checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={includeArchived}
|
||||
onChange={(e) => setIncludeArchived(e.target.checked)}
|
||||
/>
|
||||
Показать архив
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{error ? <div className="error-text">{error}</div> : null}
|
||||
|
||||
{items === null ? (
|
||||
<p className="hint">Загрузка…</p>
|
||||
) : items.length === 0 ? (
|
||||
<EmptyState>
|
||||
{q ? 'Ничего не найдено.' : 'Каталог пуст. Добавьте услугу — её можно будет вставить в счёт или договор.'}
|
||||
</EmptyState>
|
||||
) : (
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Услуга</th>
|
||||
<th>Ед.</th>
|
||||
<th>Цена</th>
|
||||
<th>НДС</th>
|
||||
<th>Статус</th>
|
||||
<th aria-label="actions" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((s) => (
|
||||
<tr key={s.id} className={s.archivedAt ? 'row--archived' : ''}>
|
||||
<td>
|
||||
<div>{s.name}</div>
|
||||
{s.notes ? <div className="hint">{s.notes}</div> : null}
|
||||
</td>
|
||||
<td>{s.unit}</td>
|
||||
<td>{formatRub(s.defaultPriceCents)}</td>
|
||||
<td>{VAT_LABEL[s.defaultVat]}</td>
|
||||
<td>{s.archivedAt ? 'архив' : 'активна'}</td>
|
||||
<td className="row-actions">
|
||||
<Button variant="ghost" onClick={() => setEditing(toDraft(s))}>
|
||||
Изменить
|
||||
</Button>
|
||||
{s.archivedAt ? (
|
||||
<Button variant="ghost" onClick={() => unarchive(s)}>
|
||||
Восстановить
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="danger" onClick={() => archive(s)}>
|
||||
В архив
|
||||
</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>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="form-grid">
|
||||
<Field
|
||||
label="Название"
|
||||
value={editing?.name ?? ''}
|
||||
onChange={(e) => set('name', e.target.value)}
|
||||
placeholder="Монтаж видеостены"
|
||||
/>
|
||||
<Field
|
||||
label="Единица"
|
||||
value={editing?.unit ?? ''}
|
||||
onChange={(e) => set('unit', e.target.value)}
|
||||
placeholder="шт / час / м²"
|
||||
/>
|
||||
<Field
|
||||
label="Цена ₽"
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
step="0.01"
|
||||
value={editing?.priceRub ?? ''}
|
||||
onChange={(e) => set('priceRub', e.target.value)}
|
||||
/>
|
||||
<Select
|
||||
label="НДС по умолчанию"
|
||||
value={(editing?.defaultVat ?? 'none') as Service['defaultVat']}
|
||||
onChange={(v) => set('defaultVat', v)}
|
||||
options={VAT_OPTIONS}
|
||||
/>
|
||||
<Textarea
|
||||
label="Примечания"
|
||||
value={editing?.notes ?? ''}
|
||||
onChange={(e) => set('notes', e.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
:root {
|
||||
font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
|
||||
font-size: 15px;
|
||||
line-height: 1.45;
|
||||
color-scheme: light dark;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: #f6f7f9;
|
||||
color: #1c1f24;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body { background: #14161a; color: #e7e8eb; }
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
padding: 12px 24px;
|
||||
background: #1c1f24;
|
||||
color: #f6f7f9;
|
||||
border-bottom: 1px solid #2a2e35;
|
||||
}
|
||||
.topbar h1 { margin: 0; font-size: 18px; font-weight: 600; }
|
||||
.topbar nav { display: flex; gap: 16px; flex: 1; }
|
||||
.topbar nav a { color: #c9cbcf; text-decoration: none; }
|
||||
.topbar nav a:hover { color: #fff; }
|
||||
.topbar .user { opacity: 0.7; font-size: 13px; }
|
||||
|
||||
.content { padding: 24px; max-width: 1200px; margin: 0 auto; }
|
||||
|
||||
.loading {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
height: 100vh; opacity: 0.6;
|
||||
}
|
||||
|
||||
/* === page primitives === */
|
||||
.page-head {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.page-head h2 { margin: 0; }
|
||||
.toolbar {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.search {
|
||||
flex: 1; max-width: 360px;
|
||||
padding: 8px 12px; border: 1px solid #d6d8dd; border-radius: 6px;
|
||||
background: #fff; color: inherit; font-size: 14px;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.search { background: #1c1f24; border-color: #2a2e35; }
|
||||
}
|
||||
.checkbox {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
font-size: 13px; cursor: pointer;
|
||||
}
|
||||
.hint { opacity: 0.65; font-size: 13px; margin: 4px 0; }
|
||||
.error-text { color: #c0392b; font-size: 13px; margin: 8px 0; }
|
||||
.empty {
|
||||
padding: 48px 24px; text-align: center; opacity: 0.6;
|
||||
border: 1px dashed #d6d8dd; border-radius: 8px;
|
||||
}
|
||||
|
||||
/* === buttons === */
|
||||
.btn {
|
||||
appearance: none; cursor: pointer;
|
||||
padding: 6px 14px; border: 1px solid transparent; border-radius: 6px;
|
||||
font-size: 14px; line-height: 1.4;
|
||||
transition: background-color 0.1s, border-color 0.1s;
|
||||
}
|
||||
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.btn--default { background: #fff; border-color: #d6d8dd; color: inherit; }
|
||||
.btn--default:hover:not(:disabled) { background: #f1f2f5; }
|
||||
.btn--primary { background: #2563eb; color: #fff; }
|
||||
.btn--primary:hover:not(:disabled) { background: #1d4ed8; }
|
||||
.btn--danger { background: transparent; border-color: #c0392b; color: #c0392b; }
|
||||
.btn--danger:hover:not(:disabled) { background: #c0392b; color: #fff; }
|
||||
.btn--ghost { background: transparent; color: inherit; }
|
||||
.btn--ghost:hover:not(:disabled) { background: rgba(127,127,127,0.1); }
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.btn--default { background: #1c1f24; border-color: #2a2e35; }
|
||||
.btn--default:hover:not(:disabled) { background: #25282e; }
|
||||
}
|
||||
|
||||
/* === tables === */
|
||||
.table {
|
||||
width: 100%; border-collapse: collapse;
|
||||
background: #fff; border-radius: 8px; overflow: hidden;
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,0.05);
|
||||
}
|
||||
.table th, .table td {
|
||||
text-align: left; padding: 10px 14px; vertical-align: top;
|
||||
border-bottom: 1px solid #eef0f3;
|
||||
font-size: 14px;
|
||||
}
|
||||
.table th { font-weight: 600; opacity: 0.7; font-size: 12px; text-transform: uppercase; letter-spacing: 0.04em; }
|
||||
.table tr:last-child td { border-bottom: none; }
|
||||
.row-actions { white-space: nowrap; text-align: right; }
|
||||
.row-actions .btn { margin-left: 6px; }
|
||||
.row--archived { opacity: 0.55; }
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.table { background: #1c1f24; box-shadow: none; }
|
||||
.table th, .table td { border-bottom-color: #2a2e35; }
|
||||
}
|
||||
|
||||
/* === fields === */
|
||||
.field { display: flex; flex-direction: column; gap: 4px; }
|
||||
.field__label { font-size: 12px; opacity: 0.7; }
|
||||
.field__input {
|
||||
padding: 8px 10px; border: 1px solid #d6d8dd; border-radius: 6px;
|
||||
background: #fff; color: inherit; font-size: 14px;
|
||||
font-family: inherit;
|
||||
}
|
||||
.field__input--area { min-height: 64px; resize: vertical; }
|
||||
.field__input--err { border-color: #c0392b; }
|
||||
.field__error { color: #c0392b; font-size: 12px; }
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.field__input { background: #1c1f24; border-color: #2a2e35; }
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 12px;
|
||||
}
|
||||
.form-actions { display: flex; align-items: center; gap: 12px; margin-top: 16px; }
|
||||
|
||||
/* === modal === */
|
||||
.modal__backdrop {
|
||||
position: fixed; inset: 0; background: rgba(0,0,0,0.5);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
z-index: 100; padding: 24px;
|
||||
}
|
||||
.modal {
|
||||
background: #fff; color: inherit;
|
||||
border-radius: 8px; width: min(720px, 100%);
|
||||
max-height: calc(100vh - 48px); overflow: hidden;
|
||||
display: flex; flex-direction: column;
|
||||
box-shadow: 0 10px 40px rgba(0,0,0,0.2);
|
||||
}
|
||||
.modal__header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 14px 20px; border-bottom: 1px solid #eef0f3;
|
||||
}
|
||||
.modal__header h3 { margin: 0; font-size: 16px; }
|
||||
.modal__close {
|
||||
background: none; border: none; color: inherit; cursor: pointer;
|
||||
font-size: 22px; line-height: 1; padding: 4px 8px; border-radius: 4px;
|
||||
}
|
||||
.modal__close:hover { background: rgba(127,127,127,0.1); }
|
||||
.modal__body { padding: 20px; overflow: auto; }
|
||||
.modal__footer {
|
||||
display: flex; justify-content: flex-end; gap: 8px;
|
||||
padding: 12px 20px; border-top: 1px solid #eef0f3;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.modal { background: #1c1f24; }
|
||||
.modal__header, .modal__footer { border-color: #2a2e35; }
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"jsx": "react-jsx",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"noEmit": true,
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"root":["./src/app.tsx","./src/api.ts","./src/auth.ts","./src/main.tsx","./src/components/ui.tsx","./src/pages/clients.tsx","./src/pages/organization.tsx","./src/pages/services.tsx"],"version":"5.9.3"}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
host: true, // 0.0.0.0 — доступно с других устройств в LAN
|
||||
port: 5173,
|
||||
proxy: {
|
||||
// В dev фронт ходит на API через /api и /webhooks. CORS+credentials уже настроены, но прокси убирает cross-origin.
|
||||
'/api': { target: 'http://localhost:3030', changeOrigin: true },
|
||||
'/webhooks': { target: 'http://localhost:3030', changeOrigin: true },
|
||||
'/health': { target: 'http://localhost:3030', changeOrigin: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
# Заполнить и переименовать в .env (рядом с docker-compose.yml)
|
||||
|
||||
POSTGRES_DB=docmanager
|
||||
POSTGRES_USER=docmanager
|
||||
POSTGRES_PASSWORD=change-me-strong-password
|
||||
|
||||
# AES-256-GCM ключ для шифрования JWT-токенов Точки в БД (32 байта в base64).
|
||||
# node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"
|
||||
TOCHKA_JWT_KEY=
|
||||
|
||||
# Случайная строка в URL-пути приёмника webhook (длинная, например 32+ символов).
|
||||
# node -e "console.log(require('crypto').randomBytes(24).toString('hex'))"
|
||||
TOCHKA_WEBHOOK_SECRET=
|
||||
|
||||
# UUID единственной организации в v1.
|
||||
DEFAULT_ORGANIZATION_ID=00000000-0000-0000-0000-000000000001
|
||||
|
||||
# Токен для browserless/chromium контейнера.
|
||||
CHROMIUM_TOKEN=
|
||||
@@ -0,0 +1,24 @@
|
||||
# Caddy для Doc_manager
|
||||
# Public host: doc.queo.ru (cookie-домен .queo.ru — общий с auth.queo.ru/hall.queo.ru)
|
||||
|
||||
doc.queo.ru {
|
||||
encode zstd gzip
|
||||
|
||||
# API + webhooks + health → Fastify
|
||||
@api path /api/* /webhooks/* /health /health/*
|
||||
handle @api {
|
||||
reverse_proxy api:3030
|
||||
}
|
||||
|
||||
# Всё остальное — статика SPA
|
||||
handle {
|
||||
reverse_proxy web:80
|
||||
}
|
||||
|
||||
log {
|
||||
output file /data/access.log {
|
||||
roll_size 10mb
|
||||
roll_keep 5
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
FROM node:20-alpine
|
||||
WORKDIR /app
|
||||
RUN apk add --no-cache openssl tini
|
||||
|
||||
# Корневой манифест для npm workspaces
|
||||
COPY package.json package-lock.json* tsconfig.base.json ./
|
||||
|
||||
# Манифесты воркспейсов
|
||||
COPY apps/api/package.json apps/api/
|
||||
COPY packages/shared/package.json packages/shared/
|
||||
|
||||
# Все зависимости (включая dev — нужен tsx и prisma CLI). Образ на api ~250MB,
|
||||
# приемлемо для small-scale деплоя; оптимизируем многоэтапной сборкой когда понадобится.
|
||||
RUN npm install --include=dev
|
||||
|
||||
# Исходники
|
||||
COPY apps/api ./apps/api
|
||||
COPY packages/shared ./packages/shared
|
||||
|
||||
# Prisma client (без коннекта к БД)
|
||||
RUN cd apps/api && npx prisma generate
|
||||
|
||||
ENV NODE_ENV=production
|
||||
WORKDIR /app/apps/api
|
||||
EXPOSE 3030
|
||||
|
||||
ENTRYPOINT ["/sbin/tini", "--"]
|
||||
# `prisma migrate deploy` накатывает все миграции из prisma/migrations.
|
||||
# При первом деплое (миграций ещё нет) выполнит `db push` — но db push в проде
|
||||
# опасен; на продакшен-этапе всегда коммитим миграции в репо через `prisma migrate dev`.
|
||||
CMD ["sh", "-c", "npx prisma migrate deploy && npx tsx src/server.ts"]
|
||||
@@ -0,0 +1,14 @@
|
||||
FROM node:20-alpine AS build
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json* tsconfig.base.json ./
|
||||
COPY apps/web/package.json apps/web/
|
||||
COPY packages/shared/package.json packages/shared/
|
||||
RUN npm install
|
||||
COPY apps/web ./apps/web
|
||||
COPY packages/shared ./packages/shared
|
||||
RUN npm run build --workspace apps/web
|
||||
|
||||
FROM nginx:1.27-alpine AS runtime
|
||||
COPY docker/nginx-spa.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=build /app/apps/web/dist /usr/share/nginx/html
|
||||
EXPOSE 80
|
||||
@@ -0,0 +1,61 @@
|
||||
name: doc-manager
|
||||
|
||||
# Деплой на queoserver (192.168.0.158): хостовый Caddy в /etc/caddy/Caddyfile
|
||||
# проксирует doc.queo.ru → localhost:3031 (web с внутренним прокси /api/* → api).
|
||||
# Локально для разработки используем npm run dev:demo, не этот compose.
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: ${POSTGRES_DB:-docmanager}
|
||||
POSTGRES_USER: ${POSTGRES_USER:-docmanager}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-docmanager}
|
||||
volumes:
|
||||
- pg_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-docmanager} -d ${POSTGRES_DB:-docmanager}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
api:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: docker/Dockerfile.api
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
PORT: 3030
|
||||
HOST: 0.0.0.0
|
||||
DATABASE_URL: postgresql://${POSTGRES_USER:-docmanager}:${POSTGRES_PASSWORD:-docmanager}@postgres:5432/${POSTGRES_DB:-docmanager}?schema=public
|
||||
AUTH_ISSUER: ${AUTH_ISSUER:-https://auth.queo.ru}
|
||||
AUTH_AUDIENCE: ${AUTH_AUDIENCE:-queo.ru}
|
||||
AUTH_JWKS_URL: ${AUTH_JWKS_URL:-https://auth.queo.ru/.well-known/jwks.json}
|
||||
AUTH_COOKIE_NAME: q_at
|
||||
AUTH_LOGIN_URL: ${AUTH_LOGIN_URL:-https://auth.queo.ru/auth/login}
|
||||
CORS_ORIGINS: ${CORS_ORIGINS:-https://doc.queo.ru}
|
||||
TOCHKA_JWT_KEY: ${TOCHKA_JWT_KEY:-}
|
||||
TOCHKA_WEBHOOK_SECRET: ${TOCHKA_WEBHOOK_SECRET:-}
|
||||
DEFAULT_ORGANIZATION_ID: ${DEFAULT_ORGANIZATION_ID:-00000000-0000-0000-0000-000000000001}
|
||||
DEV_BYPASS_AUTH: "0"
|
||||
expose:
|
||||
- "3030"
|
||||
|
||||
web:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: docker/Dockerfile.web
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- api
|
||||
ports:
|
||||
# Хостовый Caddy на queoserver: doc.queo.ru → localhost:3031
|
||||
- "127.0.0.1:3031:80"
|
||||
|
||||
volumes:
|
||||
pg_data:
|
||||
@@ -0,0 +1,48 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# SPA fallback
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Долгий кэш для хэшированных ассетов Vite
|
||||
location /assets/ {
|
||||
access_log off;
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# Прокси на API. Web и api живут в одной compose-сети, dns "api" резолвится.
|
||||
# Браузер видит всё как один origin (https://doc.queo.ru), куки auth.queo.ru
|
||||
# отправляются автоматически на запросы к /api/me.
|
||||
location /api/ {
|
||||
proxy_pass http://api:3030;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Cookie $http_cookie;
|
||||
}
|
||||
|
||||
# Webhook'и от Точки приходят на этот же origin.
|
||||
location /webhooks/ {
|
||||
proxy_pass http://api:3030;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
# Webhook payload может быть большим (тело документа PDF в base64) — не ограничиваем.
|
||||
client_max_body_size 10m;
|
||||
}
|
||||
|
||||
# Health для мониторинга (не для пользователей).
|
||||
location = /health {
|
||||
proxy_pass http://api:3030;
|
||||
}
|
||||
}
|
||||
Generated
+4011
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "doc-manager",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"description": "Контракты, счета и акты с интеграцией банка Точка. Часть экосистемы Queo.",
|
||||
"workspaces": [
|
||||
"apps/*",
|
||||
"packages/*"
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "npm run dev --workspaces --if-present",
|
||||
"dev:api": "npm run dev -w @doc-manager/api",
|
||||
"dev:web": "npm run dev -w @doc-manager/web",
|
||||
"dev:demo": "concurrently --names api,web --prefix-colors blue,green \"npm:dev:demo:api\" \"npm:dev:web\"",
|
||||
"dev:demo:api": "npm run dev:demo -w @doc-manager/api",
|
||||
"build": "npm run build --workspaces --if-present",
|
||||
"typecheck": "npm run typecheck --workspaces --if-present",
|
||||
"prisma:generate": "npm run prisma:generate -w @doc-manager/api",
|
||||
"prisma:migrate": "npm run prisma:migrate -w @doc-manager/api"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"devDependencies": {
|
||||
"concurrently": "^9.2.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "@doc-manager/shared",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./blocks": "./src/blocks/schema.ts",
|
||||
"./tochka": "./src/tochka/dto.ts",
|
||||
"./auth": "./src/auth/types.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.6.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
// Контракт JWT-payload, выдаваемого auth.queo.ru.
|
||||
// Источник: C:\project\Auth_server\backend\src\services\tokens.js
|
||||
export const PermissionRole = z.enum(['viewer', 'user', 'admin']);
|
||||
export type PermissionRole = z.infer<typeof PermissionRole>;
|
||||
|
||||
export const AuthPayload = z.object({
|
||||
sub: z.string().uuid(),
|
||||
email: z.string().email(),
|
||||
groups: z.array(z.string()).default([]),
|
||||
permissions: z.record(PermissionRole).default({}),
|
||||
isSuperuser: z.boolean().default(false),
|
||||
iat: z.number().optional(),
|
||||
exp: z.number().optional(),
|
||||
iss: z.literal('https://auth.queo.ru').optional(),
|
||||
aud: z.union([z.literal('queo.ru'), z.array(z.string())]).optional(),
|
||||
});
|
||||
export type AuthPayload = z.infer<typeof AuthPayload>;
|
||||
|
||||
export const DOC_MANAGER_RESOURCE = 'doc_manager' as const;
|
||||
|
||||
// Иерархия ролей: admin ⊃ user ⊃ viewer
|
||||
export function hasDocPermission(payload: AuthPayload, required: PermissionRole): boolean {
|
||||
if (payload.isSuperuser) return true;
|
||||
const role = payload.permissions[DOC_MANAGER_RESOURCE];
|
||||
if (!role) return false;
|
||||
const order: PermissionRole[] = ['viewer', 'user', 'admin'];
|
||||
return order.indexOf(role) >= order.indexOf(required);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
// TipTap-совместимое JSON-дерево rich-текста.
|
||||
// Не валидируем глубоко — TipTap отдаёт собственный формат, нам важно только сохранить и доставить.
|
||||
export const RichTextSchema: z.ZodType<unknown> = z.object({
|
||||
type: z.string(),
|
||||
content: z.array(z.lazy((): z.ZodType<unknown> => RichTextSchema)).optional(),
|
||||
text: z.string().optional(),
|
||||
marks: z.array(z.object({ type: z.string(), attrs: z.record(z.unknown()).optional() })).optional(),
|
||||
attrs: z.record(z.unknown()).optional(),
|
||||
});
|
||||
|
||||
export type RichText = z.infer<typeof RichTextSchema>;
|
||||
|
||||
// VAT-ставки актуальные на 2026-04 (включая 5% и 7% для УСН-плательщиков).
|
||||
// Имена совпадают с TS-клиентом Prisma (в БД хранится как '0','5',... через @map).
|
||||
// При отправке в Tochka API используется отдельный маппер (см. packages/shared/src/tochka/dto.ts).
|
||||
export const VatRate = z.enum(['none', 'vat_0', 'vat_5', 'vat_7', 'vat_10', 'vat_20']);
|
||||
export type VatRate = z.infer<typeof VatRate>;
|
||||
|
||||
export const VAT_LABEL: Record<VatRate, string> = {
|
||||
none: 'Без НДС',
|
||||
vat_0: '0%',
|
||||
vat_5: '5%',
|
||||
vat_7: '7%',
|
||||
vat_10: '10%',
|
||||
vat_20: '20%',
|
||||
};
|
||||
|
||||
export const VAT_PERCENT: Record<VatRate, number> = {
|
||||
none: 0,
|
||||
vat_0: 0,
|
||||
vat_5: 5,
|
||||
vat_7: 7,
|
||||
vat_10: 10,
|
||||
vat_20: 20,
|
||||
};
|
||||
|
||||
const blockBase = z.object({ id: z.string().min(1) });
|
||||
|
||||
export const HeadingBlock = blockBase.extend({
|
||||
type: z.literal('heading'),
|
||||
level: z.union([z.literal(1), z.literal(2), z.literal(3)]),
|
||||
text: RichTextSchema,
|
||||
});
|
||||
|
||||
export const ParagraphBlock = blockBase.extend({
|
||||
type: z.literal('paragraph'),
|
||||
text: RichTextSchema,
|
||||
});
|
||||
|
||||
export const PartyBind = z.discriminatedUnion('kind', [
|
||||
z.object({ kind: z.literal('client'), clientId: z.string().uuid().optional() }),
|
||||
z.object({ kind: z.literal('self') }),
|
||||
]);
|
||||
|
||||
export const PartyBlock = blockBase.extend({
|
||||
type: z.literal('party'),
|
||||
role: z.enum(['executor', 'customer']),
|
||||
bind: PartyBind,
|
||||
});
|
||||
|
||||
export const ServicesTableBlock = blockBase.extend({
|
||||
type: z.literal('services_table'),
|
||||
columns: z.array(z.enum(['name', 'qty', 'unit', 'price', 'vat', 'sum'])).min(1),
|
||||
// строки услуг живут в таблице document_lines; здесь только ссылки на строки этого документа
|
||||
lines: z.array(z.object({ lineId: z.string().uuid() })),
|
||||
});
|
||||
|
||||
export const TotalsBlock = blockBase.extend({
|
||||
type: z.literal('totals'),
|
||||
showVat: z.boolean(),
|
||||
showInWords: z.boolean(),
|
||||
});
|
||||
|
||||
export const TermsBlock = blockBase.extend({
|
||||
type: z.literal('terms'),
|
||||
text: RichTextSchema,
|
||||
});
|
||||
|
||||
export const SignaturesBlock = blockBase.extend({
|
||||
type: z.literal('signatures'),
|
||||
sides: z.array(z.enum(['executor', 'customer'])).min(1),
|
||||
});
|
||||
|
||||
export const CustomTextBlock = blockBase.extend({
|
||||
type: z.literal('custom_text'),
|
||||
text: RichTextSchema,
|
||||
});
|
||||
|
||||
export const PageBreakBlock = blockBase.extend({
|
||||
type: z.literal('page_break'),
|
||||
});
|
||||
|
||||
export const Block = z.discriminatedUnion('type', [
|
||||
HeadingBlock,
|
||||
ParagraphBlock,
|
||||
PartyBlock,
|
||||
ServicesTableBlock,
|
||||
TotalsBlock,
|
||||
TermsBlock,
|
||||
SignaturesBlock,
|
||||
CustomTextBlock,
|
||||
PageBreakBlock,
|
||||
]);
|
||||
export type Block = z.infer<typeof Block>;
|
||||
|
||||
export const DocBody = z.object({
|
||||
version: z.literal(1),
|
||||
blocks: z.array(Block),
|
||||
vars: z.record(z.unknown()).default({}),
|
||||
});
|
||||
export type DocBody = z.infer<typeof DocBody>;
|
||||
|
||||
export const emptyDocBody = (): DocBody => ({ version: 1, blocks: [], vars: {} });
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './blocks/schema.js';
|
||||
export * from './tochka/dto.js';
|
||||
export * from './auth/types.js';
|
||||
@@ -0,0 +1,74 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const TochkaEnv = z.enum(['sandbox', 'prod']);
|
||||
export type TochkaEnv = z.infer<typeof TochkaEnv>;
|
||||
|
||||
export const TochkaBaseUrl: Record<TochkaEnv, string> = {
|
||||
sandbox: 'https://enter.tochka.com/sandbox/v2',
|
||||
prod: 'https://enter.tochka.com/uapi',
|
||||
};
|
||||
|
||||
// Минимальный набор полей для M4. Расширим, когда возьмёмся за акты/УПД.
|
||||
export const TochkaSecondSide = z.object({
|
||||
inn: z.string().min(10).max(12),
|
||||
kpp: z.string().length(9).optional(),
|
||||
legalAddress: z.string().optional(),
|
||||
contactName: z.string().optional(),
|
||||
contactEmail: z.string().email().optional(),
|
||||
contactPhone: z.string().optional(),
|
||||
});
|
||||
export type TochkaSecondSide = z.infer<typeof TochkaSecondSide>;
|
||||
|
||||
export const TochkaInvoiceLine = z.object({
|
||||
name: z.string(),
|
||||
measure: z.string(), // ед. измерения, напр. "шт", "час"
|
||||
amount: z.number().positive(), // количество
|
||||
price: z.number().nonnegative(), // цена за единицу в рублях (Точка ждёт число, не копейки)
|
||||
vatRate: z.enum(['none', '0', '5', '7', '10', '20']),
|
||||
});
|
||||
export type TochkaInvoiceLine = z.infer<typeof TochkaInvoiceLine>;
|
||||
|
||||
export const TochkaCreateInvoiceRequest = z.object({
|
||||
Data: z.object({
|
||||
accountId: z.string(),
|
||||
customerCode: z.string(),
|
||||
documentNumber: z.string().optional(),
|
||||
documentDate: z.string().optional(), // YYYY-MM-DD
|
||||
secondSide: TochkaSecondSide,
|
||||
items: z.array(TochkaInvoiceLine).min(1),
|
||||
paymentExpiryDate: z.string().optional(),
|
||||
}),
|
||||
});
|
||||
export type TochkaCreateInvoiceRequest = z.infer<typeof TochkaCreateInvoiceRequest>;
|
||||
|
||||
export const TochkaCreateInvoiceResponse = z.object({
|
||||
Data: z.object({
|
||||
documentId: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
// Webhook payloads (минимум, что мы используем для статуса оплаты).
|
||||
export const TochkaWebhookEvent = z.object({
|
||||
webhookType: z.enum([
|
||||
'incomingPayment',
|
||||
'incomingSbpPayment',
|
||||
'outgoingPayment',
|
||||
'incomingSbpB2BPayment',
|
||||
]),
|
||||
paymentId: z.string(),
|
||||
amount: z.number().optional(),
|
||||
payerInn: z.string().optional(),
|
||||
payerName: z.string().optional(),
|
||||
purpose: z.string().optional(),
|
||||
paidAt: z.string().optional(),
|
||||
});
|
||||
export type TochkaWebhookEvent = z.infer<typeof TochkaWebhookEvent>;
|
||||
|
||||
export const TochkaPaymentStatus = z.enum([
|
||||
'awaiting_payment',
|
||||
'partially_paid',
|
||||
'paid',
|
||||
'overdue',
|
||||
'cancelled',
|
||||
]);
|
||||
export type TochkaPaymentStatus = z.infer<typeof TochkaPaymentStatus>;
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"declaration": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"lib": ["ES2022"],
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"exactOptionalPropertyTypes": true,
|
||||
"esModuleInterop": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"isolatedModules": true,
|
||||
"verbatimModuleSyntax": true
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user