Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5a28f75303 | ||
|
|
07cb428287 | ||
|
|
b197017644 | ||
|
|
e9f07a4a39 | ||
|
|
44d389201c | ||
|
|
3106aaf314 |
295
CLAUDE.md
Normal file
295
CLAUDE.md
Normal 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.
|
||||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "app-ts",
|
"name": "app-ts",
|
||||||
"version": "1.4.9",
|
"version": "1.5.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "app-ts",
|
"name": "app-ts",
|
||||||
"version": "1.4.9",
|
"version": "1.5.1",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "app-ts",
|
"name": "app-ts",
|
||||||
"version": "1.4.9",
|
"version": "1.5.3",
|
||||||
"description": "",
|
"description": "",
|
||||||
"main": "dist/server.js",
|
"main": "dist/server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -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`);
|
||||||
@@ -253,6 +253,8 @@ model number_sequences {
|
|||||||
type String? @db.VarChar(50)
|
type String? @db.VarChar(50)
|
||||||
year Int?
|
year Int?
|
||||||
last_number Int? @default(0)
|
last_number Int? @default(0)
|
||||||
|
|
||||||
|
@@unique([type, year], map: "idx_number_sequences_type_year")
|
||||||
}
|
}
|
||||||
|
|
||||||
model order_items {
|
model order_items {
|
||||||
|
|||||||
@@ -1,21 +1,50 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||||
import {
|
import {
|
||||||
generateSharedNumber,
|
generateSharedNumber,
|
||||||
generateOfferNumber,
|
generateOfferNumber,
|
||||||
} from "../services/numbering.service";
|
} from "../services/numbering.service";
|
||||||
|
import prisma from "../config/database";
|
||||||
|
|
||||||
describe("generateSharedNumber", () => {
|
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 num = await generateSharedNumber();
|
||||||
const yy = String(new Date().getFullYear()).slice(-2);
|
expect(typeof num).toBe("string");
|
||||||
expect(num).toMatch(new RegExp(`^${yy}\\d{2,}\\d{4}$`));
|
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", () => {
|
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 num = await generateOfferNumber();
|
||||||
const year = new Date().getFullYear();
|
expect(typeof num).toBe("string");
|
||||||
expect(num).toMatch(new RegExp(`^${year}/[A-Z]+/\\d{3,}$`));
|
expect(num.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("increments on consecutive calls", async () => {
|
||||||
|
const num1 = await generateOfferNumber();
|
||||||
|
const num2 = await generateOfferNumber();
|
||||||
|
expect(num1).not.toBe(num2);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -70,8 +70,11 @@ function renderProjectCell(record: AttendanceRecord): React.ReactNode {
|
|||||||
} else {
|
} else {
|
||||||
isActive = !log.ended_at;
|
isActive = !log.ended_at;
|
||||||
const end = log.ended_at ? new Date(log.ended_at) : new Date();
|
const end = log.ended_at ? new Date(log.ended_at) : new Date();
|
||||||
const mins = Math.floor(
|
const mins = Math.max(
|
||||||
(end.getTime() - new Date(log.started_at!).getTime()) / 60000,
|
0,
|
||||||
|
Math.floor(
|
||||||
|
(end.getTime() - new Date(log.started_at!).getTime()) / 60000,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
h = Math.floor(mins / 60);
|
h = Math.floor(mins / 60);
|
||||||
m = mins % 60;
|
m = mins % 60;
|
||||||
|
|||||||
382
src/admin/components/OrderConfirmationModal.tsx
Normal file
382
src/admin/components/OrderConfirmationModal.tsx
Normal file
@@ -0,0 +1,382 @@
|
|||||||
|
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,
|
||||||
|
applyVat: boolean,
|
||||||
|
items?: ConfirmationItem[],
|
||||||
|
) => Promise<void>;
|
||||||
|
initialItems: ConfirmationItem[];
|
||||||
|
orderNumber: string;
|
||||||
|
defaultVatRate: number;
|
||||||
|
applyVat: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function OrderConfirmationModal({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
onGenerate,
|
||||||
|
initialItems,
|
||||||
|
orderNumber,
|
||||||
|
defaultVatRate,
|
||||||
|
applyVat,
|
||||||
|
}: OrderConfirmationModalProps) {
|
||||||
|
const [step, setStep] = useState<"choose" | "edit">("choose");
|
||||||
|
const [lang, setLang] = useState<string>("cs");
|
||||||
|
const [applyVatState, setApplyVatState] = useState(applyVat);
|
||||||
|
const [items, setItems] = useState<ConfirmationItem[]>(initialItems);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleUseExisting = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await onGenerate(lang, applyVatState, undefined);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
setStep("choose");
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditGenerate = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await onGenerate(lang, applyVatState, 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">DPH</label>
|
||||||
|
<div className="flex-row gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setApplyVatState(true)}
|
||||||
|
className={
|
||||||
|
applyVatState
|
||||||
|
? "admin-btn admin-btn-primary admin-btn-sm"
|
||||||
|
: "admin-btn admin-btn-secondary admin-btn-sm"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
S DPH
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setApplyVatState(false)}
|
||||||
|
className={
|
||||||
|
!applyVatState
|
||||||
|
? "admin-btn admin-btn-primary admin-btn-sm"
|
||||||
|
: "admin-btn admin-btn-secondary admin-btn-sm"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Bez DPH
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,66 +1,7 @@
|
|||||||
import { useMemo, useRef, useCallback } from "react";
|
import { useMemo, useRef, useCallback, useEffect } from "react";
|
||||||
import ReactQuill from "react-quill-new";
|
import ReactQuill from "react-quill-new";
|
||||||
import "react-quill-new/dist/quill.snow.css";
|
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 = [
|
const COLORS = [
|
||||||
"#000000",
|
"#000000",
|
||||||
"#1a1a1a",
|
"#1a1a1a",
|
||||||
@@ -95,8 +36,6 @@ const COLORS = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const TOOLBAR = [
|
const TOOLBAR = [
|
||||||
[{ font: Font.whitelist }],
|
|
||||||
[{ size: SIZE_WHITELIST }],
|
|
||||||
["bold", "italic", "underline", "strike"],
|
["bold", "italic", "underline", "strike"],
|
||||||
[{ color: COLORS }, { background: COLORS }],
|
[{ color: COLORS }, { background: COLORS }],
|
||||||
[{ list: "ordered" }, { list: "bullet" }],
|
[{ list: "ordered" }, { list: "bullet" }],
|
||||||
@@ -107,8 +46,6 @@ const TOOLBAR = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const FORMATS = [
|
const FORMATS = [
|
||||||
"font",
|
|
||||||
"size",
|
|
||||||
"bold",
|
"bold",
|
||||||
"italic",
|
"italic",
|
||||||
"underline",
|
"underline",
|
||||||
@@ -159,6 +96,13 @@ export default function RichEditor({
|
|||||||
[onChange],
|
[onChange],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!quillRef.current) return;
|
||||||
|
const editor = quillRef.current.getEditor();
|
||||||
|
editor.format("font", "tahoma");
|
||||||
|
editor.format("size", "14px");
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="admin-rich-editor"
|
className="admin-rich-editor"
|
||||||
|
|||||||
@@ -381,7 +381,8 @@
|
|||||||
.admin-rich-editor .ql-container.ql-snow {
|
.admin-rich-editor .ql-container.ql-snow {
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 0 0 0.5rem 0.5rem;
|
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 {
|
.admin-rich-editor .ql-editor {
|
||||||
@@ -389,7 +390,8 @@
|
|||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
font-size: 0.875rem;
|
font-family: Tahoma, sans-serif;
|
||||||
|
font-size: 14px;
|
||||||
background: var(--input-bg);
|
background: var(--input-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -609,8 +609,11 @@ export default function Attendance() {
|
|||||||
const end = log.ended_at
|
const end = log.ended_at
|
||||||
? new Date(log.ended_at)
|
? new Date(log.ended_at)
|
||||||
: new Date();
|
: new Date();
|
||||||
const mins = Math.floor(
|
const mins = Math.max(
|
||||||
(end.getTime() - start.getTime()) / 60000,
|
0,
|
||||||
|
Math.floor(
|
||||||
|
(end.getTime() - start.getTime()) / 60000,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
const h = Math.floor(mins / 60);
|
const h = Math.floor(mins / 60);
|
||||||
const mm = mins % 60;
|
const mm = mins % 60;
|
||||||
|
|||||||
@@ -85,8 +85,11 @@ const renderProjectCell = (record: AttendanceRecord) => {
|
|||||||
} else {
|
} else {
|
||||||
isActive = !log.ended_at;
|
isActive = !log.ended_at;
|
||||||
const end = log.ended_at ? new Date(log.ended_at) : new Date();
|
const end = log.ended_at ? new Date(log.ended_at) : new Date();
|
||||||
const mins = Math.floor(
|
const mins = Math.max(
|
||||||
(end.getTime() - new Date(log.started_at!).getTime()) / 60000,
|
0,
|
||||||
|
Math.floor(
|
||||||
|
(end.getTime() - new Date(log.started_at!).getTime()) / 60000,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
h = Math.floor(mins / 60);
|
h = Math.floor(mins / 60);
|
||||||
m = mins % 60;
|
m = mins % 60;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -194,7 +194,6 @@ export default function Invoices() {
|
|||||||
}>({ show: false, invoice: null });
|
}>({ show: false, invoice: null });
|
||||||
const [deleting, setDeleting] = useState(false);
|
const [deleting, setDeleting] = useState(false);
|
||||||
const [pdfLoading, setPdfLoading] = useState<number | null>(null);
|
const [pdfLoading, setPdfLoading] = useState<number | null>(null);
|
||||||
const [langModal, setLangModal] = useState<Invoice | null>(null);
|
|
||||||
const [draft, setDraft] = useState<DraftData | null>(() => {
|
const [draft, setDraft] = useState<DraftData | null>(() => {
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(DRAFT_KEY);
|
const raw = localStorage.getItem(DRAFT_KEY);
|
||||||
@@ -284,29 +283,25 @@ export default function Invoices() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePdf = async (inv: Invoice, lang = "cs") => {
|
const handlePdf = async (inv: Invoice) => {
|
||||||
if (pdfLoading) return;
|
if (pdfLoading) return;
|
||||||
setLangModal(null);
|
const newWindow = window.open("", "_blank");
|
||||||
setPdfLoading(inv.id);
|
setPdfLoading(inv.id);
|
||||||
try {
|
try {
|
||||||
const response = await apiFetch(
|
const response = await apiFetch(`${API_BASE}/invoices/${inv.id}/file`);
|
||||||
`${API_BASE}/invoices-pdf/${inv.id}?lang=${encodeURIComponent(lang)}`,
|
if (response.status === 401) {
|
||||||
);
|
newWindow?.close();
|
||||||
if (response.status === 401) return;
|
|
||||||
if (!response.ok) {
|
|
||||||
alert.error("Nepodařilo se vygenerovat PDF");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const html = await response.text();
|
if (!response.ok) {
|
||||||
const w = window.open("", "_blank");
|
newWindow?.close();
|
||||||
if (w) {
|
alert.error("PDF soubor nenalezen — otevřete fakturu a uložte ji");
|
||||||
w.document.open();
|
return;
|
||||||
w.document.write(html);
|
|
||||||
w.document.close();
|
|
||||||
w.onload = () => w.print();
|
|
||||||
} else {
|
|
||||||
alert.error("Prohlížeč zablokoval vyskakovací okno");
|
|
||||||
}
|
}
|
||||||
|
const blob = await response.blob();
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
if (newWindow) newWindow.location.href = url;
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), 60000);
|
||||||
} catch {
|
} catch {
|
||||||
alert.error("Chyba při generování PDF");
|
alert.error("Chyba při generování PDF");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -996,26 +991,44 @@ export default function Invoices() {
|
|||||||
<Link
|
<Link
|
||||||
to={`/invoices/${inv.id}`}
|
to={`/invoices/${inv.id}`}
|
||||||
className="admin-btn-icon"
|
className="admin-btn-icon"
|
||||||
title="Detail"
|
title={
|
||||||
aria-label="Detail"
|
inv.status === "paid" ? "Detail" : "Upravit"
|
||||||
|
}
|
||||||
|
aria-label={
|
||||||
|
inv.status === "paid" ? "Detail" : "Upravit"
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<svg
|
{inv.status === "paid" ? (
|
||||||
width="18"
|
<svg
|
||||||
height="18"
|
width="18"
|
||||||
viewBox="0 0 24 24"
|
height="18"
|
||||||
fill="none"
|
viewBox="0 0 24 24"
|
||||||
stroke="currentColor"
|
fill="none"
|
||||||
strokeWidth="2"
|
stroke="currentColor"
|
||||||
>
|
strokeWidth="2"
|
||||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
|
>
|
||||||
<circle cx="12" cy="12" r="3" />
|
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
|
||||||
</svg>
|
<circle cx="12" cy="12" r="3" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
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>
|
||||||
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
{hasPermission("invoices.export") && (
|
{hasPermission("invoices.export") && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setLangModal(inv)}
|
onClick={() => handlePdf(inv)}
|
||||||
className="admin-btn-icon"
|
className="admin-btn-icon"
|
||||||
title="PDF"
|
title="Zobrazit fakturu"
|
||||||
disabled={pdfLoading === inv.id}
|
disabled={pdfLoading === inv.id}
|
||||||
>
|
>
|
||||||
{pdfLoading === inv.id ? (
|
{pdfLoading === inv.id ? (
|
||||||
@@ -1092,69 +1105,6 @@ export default function Invoices() {
|
|||||||
type="danger"
|
type="danger"
|
||||||
loading={deleting}
|
loading={deleting}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AnimatePresence>
|
|
||||||
{langModal && (
|
|
||||||
<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={() => setLangModal(null)}
|
|
||||||
/>
|
|
||||||
<motion.div
|
|
||||||
className="admin-modal admin-confirm-modal"
|
|
||||||
role="dialog"
|
|
||||||
aria-modal="true"
|
|
||||||
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-body admin-confirm-content">
|
|
||||||
<div className="admin-confirm-icon admin-confirm-icon-info">
|
|
||||||
<svg
|
|
||||||
width="24"
|
|
||||||
height="24"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
>
|
|
||||||
<path d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10z" />
|
|
||||||
<path d="M2 12h20" />
|
|
||||||
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<h2 className="admin-confirm-title">Jazyk faktury</h2>
|
|
||||||
<p className="admin-confirm-message">
|
|
||||||
V jakém jazyce chcete vygenerovat fakturu?
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="admin-modal-footer">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => handlePdf(langModal, "cs")}
|
|
||||||
className="admin-btn admin-btn-primary"
|
|
||||||
>
|
|
||||||
Čeština
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => handlePdf(langModal, "en")}
|
|
||||||
className="admin-btn admin-btn-primary"
|
|
||||||
>
|
|
||||||
English
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -635,14 +635,17 @@ export default function OfferDetail() {
|
|||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
const url = isEdit ? `${API_BASE}/offers/${id}` : `${API_BASE}/offers`;
|
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, {
|
const response = await apiFetch(url, {
|
||||||
method: isEdit ? "PUT" : "POST",
|
method: isEdit ? "PUT" : "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify(payload),
|
||||||
...form,
|
|
||||||
items: items.map((item, i) => ({ ...item, position: i })),
|
|
||||||
sections: sections.map((s, i) => ({ ...s, position: i })),
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
@@ -1016,13 +1019,12 @@ export default function OfferDetail() {
|
|||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={form.quotation_number}
|
value={form.quotation_number}
|
||||||
onChange={(e) =>
|
readOnly
|
||||||
setForm((prev) => ({
|
|
||||||
...prev,
|
|
||||||
quotation_number: e.target.value,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
className="admin-form-input"
|
className="admin-form-input"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--bg-secondary)",
|
||||||
|
cursor: "default",
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="Kód projektu">
|
<FormField label="Kód projektu">
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { useAuth } from "../context/AuthContext";
|
|||||||
import { useParams, useNavigate, Link } from "react-router-dom";
|
import { useParams, useNavigate, Link } from "react-router-dom";
|
||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
import ConfirmModal from "../components/ConfirmModal";
|
import ConfirmModal from "../components/ConfirmModal";
|
||||||
|
import OrderConfirmationModal from "../components/OrderConfirmationModal";
|
||||||
import FormField from "../components/FormField";
|
import FormField from "../components/FormField";
|
||||||
import Forbidden from "../components/Forbidden";
|
import Forbidden from "../components/Forbidden";
|
||||||
|
|
||||||
@@ -112,13 +113,12 @@ export default function OrderDetail() {
|
|||||||
show: boolean;
|
show: boolean;
|
||||||
status: string | null;
|
status: string | null;
|
||||||
}>({ show: false, status: 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 [attachmentLoading, setAttachmentLoading] = useState(false);
|
||||||
const [deleteConfirm, setDeleteConfirm] = useState(false);
|
const [deleteConfirm, setDeleteConfirm] = useState(false);
|
||||||
const [deleting, setDeleting] = useState(false);
|
const [deleting, setDeleting] = useState(false);
|
||||||
const [deleteFiles, setDeleteFiles] = useState(false);
|
const [deleteFiles, setDeleteFiles] = useState(false);
|
||||||
|
const [showConfirmationModal, setShowConfirmationModal] = useState(false);
|
||||||
|
const [confirmationLoading, setConfirmationLoading] = useState(false);
|
||||||
|
|
||||||
const fetchDetail = useCallback(async () => {
|
const fetchDetail = useCallback(async () => {
|
||||||
try {
|
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 () => {
|
const handleSaveNotes = async () => {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
@@ -265,6 +229,49 @@ export default function OrderDetail() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleGenerateConfirmation = async (
|
||||||
|
lang: string,
|
||||||
|
applyVat: boolean,
|
||||||
|
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, applyVat, 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 () => {
|
const handleDelete = async () => {
|
||||||
setDeleting(true);
|
setDeleting(true);
|
||||||
try {
|
try {
|
||||||
@@ -361,102 +368,7 @@ export default function OrderDetail() {
|
|||||||
</Link>
|
</Link>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="admin-page-title flex-row-gap">
|
<h1 className="admin-page-title flex-row-gap">
|
||||||
{editingNumber ? (
|
<span>Objednávka {order.order_number}</span>
|
||||||
<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
|
<span
|
||||||
className={`admin-badge ${STATUS_CLASSES[order.status] || ""}`}
|
className={`admin-badge ${STATUS_CLASSES[order.status] || ""}`}
|
||||||
>
|
>
|
||||||
@@ -506,6 +418,24 @@ export default function OrderDetail() {
|
|||||||
</Link>
|
</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") &&
|
{hasPermission("orders.edit") &&
|
||||||
order.valid_transitions?.filter((s) => s !== "stornovana").length! >
|
order.valid_transitions?.filter((s) => s !== "stornovana").length! >
|
||||||
0 &&
|
0 &&
|
||||||
@@ -900,6 +830,26 @@ export default function OrderDetail() {
|
|||||||
type="danger"
|
type="danger"
|
||||||
loading={deleting}
|
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}
|
||||||
|
applyVat={!!order.apply_vat}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -161,7 +161,6 @@ export default function ProjectCreate() {
|
|||||||
name: form.name.trim(),
|
name: form.name.trim(),
|
||||||
customer_id: form.customer_id,
|
customer_id: form.customer_id,
|
||||||
start_date: form.start_date,
|
start_date: form.start_date,
|
||||||
project_number: form.project_number.trim(),
|
|
||||||
responsible_user_id: form.responsible_user_id || null,
|
responsible_user_id: form.responsible_user_id || null,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -172,7 +171,7 @@ export default function ProjectCreate() {
|
|||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
navigate(`/projects/${data.data.project_id}`, {
|
navigate(`/projects/${data.data.id}`, {
|
||||||
state: { created: true },
|
state: { created: true },
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
@@ -265,9 +264,12 @@ export default function ProjectCreate() {
|
|||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={form.project_number}
|
value={form.project_number}
|
||||||
onChange={(e) => updateForm("project_number", e.target.value)}
|
readOnly
|
||||||
className="admin-form-input"
|
className="admin-form-input"
|
||||||
placeholder="Ponechte prázdné pro automatické"
|
style={{
|
||||||
|
backgroundColor: "var(--bg-secondary)",
|
||||||
|
cursor: "default",
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="Název" error={errors.name} required>
|
<FormField label="Název" error={errors.name} required>
|
||||||
|
|||||||
@@ -808,20 +808,18 @@ export default async function invoicesPdfRoutes(
|
|||||||
.invoice-notes-content p { margin: 0 0 0.4em 0; }
|
.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 ul, .invoice-notes-content ol { margin: 0 0 0.4em 1.5em; }
|
||||||
.invoice-notes-content li { margin-bottom: 0.2em; }
|
.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 */
|
/* Quill fonty – v PDF vynuceno Tahoma */
|
||||||
.ql-font-arial { font-family: Arial, sans-serif; }
|
[class*="ql-font-"] { font-family: Tahoma, sans-serif !important; }
|
||||||
.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; }
|
|
||||||
.ql-align-center { text-align: center; }
|
.ql-align-center { text-align: center; }
|
||||||
.ql-align-right { text-align: right; }
|
.ql-align-right { text-align: right; }
|
||||||
.ql-align-justify { text-align: justify; }
|
.ql-align-justify { text-align: justify; }
|
||||||
@@ -1008,6 +1006,7 @@ ${indentCSS}
|
|||||||
? new Date(invoice.issue_date)
|
? new Date(invoice.issue_date)
|
||||||
: new Date();
|
: new Date();
|
||||||
const saveMode = query.save === "1";
|
const saveMode = query.save === "1";
|
||||||
|
nasFinancialsManager.cleanIssuedInvoice(invoice.invoice_number!);
|
||||||
const pdfPromise = htmlToPdf(html)
|
const pdfPromise = htmlToPdf(html)
|
||||||
.then((pdfBuffer) => {
|
.then((pdfBuffer) => {
|
||||||
nasFinancialsManager.saveIssuedInvoicePdf(
|
nasFinancialsManager.saveIssuedInvoicePdf(
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
markOverdueInvoices,
|
markOverdueInvoices,
|
||||||
listInvoices,
|
listInvoices,
|
||||||
getNextInvoiceNumberFormatted,
|
getNextInvoiceNumberFormatted,
|
||||||
|
getNextInvoiceNumberPreview,
|
||||||
getInvoiceStats,
|
getInvoiceStats,
|
||||||
getOrderDataForInvoice,
|
getOrderDataForInvoice,
|
||||||
getInvoice,
|
getInvoice,
|
||||||
@@ -65,7 +66,7 @@ export default async function invoicesRoutes(
|
|||||||
"/next-number",
|
"/next-number",
|
||||||
{ preHandler: requirePermission("invoices.create") },
|
{ preHandler: requirePermission("invoices.create") },
|
||||||
async (_request, reply) => {
|
async (_request, reply) => {
|
||||||
const result = await getNextInvoiceNumberFormatted();
|
const result = await getNextInvoiceNumberPreview();
|
||||||
return success(reply, result);
|
return success(reply, result);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -381,19 +381,8 @@ export default async function offersPdfRoutes(
|
|||||||
|
|
||||||
img, table, pre, code { max-width: 100%; }
|
img, table, pre, code { max-width: 100%; }
|
||||||
|
|
||||||
/* ---- Quill font classes ---- */
|
/* ---- Quill font classes – v PDF vynuceno Tahoma ---- */
|
||||||
.ql-font-arial { font-family: Arial, sans-serif; }
|
[class*="ql-font-"] { font-family: Tahoma, sans-serif !important; }
|
||||||
.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 alignment ---- */
|
/* ---- Quill alignment ---- */
|
||||||
.ql-align-center { text-align: center; }
|
.ql-align-center { text-align: center; }
|
||||||
@@ -606,6 +595,15 @@ ${indentCSS}
|
|||||||
word-break: normal;
|
word-break: normal;
|
||||||
overflow-wrap: anywhere;
|
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 p { margin: 0 0 0.4em 0; }
|
||||||
.section-content ul, .section-content ol { margin: 0 0 0.4em 1.5em; }
|
.section-content ul, .section-content ol { margin: 0 0 0.4em 1.5em; }
|
||||||
.section-content li { margin-bottom: 0.2em; }
|
.section-content li { margin-bottom: 0.2em; }
|
||||||
|
|||||||
858
src/routes/admin/orders-pdf.ts
Normal file
858
src/routes/admin/orders-pdf.ts
Normal file
@@ -0,0 +1,858 @@
|
|||||||
|
import { FastifyInstance } from "fastify";
|
||||||
|
import prisma from "../../config/database";
|
||||||
|
import { requirePermission } from "../../middleware/auth";
|
||||||
|
import { localDateCzStr } from "../../utils/date";
|
||||||
|
import { htmlToPdf } from "../../utils/html-to-pdf";
|
||||||
|
|
||||||
|
/* ── Helpers ─────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
function formatDate(date: Date | string | null | undefined): string {
|
||||||
|
if (!date) return "";
|
||||||
|
const d = new Date(date);
|
||||||
|
if (isNaN(d.getTime())) return String(date);
|
||||||
|
return localDateCzStr(d);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatNum(n: number, decimals = 2): string {
|
||||||
|
const abs = Math.abs(n);
|
||||||
|
const fixed = abs.toFixed(decimals);
|
||||||
|
const [intPart, decPart] = fixed.split(".");
|
||||||
|
const withSep = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, " ");
|
||||||
|
const result = decPart ? `${withSep},${decPart}` : withSep;
|
||||||
|
return n < 0 ? `-${result}` : result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(str: string | null | undefined): string {
|
||||||
|
if (!str) return "";
|
||||||
|
return str
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanQuillHtml(html: string | null | undefined): string {
|
||||||
|
if (!html) return "";
|
||||||
|
let s = html;
|
||||||
|
s = s.replace(
|
||||||
|
/<(script|iframe|object|embed|style|link|meta|base|form|input|textarea|button|select|svg|math)[^>]*>[\s\S]*?<\/\1>/gi,
|
||||||
|
"",
|
||||||
|
);
|
||||||
|
s = s.replace(
|
||||||
|
/<(script|iframe|object|embed|style|link|meta|base|form|input|textarea|button|select|svg|math)[^>]*\/?>/gi,
|
||||||
|
"",
|
||||||
|
);
|
||||||
|
s = s.replace(/\s+on\w+\s*=\s*("[^"]*"|'[^']*'|[^\s>]*)/gi, "");
|
||||||
|
s = s.replace(/\s+on\w+\s*=\s*[^\s>]*/gi, "");
|
||||||
|
s = s.replace(/href\s*=\s*["']?\s*javascript\s*:[^"'>\s]*/gi, 'href="#"');
|
||||||
|
s = s.replace(/( )/g, " ");
|
||||||
|
let prev = "";
|
||||||
|
while (prev !== s) {
|
||||||
|
prev = s;
|
||||||
|
s = s.replace(/<span([^>]*)>(.*?)<\/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 =
|
||||||
|
body.applyVat !== undefined ? !!body.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);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -91,8 +91,11 @@ export default async function projectsRoutes(
|
|||||||
const parsed = parseBody(UpdateProjectSchema, request.body);
|
const parsed = parseBody(UpdateProjectSchema, request.body);
|
||||||
if ("error" in parsed) return error(reply, parsed.error, 400);
|
if ("error" in parsed) return error(reply, parsed.error, 400);
|
||||||
|
|
||||||
const existing = await updateProject(id, parsed.data);
|
const result = await updateProject(id, parsed.data);
|
||||||
if (!existing) return error(reply, "Projekt nenalezen", 404);
|
if (!result) return error(reply, "Projekt nenalezen", 404);
|
||||||
|
if ("error" in result) {
|
||||||
|
return error(reply, result.error, (result as any).status ?? 400);
|
||||||
|
}
|
||||||
|
|
||||||
await logAudit({
|
await logAudit({
|
||||||
request,
|
request,
|
||||||
@@ -100,7 +103,7 @@ export default async function projectsRoutes(
|
|||||||
action: "update",
|
action: "update",
|
||||||
entityType: "project",
|
entityType: "project",
|
||||||
entityId: id,
|
entityId: id,
|
||||||
description: `Upraven projekt ${existing.name}`,
|
description: `Upraven projekt ${result.name}`,
|
||||||
});
|
});
|
||||||
return success(reply, { id }, 200, "Projekt byl uložen");
|
return success(reply, { id }, 200, "Projekt byl uložen");
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -278,7 +278,11 @@ export default async function quotationsRoutes(
|
|||||||
return error(reply, "Nabídka nenalezena", 404);
|
return error(reply, "Nabídka nenalezena", 404);
|
||||||
if (result.error === "invalidated")
|
if (result.error === "invalidated")
|
||||||
return error(reply, "Nelze upravit zneplatněnou nabídku", 400);
|
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
|
// Keep lock — user stays on the page after save
|
||||||
|
|||||||
@@ -15,6 +15,11 @@ import { nasFinancialsManager } from "../../services/nas-financials-manager";
|
|||||||
import { toCzk } from "../../services/exchange-rates";
|
import { toCzk } from "../../services/exchange-rates";
|
||||||
|
|
||||||
const VALID_STATUSES = ["unpaid", "paid"] as const;
|
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 = [
|
const ALLOWED_SORT_FIELDS = [
|
||||||
"id",
|
"id",
|
||||||
"supplier_name",
|
"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)
|
// Recalculate vat_amount when amount or vat_rate changes (matching PHP)
|
||||||
const finalAmount =
|
const finalAmount =
|
||||||
body.amount !== undefined
|
body.amount !== undefined
|
||||||
@@ -423,9 +437,9 @@ export default async function receivedInvoicesRoutes(
|
|||||||
// Amount includes VAT — extract VAT portion: amount - amount/(1 + rate/100)
|
// Amount includes VAT — extract VAT portion: amount - amount/(1 + rate/100)
|
||||||
const computedVat =
|
const computedVat =
|
||||||
finalVatRate > 0
|
finalVatRate > 0
|
||||||
? Math.round(
|
? roundMoney(
|
||||||
(finalAmount - finalAmount / (1 + finalVatRate / 100)) * 100,
|
finalAmount - roundMoney(finalAmount / (1 + finalVatRate / 100)),
|
||||||
) / 100
|
)
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
// Auto-set paid_date when status transitions to paid (matching PHP)
|
// Auto-set paid_date when status transitions to paid (matching PHP)
|
||||||
|
|||||||
@@ -66,7 +66,6 @@ export const CreateQuotationSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const UpdateQuotationSchema = z.object({
|
export const UpdateQuotationSchema = z.object({
|
||||||
quotation_number: z.string().optional(),
|
|
||||||
project_code: z.string().nullish(),
|
project_code: z.string().nullish(),
|
||||||
customer_id: z
|
customer_id: z
|
||||||
.union([z.number(), z.string()])
|
.union([z.number(), z.string()])
|
||||||
|
|||||||
@@ -75,7 +75,6 @@ export const CreateOrderSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const UpdateOrderSchema = z.object({
|
export const UpdateOrderSchema = z.object({
|
||||||
order_number: z.string().nullish(),
|
|
||||||
customer_order_number: z.string().nullish(),
|
customer_order_number: z.string().nullish(),
|
||||||
status: z.string().optional(),
|
status: z.string().optional(),
|
||||||
currency: z.string().optional(),
|
currency: z.string().optional(),
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
import { z } from "zod";
|
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({
|
export const CreateProjectSchema = z.object({
|
||||||
project_number: z.string().nullish(),
|
project_number: safeProjectNumber,
|
||||||
name: z.string().nullish(),
|
name: z.string().nullish(),
|
||||||
customer_id: z
|
customer_id: z
|
||||||
.union([z.number(), z.string()])
|
.union([z.number(), z.string()])
|
||||||
@@ -26,7 +31,6 @@ export const CreateProjectSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const UpdateProjectSchema = z.object({
|
export const UpdateProjectSchema = z.object({
|
||||||
project_number: z.string().nullish(),
|
|
||||||
name: z.string().nullish(),
|
name: z.string().nullish(),
|
||||||
status: z.string().optional(),
|
status: z.string().optional(),
|
||||||
notes: z.string().nullish(),
|
notes: z.string().nullish(),
|
||||||
|
|||||||
@@ -29,13 +29,16 @@ import totpRoutes from "./routes/admin/totp";
|
|||||||
import scopeTemplatesRoutes from "./routes/admin/scope-templates";
|
import scopeTemplatesRoutes from "./routes/admin/scope-templates";
|
||||||
import invoicesPdfRoutes from "./routes/admin/invoices-pdf";
|
import invoicesPdfRoutes from "./routes/admin/invoices-pdf";
|
||||||
import offersPdfRoutes from "./routes/admin/offers-pdf";
|
import offersPdfRoutes from "./routes/admin/offers-pdf";
|
||||||
|
import ordersPdfRoutes from "./routes/admin/orders-pdf";
|
||||||
import projectFilesRoutes from "./routes/admin/project-files";
|
import projectFilesRoutes from "./routes/admin/project-files";
|
||||||
|
|
||||||
const app = Fastify({
|
const app = Fastify({
|
||||||
logger: {
|
logger: {
|
||||||
level: config.isProduction ? "warn" : "info",
|
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,
|
bodyLimit: 1048576,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -57,6 +60,12 @@ async function start() {
|
|||||||
|
|
||||||
await app.register(cookie);
|
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, {
|
await app.register(rateLimit, {
|
||||||
max: 300,
|
max: 300,
|
||||||
timeWindow: "1 minute",
|
timeWindow: "1 minute",
|
||||||
@@ -116,16 +125,11 @@ async function start() {
|
|||||||
});
|
});
|
||||||
await app.register(invoicesPdfRoutes, { prefix: "/api/admin/invoices-pdf" });
|
await app.register(invoicesPdfRoutes, { prefix: "/api/admin/invoices-pdf" });
|
||||||
await app.register(offersPdfRoutes, { prefix: "/api/admin/offers-pdf" });
|
await app.register(offersPdfRoutes, { prefix: "/api/admin/offers-pdf" });
|
||||||
|
await app.register(ordersPdfRoutes, { prefix: "/api/admin/orders-pdf" });
|
||||||
await app.register(projectFilesRoutes, {
|
await app.register(projectFilesRoutes, {
|
||||||
prefix: "/api/admin/project-files",
|
prefix: "/api/admin/project-files",
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- Health check ---
|
|
||||||
app.get("/api/health", async () => ({
|
|
||||||
status: "ok",
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// --- Frontend: Vite dev middleware (dev only) ---
|
// --- Frontend: Vite dev middleware (dev only) ---
|
||||||
if (!config.isProduction) {
|
if (!config.isProduction) {
|
||||||
const viteModule = await (Function(
|
const viteModule = await (Function(
|
||||||
|
|||||||
@@ -419,17 +419,32 @@ export async function switchProject(userId: number, projectId: number | null) {
|
|||||||
|
|
||||||
const now = new Date();
|
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 },
|
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) {
|
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({
|
await prisma.attendance_project_logs.create({
|
||||||
data: {
|
data: {
|
||||||
attendance_id: ongoing.id,
|
attendance_id: ongoing.id,
|
||||||
project_id: projectId,
|
project_id: projectId,
|
||||||
started_at: now,
|
started_at: startedAt,
|
||||||
ended_at: null,
|
ended_at: null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -630,19 +645,28 @@ export async function getProjectReport(year: number) {
|
|||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
users: { select: { id: true, first_name: true, last_name: true } },
|
users: { select: { id: true, first_name: true, last_name: true } },
|
||||||
|
attendance_project_logs: {
|
||||||
|
orderBy: { started_at: "asc" },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const projectIds = [
|
// Collect all project ids from both attendance.project_id and project logs
|
||||||
...new Set(records.filter((r) => r.project_id).map((r) => r.project_id!)),
|
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<
|
const projectsMap = new Map<
|
||||||
number,
|
number,
|
||||||
{ name: string; project_number: string }
|
{ name: string; project_number: string }
|
||||||
>();
|
>();
|
||||||
if (projectIds.length > 0) {
|
if (projectIds.size > 0) {
|
||||||
const projects = await prisma.projects.findMany({
|
const projects = await prisma.projects.findMany({
|
||||||
where: { id: { in: projectIds } },
|
where: { id: { in: [...projectIds] } },
|
||||||
select: { id: true, name: true, project_number: true },
|
select: { id: true, name: true, project_number: true },
|
||||||
});
|
});
|
||||||
for (const p of projects) {
|
for (const p of projects) {
|
||||||
@@ -686,32 +710,68 @@ export async function getProjectReport(year: number) {
|
|||||||
>();
|
>();
|
||||||
|
|
||||||
for (const rec of monthRecs) {
|
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 uid = rec.user_id;
|
||||||
const uName = rec.users
|
const uName = rec.users
|
||||||
? `${rec.users.first_name} ${rec.users.last_name}`.trim()
|
? `${rec.users.first_name} ${rec.users.last_name}`.trim()
|
||||||
: `User #${uid}`;
|
: `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]) => ({
|
const projects = Array.from(projectMap.entries()).map(([pid, pg]) => ({
|
||||||
@@ -1315,10 +1375,20 @@ export async function punchAction(userId: number, data: PunchData) {
|
|||||||
data: updateData,
|
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 },
|
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 {
|
return {
|
||||||
id: ongoing.id,
|
id: ongoing.id,
|
||||||
|
|||||||
@@ -2,6 +2,27 @@ import { FastifyRequest } from "fastify";
|
|||||||
import prisma from "../config/database";
|
import prisma from "../config/database";
|
||||||
import { AuditAction, EntityType, AuthData } from "../types";
|
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: {
|
export async function logAudit(params: {
|
||||||
request: FastifyRequest;
|
request: FastifyRequest;
|
||||||
authData?: AuthData | null;
|
authData?: AuthData | null;
|
||||||
@@ -22,8 +43,12 @@ export async function logAudit(params: {
|
|||||||
entity_type: params.entityType ?? null,
|
entity_type: params.entityType ?? null,
|
||||||
entity_id: params.entityId ?? null,
|
entity_id: params.entityId ?? null,
|
||||||
description: params.description ?? null,
|
description: params.description ?? null,
|
||||||
old_values: params.oldValues ? JSON.stringify(params.oldValues) : null,
|
old_values: params.oldValues
|
||||||
new_values: params.newValues ? JSON.stringify(params.newValues) : null,
|
? JSON.stringify(params.oldValues, safeJsonReplacer)
|
||||||
|
: null,
|
||||||
|
new_values: params.newValues
|
||||||
|
? JSON.stringify(params.newValues, safeJsonReplacer)
|
||||||
|
: null,
|
||||||
user_agent: params.request.headers["user-agent"] ?? null,
|
user_agent: params.request.headers["user-agent"] ?? null,
|
||||||
session_id: null,
|
session_id: null,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -99,14 +99,6 @@ export async function checkInvoiceAlerts(): Promise<void> {
|
|||||||
due_date: localDateCzStr(new Date(inv.due_date)),
|
due_date: localDateCzStr(new Date(inv.due_date)),
|
||||||
days_label: daysLabel,
|
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) ---
|
// --- Received invoices (we owe supplier) ---
|
||||||
@@ -155,14 +147,6 @@ export async function checkInvoiceAlerts(): Promise<void> {
|
|||||||
due_date: localDateCzStr(new Date(inv.due_date)),
|
due_date: localDateCzStr(new Date(inv.due_date)),
|
||||||
days_label: daysLabel,
|
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;
|
if (alerts.length === 0) return;
|
||||||
@@ -221,9 +205,26 @@ export async function checkInvoiceAlerts(): Promise<void> {
|
|||||||
const sent = await sendMail(alertEmail, subject, html);
|
const sent = await sendMail(alertEmail, subject, html);
|
||||||
if (!sent) {
|
if (!sent) {
|
||||||
console.error(`InvoiceAlerts: Failed to send alert to ${alertEmail}`);
|
console.error(`InvoiceAlerts: Failed to send alert to ${alertEmail}`);
|
||||||
} else {
|
return;
|
||||||
console.log(
|
}
|
||||||
`InvoiceAlerts: Sent ${alerts.length} alert(s) to ${alertEmail}`,
|
|
||||||
);
|
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",
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import prisma from "../config/database";
|
import prisma from "../config/database";
|
||||||
import { toCzk } from "./exchange-rates";
|
import { toCzk } from "./exchange-rates";
|
||||||
|
import {
|
||||||
|
generateInvoiceNumber,
|
||||||
|
releaseInvoiceNumber,
|
||||||
|
} from "./numbering.service";
|
||||||
|
|
||||||
// Status transition rules matching PHP
|
// Status transition rules matching PHP
|
||||||
const VALID_TRANSITIONS: Record<string, string[]> = {
|
const VALID_TRANSITIONS: Record<string, string[]> = {
|
||||||
@@ -70,6 +74,11 @@ export async function markOverdueInvoices() {
|
|||||||
where: { status: "issued", due_date: { lt: new Date() } },
|
where: { status: "issued", due_date: { lt: new Date() } },
|
||||||
data: { status: "overdue" },
|
data: { status: "overdue" },
|
||||||
});
|
});
|
||||||
|
// Reverse: if due_date was changed to future, set back to issued
|
||||||
|
await prisma.invoices.updateMany({
|
||||||
|
where: { status: "overdue", due_date: { gte: new Date() } },
|
||||||
|
data: { status: "issued" },
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("markOverdueInvoices failed:", err);
|
console.error("markOverdueInvoices failed:", err);
|
||||||
}
|
}
|
||||||
@@ -142,7 +151,10 @@ export async function listInvoices(params: ListInvoicesParams) {
|
|||||||
return { data: enriched, total, page, limit };
|
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) {
|
export async function getInvoiceStats(queryMonth?: number, queryYear?: number) {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@@ -288,9 +300,14 @@ export async function getInvoice(id: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function createInvoice(body: Record<string, any>) {
|
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({
|
const invoice = await prisma.invoices.create({
|
||||||
data: {
|
data: {
|
||||||
invoice_number: body.invoice_number ? String(body.invoice_number) : null,
|
invoice_number: invoiceNumber,
|
||||||
order_id: body.order_id ? Number(body.order_id) : null,
|
order_id: body.order_id ? Number(body.order_id) : null,
|
||||||
customer_id: body.customer_id ? Number(body.customer_id) : null,
|
customer_id: body.customer_id ? Number(body.customer_id) : null,
|
||||||
status: body.status ? String(body.status) : "issued",
|
status: body.status ? String(body.status) : "issued",
|
||||||
@@ -350,8 +367,8 @@ export async function updateInvoice(id: number, body: Record<string, any>) {
|
|||||||
|
|
||||||
const data: Record<string, unknown> = { modified_at: new Date() };
|
const data: Record<string, unknown> = { modified_at: new Date() };
|
||||||
|
|
||||||
// Only allow full editing in 'issued' state
|
// Allow full editing in 'issued' and 'overdue' states
|
||||||
const isDraft = currentStatus === "issued";
|
const isDraft = currentStatus === "issued" || currentStatus === "overdue";
|
||||||
if (isDraft) {
|
if (isDraft) {
|
||||||
const strFields = [
|
const strFields = [
|
||||||
"currency",
|
"currency",
|
||||||
@@ -405,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;
|
data.paid_date = body.paid_date ? new Date(String(body.paid_date)) : null;
|
||||||
|
|
||||||
await prisma.invoices.update({ where: { id }, data });
|
await prisma.invoices.update({ where: { id }, data });
|
||||||
@@ -436,5 +453,11 @@ export async function deleteInvoice(id: number) {
|
|||||||
if (!existing) return null;
|
if (!existing) return null;
|
||||||
|
|
||||||
await prisma.invoices.delete({ where: { id } });
|
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;
|
return existing;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -545,13 +545,14 @@ export class NasFileManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private buildFolderName(projectNumber: string, projectName: string): string {
|
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, "");
|
let safe = projectName.replace(/[^\p{L}\p{N}_\-. ]/gu, "");
|
||||||
safe = safe.trim().replace(/ /g, "_");
|
safe = safe.trim().replace(/ /g, "_").replace(/_+/g, "_");
|
||||||
safe = safe.replace(/_+/g, "_");
|
|
||||||
if ([...safe].length > 200) {
|
if ([...safe].length > 200) {
|
||||||
safe = [...safe].slice(0, 200).join("");
|
safe = [...safe].slice(0, 200).join("");
|
||||||
}
|
}
|
||||||
return projectNumber + "_" + safe;
|
return safeNum + "_" + safe;
|
||||||
}
|
}
|
||||||
|
|
||||||
private resolveProjectPath(
|
private resolveProjectPath(
|
||||||
|
|||||||
@@ -30,6 +30,34 @@ class NasFinancialsManager {
|
|||||||
|
|
||||||
// ── Created (issued) invoices ────────────────────────────────────
|
// ── Created (issued) invoices ────────────────────────────────────
|
||||||
|
|
||||||
|
/** Remove any existing PDF for this invoice number across all year/month folders */
|
||||||
|
cleanIssuedInvoice(invoiceNumber: string): void {
|
||||||
|
if (!this.basePath) return;
|
||||||
|
const safeName = this.sanitizeFilename(invoiceNumber) + ".pdf";
|
||||||
|
const issuedDir = path.join(this.basePath, DIR_ISSUED);
|
||||||
|
try {
|
||||||
|
if (!fs.existsSync(issuedDir)) return;
|
||||||
|
for (const yearDir of fs.readdirSync(issuedDir)) {
|
||||||
|
const yearPath = path.join(issuedDir, yearDir);
|
||||||
|
if (!fs.statSync(yearPath).isDirectory()) continue;
|
||||||
|
for (const monthDir of fs.readdirSync(yearPath)) {
|
||||||
|
const monthPath = path.join(yearPath, monthDir);
|
||||||
|
try {
|
||||||
|
if (!fs.statSync(monthPath).isDirectory()) continue;
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const filePath = path.join(monthPath, safeName);
|
||||||
|
if (fs.existsSync(filePath)) {
|
||||||
|
fs.unlinkSync(filePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// best effort
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
saveIssuedInvoicePdf(
|
saveIssuedInvoicePdf(
|
||||||
invoiceNumber: string,
|
invoiceNumber: string,
|
||||||
year: number,
|
year: number,
|
||||||
|
|||||||
@@ -1,4 +1,11 @@
|
|||||||
import prisma from "../config/database";
|
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)
|
// Default patterns (backward compatible with existing numbers)
|
||||||
const DEFAULT_OFFER_PATTERN = "{YYYY}/{PREFIX}/{NNN}";
|
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() {
|
async function getSettings() {
|
||||||
return prisma.company_settings.findFirst({
|
return prisma.company_settings.findFirst({
|
||||||
select: {
|
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 settings = await getSettings();
|
||||||
const pattern = settings?.offer_number_pattern || DEFAULT_OFFER_PATTERN;
|
const pattern = settings?.offer_number_pattern || DEFAULT_OFFER_PATTERN;
|
||||||
const prefix = settings?.quotation_prefix || "NA";
|
const prefix = settings?.quotation_prefix || "NA";
|
||||||
const year = new Date().getFullYear();
|
const year = new Date().getFullYear();
|
||||||
|
|
||||||
const { likePattern, prefixLen } = buildLikePattern(pattern, {
|
for (let attempt = 0; attempt < 100; attempt++) {
|
||||||
year,
|
const seq = await getNextSequence("offer", year, tx);
|
||||||
prefix,
|
const number = applyPattern(pattern, { year, prefix, code: "", seq });
|
||||||
code: "",
|
if (!(await isOfferNumberTaken(number))) {
|
||||||
});
|
return number;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const result = await prisma.$queryRaw<[{ max_seq: bigint | null }]>`
|
throw new Error("Nepodařilo se vygenerovat jedinečné číslo nabídky");
|
||||||
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 });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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 settings = await getSettings();
|
||||||
const pattern = settings?.order_number_pattern || DEFAULT_ORDER_PATTERN;
|
const pattern = settings?.order_number_pattern || DEFAULT_ORDER_PATTERN;
|
||||||
const code = settings?.order_type_code || "71";
|
const code = settings?.order_type_code || "71";
|
||||||
const year = new Date().getFullYear();
|
const year = new Date().getFullYear();
|
||||||
|
|
||||||
const { likePattern, prefixLen } = buildLikePattern(pattern, {
|
for (let attempt = 0; attempt < 100; attempt++) {
|
||||||
year,
|
const seq = await getNextSequence("shared", year, tx);
|
||||||
prefix: "",
|
const number = applyPattern(pattern, { year, prefix: "", code, seq });
|
||||||
code,
|
if (!(await isSharedNumberTaken(number))) {
|
||||||
});
|
return number;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const result = await prisma.$queryRaw<[{ max_seq: bigint | null }]>`
|
throw new Error(
|
||||||
SELECT COALESCE(MAX(seq), 0) as max_seq FROM (
|
"Nepodařilo se vygenerovat jedinečné číslo objednávky/projekt",
|
||||||
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 });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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(
|
export async function generateInvoiceNumber(
|
||||||
_year?: number,
|
_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 }> {
|
): Promise<{ number: string; next_number: string }> {
|
||||||
const settings = await getSettings();
|
const settings = await getSettings();
|
||||||
const pattern = settings?.invoice_number_pattern || DEFAULT_INVOICE_PATTERN;
|
const pattern = settings?.invoice_number_pattern || DEFAULT_INVOICE_PATTERN;
|
||||||
const code = settings?.invoice_type_code || "81";
|
const code = settings?.invoice_type_code || "81";
|
||||||
const year = _year || new Date().getFullYear();
|
const year = _year || new Date().getFullYear();
|
||||||
|
|
||||||
const { likePattern, prefixLen } = buildLikePattern(pattern, {
|
const seq = await previewNextSequence("invoice", year);
|
||||||
year,
|
const number = applyPattern(pattern, { year, prefix: "", code, seq });
|
||||||
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,
|
|
||||||
});
|
|
||||||
return { number, next_number: number };
|
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) */
|
/** Preview what a pattern would produce (for settings UI) */
|
||||||
export function previewPattern(
|
export function previewPattern(
|
||||||
pattern: string,
|
pattern: string,
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import prisma from "../config/database";
|
import prisma from "../config/database";
|
||||||
import { generateOfferNumber } from "./numbering.service";
|
import {
|
||||||
|
generateOfferNumber,
|
||||||
|
previewOfferNumber,
|
||||||
|
releaseOfferNumber,
|
||||||
|
} from "./numbering.service";
|
||||||
|
|
||||||
interface QuotationItemInput {
|
interface QuotationItemInput {
|
||||||
description?: string;
|
description?: string;
|
||||||
@@ -18,7 +22,7 @@ interface ScopeSectionInput {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Re-export for convenience
|
// Re-export for convenience
|
||||||
export { generateOfferNumber as getNextOfferNumber } from "./numbering.service";
|
export { previewOfferNumber as getNextOfferNumber } from "./numbering.service";
|
||||||
|
|
||||||
const ALLOWED_SORT_FIELDS = [
|
const ALLOWED_SORT_FIELDS = [
|
||||||
"id",
|
"id",
|
||||||
@@ -133,11 +137,14 @@ export async function getOffer(id: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function createOffer(body: Record<string, any>) {
|
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({
|
const quotation = await prisma.quotations.create({
|
||||||
data: {
|
data: {
|
||||||
quotation_number: body.quotation_number
|
quotation_number: quotationNumber,
|
||||||
? String(body.quotation_number)
|
|
||||||
: null,
|
|
||||||
project_code: body.project_code ? String(body.project_code) : null,
|
project_code: body.project_code ? String(body.project_code) : null,
|
||||||
customer_id: body.customer_id ? Number(body.customer_id) : null,
|
customer_id: body.customer_id ? Number(body.customer_id) : null,
|
||||||
valid_until: body.valid_until ? new Date(String(body.valid_until)) : 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")
|
if (existing.status === "invalidated")
|
||||||
return { error: "invalidated" as const };
|
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({
|
await prisma.quotations.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: {
|
data: {
|
||||||
quotation_number:
|
|
||||||
body.quotation_number !== undefined
|
|
||||||
? String(body.quotation_number)
|
|
||||||
: undefined,
|
|
||||||
customer_id:
|
customer_id:
|
||||||
body.customer_id !== undefined ? Number(body.customer_id) : undefined,
|
body.customer_id !== undefined ? Number(body.customer_id) : undefined,
|
||||||
valid_until:
|
valid_until:
|
||||||
@@ -281,6 +291,12 @@ export async function deleteOffer(id: number) {
|
|||||||
if (!existing) return null;
|
if (!existing) return null;
|
||||||
|
|
||||||
await prisma.quotations.delete({ where: { id } });
|
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;
|
return existing;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import prisma from "../config/database";
|
import prisma from "../config/database";
|
||||||
import { generateSharedNumber } from "./numbering.service";
|
import {
|
||||||
|
generateSharedNumber,
|
||||||
|
previewSharedNumber,
|
||||||
|
releaseSharedNumber,
|
||||||
|
} from "./numbering.service";
|
||||||
|
|
||||||
interface OrderItemInput {
|
interface OrderItemInput {
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
@@ -180,10 +184,10 @@ export async function createOrderFromQuotation(
|
|||||||
status: 400,
|
status: 400,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const orderNumber = await generateSharedNumber();
|
|
||||||
const projectNumber = await generateSharedNumber();
|
|
||||||
|
|
||||||
const result = await prisma.$transaction(async (tx) => {
|
const result = await prisma.$transaction(async (tx) => {
|
||||||
|
const orderNumber = await generateSharedNumber(tx);
|
||||||
|
const projectNumber = orderNumber;
|
||||||
|
|
||||||
const order = await tx.orders.create({
|
const order = await tx.orders.create({
|
||||||
data: {
|
data: {
|
||||||
order_number: orderNumber,
|
order_number: orderNumber,
|
||||||
@@ -249,14 +253,14 @@ export async function createOrderFromQuotation(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return { order, project };
|
return { order, project, orderNumber };
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
data: {
|
data: {
|
||||||
order_id: result.order.id,
|
order_id: result.order.id,
|
||||||
id: result.order.id,
|
id: result.order.id,
|
||||||
order_number: orderNumber,
|
order_number: result.orderNumber,
|
||||||
quotationId,
|
quotationId,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -281,9 +285,14 @@ interface CreateOrderData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function createOrder(body: 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({
|
const order = await prisma.orders.create({
|
||||||
data: {
|
data: {
|
||||||
order_number: body.order_number ?? null,
|
order_number: orderNumber,
|
||||||
customer_order_number: body.customer_order_number ?? null,
|
customer_order_number: body.customer_order_number ?? null,
|
||||||
quotation_id: body.quotation_id ?? null,
|
quotation_id: body.quotation_id ?? null,
|
||||||
customer_id: body.customer_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;
|
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) {
|
if (body.status !== undefined && String(body.status) !== currentStatus) {
|
||||||
const newStatus = String(body.status);
|
const newStatus = String(body.status);
|
||||||
const allowed = VALID_TRANSITIONS[currentStatus] || [];
|
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 data: Record<string, unknown> = { modified_at: new Date() };
|
||||||
const strFields = [
|
const strFields = [
|
||||||
"order_number",
|
|
||||||
"customer_order_number",
|
"customer_order_number",
|
||||||
"status",
|
"status",
|
||||||
"currency",
|
"currency",
|
||||||
@@ -377,17 +395,6 @@ export async function updateOrder(id: number, body: UpdateOrderData) {
|
|||||||
|
|
||||||
await prisma.orders.update({ where: { id }, data });
|
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)
|
// Sync project status when order status changes (matching PHP)
|
||||||
if (body.status !== undefined && String(body.status) !== currentStatus) {
|
if (body.status !== undefined && String(body.status) !== currentStatus) {
|
||||||
const statusMap: Record<string, string> = {
|
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 (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) => {
|
await prisma.$transaction(async (tx) => {
|
||||||
if (Array.isArray(body.items)) {
|
if (Array.isArray(body.items)) {
|
||||||
await tx.order_items.deleteMany({ where: { order_id: id } });
|
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)
|
// Delete linked project and its notes (matching PHP)
|
||||||
const linkedProjects = await prisma.projects.findMany({
|
const linkedProjects = await prisma.projects.findMany({
|
||||||
where: { order_id: id },
|
where: { order_id: id },
|
||||||
select: { id: true },
|
select: { id: true, created_at: true },
|
||||||
});
|
});
|
||||||
if (linkedProjects.length > 0) {
|
if (linkedProjects.length > 0) {
|
||||||
const projectIds = linkedProjects.map((p) => p.id);
|
const projectIds = linkedProjects.map((p) => p.id);
|
||||||
@@ -464,9 +477,23 @@ export async function deleteOrder(id: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await prisma.orders.delete({ where: { id } });
|
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 } };
|
return { data: { id, order_number: existing.order_number } };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getNextOrderNumber() {
|
export async function getNextOrderNumber() {
|
||||||
return generateSharedNumber();
|
return previewSharedNumber();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import prisma from "../config/database";
|
import prisma from "../config/database";
|
||||||
import { generateSharedNumber } from "./numbering.service";
|
import {
|
||||||
|
generateSharedNumber,
|
||||||
|
previewSharedNumber,
|
||||||
|
releaseSharedNumber,
|
||||||
|
} from "./numbering.service";
|
||||||
import { NasFileManager } from "./nas-file-manager";
|
import { NasFileManager } from "./nas-file-manager";
|
||||||
|
|
||||||
const nasFileManager = new NasFileManager();
|
const nasFileManager = new NasFileManager();
|
||||||
@@ -93,9 +97,14 @@ export async function getProject(id: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function createProject(body: Record<string, any>) {
|
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({
|
const project = await prisma.projects.create({
|
||||||
data: {
|
data: {
|
||||||
project_number: body.project_number ? String(body.project_number) : null,
|
project_number: projectNumber,
|
||||||
name: body.name ? String(body.name) : null,
|
name: body.name ? String(body.name) : null,
|
||||||
customer_id: body.customer_id ? Number(body.customer_id) : null,
|
customer_id: body.customer_id ? Number(body.customer_id) : null,
|
||||||
responsible_user_id: body.responsible_user_id
|
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 } });
|
const existing = await prisma.projects.findUnique({ where: { id } });
|
||||||
if (!existing) return null;
|
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 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)
|
for (const f of strFields)
|
||||||
if (body[f] !== undefined) data[f] = body[f] ? String(body[f]) : null;
|
if (body[f] !== undefined) data[f] = body[f] ? String(body[f]) : null;
|
||||||
if (body.customer_id !== undefined)
|
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 });
|
await prisma.projects.update({ where: { id }, data });
|
||||||
|
|
||||||
if (
|
if (
|
||||||
existing.name !== data.name &&
|
body.name !== undefined &&
|
||||||
|
existing.name !== body.name &&
|
||||||
existing.project_number &&
|
existing.project_number &&
|
||||||
nasFileManager.isConfigured()
|
nasFileManager.isConfigured()
|
||||||
) {
|
) {
|
||||||
nasFileManager.renameProjectFolder(
|
nasFileManager.renameProjectFolder(
|
||||||
existing.project_number,
|
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 } });
|
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;
|
return existing;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,5 +228,5 @@ export async function deleteProjectNote(projectId: number, noteId: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getNextProjectNumber() {
|
export async function getNextProjectNumber() {
|
||||||
return generateSharedNumber();
|
return previewSharedNumber();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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({
|
export default defineConfig({
|
||||||
test: {
|
test: {
|
||||||
globals: true,
|
globals: true,
|
||||||
environment: 'node',
|
environment: "node",
|
||||||
setupFiles: ['./src/__tests__/setup.ts'],
|
setupFiles: ["./src/__tests__/setup.ts"],
|
||||||
testTimeout: 15000,
|
testTimeout: 15000,
|
||||||
hookTimeout: 15000,
|
hookTimeout: 15000,
|
||||||
|
exclude: ["dist/**", "node_modules/**"],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user