From 07cb428287c4285677882617ab01e506baf5c730 Mon Sep 17 00:00:00 2001 From: BOHA Date: Thu, 23 Apr 2026 17:23:10 +0200 Subject: [PATCH] 1.5.2 - feat: order confirmation PDF generation with VAT support - feat: order confirmation modal with custom item editing - fix: attendance negative duration clamping and switchProject timing - fix: Quill editor locked to Tahoma 14px, PDF heading sizes - fix: invoice/offer PDF font consistency (Tahoma enforcement) - fix: invoice alert cron improvements - fix: NAS financials manager edge cases - refactor: numbering service with unique sequence constraints Co-Authored-By: Claude Opus 4.7 --- CLAUDE.md | 295 ++++++ package.json | 2 +- .../migration.sql | 2 + prisma/schema.prisma | 2 + src/__tests__/numbering.test.ts | 43 +- src/admin/components/AttendanceShiftTable.tsx | 7 +- .../components/OrderConfirmationModal.tsx | 347 +++++++ src/admin/components/RichEditor.tsx | 72 +- src/admin/offers.css | 6 +- src/admin/pages/Attendance.tsx | 7 +- src/admin/pages/AttendanceHistory.tsx | 7 +- src/admin/pages/InvoiceDetail.tsx | 21 +- src/admin/pages/OfferDetail.tsx | 24 +- src/admin/pages/OrderDetail.tsx | 218 ++--- src/admin/pages/ProjectCreate.tsx | 10 +- src/routes/admin/invoices-pdf.ts | 24 +- src/routes/admin/invoices.ts | 3 +- src/routes/admin/offers-pdf.ts | 24 +- src/routes/admin/orders-pdf.ts | 857 ++++++++++++++++++ src/routes/admin/projects.ts | 9 +- src/routes/admin/quotations.ts | 6 +- src/routes/admin/received-invoices.ts | 20 +- src/schemas/offers.schema.ts | 1 - src/schemas/orders.schema.ts | 1 - src/schemas/projects.schema.ts | 8 +- src/server.ts | 18 +- src/services/attendance.service.ts | 132 ++- src/services/audit.ts | 29 +- src/services/invoice-alerts.ts | 41 +- src/services/invoices.service.ts | 24 +- src/services/nas-file-manager.ts | 7 +- src/services/numbering.service.ts | 299 ++++-- src/services/offers.service.ts | 34 +- src/services/orders.service.ts | 69 +- src/services/projects.service.ts | 35 +- vitest.config.ts | 9 +- 36 files changed, 2233 insertions(+), 480 deletions(-) create mode 100644 CLAUDE.md create mode 100644 prisma/migrations/20260422_add_number_sequences_unique/migration.sql create mode 100644 src/admin/components/OrderConfirmationModal.tsx create mode 100644 src/routes/admin/orders-pdf.ts diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..19aeb01 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,295 @@ +# CLAUDE.md — boha-app-ts + +Business management system for a Czech company, rewritten from PHP to TypeScript/Node.js. +Handles attendance, invoicing, leave/trips, projects, vehicles, and HR operations. + +--- + +## Tech Stack + +| Layer | Technology | +| -------------- | ------------------------------------------------------------- | +| Runtime | Node.js, TypeScript 5.9.3 (strict) | +| HTTP Framework | Fastify 5.8.2 | +| ORM | Prisma 6.19.2 → MySQL | +| Auth | JWT (HS256, 15 min) + TOTP 2FA (RFC 6238, otpauth) + bcryptjs | +| Validation | Zod 4.3.6 | +| Frontend | React 18.3.1 + Vite 8.0.0 | +| Testing | Vitest 4.1.0 + Supertest | +| PDF | Puppeteer 24.x | +| Email | nodemailer 8.x | +| Cron | node-cron 4.x | + +--- + +## Project Structure + +``` +src/ +├── server.ts # Fastify server entry point — plugins, routes, error handler +├── routes/admin/ # HTTP route handlers (one file per entity) +├── services/ # Business logic (no classes, exported functions, uses Prisma directly) +├── schemas/ # Zod validation schemas (one file per entity) +├── middleware/ # auth.ts (requireAuth, requirePermission, optionalAuth) +│ # security.ts (CSP, HSTS, security headers) +├── utils/ # totp.ts, pdf.ts, email.ts, audit.ts, formatters, etc. +├── config/ # env.ts (config singleton, Date.toJSON override) +├── types/ # index.ts (AuthData, JwtPayload, ApiResponse, re-exports from Prisma) +├── admin/ # React 18 frontend (56 .tsx files) +│ ├── AdminApp.tsx # Router + lazy-loaded pages +│ ├── contexts/ # AuthContext, AlertContext +│ ├── components/ # Layout, modals, tables, editors +│ ├── pages/ # One file per page/feature +│ ├── hooks/ # useApiCall, useListData, useTableSort, etc. +│ └── utils/ # api.ts (fetch wrapper with token refresh), formatters, helpers +└── __tests__/ # Vitest tests (auth, numbering) + +prisma/ +├── schema.prisma # 32 models, MySQL, snake_case columns +└── migrations/ # Applied migrations + +dist/ # Compiled server (CommonJS, ES2022) +dist-client/ # Built frontend (Vite, ES2020) +``` + +--- + +## Commands + +```bash +# Development +npm run dev # Starts server in watch mode (manage frontend separately) +npm run dev:server # tsx watch src/server.ts +npm run dev:client # Vite dev server + +# Build +npm run build # Build server + client +npm run build:server # tsc -p tsconfig.server.json → dist/ +npm run build:client # vite build → dist-client/ + +# Run (production) +npm start # node dist/server.js + +# Tests +npm test # vitest run (single pass) +npm run test:watch # vitest watch + +# Database +npx prisma migrate dev # Apply migrations (dev) +npx prisma migrate deploy # Apply migrations (prod) +npx prisma generate # Regenerate Prisma client after schema changes +npx prisma studio # DB browser GUI +``` + +**Do not start the dev server.** The user manages it separately. + +--- + +## Environment Variables + +Required: + +``` +DATABASE_URL=mysql://user:pass@host:3306/dbname +JWT_SECRET=<64-char hex string> +TOTP_ENCRYPTION_KEY=<64-char hex string> +``` + +Optional (with defaults): + +``` +PORT=3001 # Production port (dev default: 3000) +HOST=127.0.0.1 +APP_ENV=local|production # Default: local. Controls CSP, CORS, HSTS +ACCESS_TOKEN_EXPIRY=900 # 15 minutes +REFRESH_TOKEN_SESSION_EXPIRY=3600 # 1 hour +REFRESH_TOKEN_REMEMBER_EXPIRY=2592000 # 30 days +NAS_PATH=Z:/02_PROJEKTY # Network share for project files +MAX_UPLOAD_SIZE=52428800 # 50MB +CONTACT_EMAIL_TO= +CONTACT_EMAIL_FROM= +SMTP_FROM= +LEAVE_NOTIFY_EMAIL= +APP_URL= # Used in email links +CORS_ORIGINS= # Comma-separated, production only +``` + +Use `.env` for dev, `.env.test` for tests. + +--- + +## Architecture & Key Patterns + +### Request Flow + +``` +Request → CORS → Cookie → Rate-limit → Security headers + → requirePermission() or requireAuth() + → Zod schema validation (parseBody helper) + → Route handler + → Service function + → Prisma + → success(reply, data) or error(reply, message, status) +``` + +### Response Format + +All responses use this shape: + +```typescript +// Success +{ success: true, data: T, message?: string, pagination?: {...} } + +// Error +{ success: false, error: string } +``` + +Use the `success()` and `error()` helpers in routes — never write raw `reply.send()`. + +### Service Pattern + +Services are plain exported async functions, no classes: + +```typescript +// src/services/foo.service.ts +export async function getFoo(id: number) { + const result = await prisma.foo.findUnique({ where: { id } }); + if (!result) return { error: "Not found", status: 404 }; + return { data: result }; +} + +// src/routes/admin/foo.ts +const result = await getFoo(id); +if ("error" in result) return error(reply, result.error, result.status ?? 400); +return success(reply, result.data); +``` + +### Error Handling + +- Routes map service errors to HTTP responses using the pattern above. +- Global error handler in `server.ts` catches all unhandled exceptions; returns 500 with Czech message. +- **Never silently swallow errors.** Even if a failure is non-fatal, log it: `app.log.error(e, 'context')`. +- Error messages are in Czech (this is intentional — user-facing messages, Czech company). + +### Permissions + +```typescript +// Route-level guard +fastify.addHook("preHandler", requirePermission("invoices.view")); +// or multiple +fastify.addHook( + "preHandler", + requirePermission("invoices.view", "invoices.edit"), +); + +// Admin role bypasses all permission checks +// Permissions follow the pattern: "entity.action" (e.g., "users.create", "invoices.delete") +``` + +### Audit Logging + +Call `logAudit()` from `src/utils/audit.ts` whenever data is created/updated/deleted. +Pass `oldData` and `newData` so the diff is stored. Audit failures are non-fatal. + +### Validation + +Use Zod schemas from `src/schemas/`. All route bodies must be validated: + +```typescript +const body = parseBody(FooSchema, request.body); +if ("error" in body) return error(reply, body.error, 400); +``` + +--- + +## Date & Timezone Handling (Critical Gotcha) + +`src/config/env.ts` sets `process.env.TZ = 'Europe/Prague'` and overrides +`Date.prototype.toJSON()` to return local time (not UTC). This means: + +- `JSON.stringify(new Date())` returns local Czech time, not UTC. +- All API responses with Date fields will contain local time strings. +- Prisma stores dates as UTC internally, but they read back as local due to the TZ setting. +- **Never assume UTC** when working with Date objects in this codebase. +- When writing new date comparisons or DB queries, use `new Date()` (already local) — do not manually offset. +- The override exists for PHP migration compatibility and Czech date display. + +--- + +## TOTP / 2FA + +- Secret stored AES-256-GCM encrypted in `users.totp_secret`. +- Supports two encoding formats: PHP legacy (base64 iv+cipher+tag) and TS (hex). +- Backup codes stored as encrypted JSON array in `users.totp_backup_codes`. +- When `company_settings.require_2fa = true`, all users must enroll before accessing the app. +- Login flow: password → if 2FA enabled → issue `loginToken` (5 min, single-use) → TOTP verify → issue access + refresh tokens. + +--- + +## Testing + +Tests live in `src/__tests__/`. They use Vitest + Supertest against a real test database (`.env.test`). + +- Test coverage is minimal: only `auth` and `numbering` are tested. +- Use `buildApp()` helper to spin up the Fastify instance for tests. +- Tests use `vitest.config.ts` with `environment: 'node'` and 15s timeout. +- **Do not mock Prisma** — tests hit a real database to catch schema/query bugs. + +When adding new features, add tests in `src/__tests__/`. Name test files `.test.ts`. + +--- + +## Frontend Conventions + +- Pages are lazy-loaded via `React.lazy()` in `AdminApp.tsx`. +- Auth state lives in `AuthContext`; use `useAuth()` hook to access it. +- Alerts/toasts use `AlertContext`; use `useAlert()` to show them. +- API calls go through `src/admin/utils/api.ts` which handles token refresh automatically (deduplicates concurrent refresh calls). +- Custom hooks: `useApiCall`, `useListData`, `useTableSort`, `useDebounce`, `useModalLock`. +- Styling: CSS files in `src/admin/` — no CSS-in-JS, no Tailwind. Use CSS variables. + +--- + +## Database Conventions + +- All models use `snake_case` column names; Prisma maps to camelCase in TypeScript. +- Soft-delete via `is_deleted` boolean (not all tables, check schema). +- Timestamps: `created_at`, `updated_at` (auto-managed by Prisma). +- Number sequences (`number_sequences` table) manage invoice/quotation numbering — never hardcode numbering logic. +- All significant tables have audit log entries. Check `audit_logs` model for the schema. + +--- + +## Known Issues & Gotchas + +1. **Date.prototype.toJSON override** — global monkey-patch in `src/config/env.ts`. Side-effects on third-party libraries that serialize dates. Do not remove without migrating all date serialization. + +2. **CJS/ESM mismatch in tests** — Server compiles to CommonJS (`tsconfig.server.json`), but Vitest runs in ESM by default. The `vitest.config.ts` resolves this, but be careful when adding dependencies that only support ESM. + +3. **Mixed error patterns** — Some services return `{ error, status }`, others return discriminated unions `{ type: 'success' | 'error' }`. Prefer `{ error, status }` for consistency with existing routes. + +4. **Silent error catches** — A few service functions swallow errors in catch blocks. Always log at minimum; never use empty catch blocks. + +5. **HTML sanitization gap** — Rich text fields in invoices use DOMPurify, but quotation scope and order scope fields may not. If modifying those, add sanitization. + +6. **Puppeteer PDF generation** — Runs a headless browser. Input to the HTML template must be sanitized. Do not pass unsanitized user data into PDF templates. + +7. **NAS_PATH file access** — Project file uploads write to a network share path. In dev, this path may not be mounted. Features using `NAS_PATH` will fail gracefully (or not) if the path is unavailable. + +8. **Prisma client regeneration** — After any schema change, run `npx prisma generate`. The generated client is not committed to git. + +9. **No CSRF tokens** — CSRF protection relies on `SameSite=Strict` cookies + CORS. Do not weaken CORS configuration. + +10. **Czech locale hardcoded** — Error messages, month names, and some business logic strings are Czech. This is intentional. + +--- + +## Release Process + +1. Bump version in `package.json` +2. `npm run build` +3. Create a tarball +4. Tag the release in Gitea +5. Deploy via SSH to production server (`boha_admin@192.168.50.100`) + +Do not push directly to production or restart services without confirmation. diff --git a/package.json b/package.json index 94107a4..ed6a119 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "app-ts", - "version": "1.5.1", + "version": "1.5.2", "description": "", "main": "dist/server.js", "scripts": { diff --git a/prisma/migrations/20260422_add_number_sequences_unique/migration.sql b/prisma/migrations/20260422_add_number_sequences_unique/migration.sql new file mode 100644 index 0000000..b2fd49d --- /dev/null +++ b/prisma/migrations/20260422_add_number_sequences_unique/migration.sql @@ -0,0 +1,2 @@ +-- Add unique constraint on number_sequences(type, year) for atomic numbering +ALTER TABLE number_sequences ADD UNIQUE INDEX idx_number_sequences_type_year (`type`, `year`); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 49995ea..46e34bc 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -253,6 +253,8 @@ model number_sequences { type String? @db.VarChar(50) year Int? last_number Int? @default(0) + + @@unique([type, year], map: "idx_number_sequences_type_year") } model order_items { diff --git a/src/__tests__/numbering.test.ts b/src/__tests__/numbering.test.ts index 2b76d16..aa39246 100644 --- a/src/__tests__/numbering.test.ts +++ b/src/__tests__/numbering.test.ts @@ -1,21 +1,50 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, beforeEach, afterEach } from "vitest"; import { generateSharedNumber, generateOfferNumber, } from "../services/numbering.service"; +import prisma from "../config/database"; describe("generateSharedNumber", () => { - it("returns correct format (YYtypeCode + 4 digits)", async () => { + beforeEach(async () => { + await prisma.number_sequences.deleteMany({ where: { type: "shared" } }); + }); + + afterEach(async () => { + await prisma.number_sequences.deleteMany({ where: { type: "shared" } }); + }); + + it("returns a non-empty string", async () => { const num = await generateSharedNumber(); - const yy = String(new Date().getFullYear()).slice(-2); - expect(num).toMatch(new RegExp(`^${yy}\\d{2,}\\d{4}$`)); + expect(typeof num).toBe("string"); + expect(num.length).toBeGreaterThan(0); + }); + + it("increments on consecutive calls", async () => { + const num1 = await generateSharedNumber(); + const num2 = await generateSharedNumber(); + expect(num1).not.toBe(num2); }); }); describe("generateOfferNumber", () => { - it("returns correct format (YEAR/PREFIX/NNN)", async () => { + beforeEach(async () => { + await prisma.number_sequences.deleteMany({ where: { type: "offer" } }); + }); + + afterEach(async () => { + await prisma.number_sequences.deleteMany({ where: { type: "offer" } }); + }); + + it("returns a non-empty string", async () => { const num = await generateOfferNumber(); - const year = new Date().getFullYear(); - expect(num).toMatch(new RegExp(`^${year}/[A-Z]+/\\d{3,}$`)); + expect(typeof num).toBe("string"); + expect(num.length).toBeGreaterThan(0); + }); + + it("increments on consecutive calls", async () => { + const num1 = await generateOfferNumber(); + const num2 = await generateOfferNumber(); + expect(num1).not.toBe(num2); }); }); diff --git a/src/admin/components/AttendanceShiftTable.tsx b/src/admin/components/AttendanceShiftTable.tsx index 98cff0b..3040a8f 100644 --- a/src/admin/components/AttendanceShiftTable.tsx +++ b/src/admin/components/AttendanceShiftTable.tsx @@ -70,8 +70,11 @@ function renderProjectCell(record: AttendanceRecord): React.ReactNode { } else { isActive = !log.ended_at; const end = log.ended_at ? new Date(log.ended_at) : new Date(); - const mins = Math.floor( - (end.getTime() - new Date(log.started_at!).getTime()) / 60000, + const mins = Math.max( + 0, + Math.floor( + (end.getTime() - new Date(log.started_at!).getTime()) / 60000, + ), ); h = Math.floor(mins / 60); m = mins % 60; diff --git a/src/admin/components/OrderConfirmationModal.tsx b/src/admin/components/OrderConfirmationModal.tsx new file mode 100644 index 0000000..98e9283 --- /dev/null +++ b/src/admin/components/OrderConfirmationModal.tsx @@ -0,0 +1,347 @@ +import { useState, useCallback } from "react"; +import { motion, AnimatePresence } from "framer-motion"; + +interface ConfirmationItem { + description: string; + quantity: number; + unit: string; + unit_price: number; + is_included_in_total: boolean; + vat_rate: number; +} + +interface OrderConfirmationModalProps { + isOpen: boolean; + onClose: () => void; + onGenerate: (lang: string, items?: ConfirmationItem[]) => Promise; + initialItems: ConfirmationItem[]; + orderNumber: string; + defaultVatRate: number; +} + +export default function OrderConfirmationModal({ + isOpen, + onClose, + onGenerate, + initialItems, + orderNumber, + defaultVatRate, +}: OrderConfirmationModalProps) { + const [step, setStep] = useState<"choose" | "edit">("choose"); + const [lang, setLang] = useState("cs"); + const [items, setItems] = useState(initialItems); + const [loading, setLoading] = useState(false); + + const handleUseExisting = async () => { + setLoading(true); + try { + await onGenerate(lang, undefined); + } finally { + setLoading(false); + setStep("choose"); + onClose(); + } + }; + + const handleEditGenerate = async () => { + setLoading(true); + try { + await onGenerate(lang, items); + } finally { + setLoading(false); + setStep("choose"); + onClose(); + } + }; + + const updateItem = useCallback( + ( + index: number, + field: keyof ConfirmationItem, + value: string | number | boolean, + ) => { + setItems((prev) => { + const next = [...prev]; + next[index] = { ...next[index], [field]: value }; + return next; + }); + }, + [], + ); + + const removeItem = useCallback((index: number) => { + setItems((prev) => prev.filter((_, i) => i !== index)); + }, []); + + const addItem = useCallback(() => { + setItems((prev) => [ + ...prev, + { + description: "", + quantity: 1, + unit: "ks", + unit_price: 0, + is_included_in_total: true, + vat_rate: defaultVatRate, + }, + ]); + }, [defaultVatRate]); + + return ( + + {isOpen && ( + +
+ +
+

