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:
admin
2026-04-30 21:24:26 +03:00
commit 4553f63deb
52 changed files with 7110 additions and 0 deletions
+36
View File
@@ -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
+37
View File
@@ -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"
+250
View File
@@ -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])
}
+32
View File
@@ -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();
});
+129
View File
@@ -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');
});
+5
View File
@@ -0,0 +1,5 @@
import { PrismaClient } from '@prisma/client';
export const prisma = new PrismaClient({
log: process.env.NODE_ENV === 'development' ? ['warn', 'error'] : ['error'],
});
+41
View File
@@ -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);
}
+18
View File
@@ -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 {};
+11
View File
@@ -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;
}
+111
View File
@@ -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;
},
);
}
+121
View File
@@ -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 } });
});
}
+84
View File
@@ -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' });
}
};
});
});
+16
View File
@@ -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' });
}
});
}
+15
View File
@@ -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,
};
});
}
+55
View File
@@ -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);
});
+14
View File
@@ -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/**/*"]
}
+2
View File
@@ -0,0 +1,2 @@
# URL центра аутентификации Queo
VITE_AUTH_LOGIN_URL=https://auth.queo.ru/auth/login
+12
View File
@@ -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>
+25
View File
@@ -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"
}
}
+72
View File
@@ -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>В разработке. См. план: M2M7.</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>
</>
);
}
+74
View File
@@ -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;
};
+58
View File
@@ -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;
}
+109
View File
@@ -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,
});
}
+13
View File
@@ -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>,
);
+189
View File
@@ -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>
);
}
+134
View File
@@ -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>
);
}
+250
View File
@@ -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>
);
}
+166
View File
@@ -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; }
}
+14
View File
@@ -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/**/*"]
}
+1
View File
@@ -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"}
+16
View File
@@ -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 },
},
},
});