- 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 <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-04-23 17:23:10 +02:00
parent b197017644
commit 07cb428287
36 changed files with 2233 additions and 480 deletions

295
CLAUDE.md Normal file
View File

@@ -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 `<feature>.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.

View File

@@ -1,6 +1,6 @@
{
"name": "app-ts",
"version": "1.5.1",
"version": "1.5.2",
"description": "",
"main": "dist/server.js",
"scripts": {

View File

@@ -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`);

View File

@@ -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 {

View File

@@ -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);
});
});

View File

@@ -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;

View File

@@ -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<void>;
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<string>("cs");
const [items, setItems] = useState<ConfirmationItem[]>(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 (
<AnimatePresence>
{isOpen && (
<motion.div
className="admin-modal-overlay"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
>
<div className="admin-modal-backdrop" onClick={onClose} />
<motion.div
className={
step === "edit" ? "admin-modal admin-modal-lg" : "admin-modal"
}
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
transition={{ duration: 0.2 }}
>
<div className="admin-modal-header">
<h2 className="admin-modal-title">
Potvrzení objednávky {orderNumber}
</h2>
</div>
<div className="admin-modal-body">
{step === "choose" ? (
<div className="admin-form">
<div className="admin-form-group">
<label className="admin-form-label">Jazyk dokumentu</label>
<div className="flex-row gap-2">
<button
type="button"
onClick={() => setLang("cs")}
className={
lang === "cs"
? "admin-btn admin-btn-primary admin-btn-sm"
: "admin-btn admin-btn-secondary admin-btn-sm"
}
>
Čeština
</button>
<button
type="button"
onClick={() => setLang("en")}
className={
lang === "en"
? "admin-btn admin-btn-primary admin-btn-sm"
: "admin-btn admin-btn-secondary admin-btn-sm"
}
>
English
</button>
</div>
</div>
<div className="admin-form-group">
<label className="admin-form-label">Obsah potvrzení</label>
<p
className="text-secondary"
style={{ marginBottom: "0.75rem" }}
>
Jak chcete připravit potvrzení objednávky?
</p>
<button
onClick={handleUseExisting}
disabled={loading}
className="admin-btn admin-btn-primary w-full"
style={{ marginBottom: "0.5rem" }}
>
{loading ? (
<>
<div className="admin-spinner admin-spinner-sm" />
Generuji...
</>
) : (
"Použít položky z objednávky"
)}
</button>
<button
onClick={() => {
setItems(initialItems.length > 0 ? initialItems : []);
setStep("edit");
}}
disabled={loading}
className="admin-btn admin-btn-secondary w-full"
>
Upravit položky
</button>
</div>
</div>
) : (
<div className="admin-form">
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th>Popis</th>
<th>Mn.</th>
<th>Jedn.</th>
<th>Cena</th>
<th>%DPH</th>
<th style={{ width: "40px" }} />
</tr>
</thead>
<tbody>
{items.map((item, i) => (
<tr key={i}>
<td>
<input
type="text"
value={item.description}
onChange={(e) =>
updateItem(i, "description", e.target.value)
}
className="admin-form-input"
style={{ minWidth: "200px" }}
/>
</td>
<td>
<input
type="number"
value={item.quantity}
onChange={(e) =>
updateItem(
i,
"quantity",
Number(e.target.value) || 0,
)
}
className="admin-form-input"
style={{ width: "80px" }}
step="0.001"
/>
</td>
<td>
<input
type="text"
value={item.unit}
onChange={(e) =>
updateItem(i, "unit", e.target.value)
}
className="admin-form-input"
style={{ width: "60px" }}
/>
</td>
<td>
<input
type="number"
value={item.unit_price}
onChange={(e) =>
updateItem(
i,
"unit_price",
Number(e.target.value) || 0,
)
}
className="admin-form-input"
style={{ width: "100px" }}
step="0.01"
/>
</td>
<td>
<input
type="number"
value={item.vat_rate}
onChange={(e) =>
updateItem(
i,
"vat_rate",
Number(e.target.value) || 0,
)
}
className="admin-form-input"
style={{ width: "70px" }}
step="1"
/>
</td>
<td>
<button
onClick={() => removeItem(i)}
className="admin-btn-icon danger"
title="Odstranit"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
<button
onClick={addItem}
className="admin-btn admin-btn-secondary admin-btn-sm"
>
+ Přidat položku
</button>
</div>
)}
</div>
<div className="admin-modal-footer">
{step === "edit" && (
<>
<button
type="button"
onClick={() => setStep("choose")}
className="admin-btn admin-btn-secondary"
disabled={loading}
>
Zpět
</button>
<button
type="button"
onClick={handleEditGenerate}
className="admin-btn admin-btn-primary"
disabled={loading || items.length === 0}
>
{loading ? (
<>
<div className="admin-spinner admin-spinner-sm" />
Generuji...
</>
) : (
"Vygenerovat PDF"
)}
</button>
</>
)}
{step === "choose" && (
<button
type="button"
onClick={onClose}
className="admin-btn admin-btn-secondary"
disabled={loading}
>
Zrušit
</button>
)}
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
);
}

View File

@@ -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 (
<div
className="admin-rich-editor"

View File

@@ -381,7 +381,8 @@
.admin-rich-editor .ql-container.ql-snow {
border: none;
border-radius: 0 0 0.5rem 0.5rem;
font-size: 0.875rem;
font-family: Tahoma, sans-serif;
font-size: 14px;
}
.admin-rich-editor .ql-editor {
@@ -389,7 +390,8 @@
padding: 0.75rem;
color: var(--text-primary);
line-height: 1.6;
font-size: 0.875rem;
font-family: Tahoma, sans-serif;
font-size: 14px;
background: var(--input-bg);
}

View File

@@ -609,8 +609,11 @@ export default function Attendance() {
const end = log.ended_at
? new Date(log.ended_at)
: new Date();
const mins = Math.floor(
(end.getTime() - start.getTime()) / 60000,
const mins = Math.max(
0,
Math.floor(
(end.getTime() - start.getTime()) / 60000,
),
);
const h = Math.floor(mins / 60);
const mm = mins % 60;

View File

@@ -85,8 +85,11 @@ const renderProjectCell = (record: AttendanceRecord) => {
} 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;

View File

@@ -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() {
<input
type="text"
value={invoiceNumber}
onChange={(e) => {
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",
}}
/>
</FormField>
<FormField label="Odběratel" error={errors.customer_id} required>

View File

@@ -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() {
<input
type="text"
value={form.quotation_number}
onChange={(e) =>
setForm((prev) => ({
...prev,
quotation_number: e.target.value,
}))
}
readOnly
className="admin-form-input"
style={{
backgroundColor: "var(--bg-secondary)",
cursor: "default",
}}
/>
</FormField>
<FormField label="Kód projektu">

View File

@@ -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() {
</Link>
<div>
<h1 className="admin-page-title flex-row-gap">
{editingNumber ? (
<span
style={{
display: "inline-flex",
alignItems: "center",
gap: "0.5rem",
}}
>
Objednávka
<input
type="text"
value={orderNumber}
onChange={(e) => 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}
/>
<button
onClick={handleSaveNumber}
className="admin-btn-icon"
title="Uložit"
aria-label="Uložit"
disabled={savingNumber}
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="var(--accent-color)"
strokeWidth="2"
>
<polyline points="20 6 9 17 4 12" />
</svg>
</button>
<button
onClick={() => setEditingNumber(false)}
className="admin-btn-icon"
title="Zrušit"
aria-label="Zrušit"
disabled={savingNumber}
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
</span>
) : (
<span
style={{
display: "inline-flex",
alignItems: "center",
gap: "0.5rem",
}}
>
Objednávka {order.order_number}
{hasPermission("orders.edit") && (
<button
onClick={handleStartEditNumber}
className="admin-btn-icon"
title="Změnit číslo"
aria-label="Změnit číslo"
style={{ opacity: 0.5 }}
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
</button>
)}
</span>
)}
<span>Objednávka {order.order_number}</span>
<span
className={`admin-badge ${STATUS_CLASSES[order.status] || ""}`}
>
@@ -506,6 +417,24 @@ export default function OrderDetail() {
</Link>
)
)}
<button
onClick={() => setShowConfirmationModal(true)}
className="admin-btn admin-btn-secondary"
disabled={confirmationLoading}
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
</svg>
Potvrzení objednávky
</button>
{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 && (
<OrderConfirmationModal
isOpen={showConfirmationModal}
onClose={() => 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}
/>
)}
</div>
);
}

View File

@@ -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() {
<input
type="text"
value={form.project_number}
onChange={(e) => updateForm("project_number", e.target.value)}
readOnly
className="admin-form-input"
placeholder="Ponechte prázdné pro automatické"
style={{
backgroundColor: "var(--bg-secondary)",
cursor: "default",
}}
/>
</FormField>
<FormField label="Název" error={errors.name} required>

View File

@@ -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; }

View File

@@ -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);
},
);

View File

@@ -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; }

View File

@@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
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(/(&nbsp;)/g, " ");
let prev = "";
while (prev !== s) {
prev = s;
s = s.replace(/<span([^>]*)>(.*?)<\/span>\s*<span\1>/gs, "<span$1>$2");
}
return s;
}
interface AddressResult {
name: string;
lines: string[];
}
function buildAddressLines(
entity: Record<string, unknown> | null,
isSupplier: boolean,
tObj: Record<string, string>,
): 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<string, unknown>).fields) {
cfData =
((parsed as Record<string, unknown>).fields as typeof cfData) || [];
fieldOrder = ((parsed as Record<string, unknown>).field_order ||
(parsed as Record<string, unknown>).fieldOrder) as string[] | null;
} else if (Array.isArray(parsed)) {
cfData = parsed;
}
}
}
if (Array.isArray(fieldOrder)) {
const legacyMap: Record<string, string> = {
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<string, string> = {};
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<string, Record<string, string>> = {
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<void> {
fastify.post<{ Params: { id: string }; Body: Record<string, unknown> }>(
"/: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("<html><body><h1>Objednávka nenalezena</h1></body></html>");
}
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 = `<img src="data:${escapeHtml(mime)};base64,${b64}" class="logo" />`;
}
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<string, unknown>) => ({
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<string, { base: number; vat: number }> = {};
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<string, unknown>) || null,
false,
t,
);
const suppLinesHtml = supp.lines
.map((l) => `<div class="address-line">${escapeHtml(l)}</div>`)
.join("");
const custLinesHtml = cust.lines
.map((l) => `<div class="address-line">${escapeHtml(l)}</div>`)
.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 `<tr>
<td class="row-num">${i + 1}</td>
<td class="desc">${escapeHtml(item.description)}</td>
<td class="center">${formatNum(item.quantity, qtyDecimals)}${item.unit ? ` / ${escapeHtml(item.unit)}` : ""}</td>
<td class="right">${formatNum(item.unit_price)}</td>
<td class="right">${formatNum(lineSubtotal)}</td>
<td class="center">${applyVat ? Math.floor(item.vat_rate) : 0}%</td>
<td class="right">${formatNum(lineVat)}</td>
<td class="right total-cell">${formatNum(lineTotal)}</td>
</tr>`;
})
.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 += `
<div class="row">
<span class="label">${escapeHtml(t.vat_label)} ${Math.floor(Number(rate))}%:</span>
<span class="value">${formatNum(data.vat)} ${escapeHtml(currency)}</span>
</div>`;
}
}
}
const notesRaw = order.notes ?? "";
const notesStripped = notesRaw.replace(/<[^>]*>/g, "").trim();
const notesHtml = notesStripped
? `
<div class="invoice-notes">
<div class="invoice-notes-label">${escapeHtml(t.notes)}</div>
<div class="invoice-notes-content">${cleanQuillHtml(notesRaw)}</div>
</div>
`
: "";
// 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 = `<!DOCTYPE html>
<html lang="${escapeHtml(lang)}">
<head>
<meta charset="utf-8" />
<title>${escapeHtml(t.title)} ${orderNumber}</title>
<style>
@page {
size: A4;
margin: 8mm 12mm 10mm 12mm;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
font-family: "Segoe UI", Tahoma, Arial, sans-serif;
font-size: 10pt;
color: #1a1a1a;
width: 186mm;
}
.invoice-page {
display: flex;
flex-direction: column;
min-height: calc(297mm - 27mm);
}
.invoice-content { flex: 1 1 auto; }
.invoice-footer {
flex-shrink: 0;
}
.accent { color: #de3a3a; }
/* ── Hlavicka ── */
.invoice-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1mm;
padding-bottom: 1mm;
border-bottom: 2pt solid #de3a3a;
}
.invoice-header .left {
display: flex;
align-items: center;
gap: 3mm;
}
.logo-header { text-align: left; }
.company-title {
font-size: 12pt;
font-weight: 700;
}
.invoice-title {
font-size: 13pt;
font-weight: 700;
color: #de3a3a;
text-align: right;
letter-spacing: 0.03em;
}
.logo {
max-width: 42mm;
max-height: 22mm;
object-fit: contain;
}
/* ── Adresy ── */
.header-grid {
border: 0.5pt solid #d0d0d0;
border-collapse: collapse;
width: 100%;
margin-bottom: 1mm;
}
.header-grid td {
padding: 2mm 3mm;
border: 0.5pt solid #d0d0d0;
vertical-align: top;
width: 50%;
}
.header-grid td.addr-customer {
background: #f5f5f5;
}
.header-grid td.details-bank {
background: #f5f5f5;
}
.address-label {
font-size: 8pt;
font-weight: 700;
color: #de3a3a;
text-transform: uppercase;
letter-spacing: 0.08em;
margin-bottom: 1mm;
}
.address-name {
font-size: 10pt;
font-weight: 700;
color: #1a1a1a;
line-height: 1.3;
margin-bottom: 1mm;
}
.address-line {
font-size: 9pt;
color: #444;
line-height: 1.5;
}
/* ── Detaily (banka + datumy) — inside header-grid ── */
.info-row {
display: flex;
align-items: baseline;
font-size: 9pt;
padding: 1mm 0;
border-bottom: 0.5pt solid #f0f0f0;
}
.info-row:last-child { border-bottom: none; }
.info-row .lbl {
color: #666;
font-weight: 400;
flex-shrink: 0;
white-space: nowrap;
margin-right: 3mm;
}
.info-row .val {
font-weight: 600;
color: #1a1a1a;
text-align: right;
margin-left: auto;
}
/* VS/KS blok */
.vs-block {
font-size: 9pt;
line-height: 1.4;
padding-top: 2mm;
}
/* ── Polozky ── */
.billing-label {
font-weight: 700;
color: #1a1a1a;
font-size: 10pt;
padding: 2mm 0 1mm 0;
border-bottom: 1.5pt solid #de3a3a;
margin-bottom: 0;
text-transform: uppercase;
letter-spacing: 0.03em;
}
table.items {
width: 100%;
table-layout: fixed;
border-collapse: collapse;
font-size: 9pt;
margin-bottom: 2mm;
}
table.items thead th {
font-size: 8.5pt;
font-weight: 600;
color: #646464;
padding: 4px 4px;
text-align: left;
border-bottom: 0.5pt solid #d0d0d0;
white-space: nowrap;
}
table.items thead th.center { text-align: center; }
table.items thead th.right { text-align: right; }
table.items tbody td {
padding: 4px 4px;
border-bottom: 0.5pt solid #e0e0e0;
vertical-align: middle;
color: #1a1a1a;
}
table.items tbody tr:nth-child(even) { background: #f8f9fa; }
table.items tbody td.center { text-align: center; white-space: nowrap; }
table.items tbody td.right { text-align: right; }
table.items tbody td.row-num {
text-align: center;
color: #969696;
font-size: 9pt;
}
table.items tbody td.desc {
font-size: 9pt;
font-weight: 600;
color: #1a1a1a;
}
table.items tbody td.total-cell { font-weight: 700; }
/* Soucet + total - styl z nabidek */
.totals-wrapper {
display: flex;
justify-content: flex-end;
margin-top: 2mm;
}
.totals {
width: 80mm;
}
.totals .detail-rows {
margin-bottom: 3mm;
}
.totals .row {
display: flex;
justify-content: space-between;
align-items: baseline;
font-size: 9.5pt;
color: #1a1a1a;
margin-bottom: 2mm;
}
.totals .grand {
border-top: 0.5pt solid #e0e0e0;
padding-top: 4mm;
display: flex;
justify-content: space-between;
align-items: baseline;
}
.totals .grand .label {
font-size: 10.5pt;
font-weight: 400;
color: #1a1a1a;
align-self: center;
}
.totals .grand .value {
font-size: 14pt;
font-weight: 600;
color: #1a1a1a;
border-bottom: 2.5pt solid #de3a3a;
padding-bottom: 1mm;
}
.totals .currency-note {
text-align: right;
font-size: 8pt;
color: #1a1a1a;
margin-top: 2mm;
}
/* Vystavil */
.issued-by {
font-size: 9pt;
margin: 2mm 0;
line-height: 1.4;
}
.issued-by .lbl { font-weight: 600; }
/* Upozorneni */
.notice {
font-size: 8pt;
color: #1a1a1a;
margin: 2mm 0;
line-height: 1.3;
}
/* DPH rekapitulace + QR */
.recap-section {
display: flex;
gap: 5mm;
align-items: flex-start;
margin-top: 1mm;
}
.recap-section .qr {
flex-shrink: 0;
width: 28mm;
}
.recap-section .qr img,
.recap-section .qr svg { width: 28mm; height: 28mm; }
.recap-section table {
border-collapse: collapse;
font-size: 9pt;
flex: 1;
}
.recap-section table th {
font-size: 8pt;
font-weight: 600;
color: #555;
padding: 3px 6px;
text-align: right;
border-bottom: 0.5pt solid #ccc;
}
.recap-section table td {
padding: 3px 6px;
text-align: right;
border-bottom: 0.5pt solid #eee;
}
.recap-section table td.center { text-align: center; }
.recap-section table td.cnb-rate {
font-size: 8pt;
color: #888;
text-align: right;
border-bottom: none;
padding-top: 4px;
}
/* Prevzal / razitko */
.footer-row {
display: flex;
justify-content: space-between;
margin-top: 4mm;
font-size: 9pt;
border-top: 0.5pt solid #aaa;
padding-top: 2mm;
min-height: 15mm;
}
.footer-row .col {
font-weight: 600;
color: #555;
}
/* Poznamky */
.invoice-notes {
margin-top: 4mm;
font-size: 10pt;
line-height: 1.5;
color: #1a1a1a;
}
.invoice-notes-label {
font-weight: 600;
font-size: 9pt;
text-transform: uppercase;
color: #555;
margin-bottom: 1mm;
}
.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 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; }
${indentCSS}
@media print {
body { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
}
@media screen {
html { background: #525659; }
body {
width: 100vw !important;
margin: 0;
padding: 30px 0;
background: transparent;
display: flex;
flex-direction: column;
align-items: center;
min-height: 100vh;
overflow-x: hidden;
}
.invoice-page {
width: 210mm;
min-height: 297mm;
padding: 15mm;
background: white;
box-shadow: 0 4px 20px rgba(0,0,0,0.3);
box-sizing: border-box;
border-radius: 2px;
}
}
</style>
</head>
<body>
<div class="invoice-page">
<div class="invoice-content">
<!-- Hlavicka -->
<div class="invoice-header">
<div class="left">
${logoImg ? `<div class="logo-header">${logoImg}</div>` : ""}
</div>
<div class="invoice-title">${escapeHtml(t.title)}</div>
</div>
<!-- Dodavatel / Odberatel + Detaily -->
<table class="header-grid" cellspacing="0">
<tr>
<td>
<div class="address-label">${escapeHtml(t.supplier)}</div>
<div class="address-name">${escapeHtml(supp.name)}</div>
${suppLinesHtml}
</td>
<td class="addr-customer">
<div class="address-label">${escapeHtml(t.customer)}</div>
<div class="address-name">${escapeHtml(cust.name)}</div>
${custLinesHtml}
</td>
</tr>
<tr>
<td class="details-bank">
<div class="info-row"><span class="lbl">${escapeHtml(t.order_no)}</span> <span class="val">${orderNumber}</span></div>
${poNumber ? `<div class="info-row"><span class="lbl">${escapeHtml(t.po_no)}</span> <span class="val">${poNumber}</span></div>` : ""}
<div class="info-row"><span class="lbl">${escapeHtml(t.payment_method)}</span> <span class="val">${escapeHtml(paymentMethod)}</span></div>
</td>
<td>
<div class="info-row"><span class="lbl">${escapeHtml(t.date)}</span> <span class="val">${escapeHtml(orderDateStr)}</span></div>
</td>
</tr>
</table>
<!-- Polozky -->
<div class="billing-label">${escapeHtml(t.billing)}</div>
<table class="items">
<thead>
<tr>
<th class="center" style="width:3%">${escapeHtml(t.col_no)}</th>
<th style="width:36%">${escapeHtml(t.col_desc)}</th>
<th class="center" style="width:10%">${escapeHtml(t.col_qty)}</th>
<th class="right" style="width:10%">${escapeHtml(t.col_unit_price)}</th>
<th class="right" style="width:10%">${escapeHtml(t.col_price)}</th>
<th class="center" style="width:5%">${escapeHtml(t.col_vat_pct)}</th>
<th class="right" style="width:10%">${escapeHtml(t.col_vat)}</th>
<th class="right" style="width:16%">${escapeHtml(t.col_total)}</th>
</tr>
</thead>
<tbody>
${itemsHtml}
</tbody>
</table>
<!-- Soucty -->
<div class="totals-wrapper">
<div class="totals">
<div class="detail-rows">
<div class="row">
<span class="label">${escapeHtml(t.subtotal)}</span>
<span class="value">${formatNum(subtotal)} ${escapeHtml(currency)}</span>
</div>${vatDetailHtml}
</div>
<div class="grand">
<span class="label">${escapeHtml(t.total)}</span>
<span class="value">${formatNum(totalToPay)} ${escapeHtml(currency)}</span>
</div>
<div class="currency-note">${escapeHtml(t.amounts_in)} ${escapeHtml(currency)}</div>
</div>
</div>
${notesHtml}
</div><!-- /.invoice-content -->
<div class="invoice-footer">
<!-- Vystavil -->
<div class="issued-by">
<span class="lbl">${escapeHtml(t.issued_by)}</span> ${escapeHtml(userName)}
</div>
<!-- Prevzal / razitko -->
<div class="footer-row">
<div class="col">${escapeHtml(t.received_by)}</div>
<div class="col" style="text-align:right">${escapeHtml(t.stamp)}</div>
</div>
</div><!-- /.invoice-footer -->
</div><!-- /.invoice-page -->
</body>
</html>`;
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);
},
);
}

View File

@@ -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");
},

View File

@@ -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

View File

@@ -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)

View File

@@ -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()])

View File

@@ -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(),

View File

@@ -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(),

View File

@@ -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(

View File

@@ -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<number>();
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,

View File

@@ -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,
},

View File

@@ -99,14 +99,6 @@ export async function checkInvoiceAlerts(): Promise<void> {
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<void> {
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<void> {
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",
},
});
}
}

View File

@@ -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<string, string[]> = {
@@ -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<string, any>) {
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<string, any>) {
}
}
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;
}

View File

@@ -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(

View File

@@ -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<string> {
async function getNextSequence(
type: string,
year: number,
tx?: TxClient,
): Promise<number> {
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<number> {
const existing = await prisma.$queryRaw<Array<{ last_number: number }>>`
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<boolean> {
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<boolean> {
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<boolean> {
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<string> {
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<string> {
export async function previewOfferNumber(): Promise<string> {
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<string> {
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<string> {
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,

View File

@@ -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<string, any>) {
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<string, any>) {
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;
}

View File

@@ -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<string, unknown> = { 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<string, string> = {
@@ -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();
}

View File

@@ -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<string, any>) {
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<string, any>) {
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<string, unknown> = { 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<string, any>) {
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();
}

View File

@@ -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/**"],
},
});