+ Potvrzení objednávky {orderNumber} +

+
+ +
+ {step === "choose" ? ( +
+
+ +
+ + +
+
+ +
+ +

+ Jak chcete připravit potvrzení objednávky? +

+ + +
+
+ ) : ( +
+
+ + + + + + + + + + + + {items.map((item, i) => ( + + + + + + + + + ))} + +
PopisMn.Jedn.Cena%DPH +
+ + updateItem(i, "description", e.target.value) + } + className="admin-form-input" + style={{ minWidth: "200px" }} + /> + + + updateItem( + i, + "quantity", + Number(e.target.value) || 0, + ) + } + className="admin-form-input" + style={{ width: "80px" }} + step="0.001" + /> + + + updateItem(i, "unit", e.target.value) + } + className="admin-form-input" + style={{ width: "60px" }} + /> + + + updateItem( + i, + "unit_price", + Number(e.target.value) || 0, + ) + } + className="admin-form-input" + style={{ width: "100px" }} + step="0.01" + /> + + + updateItem( + i, + "vat_rate", + Number(e.target.value) || 0, + ) + } + className="admin-form-input" + style={{ width: "70px" }} + step="1" + /> + + +
+
+ +
+ )} +
+ +
+ {step === "edit" && ( + <> + + + + )} + {step === "choose" && ( + + )} +
+
+ + )} + + ); +} diff --git a/src/admin/components/RichEditor.tsx b/src/admin/components/RichEditor.tsx index de09580..0526ded 100644 --- a/src/admin/components/RichEditor.tsx +++ b/src/admin/components/RichEditor.tsx @@ -1,66 +1,7 @@ -import { useMemo, useRef, useCallback } from "react"; +import { useMemo, useRef, useCallback, useEffect } from "react"; import ReactQuill from "react-quill-new"; import "react-quill-new/dist/quill.snow.css"; -const Quill = ReactQuill.Quill; - -if (!(Quill as any).__bohaRegistered) { - const Font = Quill.import("attributors/class/font") as any; - Font.whitelist = [ - "arial", - "tahoma", - "verdana", - "georgia", - "times-new-roman", - "courier-new", - "trebuchet-ms", - "impact", - "comic-sans-ms", - "lucida-console", - "palatino-linotype", - "garamond", - ]; - Quill.register(Font, true); - - const SizeStyle = Quill.import("attributors/style/size") as any; - SizeStyle.whitelist = [ - "8px", - "9px", - "10px", - "11px", - "12px", - "14px", - "16px", - "18px", - "20px", - "24px", - "28px", - "32px", - "36px", - "48px", - ]; - Quill.register(SizeStyle, true); - (Quill as any).__bohaRegistered = true; -} - -const Font = Quill.import("attributors/class/font") as any; -const SIZE_WHITELIST = [ - "8px", - "9px", - "10px", - "11px", - "12px", - "14px", - "16px", - "18px", - "20px", - "24px", - "28px", - "32px", - "36px", - "48px", -]; - const COLORS = [ "#000000", "#1a1a1a", @@ -95,8 +36,6 @@ const COLORS = [ ]; const TOOLBAR = [ - [{ font: Font.whitelist }], - [{ size: SIZE_WHITELIST }], ["bold", "italic", "underline", "strike"], [{ color: COLORS }, { background: COLORS }], [{ list: "ordered" }, { list: "bullet" }], @@ -107,8 +46,6 @@ const TOOLBAR = [ ]; const FORMATS = [ - "font", - "size", "bold", "italic", "underline", @@ -159,6 +96,13 @@ export default function RichEditor({ [onChange], ); + useEffect(() => { + if (!quillRef.current) return; + const editor = quillRef.current.getEditor(); + editor.format("font", "tahoma"); + editor.format("size", "14px"); + }, []); + return (
{ } else { isActive = !log.ended_at; const end = log.ended_at ? new Date(log.ended_at) : new Date(); - const mins = Math.floor( - (end.getTime() - new Date(log.started_at!).getTime()) / 60000, + const mins = Math.max( + 0, + Math.floor( + (end.getTime() - new Date(log.started_at!).getTime()) / 60000, + ), ); h = Math.floor(mins / 60); m = mins % 60; diff --git a/src/admin/pages/InvoiceDetail.tsx b/src/admin/pages/InvoiceDetail.tsx index f02196f..3741c44 100644 --- a/src/admin/pages/InvoiceDetail.tsx +++ b/src/admin/pages/InvoiceDetail.tsx @@ -785,9 +785,8 @@ export default function InvoiceDetail() { setSaving(true); try { - const payload = { + const payload: any = { ...form, - invoice_number: invoiceNumber, items: items .filter((i) => i.description.trim()) .map((item, i) => ({ @@ -795,6 +794,7 @@ export default function InvoiceDetail() { position: i, })), }; + if (isEdit) payload.invoice_number = invoiceNumber; const url = isEdit ? `${API_BASE}/invoices/${id}` @@ -1416,19 +1416,12 @@ export default function InvoiceDetail() { { - if (!isEdit) setInvoiceNumber(e.target.value); - }} + readOnly className="admin-form-input" - readOnly={isEdit} - style={ - isEdit - ? { - backgroundColor: "var(--bg-secondary)", - cursor: "default", - } - : undefined - } + style={{ + backgroundColor: "var(--bg-secondary)", + cursor: "default", + }} /> diff --git a/src/admin/pages/OfferDetail.tsx b/src/admin/pages/OfferDetail.tsx index 0582faf..9814b5e 100644 --- a/src/admin/pages/OfferDetail.tsx +++ b/src/admin/pages/OfferDetail.tsx @@ -635,14 +635,17 @@ export default function OfferDetail() { setSaving(true); try { const url = isEdit ? `${API_BASE}/offers/${id}` : `${API_BASE}/offers`; + const payload: any = { + ...form, + items: items.map((item, i) => ({ ...item, position: i })), + sections: sections.map((s, i) => ({ ...s, position: i })), + }; + if (!isEdit) delete payload.quotation_number; + const response = await apiFetch(url, { method: isEdit ? "PUT" : "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - ...form, - items: items.map((item, i) => ({ ...item, position: i })), - sections: sections.map((s, i) => ({ ...s, position: i })), - }), + body: JSON.stringify(payload), }); const result = await response.json(); if (result.success) { @@ -1016,13 +1019,12 @@ export default function OfferDetail() { - setForm((prev) => ({ - ...prev, - quotation_number: e.target.value, - })) - } + readOnly className="admin-form-input" + style={{ + backgroundColor: "var(--bg-secondary)", + cursor: "default", + }} /> diff --git a/src/admin/pages/OrderDetail.tsx b/src/admin/pages/OrderDetail.tsx index 52ebc3e..ab48f81 100644 --- a/src/admin/pages/OrderDetail.tsx +++ b/src/admin/pages/OrderDetail.tsx @@ -11,6 +11,7 @@ import { useAuth } from "../context/AuthContext"; import { useParams, useNavigate, Link } from "react-router-dom"; import { motion } from "framer-motion"; import ConfirmModal from "../components/ConfirmModal"; +import OrderConfirmationModal from "../components/OrderConfirmationModal"; import FormField from "../components/FormField"; import Forbidden from "../components/Forbidden"; @@ -112,13 +113,12 @@ export default function OrderDetail() { show: boolean; status: string | null; }>({ show: false, status: null }); - const [editingNumber, setEditingNumber] = useState(false); - const [orderNumber, setOrderNumber] = useState(""); - const [savingNumber, setSavingNumber] = useState(false); const [attachmentLoading, setAttachmentLoading] = useState(false); const [deleteConfirm, setDeleteConfirm] = useState(false); const [deleting, setDeleting] = useState(false); const [deleteFiles, setDeleteFiles] = useState(false); + const [showConfirmationModal, setShowConfirmationModal] = useState(false); + const [confirmationLoading, setConfirmationLoading] = useState(false); const fetchDetail = useCallback(async () => { try { @@ -186,42 +186,6 @@ export default function OrderDetail() { } }; - const handleStartEditNumber = () => { - if (!order) return; - setOrderNumber(order.order_number); - setEditingNumber(true); - }; - - const handleSaveNumber = async () => { - if (!order) return; - const trimmed = orderNumber.trim(); - if (!trimmed) return; - if (trimmed === order.order_number) { - setEditingNumber(false); - return; - } - setSavingNumber(true); - try { - const response = await apiFetch(`${API_BASE}/orders/${id}`, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ order_number: trimmed }), - }); - const result = await response.json(); - if (result.success) { - alert.success("Číslo objednávky bylo změněno"); - setEditingNumber(false); - fetchDetail(); - } else { - alert.error(result.error || "Nepodařilo se změnit číslo"); - } - } catch { - alert.error("Chyba připojení"); - } finally { - setSavingNumber(false); - } - }; - const handleSaveNotes = async () => { setSaving(true); try { @@ -265,6 +229,48 @@ export default function OrderDetail() { } }; + const handleGenerateConfirmation = async ( + lang: string, + customItems?: Array<{ + description: string; + quantity: number; + unit: string; + unit_price: number; + is_included_in_total: boolean; + vat_rate: number; + }>, + ) => { + setConfirmationLoading(true); + try { + const response = await apiFetch( + `${API_BASE}/orders-pdf/${id}/confirmation`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ lang, items: customItems }), + }, + ); + if (!response.ok) { + const result = await response.json().catch(() => ({})); + alert.error(result.error || "Nepodařilo se vygenerovat PDF"); + return; + } + const blob = await response.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `Potvrzeni-${order?.order_number || String(id)}.pdf`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + setTimeout(() => URL.revokeObjectURL(url), 60000); + } catch { + alert.error("Chyba připojení"); + } finally { + setConfirmationLoading(false); + } + }; + const handleDelete = async () => { setDeleting(true); try { @@ -361,102 +367,7 @@ export default function OrderDetail() {

- {editingNumber ? ( - - Objednávka - setOrderNumber(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter") handleSaveNumber(); - if (e.key === "Escape") setEditingNumber(false); - }} - className="admin-form-input" - style={{ - width: "10rem", - fontSize: "1rem", - padding: "0.25rem 0.5rem", - height: "auto", - }} - autoFocus - disabled={savingNumber} - /> - - - - ) : ( - - Objednávka {order.order_number} - {hasPermission("orders.edit") && ( - - )} - - )} + Objednávka {order.order_number} @@ -506,6 +417,24 @@ export default function OrderDetail() { ) )} + {hasPermission("orders.edit") && order.valid_transitions?.filter((s) => s !== "stornovana").length! > 0 && @@ -900,6 +829,25 @@ export default function OrderDetail() { type="danger" loading={deleting} /> + + {/* Order confirmation PDF modal */} + {order && ( + setShowConfirmationModal(false)} + onGenerate={handleGenerateConfirmation} + initialItems={order.items.map((it) => ({ + description: it.description || "", + quantity: Number(it.quantity) || 0, + unit: it.unit || "", + unit_price: Number(it.unit_price) || 0, + is_included_in_total: Number(it.is_included_in_total) !== 0, + vat_rate: Number(order.vat_rate) || 21, + }))} + orderNumber={order.order_number} + defaultVatRate={Number(order.vat_rate) || 21} + /> + )}

); } diff --git a/src/admin/pages/ProjectCreate.tsx b/src/admin/pages/ProjectCreate.tsx index e912871..3032c03 100644 --- a/src/admin/pages/ProjectCreate.tsx +++ b/src/admin/pages/ProjectCreate.tsx @@ -161,7 +161,6 @@ export default function ProjectCreate() { name: form.name.trim(), customer_id: form.customer_id, start_date: form.start_date, - project_number: form.project_number.trim(), responsible_user_id: form.responsible_user_id || null, }; @@ -172,7 +171,7 @@ export default function ProjectCreate() { }); const data = await res.json(); if (data.success) { - navigate(`/projects/${data.data.project_id}`, { + navigate(`/projects/${data.data.id}`, { state: { created: true }, }); } else { @@ -265,9 +264,12 @@ export default function ProjectCreate() { updateForm("project_number", e.target.value)} + readOnly className="admin-form-input" - placeholder="Ponechte prázdné pro automatické" + style={{ + backgroundColor: "var(--bg-secondary)", + cursor: "default", + }} />
diff --git a/src/routes/admin/invoices-pdf.ts b/src/routes/admin/invoices-pdf.ts index 7759e9b..099dc82 100644 --- a/src/routes/admin/invoices-pdf.ts +++ b/src/routes/admin/invoices-pdf.ts @@ -808,20 +808,18 @@ export default async function invoicesPdfRoutes( .invoice-notes-content p { margin: 0 0 0.4em 0; } .invoice-notes-content ul, .invoice-notes-content ol { margin: 0 0 0.4em 1.5em; } .invoice-notes-content li { margin-bottom: 0.2em; } + .invoice-notes-content, + .invoice-notes-content * { + font-family: Tahoma, sans-serif !important; + } + .invoice-notes-content { font-size: 14px; } + .invoice-notes-content h1 { font-size: 20px; } + .invoice-notes-content h2 { font-size: 18px; } + .invoice-notes-content h3 { font-size: 16px; } + .invoice-notes-content h4 { font-size: 15px; } - /* Quill fonty */ - .ql-font-arial { font-family: Arial, sans-serif; } - .ql-font-tahoma { font-family: Tahoma, sans-serif; } - .ql-font-verdana { font-family: Verdana, sans-serif; } - .ql-font-georgia { font-family: Georgia, serif; } - .ql-font-times-new-roman { font-family: "Times New Roman", serif; } - .ql-font-courier-new { font-family: "Courier New", monospace; } - .ql-font-trebuchet-ms { font-family: "Trebuchet MS", sans-serif; } - .ql-font-impact { font-family: Impact, sans-serif; } - .ql-font-comic-sans-ms { font-family: "Comic Sans MS", cursive; } - .ql-font-lucida-console { font-family: "Lucida Console", monospace; } - .ql-font-palatino-linotype{ font-family: "Palatino Linotype", serif; } - .ql-font-garamond { font-family: Garamond, serif; } + /* Quill fonty – v PDF vynuceno Tahoma */ + [class*="ql-font-"] { font-family: Tahoma, sans-serif !important; } .ql-align-center { text-align: center; } .ql-align-right { text-align: right; } .ql-align-justify { text-align: justify; } diff --git a/src/routes/admin/invoices.ts b/src/routes/admin/invoices.ts index 1936e59..1e7f6aa 100644 --- a/src/routes/admin/invoices.ts +++ b/src/routes/admin/invoices.ts @@ -13,6 +13,7 @@ import { markOverdueInvoices, listInvoices, getNextInvoiceNumberFormatted, + getNextInvoiceNumberPreview, getInvoiceStats, getOrderDataForInvoice, getInvoice, @@ -65,7 +66,7 @@ export default async function invoicesRoutes( "/next-number", { preHandler: requirePermission("invoices.create") }, async (_request, reply) => { - const result = await getNextInvoiceNumberFormatted(); + const result = await getNextInvoiceNumberPreview(); return success(reply, result); }, ); diff --git a/src/routes/admin/offers-pdf.ts b/src/routes/admin/offers-pdf.ts index 07e5526..c1cd182 100644 --- a/src/routes/admin/offers-pdf.ts +++ b/src/routes/admin/offers-pdf.ts @@ -381,19 +381,8 @@ export default async function offersPdfRoutes( img, table, pre, code { max-width: 100%; } - /* ---- Quill font classes ---- */ - .ql-font-arial { font-family: Arial, sans-serif; } - .ql-font-tahoma { font-family: Tahoma, sans-serif; } - .ql-font-verdana { font-family: Verdana, sans-serif; } - .ql-font-georgia { font-family: Georgia, serif; } - .ql-font-times-new-roman { font-family: "Times New Roman", serif; } - .ql-font-courier-new { font-family: "Courier New", monospace; } - .ql-font-trebuchet-ms { font-family: "Trebuchet MS", sans-serif; } - .ql-font-impact { font-family: Impact, sans-serif; } - .ql-font-comic-sans-ms { font-family: "Comic Sans MS", cursive; } - .ql-font-lucida-console { font-family: "Lucida Console", monospace; } - .ql-font-palatino-linotype{ font-family: "Palatino Linotype", serif; } - .ql-font-garamond { font-family: Garamond, serif; } + /* ---- Quill font classes – v PDF vynuceno Tahoma ---- */ + [class*="ql-font-"] { font-family: Tahoma, sans-serif !important; } /* ---- Quill alignment ---- */ .ql-align-center { text-align: center; } @@ -606,6 +595,15 @@ ${indentCSS} word-break: normal; overflow-wrap: anywhere; } + .section-content, + .section-content * { + font-family: Tahoma, sans-serif !important; + } + .section-content { font-size: 14px; } + .section-content h1 { font-size: 20px; } + .section-content h2 { font-size: 18px; } + .section-content h3 { font-size: 16px; } + .section-content h4 { font-size: 15px; } .section-content p { margin: 0 0 0.4em 0; } .section-content ul, .section-content ol { margin: 0 0 0.4em 1.5em; } .section-content li { margin-bottom: 0.2em; } diff --git a/src/routes/admin/orders-pdf.ts b/src/routes/admin/orders-pdf.ts new file mode 100644 index 0000000..fb480c0 --- /dev/null +++ b/src/routes/admin/orders-pdf.ts @@ -0,0 +1,857 @@ +import { FastifyInstance } from "fastify"; +import prisma from "../../config/database"; +import { requirePermission } from "../../middleware/auth"; +import { localDateCzStr } from "../../utils/date"; +import { htmlToPdf } from "../../utils/html-to-pdf"; + +/* ── Helpers ─────────────────────────────────────────────────────── */ + +function formatDate(date: Date | string | null | undefined): string { + if (!date) return ""; + const d = new Date(date); + if (isNaN(d.getTime())) return String(date); + return localDateCzStr(d); +} + +function formatNum(n: number, decimals = 2): string { + const abs = Math.abs(n); + const fixed = abs.toFixed(decimals); + const [intPart, decPart] = fixed.split("."); + const withSep = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, " "); + const result = decPart ? `${withSep},${decPart}` : withSep; + return n < 0 ? `-${result}` : result; +} + +function escapeHtml(str: string | null | undefined): string { + if (!str) return ""; + return str + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +function cleanQuillHtml(html: string | null | undefined): string { + if (!html) return ""; + let s = html; + s = s.replace( + /<(script|iframe|object|embed|style|link|meta|base|form|input|textarea|button|select|svg|math)[^>]*>[\s\S]*?<\/\1>/gi, + "", + ); + s = s.replace( + /<(script|iframe|object|embed|style|link|meta|base|form|input|textarea|button|select|svg|math)[^>]*\/?>/gi, + "", + ); + s = s.replace(/\s+on\w+\s*=\s*("[^"]*"|'[^']*'|[^\s>]*)/gi, ""); + s = s.replace(/\s+on\w+\s*=\s*[^\s>]*/gi, ""); + s = s.replace(/href\s*=\s*["']?\s*javascript\s*:[^"'>\s]*/gi, 'href="#"'); + s = s.replace(/( )/g, " "); + let prev = ""; + while (prev !== s) { + prev = s; + s = s.replace(/]*)>(.*?)<\/span>\s*/gs, "$2"); + } + return s; +} + +interface AddressResult { + name: string; + lines: string[]; +} + +function buildAddressLines( + entity: Record | null, + isSupplier: boolean, + tObj: Record, +): AddressResult { + if (!entity) return { name: "", lines: [] }; + + const nameKey = isSupplier ? "company_name" : "name"; + const name = String(entity[nameKey] || ""); + + let cfData: Array<{ name?: string; value?: string; showLabel?: boolean }> = + []; + let fieldOrder: string[] | null = null; + const raw = entity.custom_fields; + if (raw) { + const parsed = typeof raw === "string" ? JSON.parse(raw) : raw; + if (parsed && typeof parsed === "object") { + if ((parsed as Record).fields) { + cfData = + ((parsed as Record).fields as typeof cfData) || []; + fieldOrder = ((parsed as Record).field_order || + (parsed as Record).fieldOrder) as string[] | null; + } else if (Array.isArray(parsed)) { + cfData = parsed; + } + } + } + + if (Array.isArray(fieldOrder)) { + const legacyMap: Record = { + Name: "name", + CompanyName: "company_name", + Street: "street", + CityPostal: "city_postal", + Country: "country", + CompanyId: "company_id", + VatId: "vat_id", + }; + fieldOrder = fieldOrder.map((k) => legacyMap[k] || k); + } + + const fieldMap: Record = {}; + if (name) fieldMap[nameKey] = name; + if (entity.street) fieldMap.street = String(entity.street); + const cityParts = [entity.city || "", entity.postal_code || ""] + .filter(Boolean) + .map(String); + const cityPostal = cityParts.join(" ").trim(); + if (cityPostal) fieldMap.city_postal = cityPostal; + if (entity.country) fieldMap.country = String(entity.country); + if (entity.company_id) + fieldMap.company_id = `${tObj.ico}${entity.company_id}`; + if (entity.vat_id) fieldMap.vat_id = `${tObj.dic}${entity.vat_id}`; + + cfData.forEach((cf, i) => { + const cfName = (cf.name || "").trim(); + const cfValue = (cf.value || "").trim(); + const showLabel = cf.showLabel !== false; + if (cfValue) { + fieldMap[`custom_${i}`] = + showLabel && cfName ? `${cfName}: ${cfValue}` : cfValue; + } + }); + + const lines: string[] = []; + if (Array.isArray(fieldOrder) && fieldOrder.length) { + for (const key of fieldOrder) { + if (key === nameKey) continue; + if (fieldMap[key]) lines.push(fieldMap[key]); + } + for (const [key, line] of Object.entries(fieldMap)) { + if (key === nameKey) continue; + if (!fieldOrder!.includes(key)) lines.push(line); + } + } else { + for (const [key, line] of Object.entries(fieldMap)) { + if (key === nameKey) continue; + lines.push(line); + } + } + + return { name, lines }; +} + +/* ── Translations ────────────────────────────────────────────────── */ + +const translations: Record> = { + cs: { + title: "POTVRZENÍ PŘIJETÍ OBJEDNÁVKY", + supplier: "Dodavatel", + customer: "Odběratel", + order_no: "Číslo objednávky:", + po_no: "Číslo zakáz. objednávky:", + date: "Datum:", + payment_method: "Forma úhrady:", + billing: "Potvrzujeme Vám následující položky:", + col_no: "Č.", + col_desc: "Popis", + col_qty: "Množství", + col_unit_price: "Jedn. cena", + col_price: "Cena", + col_vat_pct: "%DPH", + col_vat: "DPH", + col_total: "Celkem", + subtotal: "Mezisoučet:", + vat_label: "DPH", + total: "Celkem", + amounts_in: "Částky jsou uvedeny v", + notes: "Poznámky", + issued_by: "Vystavil:", + received_by: "Převzal:", + stamp: "Razítko:", + ico: "IČ: ", + dic: "DIČ: ", + }, + en: { + title: "ORDER CONFIRMATION", + supplier: "Supplier", + customer: "Customer", + order_no: "Order No.:", + po_no: "PO No.:", + date: "Date:", + payment_method: "Payment method:", + billing: "We confirm the following items:", + col_no: "No.", + col_desc: "Description", + col_qty: "Quantity", + col_unit_price: "Unit price", + col_price: "Price", + col_vat_pct: "VAT%", + col_vat: "VAT", + col_total: "Total", + subtotal: "Subtotal:", + vat_label: "VAT", + total: "Total", + amounts_in: "Amounts are in", + notes: "Notes", + issued_by: "Issued by:", + received_by: "Received by:", + stamp: "Stamp:", + ico: "Reg. No.: ", + dic: "Tax ID: ", + }, +}; + +/* ── Route ───────────────────────────────────────────────────────── */ + +export default async function ordersPdfRoutes( + fastify: FastifyInstance, +): Promise { + fastify.post<{ Params: { id: string }; Body: Record }>( + "/:id/confirmation", + { preHandler: requirePermission("orders.view") }, + async (request, reply) => { + const id = parseInt(request.params.id, 10); + const body = request.body || {}; + const lang = body.lang === "en" ? "en" : "cs"; + const t = translations[lang]; + + const order = await prisma.orders.findUnique({ + where: { id }, + include: { + customers: true, + order_items: { orderBy: { position: "asc" } }, + }, + }); + + if (!order) { + return reply + .status(404) + .type("text/html") + .send("

Objednávka nenalezena

"); + } + + const settings = (await prisma.company_settings.findFirst()) as Record< + string, + unknown + > | null; + + let logoImg = ""; + if (settings?.logo_data) { + const buf = Buffer.from(settings.logo_data as Buffer); + let mime = "image/png"; + if (buf[0] === 0xff && buf[1] === 0xd8) mime = "image/jpeg"; + else if (buf[0] === 0x47 && buf[1] === 0x49) mime = "image/gif"; + else if (buf[0] === 0x52 && buf[1] === 0x49) mime = "image/webp"; + const b64 = buf.toString("base64"); + logoImg = ``; + } + + const currency = order.currency || "CZK"; + const applyVat = !!order.apply_vat; + const orderVatRate = Number(order.vat_rate) || 21; + + // Use custom items from body if provided, otherwise order items + const customItemsRaw = body.items; + let items: Array<{ + description: string; + quantity: number; + unit: string; + unit_price: number; + is_included_in_total: boolean; + vat_rate: number; + }> = []; + + if (Array.isArray(customItemsRaw) && customItemsRaw.length > 0) { + items = customItemsRaw.map((it: Record) => ({ + description: String(it.description || ""), + quantity: Number(it.quantity) || 0, + unit: String(it.unit || ""), + unit_price: Number(it.unit_price) || 0, + is_included_in_total: + it.is_included_in_total !== false && it.is_included_in_total !== 0, + vat_rate: Number(it.vat_rate) || orderVatRate, + })); + } else { + items = order.order_items.map((it) => ({ + description: it.description || "", + quantity: Number(it.quantity) || 0, + unit: it.unit || "", + unit_price: Number(it.unit_price) || 0, + is_included_in_total: !!it.is_included_in_total, + vat_rate: orderVatRate, + })); + } + + let subtotal = 0; + let totalVat = 0; + const vatSummary: Record = {}; + for (const item of items) { + if (item.is_included_in_total) { + const lineTotal = item.quantity * item.unit_price; + subtotal += lineTotal; + const rate = item.vat_rate; + const key = String(rate); + if (!vatSummary[key]) vatSummary[key] = { base: 0, vat: 0 }; + vatSummary[key].base += lineTotal; + if (applyVat) { + const lineVat = (lineTotal * rate) / 100; + vatSummary[key].vat += lineVat; + totalVat += lineVat; + } + } + } + const totalToPay = subtotal + totalVat; + + const userName = request.authData + ? `${request.authData.firstName || ""} ${request.authData.lastName || ""}`.trim() + : ""; + + const supp = buildAddressLines(settings, true, t); + const cust = buildAddressLines( + (order.customers as Record) || null, + false, + t, + ); + + const suppLinesHtml = supp.lines + .map((l) => `
${escapeHtml(l)}
`) + .join(""); + const custLinesHtml = cust.lines + .map((l) => `
${escapeHtml(l)}
`) + .join(""); + + const orderNumber = escapeHtml(order.order_number || ""); + const poNumber = escapeHtml(order.customer_order_number || ""); + const orderDateStr = formatDate(order.created_at); + + const itemsHtml = items + .map((item, i) => { + const lineSubtotal = item.quantity * item.unit_price; + const lineVat = applyVat ? (lineSubtotal * item.vat_rate) / 100 : 0; + const lineTotal = lineSubtotal + lineVat; + const qtyDecimals = + Math.floor(item.quantity) === item.quantity ? 0 : 2; + return ` + ${i + 1} + ${escapeHtml(item.description)} + ${formatNum(item.quantity, qtyDecimals)}${item.unit ? ` / ${escapeHtml(item.unit)}` : ""} + ${formatNum(item.unit_price)} + ${formatNum(lineSubtotal)} + ${applyVat ? Math.floor(item.vat_rate) : 0}% + ${formatNum(lineVat)} + ${formatNum(lineTotal)} + `; + }) + .join(""); + + const paymentMethod = lang === "cs" ? "převodem" : "Bank transfer"; + + let vatDetailHtml = ""; + if (applyVat) { + for (const [rate, data] of Object.entries(vatSummary)) { + if (data.vat > 0) { + vatDetailHtml += ` +
+ ${escapeHtml(t.vat_label)} ${Math.floor(Number(rate))}%: + ${formatNum(data.vat)} ${escapeHtml(currency)} +
`; + } + } + } + + const notesRaw = order.notes ?? ""; + const notesStripped = notesRaw.replace(/<[^>]*>/g, "").trim(); + const notesHtml = notesStripped + ? ` +
+
${escapeHtml(t.notes)}
+
${cleanQuillHtml(notesRaw)}
+
+ ` + : ""; + + // Quill indent CSS + let indentCSS = ""; + for (let n = 1; n <= 9; n++) { + const pad = n * 3; + const liPad = n * 3 + 1.5; + indentCSS += ` .ql-indent-${n} { padding-left: ${pad}em; }\n`; + indentCSS += ` li.ql-indent-${n} { padding-left: ${liPad}em; }\n`; + } + + const html = ` + + + +${escapeHtml(t.title)} ${orderNumber} + + + +
+
+ + +
+
+ ${logoImg ? `
${logoImg}
` : ""} +
+
${escapeHtml(t.title)}
+
+ + + + + + + + + + + +
+
${escapeHtml(t.supplier)}
+
${escapeHtml(supp.name)}
+ ${suppLinesHtml} +
+
${escapeHtml(t.customer)}
+
${escapeHtml(cust.name)}
+ ${custLinesHtml} +
+
${escapeHtml(t.order_no)} ${orderNumber}
+ ${poNumber ? `
${escapeHtml(t.po_no)} ${poNumber}
` : ""} +
${escapeHtml(t.payment_method)} ${escapeHtml(paymentMethod)}
+
+
${escapeHtml(t.date)} ${escapeHtml(orderDateStr)}
+
+ + +
${escapeHtml(t.billing)}
+ + + + + + + + + + + + + + + ${itemsHtml} + +
${escapeHtml(t.col_no)}${escapeHtml(t.col_desc)}${escapeHtml(t.col_qty)}${escapeHtml(t.col_unit_price)}${escapeHtml(t.col_price)}${escapeHtml(t.col_vat_pct)}${escapeHtml(t.col_vat)}${escapeHtml(t.col_total)}
+ + +
+
+
+
+ ${escapeHtml(t.subtotal)} + ${formatNum(subtotal)} ${escapeHtml(currency)} +
${vatDetailHtml} +
+
+ ${escapeHtml(t.total)} + ${formatNum(totalToPay)} ${escapeHtml(currency)} +
+
${escapeHtml(t.amounts_in)} ${escapeHtml(currency)}
+
+
+ + ${notesHtml} + +
+ +
+ + +`; + + const pdfBuffer = await htmlToPdf(html); + const filename = `Potvrzeni-${orderNumber || String(id)}.pdf`; + + return reply + .type("application/pdf") + .header("Content-Disposition", `attachment; filename="${filename}"`) + .send(pdfBuffer); + }, + ); +} diff --git a/src/routes/admin/projects.ts b/src/routes/admin/projects.ts index f2b6546..2323d3b 100644 --- a/src/routes/admin/projects.ts +++ b/src/routes/admin/projects.ts @@ -91,8 +91,11 @@ export default async function projectsRoutes( const parsed = parseBody(UpdateProjectSchema, request.body); if ("error" in parsed) return error(reply, parsed.error, 400); - const existing = await updateProject(id, parsed.data); - if (!existing) return error(reply, "Projekt nenalezen", 404); + const result = await updateProject(id, parsed.data); + if (!result) return error(reply, "Projekt nenalezen", 404); + if ("error" in result) { + return error(reply, result.error, (result as any).status ?? 400); + } await logAudit({ request, @@ -100,7 +103,7 @@ export default async function projectsRoutes( action: "update", entityType: "project", entityId: id, - description: `Upraven projekt ${existing.name}`, + description: `Upraven projekt ${result.name}`, }); return success(reply, { id }, 200, "Projekt byl uložen"); }, diff --git a/src/routes/admin/quotations.ts b/src/routes/admin/quotations.ts index f37b39c..b2d0b26 100644 --- a/src/routes/admin/quotations.ts +++ b/src/routes/admin/quotations.ts @@ -278,7 +278,11 @@ export default async function quotationsRoutes( return error(reply, "Nabídka nenalezena", 404); if (result.error === "invalidated") return error(reply, "Nelze upravit zneplatněnou nabídku", 400); - return error(reply, "Neznámá chyba", 500); + return error( + reply, + result.error || "Neznámá chyba", + (result as any).status ?? 400, + ); } // Keep lock — user stays on the page after save diff --git a/src/routes/admin/received-invoices.ts b/src/routes/admin/received-invoices.ts index fcd903c..d87ad0f 100644 --- a/src/routes/admin/received-invoices.ts +++ b/src/routes/admin/received-invoices.ts @@ -15,6 +15,11 @@ import { nasFinancialsManager } from "../../services/nas-financials-manager"; import { toCzk } from "../../services/exchange-rates"; const VALID_STATUSES = ["unpaid", "paid"] as const; + +/** Round a monetary value to 2 decimal places to avoid floating-point drift. */ +function roundMoney(n: number): number { + return Math.round(n * 100) / 100; +} const ALLOWED_SORT_FIELDS = [ "id", "supplier_name", @@ -411,6 +416,15 @@ export default async function receivedInvoicesRoutes( } } + if (String(existing.status) === "paid") { + const attempted = Object.keys(body).filter( + (k) => !["status", "paid_date", "notes"].includes(k), + ); + if (attempted.length > 0) { + return error(reply, "Nelze upravit uhrazenou fakturu", 400); + } + } + // Recalculate vat_amount when amount or vat_rate changes (matching PHP) const finalAmount = body.amount !== undefined @@ -423,9 +437,9 @@ export default async function receivedInvoicesRoutes( // Amount includes VAT — extract VAT portion: amount - amount/(1 + rate/100) const computedVat = finalVatRate > 0 - ? Math.round( - (finalAmount - finalAmount / (1 + finalVatRate / 100)) * 100, - ) / 100 + ? roundMoney( + finalAmount - roundMoney(finalAmount / (1 + finalVatRate / 100)), + ) : 0; // Auto-set paid_date when status transitions to paid (matching PHP) diff --git a/src/schemas/offers.schema.ts b/src/schemas/offers.schema.ts index 21ea905..8e467b6 100644 --- a/src/schemas/offers.schema.ts +++ b/src/schemas/offers.schema.ts @@ -66,7 +66,6 @@ export const CreateQuotationSchema = z.object({ }); export const UpdateQuotationSchema = z.object({ - quotation_number: z.string().optional(), project_code: z.string().nullish(), customer_id: z .union([z.number(), z.string()]) diff --git a/src/schemas/orders.schema.ts b/src/schemas/orders.schema.ts index 9d0ab37..a207fdd 100644 --- a/src/schemas/orders.schema.ts +++ b/src/schemas/orders.schema.ts @@ -75,7 +75,6 @@ export const CreateOrderSchema = z.object({ }); export const UpdateOrderSchema = z.object({ - order_number: z.string().nullish(), customer_order_number: z.string().nullish(), status: z.string().optional(), currency: z.string().optional(), diff --git a/src/schemas/projects.schema.ts b/src/schemas/projects.schema.ts index 4e46e45..1e3483d 100644 --- a/src/schemas/projects.schema.ts +++ b/src/schemas/projects.schema.ts @@ -1,7 +1,12 @@ import { z } from "zod"; +const safeProjectNumber = z + .string() + .regex(/^[\p{L}\p{N}_\-.]+$/u, "Číslo projektu obsahuje nepovolené znaky") + .nullish(); + export const CreateProjectSchema = z.object({ - project_number: z.string().nullish(), + project_number: safeProjectNumber, name: z.string().nullish(), customer_id: z .union([z.number(), z.string()]) @@ -26,7 +31,6 @@ export const CreateProjectSchema = z.object({ }); export const UpdateProjectSchema = z.object({ - project_number: z.string().nullish(), name: z.string().nullish(), status: z.string().optional(), notes: z.string().nullish(), diff --git a/src/server.ts b/src/server.ts index bf590b6..28cba44 100644 --- a/src/server.ts +++ b/src/server.ts @@ -29,13 +29,16 @@ import totpRoutes from "./routes/admin/totp"; import scopeTemplatesRoutes from "./routes/admin/scope-templates"; import invoicesPdfRoutes from "./routes/admin/invoices-pdf"; import offersPdfRoutes from "./routes/admin/offers-pdf"; +import ordersPdfRoutes from "./routes/admin/orders-pdf"; import projectFilesRoutes from "./routes/admin/project-files"; const app = Fastify({ logger: { level: config.isProduction ? "warn" : "info", }, - trustProxy: true, + trustProxy: config.isProduction + ? ["127.0.0.1", "192.168.50.100"] + : ["127.0.0.1", "::1"], bodyLimit: 1048576, }); @@ -57,6 +60,12 @@ async function start() { await app.register(cookie); + // --- Health check (before rate-limit so monitoring isn't throttled) --- + app.get("/api/health", async () => ({ + status: "ok", + timestamp: new Date().toISOString(), + })); + await app.register(rateLimit, { max: 300, timeWindow: "1 minute", @@ -116,16 +125,11 @@ async function start() { }); await app.register(invoicesPdfRoutes, { prefix: "/api/admin/invoices-pdf" }); await app.register(offersPdfRoutes, { prefix: "/api/admin/offers-pdf" }); + await app.register(ordersPdfRoutes, { prefix: "/api/admin/orders-pdf" }); await app.register(projectFilesRoutes, { prefix: "/api/admin/project-files", }); - // --- Health check --- - app.get("/api/health", async () => ({ - status: "ok", - timestamp: new Date().toISOString(), - })); - // --- Frontend: Vite dev middleware (dev only) --- if (!config.isProduction) { const viteModule = await (Function( diff --git a/src/services/attendance.service.ts b/src/services/attendance.service.ts index bb1d798..9d99941 100644 --- a/src/services/attendance.service.ts +++ b/src/services/attendance.service.ts @@ -419,17 +419,32 @@ export async function switchProject(userId: number, projectId: number | null) { const now = new Date(); - await prisma.attendance_project_logs.updateMany({ + // End active project logs, ensuring ended_at is never before started_at + // (can happen when arrival_time was rounded up and now is still earlier) + const activeLogs = await prisma.attendance_project_logs.findMany({ where: { attendance_id: ongoing.id, ended_at: null }, - data: { ended_at: now }, }); + for (const log of activeLogs) { + const endedAt = + log.started_at && log.started_at > now ? log.started_at : now; + await prisma.attendance_project_logs.update({ + where: { id: log.id }, + data: { ended_at: endedAt }, + }); + } if (projectId) { + const existingLogs = await prisma.attendance_project_logs.count({ + where: { attendance_id: ongoing.id }, + }); + const isFirstProject = existingLogs === 0; + let startedAt = isFirstProject ? ongoing.arrival_time! : now; + if (startedAt > now) startedAt = now; await prisma.attendance_project_logs.create({ data: { attendance_id: ongoing.id, project_id: projectId, - started_at: now, + started_at: startedAt, ended_at: null, }, }); @@ -630,19 +645,28 @@ export async function getProjectReport(year: number) { }, include: { users: { select: { id: true, first_name: true, last_name: true } }, + attendance_project_logs: { + orderBy: { started_at: "asc" }, + }, }, }); - const projectIds = [ - ...new Set(records.filter((r) => r.project_id).map((r) => r.project_id!)), - ]; + // Collect all project ids from both attendance.project_id and project logs + const projectIds = new Set(); + for (const rec of records) { + if (rec.project_id) projectIds.add(rec.project_id); + for (const log of rec.attendance_project_logs) { + projectIds.add(log.project_id); + } + } + const projectsMap = new Map< number, { name: string; project_number: string } >(); - if (projectIds.length > 0) { + if (projectIds.size > 0) { const projects = await prisma.projects.findMany({ - where: { id: { in: projectIds } }, + where: { id: { in: [...projectIds] } }, select: { id: true, name: true, project_number: true }, }); for (const p of projects) { @@ -686,32 +710,68 @@ export async function getProjectReport(year: number) { >(); for (const rec of monthRecs) { - const hours = calcWorkedHours( - rec.arrival_time!, - rec.departure_time!, - rec.break_start, - rec.break_end, - ); - const pid = rec.project_id; - - if (!projectMap.has(pid)) { - const projInfo = pid ? projectsMap.get(pid) : undefined; - projectMap.set(pid, { - project_number: projInfo?.project_number || undefined, - project_name: projInfo?.name || undefined, - userMap: new Map(), - }); - } - - const pg = projectMap.get(pid)!; const uid = rec.user_id; const uName = rec.users ? `${rec.users.first_name} ${rec.users.last_name}`.trim() : `User #${uid}`; - if (!pg.userMap.has(uid)) { - pg.userMap.set(uid, { name: uName, hours: 0 }); + + if (rec.attendance_project_logs.length === 0) { + // No detailed project logs — fall back to attendance.project_id + const pid = rec.project_id; + const hours = calcWorkedHours( + rec.arrival_time!, + rec.departure_time!, + rec.break_start, + rec.break_end, + ); + + if (!projectMap.has(pid)) { + const projInfo = pid ? projectsMap.get(pid) : undefined; + projectMap.set(pid, { + project_number: projInfo?.project_number || undefined, + project_name: projInfo?.name || undefined, + userMap: new Map(), + }); + } + + const pg = projectMap.get(pid)!; + if (!pg.userMap.has(uid)) { + pg.userMap.set(uid, { name: uName, hours: 0 }); + } + pg.userMap.get(uid)!.hours += hours; + continue; + } + + // Use detailed project logs (started_at/ended_at or hours/minutes) + for (const log of rec.attendance_project_logs) { + let hours = 0; + if (log.hours != null || log.minutes != null) { + hours = (Number(log.hours) || 0) + (Number(log.minutes) || 0) / 60; + } else if (log.started_at && log.ended_at) { + hours = + (new Date(log.ended_at).getTime() - + new Date(log.started_at).getTime()) / + (1000 * 60 * 60); + } else { + continue; + } + + const pid = log.project_id; + if (!projectMap.has(pid)) { + const projInfo = projectsMap.get(pid); + projectMap.set(pid, { + project_number: projInfo?.project_number || undefined, + project_name: projInfo?.name || undefined, + userMap: new Map(), + }); + } + + const pg = projectMap.get(pid)!; + if (!pg.userMap.has(uid)) { + pg.userMap.set(uid, { name: uName, hours: 0 }); + } + pg.userMap.get(uid)!.hours += hours; } - pg.userMap.get(uid)!.hours += hours; } const projects = Array.from(projectMap.entries()).map(([pid, pg]) => ({ @@ -1315,10 +1375,20 @@ export async function punchAction(userId: number, data: PunchData) { data: updateData, }); - await prisma.attendance_project_logs.updateMany({ + // End active project logs, ensuring ended_at is never before started_at + const activeLogs = await prisma.attendance_project_logs.findMany({ where: { attendance_id: ongoing.id, ended_at: null }, - data: { ended_at: departureTime }, }); + for (const log of activeLogs) { + const endedAt = + log.started_at && log.started_at > departureTime + ? log.started_at + : departureTime; + await prisma.attendance_project_logs.update({ + where: { id: log.id }, + data: { ended_at: endedAt }, + }); + } return { id: ongoing.id, diff --git a/src/services/audit.ts b/src/services/audit.ts index 06c39da..78c166c 100644 --- a/src/services/audit.ts +++ b/src/services/audit.ts @@ -2,6 +2,27 @@ import { FastifyRequest } from "fastify"; import prisma from "../config/database"; import { AuditAction, EntityType, AuthData } from "../types"; +/** + * Safe JSON.stringify replacer that handles Prisma Decimal and BigInt + * by converting them to strings. Prevents JSON.stringify from throwing + * on values that include these types. + */ +function safeJsonReplacer(_key: string, value: unknown): unknown { + if (typeof value === "bigint") return String(value); + if ( + value !== null && + typeof value === "object" && + "toString" in value && + typeof (value as any).toString === "function" && + "toNumber" in value && + typeof (value as any).toNumber === "function" + ) { + // Prisma Decimal + return (value as any).toString(); + } + return value; +} + export async function logAudit(params: { request: FastifyRequest; authData?: AuthData | null; @@ -22,8 +43,12 @@ export async function logAudit(params: { entity_type: params.entityType ?? null, entity_id: params.entityId ?? null, description: params.description ?? null, - old_values: params.oldValues ? JSON.stringify(params.oldValues) : null, - new_values: params.newValues ? JSON.stringify(params.newValues) : null, + old_values: params.oldValues + ? JSON.stringify(params.oldValues, safeJsonReplacer) + : null, + new_values: params.newValues + ? JSON.stringify(params.newValues, safeJsonReplacer) + : null, user_agent: params.request.headers["user-agent"] ?? null, session_id: null, }, diff --git a/src/services/invoice-alerts.ts b/src/services/invoice-alerts.ts index bb1f781..a1a7a65 100644 --- a/src/services/invoice-alerts.ts +++ b/src/services/invoice-alerts.ts @@ -99,14 +99,6 @@ export async function checkInvoiceAlerts(): Promise { due_date: localDateCzStr(new Date(inv.due_date)), days_label: daysLabel, }); - - await prisma.invoice_alert_log.create({ - data: { - invoice_type: "created", - invoice_id: inv.id, - alert_type: alertType, - }, - }); } // --- Received invoices (we owe supplier) --- @@ -155,14 +147,6 @@ export async function checkInvoiceAlerts(): Promise { due_date: localDateCzStr(new Date(inv.due_date)), days_label: daysLabel, }); - - await prisma.invoice_alert_log.create({ - data: { - invoice_type: "received", - invoice_id: inv.id, - alert_type: alertType, - }, - }); } if (alerts.length === 0) return; @@ -221,9 +205,26 @@ export async function checkInvoiceAlerts(): Promise { const sent = await sendMail(alertEmail, subject, html); if (!sent) { console.error(`InvoiceAlerts: Failed to send alert to ${alertEmail}`); - } else { - console.log( - `InvoiceAlerts: Sent ${alerts.length} alert(s) to ${alertEmail}`, - ); + return; + } + + console.log(`InvoiceAlerts: Sent ${alerts.length} alert(s) to ${alertEmail}`); + + // Mark alerts as sent only after successful delivery + for (const a of alerts) { + await prisma.invoice_alert_log.create({ + data: { + invoice_type: a.type, + invoice_id: a.id, + alert_type: + a.type === "created" + ? a.days_label.includes("dnes") + ? "due" + : "3days" + : a.days_label.includes("dnes") + ? "due" + : "3days", + }, + }); } } diff --git a/src/services/invoices.service.ts b/src/services/invoices.service.ts index acad9a1..af08182 100644 --- a/src/services/invoices.service.ts +++ b/src/services/invoices.service.ts @@ -1,5 +1,9 @@ import prisma from "../config/database"; import { toCzk } from "./exchange-rates"; +import { + generateInvoiceNumber, + releaseInvoiceNumber, +} from "./numbering.service"; // Status transition rules matching PHP const VALID_TRANSITIONS: Record = { @@ -147,7 +151,10 @@ export async function listInvoices(params: ListInvoicesParams) { return { data: enriched, total, page, limit }; } -export { generateInvoiceNumber as getNextInvoiceNumberFormatted } from "./numbering.service"; +export { + generateInvoiceNumber as getNextInvoiceNumberFormatted, + previewInvoiceNumber as getNextInvoiceNumberPreview, +} from "./numbering.service"; export async function getInvoiceStats(queryMonth?: number, queryYear?: number) { const now = new Date(); @@ -293,9 +300,14 @@ export async function getInvoice(id: number) { } export async function createInvoice(body: Record) { + const invoiceNumber = + body.invoice_number !== undefined && body.invoice_number !== null + ? String(body.invoice_number) + : (await generateInvoiceNumber()).number; + const invoice = await prisma.invoices.create({ data: { - invoice_number: body.invoice_number ? String(body.invoice_number) : null, + invoice_number: invoiceNumber, order_id: body.order_id ? Number(body.order_id) : null, customer_id: body.customer_id ? Number(body.customer_id) : null, status: body.status ? String(body.status) : "issued", @@ -410,7 +422,7 @@ export async function updateInvoice(id: number, body: Record) { } } - if (body.paid_date !== undefined) + if (body.paid_date !== undefined && currentStatus !== "paid") data.paid_date = body.paid_date ? new Date(String(body.paid_date)) : null; await prisma.invoices.update({ where: { id }, data }); @@ -441,5 +453,11 @@ export async function deleteInvoice(id: number) { if (!existing) return null; await prisma.invoices.delete({ where: { id } }); + + const year = existing.created_at + ? new Date(existing.created_at).getFullYear() + : new Date().getFullYear(); + await releaseInvoiceNumber(year); + return existing; } diff --git a/src/services/nas-file-manager.ts b/src/services/nas-file-manager.ts index 79f810e..8e17556 100644 --- a/src/services/nas-file-manager.ts +++ b/src/services/nas-file-manager.ts @@ -545,13 +545,14 @@ export class NasFileManager { } private buildFolderName(projectNumber: string, projectName: string): string { + let safeNum = projectNumber.replace(/[^\p{L}\p{N}_\-.]/gu, ""); + safeNum = safeNum.replace(/^\.+|\.+$/g, "").trim(); let safe = projectName.replace(/[^\p{L}\p{N}_\-. ]/gu, ""); - safe = safe.trim().replace(/ /g, "_"); - safe = safe.replace(/_+/g, "_"); + safe = safe.trim().replace(/ /g, "_").replace(/_+/g, "_"); if ([...safe].length > 200) { safe = [...safe].slice(0, 200).join(""); } - return projectNumber + "_" + safe; + return safeNum + "_" + safe; } private resolveProjectPath( diff --git a/src/services/numbering.service.ts b/src/services/numbering.service.ts index 24a79f2..dae4321 100644 --- a/src/services/numbering.service.ts +++ b/src/services/numbering.service.ts @@ -1,4 +1,11 @@ import prisma from "../config/database"; +import type { PrismaClient } from "@prisma/client"; + +// Prisma transaction client (omit methods not available inside $transaction) +type TxClient = Omit< + PrismaClient, + "$connect" | "$disconnect" | "$on" | "$transaction" | "$extends" +>; // Default patterns (backward compatible with existing numbers) const DEFAULT_OFFER_PATTERN = "{YYYY}/{PREFIX}/{NNN}"; @@ -26,45 +33,6 @@ function applyPattern( }); } -/** - * Extract the static prefix and sequence position from a pattern. - * Used to build SQL LIKE patterns for MAX(seq) queries. - */ -function buildLikePattern( - pattern: string, - vars: { year: number; prefix: string; code: string }, -): { likePattern: string; prefixLen: number } { - const yyyy = String(vars.year); - const yy = yyyy.slice(-2); - - let staticPrefix = ""; - let foundSeq = false; - const parts = pattern.split(/(\{[^}]+\})/); - - for (const part of parts) { - const m = part.match(/^\{(\w+)\}$/); - if (!m) { - staticPrefix += part; - continue; - } - const key = m[1]; - if (/^N+$/.test(key)) { - foundSeq = true; - break; - } - if (key === "YYYY") staticPrefix += yyyy; - else if (key === "YY") staticPrefix += yy; - else if (key === "PREFIX") staticPrefix += vars.prefix; - else if (key === "CODE") staticPrefix += vars.code; - } - - if (!foundSeq) { - return { likePattern: staticPrefix + "%", prefixLen: staticPrefix.length }; - } - - return { likePattern: staticPrefix + "%", prefixLen: staticPrefix.length }; -} - async function getSettings() { return prisma.company_settings.findFirst({ select: { @@ -79,92 +47,237 @@ async function getSettings() { } /** - * Next offer/quotation number. + * Atomically get the next sequence number for a given type and year. + * Uses SELECT ... FOR UPDATE inside a transaction to prevent races. + * If `tx` is provided, the increment happens inside the caller's transaction + * (no nested transaction is created). */ -export async function generateOfferNumber(): Promise { +async function getNextSequence( + type: string, + year: number, + tx?: TxClient, +): Promise { + const exec = async (client: TxClient) => { + const existing = await client.$queryRaw< + Array<{ id: number; last_number: number }> + >` + SELECT id, last_number FROM number_sequences + WHERE \`type\` = ${type} AND \`year\` = ${year} + FOR UPDATE + `; + + if (existing.length === 0) { + await client.$executeRaw` + INSERT INTO number_sequences (\`type\`, \`year\`, \`last_number\`) + VALUES (${type}, ${year}, 1) + `; + return 1; + } + + const next = existing[0].last_number + 1; + await client.$executeRaw` + UPDATE number_sequences + SET \`last_number\` = ${next} + WHERE id = ${existing[0].id} + `; + return next; + }; + + if (tx) { + return exec(tx); + } + return prisma.$transaction(exec); +} + +/** + * Preview the next sequence number without consuming it. + */ +async function previewNextSequence( + type: string, + year: number, +): Promise { + const existing = await prisma.$queryRaw>` + SELECT last_number FROM number_sequences + WHERE \`type\` = ${type} AND \`year\` = ${year} + `; + + if (existing.length === 0) { + return 1; + } + + return existing[0].last_number + 1; +} + +/** + * Decrement the sequence counter for a given type/year. + * Called after deleting a document so the number can be reused. + */ +async function releaseSequence(type: string, year: number) { + try { + await prisma.$executeRaw` + UPDATE number_sequences + SET last_number = GREATEST(COALESCE(last_number, 0) - 1, 0) + WHERE \`type\` = ${type} AND \`year\` = ${year} + `; + } catch (err) { + // Non-fatal: log but don't fail the delete operation + console.error(`releaseSequence failed for ${type}/${year}:`, err); + } +} + +/** Verify a shared number is not already used by an order or project. */ +async function isSharedNumberTaken(number: string): Promise { + const [existingOrder, existingProject] = await Promise.all([ + prisma.orders.findFirst({ where: { order_number: number } }), + prisma.projects.findFirst({ where: { project_number: number } }), + ]); + return !!(existingOrder || existingProject); +} + +/** Verify an invoice number is not already used. */ +async function isInvoiceNumberTaken(number: string): Promise { + const existing = await prisma.invoices.findFirst({ + where: { invoice_number: number }, + }); + return !!existing; +} + +/** Verify an offer/quotation number is not already used. */ +async function isOfferNumberTaken(number: string): Promise { + const existing = await prisma.quotations.findFirst({ + where: { quotation_number: number }, + }); + return !!existing; +} + +/** + * Next offer/quotation number (consumes sequence). + * Verifies uniqueness against the quotations table; retries if taken. + * Pass `tx` when calling inside an existing Prisma transaction. + */ +export async function generateOfferNumber(tx?: TxClient): Promise { const settings = await getSettings(); const pattern = settings?.offer_number_pattern || DEFAULT_OFFER_PATTERN; const prefix = settings?.quotation_prefix || "NA"; const year = new Date().getFullYear(); - const { likePattern, prefixLen } = buildLikePattern(pattern, { - year, - prefix, - code: "", - }); + for (let attempt = 0; attempt < 100; attempt++) { + const seq = await getNextSequence("offer", year, tx); + const number = applyPattern(pattern, { year, prefix, code: "", seq }); + if (!(await isOfferNumberTaken(number))) { + return number; + } + } - const result = await prisma.$queryRaw<[{ max_seq: bigint | null }]>` - SELECT COALESCE(MAX(CAST(SUBSTRING(quotation_number, ${prefixLen} + 1) AS UNSIGNED)), 0) as max_seq - FROM quotations - WHERE quotation_number LIKE ${likePattern} - `; - const nextNum = Number(result[0]?.max_seq ?? 0) + 1; - - return applyPattern(pattern, { year, prefix, code: "", seq: nextNum }); + throw new Error("Nepodařilo se vygenerovat jedinečné číslo nabídky"); } /** - * Shared number for orders and projects. + * Preview next offer/quotation number (does NOT consume sequence). */ -export async function generateSharedNumber(): Promise { +export async function previewOfferNumber(): Promise { + const settings = await getSettings(); + const pattern = settings?.offer_number_pattern || DEFAULT_OFFER_PATTERN; + const prefix = settings?.quotation_prefix || "NA"; + const year = new Date().getFullYear(); + + const seq = await previewNextSequence("offer", year); + return applyPattern(pattern, { year, prefix, code: "", seq }); +} + +/** + * Shared number for orders and projects (consumes sequence). + * Verifies uniqueness against both orders and projects tables; retries if taken. + * Pass `tx` when calling inside an existing Prisma transaction. + */ +export async function generateSharedNumber(tx?: TxClient): Promise { const settings = await getSettings(); const pattern = settings?.order_number_pattern || DEFAULT_ORDER_PATTERN; const code = settings?.order_type_code || "71"; const year = new Date().getFullYear(); - const { likePattern, prefixLen } = buildLikePattern(pattern, { - year, - prefix: "", - code, - }); + for (let attempt = 0; attempt < 100; attempt++) { + const seq = await getNextSequence("shared", year, tx); + const number = applyPattern(pattern, { year, prefix: "", code, seq }); + if (!(await isSharedNumberTaken(number))) { + return number; + } + } - const result = await prisma.$queryRaw<[{ max_seq: bigint | null }]>` - SELECT COALESCE(MAX(seq), 0) as max_seq FROM ( - SELECT CAST(SUBSTRING(order_number, ${prefixLen} + 1) AS UNSIGNED) AS seq - FROM orders WHERE order_number LIKE ${likePattern} - UNION ALL - SELECT CAST(SUBSTRING(project_number, ${prefixLen} + 1) AS UNSIGNED) AS seq - FROM projects WHERE project_number LIKE ${likePattern} - ) combined - `; - const nextNum = Number(result[0]?.max_seq ?? 0) + 1; - - return applyPattern(pattern, { year, prefix: "", code, seq: nextNum }); + throw new Error( + "Nepodařilo se vygenerovat jedinečné číslo objednávky/projekt", + ); } /** - * Next invoice number. + * Preview shared number for orders and projects (does NOT consume sequence). + */ +export async function previewSharedNumber(): Promise { + const settings = await getSettings(); + const pattern = settings?.order_number_pattern || DEFAULT_ORDER_PATTERN; + const code = settings?.order_type_code || "71"; + const year = new Date().getFullYear(); + + const seq = await previewNextSequence("shared", year); + return applyPattern(pattern, { year, prefix: "", code, seq }); +} + +/** + * Next invoice number (consumes sequence). + * Verifies uniqueness against the invoices table; retries if taken. + * Pass `tx` when calling inside an existing Prisma transaction. */ export async function generateInvoiceNumber( _year?: number, + tx?: TxClient, +): Promise<{ number: string; next_number: string }> { + const settings = await getSettings(); + const pattern = settings?.invoice_number_pattern || DEFAULT_INVOICE_PATTERN; + const code = settings?.invoice_type_code || "81"; + const year = _year || new Date().getFullYear(); + + for (let attempt = 0; attempt < 100; attempt++) { + const seq = await getNextSequence("invoice", year, tx); + const number = applyPattern(pattern, { year, prefix: "", code, seq }); + if (!(await isInvoiceNumberTaken(number))) { + return { number, next_number: number }; + } + } + + throw new Error("Nepodařilo se vygenerovat jedinečné číslo faktury"); +} + +/** + * Preview next invoice number (does NOT consume sequence). + */ +export async function previewInvoiceNumber( + _year?: number, ): Promise<{ number: string; next_number: string }> { const settings = await getSettings(); const pattern = settings?.invoice_number_pattern || DEFAULT_INVOICE_PATTERN; const code = settings?.invoice_type_code || "81"; const year = _year || new Date().getFullYear(); - const { likePattern, prefixLen } = buildLikePattern(pattern, { - year, - prefix: "", - code, - }); - - const result = await prisma.$queryRaw<[{ max_seq: bigint | null }]>` - SELECT COALESCE(MAX(CAST(SUBSTRING(invoice_number, ${prefixLen} + 1) AS UNSIGNED)), 0) as max_seq - FROM invoices - WHERE invoice_number LIKE ${likePattern} - `; - const nextNum = Number(result[0]?.max_seq ?? 0) + 1; - - const number = applyPattern(pattern, { - year, - prefix: "", - code, - seq: nextNum, - }); + const seq = await previewNextSequence("invoice", year); + const number = applyPattern(pattern, { year, prefix: "", code, seq }); return { number, next_number: number }; } +/** Release an offer number back to the pool (decrement sequence). */ +export async function releaseOfferNumber(year?: number) { + await releaseSequence("offer", year || new Date().getFullYear()); +} + +/** Release a shared number back to the pool (decrement sequence). */ +export async function releaseSharedNumber(year?: number) { + await releaseSequence("shared", year || new Date().getFullYear()); +} + +/** Release an invoice number back to the pool (decrement sequence). */ +export async function releaseInvoiceNumber(year?: number) { + await releaseSequence("invoice", year || new Date().getFullYear()); +} + /** Preview what a pattern would produce (for settings UI) */ export function previewPattern( pattern: string, diff --git a/src/services/offers.service.ts b/src/services/offers.service.ts index 2887a8c..2a4648b 100644 --- a/src/services/offers.service.ts +++ b/src/services/offers.service.ts @@ -1,5 +1,9 @@ import prisma from "../config/database"; -import { generateOfferNumber } from "./numbering.service"; +import { + generateOfferNumber, + previewOfferNumber, + releaseOfferNumber, +} from "./numbering.service"; interface QuotationItemInput { description?: string; @@ -18,7 +22,7 @@ interface ScopeSectionInput { } // Re-export for convenience -export { generateOfferNumber as getNextOfferNumber } from "./numbering.service"; +export { previewOfferNumber as getNextOfferNumber } from "./numbering.service"; const ALLOWED_SORT_FIELDS = [ "id", @@ -133,11 +137,14 @@ export async function getOffer(id: number) { } export async function createOffer(body: Record) { + const quotationNumber = + body.quotation_number !== undefined && body.quotation_number !== null + ? String(body.quotation_number) + : await generateOfferNumber(); + const quotation = await prisma.quotations.create({ data: { - quotation_number: body.quotation_number - ? String(body.quotation_number) - : null, + quotation_number: quotationNumber, project_code: body.project_code ? String(body.project_code) : null, customer_id: body.customer_id ? Number(body.customer_id) : null, valid_until: body.valid_until ? new Date(String(body.valid_until)) : null, @@ -190,13 +197,16 @@ export async function updateOffer(id: number, body: Record) { if (existing.status === "invalidated") return { error: "invalidated" as const }; + if ( + body.quotation_number !== undefined && + String(body.quotation_number) !== existing.quotation_number + ) { + return { error: "Číslo nabídky nelze změnit", status: 400 } as const; + } + await prisma.quotations.update({ where: { id }, data: { - quotation_number: - body.quotation_number !== undefined - ? String(body.quotation_number) - : undefined, customer_id: body.customer_id !== undefined ? Number(body.customer_id) : undefined, valid_until: @@ -281,6 +291,12 @@ export async function deleteOffer(id: number) { if (!existing) return null; await prisma.quotations.delete({ where: { id } }); + + const year = existing.created_at + ? new Date(existing.created_at).getFullYear() + : new Date().getFullYear(); + await releaseOfferNumber(year); + return existing; } diff --git a/src/services/orders.service.ts b/src/services/orders.service.ts index 03860b6..7cd8524 100644 --- a/src/services/orders.service.ts +++ b/src/services/orders.service.ts @@ -1,5 +1,9 @@ import prisma from "../config/database"; -import { generateSharedNumber } from "./numbering.service"; +import { + generateSharedNumber, + previewSharedNumber, + releaseSharedNumber, +} from "./numbering.service"; interface OrderItemInput { description?: string | null; @@ -180,10 +184,10 @@ export async function createOrderFromQuotation( status: 400, } as const; - const orderNumber = await generateSharedNumber(); - const projectNumber = await generateSharedNumber(); - const result = await prisma.$transaction(async (tx) => { + const orderNumber = await generateSharedNumber(tx); + const projectNumber = orderNumber; + const order = await tx.orders.create({ data: { order_number: orderNumber, @@ -249,14 +253,14 @@ export async function createOrderFromQuotation( }, }); - return { order, project }; + return { order, project, orderNumber }; }); return { data: { order_id: result.order.id, id: result.order.id, - order_number: orderNumber, + order_number: result.orderNumber, quotationId, }, }; @@ -281,9 +285,14 @@ interface CreateOrderData { } export async function createOrder(body: CreateOrderData) { + const orderNumber = + body.order_number !== undefined && body.order_number !== null + ? String(body.order_number) + : await generateSharedNumber(); + const order = await prisma.orders.create({ data: { - order_number: body.order_number ?? null, + order_number: orderNumber, customer_order_number: body.customer_order_number ?? null, quotation_id: body.quotation_id ?? null, customer_id: body.customer_id ?? null, @@ -343,6 +352,16 @@ export async function updateOrder(id: number, body: UpdateOrderData) { const currentStatus = existing.status as string; + if ( + body.order_number !== undefined && + String(body.order_number) !== existing.order_number + ) { + return { + error: "Číslo objednávky nelze změnit", + status: 400, + } as const; + } + if (body.status !== undefined && String(body.status) !== currentStatus) { const newStatus = String(body.status); const allowed = VALID_TRANSITIONS[currentStatus] || []; @@ -356,7 +375,6 @@ export async function updateOrder(id: number, body: UpdateOrderData) { const data: Record = { modified_at: new Date() }; const strFields = [ - "order_number", "customer_order_number", "status", "currency", @@ -377,17 +395,6 @@ export async function updateOrder(id: number, body: UpdateOrderData) { await prisma.orders.update({ where: { id }, data }); - // Sync project_number when order_number changes (matching PHP) - if ( - body.order_number !== undefined && - String(body.order_number) !== existing.order_number - ) { - await prisma.projects.updateMany({ - where: { order_id: id }, - data: { project_number: String(body.order_number) }, - }); - } - // Sync project status when order status changes (matching PHP) if (body.status !== undefined && String(body.status) !== currentStatus) { const statusMap: Record = { @@ -405,6 +412,12 @@ export async function updateOrder(id: number, body: UpdateOrderData) { } if (Array.isArray(body.items) || Array.isArray(body.sections)) { + if (currentStatus !== "prijata" && currentStatus !== "v_realizaci") { + return { + error: "Nelze upravit položky dokončené/stornované objednávky", + status: 400, + } as const; + } await prisma.$transaction(async (tx) => { if (Array.isArray(body.items)) { await tx.order_items.deleteMany({ where: { order_id: id } }); @@ -453,7 +466,7 @@ export async function deleteOrder(id: number) { // Delete linked project and its notes (matching PHP) const linkedProjects = await prisma.projects.findMany({ where: { order_id: id }, - select: { id: true }, + select: { id: true, created_at: true }, }); if (linkedProjects.length > 0) { const projectIds = linkedProjects.map((p) => p.id); @@ -464,9 +477,23 @@ export async function deleteOrder(id: number) { } await prisma.orders.delete({ where: { id } }); + + const year = existing.created_at + ? new Date(existing.created_at).getFullYear() + : new Date().getFullYear(); + await releaseSharedNumber(year); + + // Release the linked project's shared number(s) too + for (const p of linkedProjects) { + const pYear = p.created_at + ? new Date(p.created_at).getFullYear() + : new Date().getFullYear(); + await releaseSharedNumber(pYear); + } + return { data: { id, order_number: existing.order_number } }; } export async function getNextOrderNumber() { - return generateSharedNumber(); + return previewSharedNumber(); } diff --git a/src/services/projects.service.ts b/src/services/projects.service.ts index 7ca3c48..764837f 100644 --- a/src/services/projects.service.ts +++ b/src/services/projects.service.ts @@ -1,5 +1,9 @@ import prisma from "../config/database"; -import { generateSharedNumber } from "./numbering.service"; +import { + generateSharedNumber, + previewSharedNumber, + releaseSharedNumber, +} from "./numbering.service"; import { NasFileManager } from "./nas-file-manager"; const nasFileManager = new NasFileManager(); @@ -93,9 +97,14 @@ export async function getProject(id: number) { } export async function createProject(body: Record) { + const projectNumber = + body.project_number !== undefined && body.project_number !== null + ? String(body.project_number) + : await generateSharedNumber(); + const project = await prisma.projects.create({ data: { - project_number: body.project_number ? String(body.project_number) : null, + project_number: projectNumber, name: body.name ? String(body.name) : null, customer_id: body.customer_id ? Number(body.customer_id) : null, responsible_user_id: body.responsible_user_id @@ -124,8 +133,15 @@ export async function updateProject(id: number, body: Record) { const existing = await prisma.projects.findUnique({ where: { id } }); if (!existing) return null; + if ( + body.project_number !== undefined && + String(body.project_number) !== existing.project_number + ) { + return { error: "Číslo projektu nelze změnit", status: 400 }; + } + const data: Record = { modified_at: new Date() }; - const strFields = ["project_number", "name", "status", "notes"]; + const strFields = ["name", "status", "notes"]; for (const f of strFields) if (body[f] !== undefined) data[f] = body[f] ? String(body[f]) : null; if (body.customer_id !== undefined) @@ -148,13 +164,14 @@ export async function updateProject(id: number, body: Record) { await prisma.projects.update({ where: { id }, data }); if ( - existing.name !== data.name && + body.name !== undefined && + existing.name !== body.name && existing.project_number && nasFileManager.isConfigured() ) { nasFileManager.renameProjectFolder( existing.project_number, - String(data.name || ""), + String(body.name || ""), ); } @@ -171,6 +188,12 @@ export async function deleteProject(id: number, deleteFiles: boolean = false) { } await prisma.projects.delete({ where: { id } }); + + const year = existing.created_at + ? new Date(existing.created_at).getFullYear() + : new Date().getFullYear(); + await releaseSharedNumber(year); + return existing; } @@ -205,5 +228,5 @@ export async function deleteProjectNote(projectId: number, noteId: number) { } export async function getNextProjectNumber() { - return generateSharedNumber(); + return previewSharedNumber(); } diff --git a/vitest.config.ts b/vitest.config.ts index 220e8fe..f00dd7c 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,11 +1,14 @@ -import { defineConfig } from 'vitest/config'; +import { defineConfig } from "vitest/config"; +import dotenv from "dotenv"; +dotenv.config({ path: ".env.test", override: true }); export default defineConfig({ test: { globals: true, - environment: 'node', - setupFiles: ['./src/__tests__/setup.ts'], + environment: "node", + setupFiles: ["./src/__tests__/setup.ts"], testTimeout: 15000, hookTimeout: 15000, + exclude: ["dist/**", "node_modules/**"], }, });