Compare commits
10 Commits
8ee5a443ef
...
v2.4.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
396a1d37ec | ||
|
|
ca1f07671f | ||
|
|
638264fc7c | ||
|
|
f68a4dafc4 | ||
|
|
700fb47bbc | ||
|
|
c2746d78c9 | ||
|
|
4e534471d9 | ||
|
|
975a555af5 | ||
|
|
6a22195c7d | ||
|
|
74ce24e3fa |
10
CLAUDE.md
10
CLAUDE.md
@@ -520,13 +520,19 @@ The 2026-06-09 file-by-file audit traced most bugs to a handful of patterns. The
|
|||||||
2. `npm run build`
|
2. `npm run build`
|
||||||
3. Commit and tag (`git tag -a vX.Y.Z`)
|
3. Commit and tag (`git tag -a vX.Y.Z`)
|
||||||
4. Push to Gitea (`git push origin master && git push origin vX.Y.Z`)
|
4. Push to Gitea (`git push origin master && git push origin vX.Y.Z`)
|
||||||
5. Create tarball: `tar -czf app-ts-X.Y.Z.tar.gz dist dist-client prisma package.json package-lock.json scripts`
|
5. Create tarball: `tar -czf app-ts-X.Y.Z.tar.gz dist dist-client prisma prisma.config.ts package.json package-lock.json scripts`
|
||||||
|
(⚠️ `prisma.config.ts` is REQUIRED — Prisma 7 keeps the datasource URL there;
|
||||||
|
without it, `prisma generate`/`migrate deploy` on prod have no datasource)
|
||||||
6. Deploy via SSH to production server (`boha_admin@192.168.50.100`):
|
6. Deploy via SSH to production server (`boha_admin@192.168.50.100`):
|
||||||
- Path: `/var/www/app-ts`
|
- Path: `/var/www/app-ts`
|
||||||
- Remove old files: `rm -rf dist dist-client prisma scripts package.json package-lock.json`
|
- Remove old files: `rm -rf dist dist-client prisma prisma.config.ts scripts package.json package-lock.json`
|
||||||
- Copy tarball to server: `scp app-ts-X.Y.Z.tar.gz boha_admin@192.168.50.100:/tmp/`
|
- Copy tarball to server: `scp app-ts-X.Y.Z.tar.gz boha_admin@192.168.50.100:/tmp/`
|
||||||
- Extract tarball: `tar -xzf /tmp/app-ts-X.Y.Z.tar.gz`
|
- Extract tarball: `tar -xzf /tmp/app-ts-X.Y.Z.tar.gz`
|
||||||
- Install dependencies: `npm install --omit=dev`
|
- Install dependencies: `npm install --omit=dev`
|
||||||
|
- Regenerate the Prisma client: `npx prisma generate` — **MANDATORY**.
|
||||||
|
`npm install` skips regeneration when dependencies didn't change, leaving a
|
||||||
|
stale client that still selects dropped/renamed columns → P2022 500s in
|
||||||
|
prod (bit the v2.4.0 supplier release).
|
||||||
- Apply Prisma migrations: `npx prisma migrate deploy`
|
- Apply Prisma migrations: `npx prisma migrate deploy`
|
||||||
- Restart: `pm2 restart app-ts --update-env`
|
- Restart: `pm2 restart app-ts --update-env`
|
||||||
|
|
||||||
|
|||||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "app-ts",
|
"name": "app-ts",
|
||||||
"version": "2.3.0",
|
"version": "2.4.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "app-ts",
|
"name": "app-ts",
|
||||||
"version": "2.3.0",
|
"version": "2.4.2",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@anthropic-ai/sdk": "^0.102.0",
|
"@anthropic-ai/sdk": "^0.102.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "app-ts",
|
"name": "app-ts",
|
||||||
"version": "2.3.0",
|
"version": "2.4.2",
|
||||||
"description": "",
|
"description": "",
|
||||||
"main": "dist/server.js",
|
"main": "dist/server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE `issued_orders` DROP FOREIGN KEY `issued_orders_ibfk_1`;
|
||||||
|
|
||||||
|
-- DropIndex
|
||||||
|
DROP INDEX `issued_orders_customer_id` ON `issued_orders`;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE `issued_orders` DROP COLUMN `customer_id`,
|
||||||
|
ADD COLUMN `supplier_id` INTEGER NULL;
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX `issued_orders_supplier_id` ON `issued_orders`(`supplier_id`);
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `issued_orders` ADD CONSTRAINT `issued_orders_supplier_fk` FOREIGN KEY (`supplier_id`) REFERENCES `sklad_suppliers`(`id`) ON DELETE RESTRICT ON UPDATE NO ACTION;
|
||||||
|
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE `issued_orders` ADD COLUMN `order_text` VARCHAR(500) NULL;
|
||||||
|
|
||||||
@@ -193,7 +193,6 @@ model customers {
|
|||||||
sync_version Int? @default(0)
|
sync_version Int? @default(0)
|
||||||
invoices invoices[]
|
invoices invoices[]
|
||||||
orders orders[]
|
orders orders[]
|
||||||
issued_orders issued_orders[]
|
|
||||||
projects projects[]
|
projects projects[]
|
||||||
quotations quotations[]
|
quotations quotations[]
|
||||||
}
|
}
|
||||||
@@ -370,7 +369,7 @@ model orders {
|
|||||||
model issued_orders {
|
model issued_orders {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
po_number String? @unique(map: "idx_issued_orders_number_unique") @db.VarChar(50)
|
po_number String? @unique(map: "idx_issued_orders_number_unique") @db.VarChar(50)
|
||||||
customer_id Int?
|
supplier_id Int?
|
||||||
status issued_orders_status @default(draft)
|
status issued_orders_status @default(draft)
|
||||||
currency String? @default("CZK") @db.VarChar(10)
|
currency String? @default("CZK") @db.VarChar(10)
|
||||||
vat_rate Decimal? @default(21.00) @db.Decimal(5, 2)
|
vat_rate Decimal? @default(21.00) @db.Decimal(5, 2)
|
||||||
@@ -382,14 +381,15 @@ model issued_orders {
|
|||||||
delivery_terms String? @db.VarChar(500)
|
delivery_terms String? @db.VarChar(500)
|
||||||
payment_terms String? @db.VarChar(500)
|
payment_terms String? @db.VarChar(500)
|
||||||
issued_by String? @db.VarChar(255)
|
issued_by String? @db.VarChar(255)
|
||||||
|
order_text String? @db.VarChar(500)
|
||||||
notes String? @db.Text
|
notes String? @db.Text
|
||||||
internal_notes String? @db.Text
|
internal_notes String? @db.Text
|
||||||
created_at DateTime? @default(now()) @db.DateTime(0)
|
created_at DateTime? @default(now()) @db.DateTime(0)
|
||||||
modified_at DateTime? @db.DateTime(0)
|
modified_at DateTime? @db.DateTime(0)
|
||||||
issued_order_items issued_order_items[]
|
issued_order_items issued_order_items[]
|
||||||
customers customers? @relation(fields: [customer_id], references: [id], onDelete: Restrict, onUpdate: NoAction, map: "issued_orders_ibfk_1")
|
suppliers sklad_suppliers? @relation(fields: [supplier_id], references: [id], onDelete: Restrict, onUpdate: NoAction, map: "issued_orders_supplier_fk")
|
||||||
|
|
||||||
@@index([customer_id], map: "issued_orders_customer_id")
|
@@index([supplier_id], map: "issued_orders_supplier_id")
|
||||||
@@index([status, order_date], map: "idx_issued_orders_status_date")
|
@@index([status, order_date], map: "idx_issued_orders_status_date")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -796,7 +796,8 @@ model sklad_suppliers {
|
|||||||
created_at DateTime? @default(now()) @db.DateTime(0)
|
created_at DateTime? @default(now()) @db.DateTime(0)
|
||||||
modified_at DateTime? @db.DateTime(0)
|
modified_at DateTime? @db.DateTime(0)
|
||||||
|
|
||||||
receipts sklad_receipts[]
|
receipts sklad_receipts[]
|
||||||
|
issued_orders issued_orders[]
|
||||||
|
|
||||||
@@map("sklad_suppliers")
|
@@map("sklad_suppliers")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -258,6 +258,106 @@ describe("GET /api/admin/attendance (complete month, no truncation)", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("GET /api/admin/attendance (month window boundaries, @db.Date)", () => {
|
||||||
|
it("includes the month's own LAST day and excludes the neighbor month's first day", async () => {
|
||||||
|
// shift_date is @db.Date — Prisma compares the filter Dates by their UTC
|
||||||
|
// date part. The old LOCAL-midnight month bounds (22:00/23:00 UTC of the
|
||||||
|
// previous day) shifted the whole window a day back: the June list
|
||||||
|
// included May 31 and DROPPED June 30. Fixture rows sit exactly on the
|
||||||
|
// 2098-06 / 2098-07 boundary.
|
||||||
|
const juneLast = await prisma.attendance.create({
|
||||||
|
data: {
|
||||||
|
user_id: adminUserId,
|
||||||
|
shift_date: new Date(2098, 5, 30, 12, 0, 0), // 2098-06-30, local noon
|
||||||
|
leave_type: "work",
|
||||||
|
arrival_time: new Date(2098, 5, 30, 8, 0, 0),
|
||||||
|
departure_time: new Date(2098, 5, 30, 16, 0, 0),
|
||||||
|
notes: "attendance_wire_test june-last-day",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const julyFirst = await prisma.attendance.create({
|
||||||
|
data: {
|
||||||
|
user_id: adminUserId,
|
||||||
|
shift_date: new Date(2098, 6, 1, 12, 0, 0), // 2098-07-01, local noon
|
||||||
|
leave_type: "work",
|
||||||
|
arrival_time: new Date(2098, 6, 1, 8, 0, 0),
|
||||||
|
departure_time: new Date(2098, 6, 1, 16, 0, 0),
|
||||||
|
notes: "attendance_wire_test july-first-day",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const june = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: "/api/admin/attendance?year=2098&month=6&limit=100",
|
||||||
|
headers: { Authorization: `Bearer ${adminToken}` },
|
||||||
|
});
|
||||||
|
expect(june.statusCode).toBe(200);
|
||||||
|
const juneIds = june.json().data.map((r: { id: number }) => r.id);
|
||||||
|
expect(juneIds).toContain(juneLast.id);
|
||||||
|
expect(juneIds).not.toContain(julyFirst.id);
|
||||||
|
|
||||||
|
const july = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: "/api/admin/attendance?year=2098&month=7&limit=100",
|
||||||
|
headers: { Authorization: `Bearer ${adminToken}` },
|
||||||
|
});
|
||||||
|
expect(july.statusCode).toBe(200);
|
||||||
|
const julyIds = july.json().data.map((r: { id: number }) => r.id);
|
||||||
|
expect(julyIds).toContain(julyFirst.id);
|
||||||
|
expect(julyIds).not.toContain(juneLast.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("POST /api/admin/attendance (same-day duplicate window, @db.Date)", () => {
|
||||||
|
it("rejects a second leave record on the SAME day", async () => {
|
||||||
|
const first = await authPost("/api/admin/attendance", adminToken, {
|
||||||
|
user_id: adminUserId,
|
||||||
|
shift_date: "2098-08-10",
|
||||||
|
leave_type: "vacation",
|
||||||
|
leave_hours: 8,
|
||||||
|
notes: "attendance_wire_test dup-leave-first",
|
||||||
|
});
|
||||||
|
expect(first.statusCode).toBe(201);
|
||||||
|
|
||||||
|
// Old bug: the duplicate/overlap window was built from LOCAL midnights of
|
||||||
|
// the UTC-midnight shiftDate, so the validation queried ONLY the previous
|
||||||
|
// day — a same-day duplicate leave sailed through undetected.
|
||||||
|
const second = await authPost("/api/admin/attendance", adminToken, {
|
||||||
|
user_id: adminUserId,
|
||||||
|
shift_date: "2098-08-10",
|
||||||
|
leave_type: "sick",
|
||||||
|
leave_hours: 8,
|
||||||
|
notes: "attendance_wire_test dup-leave-second",
|
||||||
|
});
|
||||||
|
expect(second.statusCode).toBe(400);
|
||||||
|
expect(second.json().success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT falsely reject a leave on the day AFTER an existing record", async () => {
|
||||||
|
const first = await authPost("/api/admin/attendance", adminToken, {
|
||||||
|
user_id: adminUserId,
|
||||||
|
shift_date: "2098-08-10",
|
||||||
|
leave_type: "vacation",
|
||||||
|
leave_hours: 8,
|
||||||
|
notes: "attendance_wire_test neighbor-day-first",
|
||||||
|
});
|
||||||
|
expect(first.statusCode).toBe(201);
|
||||||
|
|
||||||
|
// Old bug (the other half): creating a record for the NEXT day queried
|
||||||
|
// the window [Aug 10, Aug 11) — i.e. yesterday's records — and bounced
|
||||||
|
// with a false "záznam o nepřítomnosti již existuje".
|
||||||
|
const nextDay = await authPost("/api/admin/attendance", adminToken, {
|
||||||
|
user_id: adminUserId,
|
||||||
|
shift_date: "2098-08-11",
|
||||||
|
leave_type: "vacation",
|
||||||
|
leave_hours: 8,
|
||||||
|
notes: "attendance_wire_test neighbor-day-next",
|
||||||
|
});
|
||||||
|
expect(nextDay.statusCode).toBe(201);
|
||||||
|
expect(nextDay.json().success).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("PUT /api/admin/attendance/:id (combined datetimes)", () => {
|
describe("PUT /api/admin/attendance/:id (combined datetimes)", () => {
|
||||||
it("updates a record with combined datetimes (incl. overnight departure)", async () => {
|
it("updates a record with combined datetimes (incl. overnight departure)", async () => {
|
||||||
const created = await prisma.attendance.create({
|
const created = await prisma.attendance.create({
|
||||||
|
|||||||
100
src/__tests__/dashboard.test.ts
Normal file
100
src/__tests__/dashboard.test.ts
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||||
|
import Fastify from "fastify";
|
||||||
|
import jwt from "jsonwebtoken";
|
||||||
|
import prisma from "../config/database";
|
||||||
|
import { config } from "../config/env";
|
||||||
|
import dashboardRoutes from "../routes/admin/dashboard";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Regression test for the "Docházka dnes" / "Přítomní dnes" dashboard window.
|
||||||
|
//
|
||||||
|
// attendance.shift_date is @db.Date and Prisma truncates a filter Date to its
|
||||||
|
// UTC date part. The dashboard used local-midnight boundaries
|
||||||
|
// (new Date(y, m, d) = 22:00/23:00 UTC of the PREVIOUS day under
|
||||||
|
// TZ=Europe/Prague), which truncated to [yesterday, today) — today's punches
|
||||||
|
// matched zero rows and everyone showed as absent. The boundaries must be
|
||||||
|
// UTC-midnight instants of the LOCAL calendar day.
|
||||||
|
//
|
||||||
|
// Unlike the other suites this one has to use the REAL current date (the
|
||||||
|
// route computes "today" internally), so fixtures are tracked by id and
|
||||||
|
// deleted in afterAll.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
let app: Awaited<ReturnType<typeof buildApp>>;
|
||||||
|
let adminUserId: number;
|
||||||
|
let adminToken: string;
|
||||||
|
const createdIds: number[] = [];
|
||||||
|
|
||||||
|
async function buildApp() {
|
||||||
|
const a = Fastify({ logger: false });
|
||||||
|
await a.register(dashboardRoutes, { prefix: "/api/admin/dashboard" });
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
app = await buildApp();
|
||||||
|
|
||||||
|
const admin = await prisma.users.findFirst({
|
||||||
|
where: { roles: { name: "admin" } },
|
||||||
|
include: { roles: true },
|
||||||
|
});
|
||||||
|
if (!admin) throw new Error("Test setup: admin user not found");
|
||||||
|
adminUserId = admin.id;
|
||||||
|
adminToken = jwt.sign(
|
||||||
|
{ sub: admin.id, username: admin.username, role: "admin" },
|
||||||
|
config.jwt.secret,
|
||||||
|
{ expiresIn: "15m" },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
if (createdIds.length) {
|
||||||
|
await prisma.attendance.deleteMany({ where: { id: { in: createdIds } } });
|
||||||
|
}
|
||||||
|
if (app) await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("GET /api/admin/dashboard — today's attendance window", () => {
|
||||||
|
it("counts a punched-in user as present (local-noon @db.Date row)", async () => {
|
||||||
|
const now = new Date();
|
||||||
|
// The attendance regime stores shift_date at LOCAL NOON (so the UTC date
|
||||||
|
// part equals the Prague calendar date) — same as punchAction does.
|
||||||
|
const row = await prisma.attendance.create({
|
||||||
|
data: {
|
||||||
|
user_id: adminUserId,
|
||||||
|
shift_date: new Date(
|
||||||
|
now.getFullYear(),
|
||||||
|
now.getMonth(),
|
||||||
|
now.getDate(),
|
||||||
|
12,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
leave_type: "work",
|
||||||
|
arrival_time: now,
|
||||||
|
departure_time: null,
|
||||||
|
notes: "dashboard_window_test",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
createdIds.push(row.id);
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: "/api/admin/dashboard",
|
||||||
|
headers: { Authorization: `Bearer ${adminToken}` },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const body = res.json();
|
||||||
|
expect(body.success).toBe(true);
|
||||||
|
|
||||||
|
const attendance = body.data.attendance;
|
||||||
|
expect(attendance).toBeTruthy();
|
||||||
|
const me = attendance.users.find(
|
||||||
|
(u: { user_id: number }) => u.user_id === adminUserId,
|
||||||
|
);
|
||||||
|
expect(me).toBeTruthy();
|
||||||
|
expect(me.status).toBe("in");
|
||||||
|
expect(attendance.present_today).toBeGreaterThanOrEqual(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -51,6 +51,8 @@ describe("issued-order drafts — deferred numbering", () => {
|
|||||||
it("a draft has no number and does NOT consume the sequence", async () => {
|
it("a draft has no number and does NOT consume the sequence", async () => {
|
||||||
const before = (await previewIssuedOrderNumber()).number;
|
const before = (await previewIssuedOrderNumber()).number;
|
||||||
const draft = await createIssuedOrder({}); // defaults to draft
|
const draft = await createIssuedOrder({}); // defaults to draft
|
||||||
|
if ("error" in draft)
|
||||||
|
throw new Error(`createIssuedOrder failed: ${draft.error}`);
|
||||||
issuedOrderIds.push(draft.id);
|
issuedOrderIds.push(draft.id);
|
||||||
expect(draft.status).toBe("draft");
|
expect(draft.status).toBe("draft");
|
||||||
expect(draft.po_number).toBeNull();
|
expect(draft.po_number).toBeNull();
|
||||||
@@ -62,6 +64,8 @@ describe("issued-order drafts — deferred numbering", () => {
|
|||||||
it("finalizing a draft (draft -> sent) assigns the next sequence number", async () => {
|
it("finalizing a draft (draft -> sent) assigns the next sequence number", async () => {
|
||||||
const expected = (await previewIssuedOrderNumber()).number;
|
const expected = (await previewIssuedOrderNumber()).number;
|
||||||
const draft = await createIssuedOrder({});
|
const draft = await createIssuedOrder({});
|
||||||
|
if ("error" in draft)
|
||||||
|
throw new Error(`createIssuedOrder failed: ${draft.error}`);
|
||||||
issuedOrderIds.push(draft.id);
|
issuedOrderIds.push(draft.id);
|
||||||
|
|
||||||
const res = await updateIssuedOrder(draft.id, { status: "sent" });
|
const res = await updateIssuedOrder(draft.id, { status: "sent" });
|
||||||
@@ -77,6 +81,8 @@ describe("issued-order drafts — deferred numbering", () => {
|
|||||||
|
|
||||||
it("finalizing is idempotent — re-finalizing does not re-number", async () => {
|
it("finalizing is idempotent — re-finalizing does not re-number", async () => {
|
||||||
const draft = await createIssuedOrder({});
|
const draft = await createIssuedOrder({});
|
||||||
|
if ("error" in draft)
|
||||||
|
throw new Error(`createIssuedOrder failed: ${draft.error}`);
|
||||||
issuedOrderIds.push(draft.id);
|
issuedOrderIds.push(draft.id);
|
||||||
|
|
||||||
await updateIssuedOrder(draft.id, { status: "sent" });
|
await updateIssuedOrder(draft.id, { status: "sent" });
|
||||||
@@ -104,6 +110,8 @@ describe("issued-order drafts — deferred numbering", () => {
|
|||||||
it("two drafts coexist with null numbers (no unique violation)", async () => {
|
it("two drafts coexist with null numbers (no unique violation)", async () => {
|
||||||
const a = await createIssuedOrder({});
|
const a = await createIssuedOrder({});
|
||||||
const b = await createIssuedOrder({});
|
const b = await createIssuedOrder({});
|
||||||
|
if ("error" in a) throw new Error(`createIssuedOrder failed: ${a.error}`);
|
||||||
|
if ("error" in b) throw new Error(`createIssuedOrder failed: ${b.error}`);
|
||||||
issuedOrderIds.push(a.id, b.id);
|
issuedOrderIds.push(a.id, b.id);
|
||||||
expect(a.po_number).toBeNull();
|
expect(a.po_number).toBeNull();
|
||||||
expect(b.po_number).toBeNull();
|
expect(b.po_number).toBeNull();
|
||||||
@@ -112,6 +120,8 @@ describe("issued-order drafts — deferred numbering", () => {
|
|||||||
it("deleting a draft releases nothing (no number was consumed)", async () => {
|
it("deleting a draft releases nothing (no number was consumed)", async () => {
|
||||||
const before = (await previewIssuedOrderNumber()).number;
|
const before = (await previewIssuedOrderNumber()).number;
|
||||||
const draft = await createIssuedOrder({});
|
const draft = await createIssuedOrder({});
|
||||||
|
if ("error" in draft)
|
||||||
|
throw new Error(`createIssuedOrder failed: ${draft.error}`);
|
||||||
await deleteIssuedOrder(draft.id);
|
await deleteIssuedOrder(draft.id);
|
||||||
const after = (await previewIssuedOrderNumber()).number;
|
const after = (await previewIssuedOrderNumber()).number;
|
||||||
expect(after).toBe(before);
|
expect(after).toBe(before);
|
||||||
|
|||||||
50
src/__tests__/invoice-alerts.test.ts
Normal file
50
src/__tests__/invoice-alerts.test.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { computeAlertWindow } from "../services/invoice-alerts";
|
||||||
|
import { utcMidnightOfLocalDay } from "../utils/date";
|
||||||
|
|
||||||
|
// Pure tests — no DB rows are touched. due_date is @db.Date: Prisma truncates
|
||||||
|
// a Date used in a WHERE filter to its UTC date part, so the alert window
|
||||||
|
// boundaries must be UTC midnights of the LOCAL calendar day. The old code
|
||||||
|
// used local midnights (= 22:00/23:00 UTC of the PREVIOUS day), which shifted
|
||||||
|
// the [today, today+3] due_date window a day early — the "splatnost za 3 dny"
|
||||||
|
// advance alert (due == today+3) could then never fire. All assertions below
|
||||||
|
// are timezone-independent (inputs are built from local components).
|
||||||
|
|
||||||
|
describe("utcMidnightOfLocalDay", () => {
|
||||||
|
it("preserves the LOCAL calendar day shortly after local midnight (the night window)", () => {
|
||||||
|
// 00:30 local on 2098-07-01 — in Prague (UTC+2) this instant is
|
||||||
|
// 2098-06-30T22:30Z, i.e. its UTC date part is the PREVIOUS day, which is
|
||||||
|
// exactly what Prisma would truncate a bare Date to.
|
||||||
|
const justAfterMidnight = new Date(2098, 6, 1, 0, 30, 0);
|
||||||
|
expect(utcMidnightOfLocalDay(justAfterMidnight).getTime()).toBe(
|
||||||
|
Date.UTC(2098, 6, 1),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns UTC midnight of the local day for a mid-day instant", () => {
|
||||||
|
const noon = new Date(2098, 0, 15, 12, 0, 0);
|
||||||
|
expect(utcMidnightOfLocalDay(noon).getTime()).toBe(Date.UTC(2098, 0, 15));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("computeAlertWindow", () => {
|
||||||
|
it("builds [today, today+3] as UTC midnights of the LOCAL day, with matching strings", () => {
|
||||||
|
// 00:30 local — the window where the old local-midnight computation
|
||||||
|
// produced yesterday's UTC date and the whole window slid a day early.
|
||||||
|
const now = new Date(2098, 6, 1, 0, 30, 0);
|
||||||
|
const w = computeAlertWindow(now);
|
||||||
|
|
||||||
|
expect(w.today.getTime()).toBe(Date.UTC(2098, 6, 1));
|
||||||
|
expect(w.in3days.getTime()).toBe(Date.UTC(2098, 6, 4));
|
||||||
|
expect(w.todayStr).toBe("2098-07-01");
|
||||||
|
expect(w.in3daysStr).toBe("2098-07-04");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rolls the +3-day bound over a month boundary", () => {
|
||||||
|
const now = new Date(2098, 0, 30, 8, 0, 0); // 2098-01-30
|
||||||
|
const w = computeAlertWindow(now);
|
||||||
|
|
||||||
|
expect(w.in3days.getTime()).toBe(Date.UTC(2098, 1, 2)); // 2098-02-02
|
||||||
|
expect(w.in3daysStr).toBe("2098-02-02");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
import { describe, it, expect, afterEach } from "vitest";
|
import { describe, it, expect, afterEach, beforeEach } from "vitest";
|
||||||
import { Prisma } from "@prisma/client";
|
import { Prisma } from "@prisma/client";
|
||||||
import prisma from "../config/database";
|
import prisma from "../config/database";
|
||||||
import {
|
import {
|
||||||
invoiceTotalWithVat,
|
invoiceTotalWithVat,
|
||||||
createInvoice,
|
createInvoice,
|
||||||
updateInvoice,
|
updateInvoice,
|
||||||
|
listInvoices,
|
||||||
|
getInvoiceListTotals,
|
||||||
} from "../services/invoices.service";
|
} from "../services/invoices.service";
|
||||||
import { UpdateInvoiceSchema } from "../schemas/invoices.schema";
|
import { UpdateInvoiceSchema } from "../schemas/invoices.schema";
|
||||||
|
|
||||||
@@ -179,3 +181,83 @@ describe("updateInvoice — billing_text round-trips through UpdateInvoiceSchema
|
|||||||
expect(row?.billing_text).toBeNull();
|
expect(row?.billing_text).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
/* Month filter boundaries — issue_date is @db.Date */
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
// issue_date is @db.Date: Prisma compares a Date filter by its UTC date part.
|
||||||
|
// The old LOCAL-midnight month bounds (= 22:00/23:00 UTC of the previous day)
|
||||||
|
// shifted the window a day back — the list/totals included the previous
|
||||||
|
// month's last day and DROPPED invoices issued on the selected month's LAST
|
||||||
|
// day. buildInvoiceWhere is shared by listInvoices AND getInvoiceListTotals,
|
||||||
|
// so both are exercised. Fixtures use the test-only "TST" currency + far-future
|
||||||
|
// 2098 dates so totals can be asserted exactly without colliding with other
|
||||||
|
// suites' rows.
|
||||||
|
|
||||||
|
describe("listInvoices / getInvoiceListTotals — month filter (@db.Date boundaries)", () => {
|
||||||
|
const cleanup = async () => {
|
||||||
|
// invoice_items cascade on invoice delete.
|
||||||
|
await prisma.invoices.deleteMany({ where: { currency: "TST" } });
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(cleanup);
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
const listParams = {
|
||||||
|
page: 1,
|
||||||
|
limit: 100,
|
||||||
|
skip: 0,
|
||||||
|
sort: "id",
|
||||||
|
order: "asc" as const,
|
||||||
|
search: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
async function createBoundaryFixtures() {
|
||||||
|
// Drafts: no invoice number is consumed, but buildInvoiceWhere has no
|
||||||
|
// status filter so they appear in the list/totals like any invoice.
|
||||||
|
const lastDayJan = await createInvoice({
|
||||||
|
status: "draft",
|
||||||
|
issue_date: "2098-01-31",
|
||||||
|
currency: "TST",
|
||||||
|
vat_rate: 21,
|
||||||
|
items: [{ quantity: 1, unit_price: 100, vat_rate: 21 }], // total 121
|
||||||
|
});
|
||||||
|
const firstDayFeb = await createInvoice({
|
||||||
|
status: "draft",
|
||||||
|
issue_date: "2098-02-01",
|
||||||
|
currency: "TST",
|
||||||
|
vat_rate: 21,
|
||||||
|
items: [{ quantity: 1, unit_price: 200, vat_rate: 21 }], // total 242
|
||||||
|
});
|
||||||
|
return { lastDayJan, firstDayFeb };
|
||||||
|
}
|
||||||
|
|
||||||
|
it("a month's list contains its own LAST day and not the neighbor month's first day", async () => {
|
||||||
|
const { lastDayJan, firstDayFeb } = await createBoundaryFixtures();
|
||||||
|
|
||||||
|
const jan = await listInvoices({ ...listParams, month: 1, year: 2098 });
|
||||||
|
const janIds = jan.data.map((i) => i.id);
|
||||||
|
expect(janIds).toContain(lastDayJan.id);
|
||||||
|
expect(janIds).not.toContain(firstDayFeb.id);
|
||||||
|
|
||||||
|
const feb = await listInvoices({ ...listParams, month: 2, year: 2098 });
|
||||||
|
const febIds = feb.data.map((i) => i.id);
|
||||||
|
expect(febIds).toContain(firstDayFeb.id);
|
||||||
|
expect(febIds).not.toContain(lastDayJan.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("per-currency totals include the month's last day exactly once", async () => {
|
||||||
|
await createBoundaryFixtures();
|
||||||
|
|
||||||
|
const jan = await getInvoiceListTotals({ month: 1, year: 2098 });
|
||||||
|
const janTst = jan.totals.find((t) => t.currency === "TST");
|
||||||
|
// 100 + 21 % VAT — proves the Jan 31 invoice is counted and the
|
||||||
|
// Feb 1 invoice (242) is NOT pulled into January.
|
||||||
|
expect(janTst?.amount).toBe(121);
|
||||||
|
|
||||||
|
const feb = await getInvoiceListTotals({ month: 2, year: 2098 });
|
||||||
|
const febTst = feb.totals.find((t) => t.currency === "TST");
|
||||||
|
expect(febTst?.amount).toBe(242);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { describe, it, expect, afterEach } from "vitest";
|
import { describe, it, expect, beforeAll, afterAll, afterEach } from "vitest";
|
||||||
|
import Fastify from "fastify";
|
||||||
|
import jwt from "jsonwebtoken";
|
||||||
import prisma from "../config/database";
|
import prisma from "../config/database";
|
||||||
|
import { config } from "../config/env";
|
||||||
import {
|
import {
|
||||||
generateIssuedOrderNumber,
|
generateIssuedOrderNumber,
|
||||||
previewIssuedOrderNumber,
|
previewIssuedOrderNumber,
|
||||||
@@ -13,7 +16,9 @@ import {
|
|||||||
listIssuedOrders,
|
listIssuedOrders,
|
||||||
updateIssuedOrder,
|
updateIssuedOrder,
|
||||||
deleteIssuedOrder,
|
deleteIssuedOrder,
|
||||||
|
type IssuedOrderInput,
|
||||||
} from "../services/issued-orders.service";
|
} from "../services/issued-orders.service";
|
||||||
|
import issuedOrdersRoutes from "../routes/admin/issued-orders";
|
||||||
import { renderIssuedOrderHtml } from "../routes/admin/issued-orders-pdf";
|
import { renderIssuedOrderHtml } from "../routes/admin/issued-orders-pdf";
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
@@ -53,12 +58,12 @@ describe("issued-order numbering", () => {
|
|||||||
describe("CreateIssuedOrderSchema", () => {
|
describe("CreateIssuedOrderSchema", () => {
|
||||||
it("coerces string form numbers and rejects an out-of-range VAT", () => {
|
it("coerces string form numbers and rejects an out-of-range VAT", () => {
|
||||||
const ok = CreateIssuedOrderSchema.safeParse({
|
const ok = CreateIssuedOrderSchema.safeParse({
|
||||||
customer_id: "5",
|
supplier_id: "5",
|
||||||
vat_rate: "21",
|
vat_rate: "21",
|
||||||
items: [{ description: "X", quantity: "2", unit_price: "100" }],
|
items: [{ description: "X", quantity: "2", unit_price: "100" }],
|
||||||
});
|
});
|
||||||
expect(ok.success).toBe(true);
|
expect(ok.success).toBe(true);
|
||||||
if (ok.success) expect(ok.data.customer_id).toBe(5);
|
if (ok.success) expect(ok.data.supplier_id).toBe(5);
|
||||||
|
|
||||||
const bad = CreateIssuedOrderSchema.safeParse({ vat_rate: "200" });
|
const bad = CreateIssuedOrderSchema.safeParse({ vat_rate: "200" });
|
||||||
expect(bad.success).toBe(false);
|
expect(bad.success).toBe(false);
|
||||||
@@ -66,23 +71,38 @@ describe("CreateIssuedOrderSchema", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const createdIds: number[] = [];
|
const createdIds: number[] = [];
|
||||||
const createdCustomerIds: number[] = [];
|
const createdSupplierIds: number[] = [];
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
|
// Orders first — the supplier FK is onDelete Restrict.
|
||||||
for (const id of createdIds)
|
for (const id of createdIds)
|
||||||
await prisma.issued_orders.deleteMany({ where: { id } });
|
await prisma.issued_orders.deleteMany({ where: { id } });
|
||||||
for (const id of createdCustomerIds)
|
for (const id of createdSupplierIds)
|
||||||
await prisma.customers.deleteMany({ where: { id } });
|
await prisma.sklad_suppliers.deleteMany({ where: { id } });
|
||||||
createdIds.length = 0;
|
createdIds.length = 0;
|
||||||
createdCustomerIds.length = 0;
|
createdSupplierIds.length = 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
async function makeCustomer() {
|
async function makeSupplier(
|
||||||
const c = await prisma.customers.create({
|
data: Partial<{ name: string; ico: string; is_active: boolean }> = {},
|
||||||
data: { name: "Dodavatel s.r.o." },
|
) {
|
||||||
|
const s = await prisma.sklad_suppliers.create({
|
||||||
|
data: {
|
||||||
|
name: data.name ?? "io_test_Dodavatel s.r.o.",
|
||||||
|
ico: data.ico ?? null,
|
||||||
|
is_active: data.is_active ?? true,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
createdCustomerIds.push(c.id);
|
createdSupplierIds.push(s.id);
|
||||||
return c;
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** createIssuedOrder + narrow the supplier-validation union + track cleanup. */
|
||||||
|
async function mkIssued(input: IssuedOrderInput = {}) {
|
||||||
|
const res = await createIssuedOrder(input);
|
||||||
|
if ("error" in res) throw new Error(`createIssuedOrder failed: ${res.error}`);
|
||||||
|
createdIds.push(res.id);
|
||||||
|
return res;
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("computeIssuedOrderTotals (NET + VAT-on-top)", () => {
|
describe("computeIssuedOrderTotals (NET + VAT-on-top)", () => {
|
||||||
@@ -120,18 +140,18 @@ describe("computeIssuedOrderTotals (NET + VAT-on-top)", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("createIssuedOrder", () => {
|
describe("createIssuedOrder", () => {
|
||||||
it("defaults to draft with NO PO number, stores items", async () => {
|
it("defaults to draft with NO PO number, stores supplier_id + items", async () => {
|
||||||
const c = await makeCustomer();
|
const s = await makeSupplier();
|
||||||
const order = await createIssuedOrder({
|
const order = await mkIssued({
|
||||||
customer_id: c.id,
|
supplier_id: s.id,
|
||||||
items: [
|
items: [
|
||||||
{ description: "Materiál", quantity: 2, unit_price: 100, vat_rate: 21 },
|
{ description: "Materiál", quantity: 2, unit_price: 100, vat_rate: 21 },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
createdIds.push(order.id);
|
|
||||||
// Deferred numbering: a draft carries no number.
|
// Deferred numbering: a draft carries no number.
|
||||||
expect(order.po_number).toBeNull();
|
expect(order.po_number).toBeNull();
|
||||||
expect(order.status).toBe("draft");
|
expect(order.status).toBe("draft");
|
||||||
|
expect(order.supplier_id).toBe(s.id);
|
||||||
const items = await prisma.issued_order_items.findMany({
|
const items = await prisma.issued_order_items.findMany({
|
||||||
where: { issued_order_id: order.id },
|
where: { issued_order_id: order.id },
|
||||||
});
|
});
|
||||||
@@ -141,17 +161,37 @@ describe("createIssuedOrder", () => {
|
|||||||
|
|
||||||
it("numbers immediately when created already-finalized (status sent)", async () => {
|
it("numbers immediately when created already-finalized (status sent)", async () => {
|
||||||
const before = (await previewIssuedOrderNumber()).number;
|
const before = (await previewIssuedOrderNumber()).number;
|
||||||
const order = await createIssuedOrder({ status: "sent" });
|
const order = await mkIssued({ status: "sent" });
|
||||||
createdIds.push(order.id);
|
|
||||||
expect(order.po_number).toBe(before);
|
expect(order.po_number).toBe(before);
|
||||||
expect(order.status).toBe("sent");
|
expect(order.status).toBe("sent");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("rejects a nonexistent supplier_id instead of throwing P2003", async () => {
|
||||||
|
const res = await createIssuedOrder({ supplier_id: 99999999 });
|
||||||
|
expect("error" in res && res.error).toBe("supplier_not_found");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("persists order_text on create, updates and clears it on update", async () => {
|
||||||
|
const order = await mkIssued({ order_text: "Objednáváme dle smlouvy:" });
|
||||||
|
let row = await prisma.issued_orders.findUnique({
|
||||||
|
where: { id: order.id },
|
||||||
|
});
|
||||||
|
expect(row!.order_text).toBe("Objednáváme dle smlouvy:");
|
||||||
|
|
||||||
|
await updateIssuedOrder(order.id, { order_text: "Jiný text:" });
|
||||||
|
row = await prisma.issued_orders.findUnique({ where: { id: order.id } });
|
||||||
|
expect(row!.order_text).toBe("Jiný text:");
|
||||||
|
|
||||||
|
// null clears back to the PDF default.
|
||||||
|
await updateIssuedOrder(order.id, { order_text: null });
|
||||||
|
row = await prisma.issued_orders.findUnique({ where: { id: order.id } });
|
||||||
|
expect(row!.order_text).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("updateIssuedOrder status transitions", () => {
|
describe("updateIssuedOrder status transitions", () => {
|
||||||
it("allows draft -> sent and rejects draft -> completed", async () => {
|
it("allows draft -> sent and rejects draft -> completed", async () => {
|
||||||
const order = await createIssuedOrder({});
|
const order = await mkIssued({});
|
||||||
createdIds.push(order.id);
|
|
||||||
const ok = await updateIssuedOrder(order.id, { status: "sent" });
|
const ok = await updateIssuedOrder(order.id, { status: "sent" });
|
||||||
expect("error" in ok).toBe(false);
|
expect("error" in ok).toBe(false);
|
||||||
const bad = await updateIssuedOrder(order.id, { status: "completed" });
|
const bad = await updateIssuedOrder(order.id, { status: "completed" });
|
||||||
@@ -159,10 +199,9 @@ describe("updateIssuedOrder status transitions", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("locks items once confirmed", async () => {
|
it("locks items once confirmed", async () => {
|
||||||
const order = await createIssuedOrder({
|
const order = await mkIssued({
|
||||||
items: [{ description: "A", quantity: 1, unit_price: 10 }],
|
items: [{ description: "A", quantity: 1, unit_price: 10 }],
|
||||||
});
|
});
|
||||||
createdIds.push(order.id);
|
|
||||||
await updateIssuedOrder(order.id, { status: "sent" });
|
await updateIssuedOrder(order.id, { status: "sent" });
|
||||||
await updateIssuedOrder(order.id, { status: "confirmed" });
|
await updateIssuedOrder(order.id, { status: "confirmed" });
|
||||||
await updateIssuedOrder(order.id, {
|
await updateIssuedOrder(order.id, {
|
||||||
@@ -174,22 +213,28 @@ describe("updateIssuedOrder status transitions", () => {
|
|||||||
expect(items.length).toBe(1);
|
expect(items.length).toBe(1);
|
||||||
expect(items[0].description).toBe("A");
|
expect(items[0].description).toBe("A");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("rejects a nonexistent supplier_id on update", async () => {
|
||||||
|
const order = await mkIssued({});
|
||||||
|
const res = await updateIssuedOrder(order.id, { supplier_id: 99999999 });
|
||||||
|
expect("error" in res && res.error).toBe("supplier_not_found");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("getIssuedOrder", () => {
|
describe("getIssuedOrder", () => {
|
||||||
it("returns customer_name and valid_transitions", async () => {
|
it("returns supplier, supplier_name and valid_transitions", async () => {
|
||||||
const c = await makeCustomer();
|
const s = await makeSupplier();
|
||||||
const order = await createIssuedOrder({ customer_id: c.id });
|
const order = await mkIssued({ supplier_id: s.id });
|
||||||
createdIds.push(order.id);
|
|
||||||
const detail = await getIssuedOrder(order.id);
|
const detail = await getIssuedOrder(order.id);
|
||||||
expect(detail?.customer_name).toBe("Dodavatel s.r.o.");
|
expect(detail?.supplier_name).toBe("io_test_Dodavatel s.r.o.");
|
||||||
|
expect(detail?.supplier?.id).toBe(s.id);
|
||||||
expect(detail?.valid_transitions).toContain("sent");
|
expect(detail?.valid_transitions).toContain("sent");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("deleteIssuedOrder", () => {
|
describe("deleteIssuedOrder", () => {
|
||||||
it("deletes, cascades items, frees the latest number", async () => {
|
it("deletes, cascades items, frees the latest number", async () => {
|
||||||
const order = await createIssuedOrder({
|
const order = await mkIssued({
|
||||||
items: [{ description: "X", quantity: 1, unit_price: 1 }],
|
items: [{ description: "X", quantity: 1, unit_price: 1 }],
|
||||||
});
|
});
|
||||||
// Finalize so the order has a real (consumed) number to free on delete.
|
// Finalize so the order has a real (consumed) number to free on delete.
|
||||||
@@ -215,8 +260,7 @@ describe("deleteIssuedOrder", () => {
|
|||||||
// Allocate a real prior-year number (consumes that year's sequence: 0 -> 1)
|
// Allocate a real prior-year number (consumes that year's sequence: 0 -> 1)
|
||||||
// so the PO number matches the prior year's current highest.
|
// so the PO number matches the prior year's current highest.
|
||||||
const { number: poNumber } = await generateIssuedOrderNumber(priorYear);
|
const { number: poNumber } = await generateIssuedOrderNumber(priorYear);
|
||||||
const order = await createIssuedOrder({ po_number: poNumber });
|
const order = await mkIssued({ po_number: poNumber });
|
||||||
createdIds.push(order.id);
|
|
||||||
// Backdate creation into the prior year so delete derives that year.
|
// Backdate creation into the prior year so delete derives that year.
|
||||||
await prisma.issued_orders.update({
|
await prisma.issued_orders.update({
|
||||||
where: { id: order.id },
|
where: { id: order.id },
|
||||||
@@ -234,9 +278,8 @@ describe("deleteIssuedOrder", () => {
|
|||||||
|
|
||||||
describe("listIssuedOrders month filter", () => {
|
describe("listIssuedOrders month filter", () => {
|
||||||
it("returns only orders whose order_date is in the given month", async () => {
|
it("returns only orders whose order_date is in the given month", async () => {
|
||||||
const inMonth = await createIssuedOrder({ order_date: "2026-03-15" });
|
const inMonth = await mkIssued({ order_date: "2026-03-15" });
|
||||||
const outMonth = await createIssuedOrder({ order_date: "2026-04-15" });
|
const outMonth = await mkIssued({ order_date: "2026-04-15" });
|
||||||
createdIds.push(inMonth.id, outMonth.id);
|
|
||||||
const res = await listIssuedOrders({
|
const res = await listIssuedOrders({
|
||||||
page: 1,
|
page: 1,
|
||||||
limit: 50,
|
limit: 50,
|
||||||
@@ -252,6 +295,142 @@ describe("listIssuedOrders month filter", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
/* Route-level: suppliers lookup endpoint + supplier validation */
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
let app: ReturnType<typeof Fastify> | null = null;
|
||||||
|
let adminToken = "";
|
||||||
|
let noPermToken = "";
|
||||||
|
let noPermUserId = 0;
|
||||||
|
let noPermRoleId = 0;
|
||||||
|
|
||||||
|
function generateToken(user: {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
roleName: string | null;
|
||||||
|
}): string {
|
||||||
|
return jwt.sign(
|
||||||
|
{ sub: user.id, username: user.username, role: user.roleName },
|
||||||
|
config.jwt.secret,
|
||||||
|
{ expiresIn: "15m" },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
app = Fastify({ logger: false });
|
||||||
|
await app.register(issuedOrdersRoutes, {
|
||||||
|
prefix: "/api/admin/issued-orders",
|
||||||
|
});
|
||||||
|
|
||||||
|
const admin = await prisma.users.findFirst({
|
||||||
|
where: { roles: { name: "admin" } },
|
||||||
|
});
|
||||||
|
if (!admin) throw new Error("Test setup: admin user not found");
|
||||||
|
adminToken = generateToken({
|
||||||
|
id: admin.id,
|
||||||
|
username: admin.username,
|
||||||
|
roleName: "admin",
|
||||||
|
});
|
||||||
|
|
||||||
|
// A role with NO orders permissions, to prove the lookup endpoint is gated.
|
||||||
|
const noPermRole = await prisma.roles.create({
|
||||||
|
data: {
|
||||||
|
name: "io_test_no_orders",
|
||||||
|
display_name: "Test No Orders",
|
||||||
|
description: "Test role without orders permissions",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
noPermRoleId = noPermRole.id;
|
||||||
|
const noPermUser = await prisma.users.create({
|
||||||
|
data: {
|
||||||
|
username: "io_test_noperm",
|
||||||
|
email: "io_test_noperm@test.local",
|
||||||
|
password_hash:
|
||||||
|
"$2a$12$LJ3m4ys3Lg4oLBFnYP2amuPBzJnJBbGzCl5Y6X9Y8r0q5.s3L6OyO",
|
||||||
|
first_name: "No",
|
||||||
|
last_name: "Orders",
|
||||||
|
is_active: true,
|
||||||
|
role_id: noPermRole.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
noPermUserId = noPermUser.id;
|
||||||
|
noPermToken = generateToken({
|
||||||
|
id: noPermUser.id,
|
||||||
|
username: noPermUser.username,
|
||||||
|
roleName: noPermRole.name,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
if (noPermUserId)
|
||||||
|
await prisma.users.delete({ where: { id: noPermUserId } }).catch(() => {});
|
||||||
|
if (noPermRoleId)
|
||||||
|
await prisma.roles.delete({ where: { id: noPermRoleId } }).catch(() => {});
|
||||||
|
if (app) await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("GET /api/admin/issued-orders/suppliers", () => {
|
||||||
|
it("returns active suppliers only (with the picker fields)", async () => {
|
||||||
|
const active = await makeSupplier({
|
||||||
|
name: "io_test_Aktivní dodavatel",
|
||||||
|
ico: "12345678",
|
||||||
|
});
|
||||||
|
const inactive = await makeSupplier({
|
||||||
|
name: "io_test_Neaktivní dodavatel",
|
||||||
|
is_active: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await app!.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: "/api/admin/issued-orders/suppliers",
|
||||||
|
headers: { Authorization: `Bearer ${adminToken}` },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const body = res.json();
|
||||||
|
expect(body.success).toBe(true);
|
||||||
|
const ids = body.data.map((s: { id: number }) => s.id);
|
||||||
|
expect(ids).toContain(active.id);
|
||||||
|
expect(ids).not.toContain(inactive.id);
|
||||||
|
const row = body.data.find((s: { id: number }) => s.id === active.id);
|
||||||
|
expect(row.name).toBe("io_test_Aktivní dodavatel");
|
||||||
|
expect(row.ico).toBe("12345678");
|
||||||
|
// Lightweight lookup shape — all picker fields present.
|
||||||
|
expect(Object.keys(row).sort()).toEqual(
|
||||||
|
["address", "dic", "email", "ico", "id", "name", "phone"].sort(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is denied (403) to a user with no orders permissions", async () => {
|
||||||
|
const res = await app!.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: "/api/admin/issued-orders/suppliers",
|
||||||
|
headers: { Authorization: `Bearer ${noPermToken}` },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(403);
|
||||||
|
expect(res.json().success).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("POST /api/admin/issued-orders supplier validation", () => {
|
||||||
|
it("returns 400 (not a P2003 500) for a nonexistent supplier_id", async () => {
|
||||||
|
const res = await app!.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/admin/issued-orders",
|
||||||
|
headers: { Authorization: `Bearer ${adminToken}` },
|
||||||
|
payload: { supplier_id: 99999999 },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
const body = res.json();
|
||||||
|
expect(body.success).toBe(false);
|
||||||
|
expect(body.error).toBe("Dodavatel nenalezen");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
/* PDF render */
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
|
||||||
describe("renderIssuedOrderHtml", () => {
|
describe("renderIssuedOrderHtml", () => {
|
||||||
const order = {
|
const order = {
|
||||||
po_number: "26720001",
|
po_number: "26720001",
|
||||||
@@ -278,11 +457,19 @@ describe("renderIssuedOrderHtml", () => {
|
|||||||
|
|
||||||
const issuer = { name: "Jan Novák" };
|
const issuer = { name: "Jan Novák" };
|
||||||
|
|
||||||
|
// sklad_suppliers shape: single Text address blob + ico/dic columns.
|
||||||
|
const supplier = {
|
||||||
|
name: "Dodavatel s.r.o.",
|
||||||
|
ico: "12345678",
|
||||||
|
dic: "CZ12345678",
|
||||||
|
address: "Průmyslová 5\n190 00 Praha",
|
||||||
|
};
|
||||||
|
|
||||||
it("renders the PO number, items, and both party names (PO direction)", () => {
|
it("renders the PO number, items, and both party names (PO direction)", () => {
|
||||||
const html = renderIssuedOrderHtml(
|
const html = renderIssuedOrderHtml(
|
||||||
order,
|
order,
|
||||||
items,
|
items,
|
||||||
{ name: "Dodavatel s.r.o." },
|
supplier,
|
||||||
{ company_name: "Naše firma" },
|
{ company_name: "Naše firma" },
|
||||||
"cs",
|
"cs",
|
||||||
issuer,
|
issuer,
|
||||||
@@ -291,20 +478,104 @@ describe("renderIssuedOrderHtml", () => {
|
|||||||
expect(html).toContain("Materiál");
|
expect(html).toContain("Materiál");
|
||||||
// Optional item sub-line.
|
// Optional item sub-line.
|
||||||
expect(html).toContain("Detailní popis položky");
|
expect(html).toContain("Detailní popis položky");
|
||||||
// PO direction: the customer record is the Dodavatel (supplier), our
|
// PO direction: the sklad_suppliers record is the Dodavatel (supplier),
|
||||||
// company is the Odběratel (buyer).
|
// our company is the Odběratel (buyer).
|
||||||
expect(html).toContain("Dodavatel s.r.o.");
|
expect(html).toContain("Dodavatel s.r.o.");
|
||||||
expect(html).toContain("Naše firma");
|
expect(html).toContain("Naše firma");
|
||||||
expect(html).toContain("Dodavatel");
|
expect(html).toContain("Dodavatel");
|
||||||
expect(html).toContain("Odběratel");
|
expect(html).toContain("Odběratel");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("renders the supplier address (split on newlines) plus IČO and DIČ", () => {
|
||||||
|
const html = renderIssuedOrderHtml(
|
||||||
|
order,
|
||||||
|
items,
|
||||||
|
supplier,
|
||||||
|
null,
|
||||||
|
"cs",
|
||||||
|
issuer,
|
||||||
|
);
|
||||||
|
// The single Text address blob becomes one address line per newline.
|
||||||
|
expect(html).toContain('<div class="address-line">Průmyslová 5</div>');
|
||||||
|
expect(html).toContain('<div class="address-line">190 00 Praha</div>');
|
||||||
|
expect(html).toContain("IČ: 12345678");
|
||||||
|
expect(html).toContain("DIČ: CZ12345678");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders a no-newline address as a single line and skips missing IČO/DIČ", () => {
|
||||||
|
const html = renderIssuedOrderHtml(
|
||||||
|
order,
|
||||||
|
items,
|
||||||
|
{ name: "Jednořádkový", address: "Ulice 1, 100 00 Praha" },
|
||||||
|
null,
|
||||||
|
"cs",
|
||||||
|
issuer,
|
||||||
|
);
|
||||||
|
expect(html).toContain(
|
||||||
|
'<div class="address-line">Ulice 1, 100 00 Praha</div>',
|
||||||
|
);
|
||||||
|
expect(html).not.toContain("IČ: ");
|
||||||
|
expect(html).not.toContain("DIČ: ");
|
||||||
|
});
|
||||||
|
|
||||||
it("strips script tags from notes", () => {
|
it("strips script tags from notes", () => {
|
||||||
const html = renderIssuedOrderHtml(order, items, null, null, "cs", issuer);
|
const html = renderIssuedOrderHtml(order, items, null, null, "cs", issuer);
|
||||||
expect(html).not.toContain("<script>");
|
expect(html).not.toContain("<script>");
|
||||||
expect(html).not.toContain("alert(1)");
|
expect(html).not.toContain("alert(1)");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("renders the custom order_text heading when set, default when not", () => {
|
||||||
|
const custom = renderIssuedOrderHtml(
|
||||||
|
{ ...order, order_text: "Objednáváme dle nabídky č. 123:" },
|
||||||
|
items,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
"cs",
|
||||||
|
issuer,
|
||||||
|
);
|
||||||
|
expect(custom).toContain("Objednáváme dle nabídky č. 123:");
|
||||||
|
expect(custom).not.toContain("Objednáváme si u Vás:");
|
||||||
|
|
||||||
|
const fallback = renderIssuedOrderHtml(
|
||||||
|
order,
|
||||||
|
items,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
"cs",
|
||||||
|
issuer,
|
||||||
|
);
|
||||||
|
expect(fallback).toContain("Objednáváme si u Vás:");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("with VAT: keeps the VAT columns and the DPH cell holds ONLY the line VAT", () => {
|
||||||
|
const html = renderIssuedOrderHtml(order, items, null, null, "cs", issuer);
|
||||||
|
expect(html).toContain("%DPH");
|
||||||
|
expect(html).toContain(">DPH<");
|
||||||
|
// 2 × 100 @ 21 %: DPH cell = 42,00 (VAT only), Celkem cell = 242,00.
|
||||||
|
expect(html).toContain('<td class="right">42,00</td>');
|
||||||
|
expect(html).toContain('<td class="right total-cell">242,00</td>');
|
||||||
|
expect(html).not.toContain("Celkem bez DPH");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("without VAT: hides the VAT columns and labels the total 'Celkem bez DPH'", () => {
|
||||||
|
const html = renderIssuedOrderHtml(
|
||||||
|
{ ...order, apply_vat: false },
|
||||||
|
items,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
"cs",
|
||||||
|
issuer,
|
||||||
|
);
|
||||||
|
expect(html).not.toContain("%DPH");
|
||||||
|
expect(html).not.toContain(">DPH<");
|
||||||
|
// No per-line VAT cell, line total = netto.
|
||||||
|
expect(html).not.toContain('<td class="right">42,00</td>');
|
||||||
|
expect(html).toContain('<td class="right total-cell">200,00</td>');
|
||||||
|
expect(html).toContain("Celkem bez DPH");
|
||||||
|
// The subtotal detail row is dropped (it would duplicate the grand total).
|
||||||
|
expect(html).not.toContain("Mezisoučet");
|
||||||
|
});
|
||||||
|
|
||||||
it("footer shows the logged-in user's name, no e-mail, no Schválil column", () => {
|
it("footer shows the logged-in user's name, no e-mail, no Schválil column", () => {
|
||||||
const html = renderIssuedOrderHtml(order, items, null, null, "cs", issuer);
|
const html = renderIssuedOrderHtml(order, items, null, null, "cs", issuer);
|
||||||
// Footer: Vystavil <name> from authData. No e-mail line.
|
// Footer: Vystavil <name> from authData. No e-mail line.
|
||||||
|
|||||||
@@ -165,6 +165,7 @@ describe("getIssuedOrderTotals (issued orders) per-currency aggregation", () =>
|
|||||||
{ description: "X", quantity: qty, unit_price: price, vat_rate: 21 },
|
{ description: "X", quantity: qty, unit_price: price, vat_rate: 21 },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
if ("error" in o) throw new Error(`createIssuedOrder failed: ${o.error}`);
|
||||||
createdIssuedIds.push(o.id);
|
createdIssuedIds.push(o.id);
|
||||||
return o.id;
|
return o.id;
|
||||||
};
|
};
|
||||||
@@ -195,6 +196,8 @@ describe("getIssuedOrderTotals (issued orders) per-currency aggregation", () =>
|
|||||||
{ description: "X", quantity: 1, unit_price: 1000, vat_rate: 21 },
|
{ description: "X", quantity: 1, unit_price: 1000, vat_rate: 21 },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
if ("error" in draft)
|
||||||
|
throw new Error(`createIssuedOrder failed: ${draft.error}`);
|
||||||
createdIssuedIds.push(draft.id);
|
createdIssuedIds.push(draft.id);
|
||||||
|
|
||||||
const sent = await createIssuedOrder({
|
const sent = await createIssuedOrder({
|
||||||
@@ -207,6 +210,8 @@ describe("getIssuedOrderTotals (issued orders) per-currency aggregation", () =>
|
|||||||
{ description: "X", quantity: 1, unit_price: 1000, vat_rate: 21 },
|
{ description: "X", quantity: 1, unit_price: 1000, vat_rate: 21 },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
if ("error" in sent)
|
||||||
|
throw new Error(`createIssuedOrder failed: ${sent.error}`);
|
||||||
createdIssuedIds.push(sent.id);
|
createdIssuedIds.push(sent.id);
|
||||||
|
|
||||||
const next = await createIssuedOrder({
|
const next = await createIssuedOrder({
|
||||||
@@ -218,6 +223,8 @@ describe("getIssuedOrderTotals (issued orders) per-currency aggregation", () =>
|
|||||||
{ description: "X", quantity: 1, unit_price: 1000, vat_rate: 21 },
|
{ description: "X", quantity: 1, unit_price: 1000, vat_rate: 21 },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
if ("error" in next)
|
||||||
|
throw new Error(`createIssuedOrder failed: ${next.error}`);
|
||||||
createdIssuedIds.push(next.id);
|
createdIssuedIds.push(next.id);
|
||||||
|
|
||||||
// Whole target month: both count -> 2 x 1210 = 2420.
|
// Whole target month: both count -> 2 x 1210 = 2420.
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
type ReactNode,
|
type ReactNode,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { setSessionExpired, setTokenGetter, setRefreshFn } from "../utils/api";
|
import { setSessionExpired, setTokenGetter, setRefreshFn } from "../utils/api";
|
||||||
|
import { queryClient } from "../lib/queryClient";
|
||||||
|
|
||||||
const API_BASE = "/api/admin";
|
const API_BASE = "/api/admin";
|
||||||
|
|
||||||
@@ -232,6 +233,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
remember,
|
remember,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
// Query keys are user-agnostic (["dashboard"], ["attendance"], …),
|
||||||
|
// so anything cached from a previously logged-in user would be
|
||||||
|
// served to this one — drop the whole cache on every login.
|
||||||
|
queryClient.clear();
|
||||||
setAccessTokenFn(data.data.access_token, data.data.expires_in);
|
setAccessTokenFn(data.data.access_token, data.data.expires_in);
|
||||||
setUser(mapUser(data.data.user));
|
setUser(mapUser(data.data.user));
|
||||||
cachedUserRef.current = mapUser(data.data.user);
|
cachedUserRef.current = mapUser(data.data.user);
|
||||||
@@ -288,6 +293,9 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
);
|
);
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
|
// Same reason as in login(): the cache may hold the previous
|
||||||
|
// user's data under user-agnostic keys.
|
||||||
|
queryClient.clear();
|
||||||
setAccessTokenFn(data.data.access_token, data.data.expires_in);
|
setAccessTokenFn(data.data.access_token, data.data.expires_in);
|
||||||
setUser(mapUser(data.data.user));
|
setUser(mapUser(data.data.user));
|
||||||
cachedUserRef.current = mapUser(data.data.user);
|
cachedUserRef.current = mapUser(data.data.user);
|
||||||
@@ -327,6 +335,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
clearTimeout(refreshTimeoutRef.current);
|
clearTimeout(refreshTimeoutRef.current);
|
||||||
refreshTimeoutRef.current = null;
|
refreshTimeoutRef.current = null;
|
||||||
}
|
}
|
||||||
|
// The React Query cache outlives the session — without this, the next
|
||||||
|
// user to log in on this browser sees the previous user's cached
|
||||||
|
// dashboards/lists until each query refetches.
|
||||||
|
queryClient.clear();
|
||||||
}
|
}
|
||||||
}, [getAccessTokenFn]);
|
}, [getAccessTokenFn]);
|
||||||
|
|
||||||
|
|||||||
@@ -1048,6 +1048,10 @@ export default function useAttendanceAdmin({ alert }: AlertContext) {
|
|||||||
if (result.success) {
|
if (result.success) {
|
||||||
setShowCreateModal(false);
|
setShowCreateModal(false);
|
||||||
queryClient.invalidateQueries({ queryKey: ["attendance"] });
|
queryClient.invalidateQueries({ queryKey: ["attendance"] });
|
||||||
|
// The dashboard embeds attendance (Přítomní dnes / Docházka dnes /
|
||||||
|
// punch-button state) — without this it serves a stale cache for up
|
||||||
|
// to its staleTime after records change here.
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["dashboard"] });
|
||||||
await fetchData(false);
|
await fetchData(false);
|
||||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||||
alert.success(
|
alert.success(
|
||||||
@@ -1120,6 +1124,10 @@ export default function useAttendanceAdmin({ alert }: AlertContext) {
|
|||||||
if (result.success) {
|
if (result.success) {
|
||||||
setShowBulkModal(false);
|
setShowBulkModal(false);
|
||||||
queryClient.invalidateQueries({ queryKey: ["attendance"] });
|
queryClient.invalidateQueries({ queryKey: ["attendance"] });
|
||||||
|
// The dashboard embeds attendance (Přítomní dnes / Docházka dnes /
|
||||||
|
// punch-button state) — without this it serves a stale cache for up
|
||||||
|
// to its staleTime after records change here.
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["dashboard"] });
|
||||||
await fetchData(false);
|
await fetchData(false);
|
||||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||||
alert.success(
|
alert.success(
|
||||||
@@ -1255,6 +1263,10 @@ export default function useAttendanceAdmin({ alert }: AlertContext) {
|
|||||||
if (result.success) {
|
if (result.success) {
|
||||||
setShowEditModal(false);
|
setShowEditModal(false);
|
||||||
queryClient.invalidateQueries({ queryKey: ["attendance"] });
|
queryClient.invalidateQueries({ queryKey: ["attendance"] });
|
||||||
|
// The dashboard embeds attendance (Přítomní dnes / Docházka dnes /
|
||||||
|
// punch-button state) — without this it serves a stale cache for up
|
||||||
|
// to its staleTime after records change here.
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["dashboard"] });
|
||||||
await fetchData(false);
|
await fetchData(false);
|
||||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||||
alert.success(
|
alert.success(
|
||||||
@@ -1285,6 +1297,10 @@ export default function useAttendanceAdmin({ alert }: AlertContext) {
|
|||||||
if (result.success) {
|
if (result.success) {
|
||||||
setDeleteConfirm({ show: false, record: null });
|
setDeleteConfirm({ show: false, record: null });
|
||||||
queryClient.invalidateQueries({ queryKey: ["attendance"] });
|
queryClient.invalidateQueries({ queryKey: ["attendance"] });
|
||||||
|
// The dashboard embeds attendance (Přítomní dnes / Docházka dnes /
|
||||||
|
// punch-button state) — without this it serves a stale cache for up
|
||||||
|
// to its staleTime after records change here.
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["dashboard"] });
|
||||||
await fetchData(false);
|
await fetchData(false);
|
||||||
alert.success(
|
alert.success(
|
||||||
result.message || result.data?.message || "Záznam smazán",
|
result.message || result.data?.message || "Záznam smazán",
|
||||||
|
|||||||
@@ -6,6 +6,11 @@ export const dashboardOptions = () =>
|
|||||||
queryKey: ["dashboard"],
|
queryKey: ["dashboard"],
|
||||||
queryFn: () => jsonQuery<Record<string, unknown>>("/api/admin/dashboard"),
|
queryFn: () => jsonQuery<Record<string, unknown>>("/api/admin/dashboard"),
|
||||||
staleTime: 60_000,
|
staleTime: 60_000,
|
||||||
|
// The dashboard aggregates MANY domains (attendance, offers, invoices,
|
||||||
|
// orders, projects, leave). Mutations in those domains can't all be
|
||||||
|
// expected to invalidate ["dashboard"], so always refetch on mount —
|
||||||
|
// navigating back to the dashboard must never show pre-mutation data.
|
||||||
|
refetchOnMount: "always",
|
||||||
});
|
});
|
||||||
|
|
||||||
// require2FAOptions lives in ./settings.ts (the single definition consumers
|
// require2FAOptions lives in ./settings.ts (the single definition consumers
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import { jsonQuery, paginatedJsonQuery } from "../apiAdapter";
|
|||||||
export interface IssuedOrder {
|
export interface IssuedOrder {
|
||||||
id: number;
|
id: number;
|
||||||
po_number: string | null;
|
po_number: string | null;
|
||||||
customer_id: number | null;
|
supplier_id: number | null;
|
||||||
customer_name: string | null;
|
supplier_name: string | null;
|
||||||
status: string;
|
status: string;
|
||||||
currency: string | null;
|
currency: string | null;
|
||||||
order_date: string | null;
|
order_date: string | null;
|
||||||
@@ -14,6 +14,17 @@ export interface IssuedOrder {
|
|||||||
total: number;
|
total: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Active supplier row from the lightweight PO-picker lookup endpoint. */
|
||||||
|
export interface Supplier {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
ico: string | null;
|
||||||
|
dic: string | null;
|
||||||
|
address: string | null;
|
||||||
|
email: string | null;
|
||||||
|
phone: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface IssuedOrderItem {
|
export interface IssuedOrderItem {
|
||||||
id?: number;
|
id?: number;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
@@ -34,13 +45,23 @@ export interface IssuedOrderDetail extends IssuedOrder {
|
|||||||
delivery_terms: string | null;
|
delivery_terms: string | null;
|
||||||
payment_terms: string | null;
|
payment_terms: string | null;
|
||||||
issued_by: string | null;
|
issued_by: string | null;
|
||||||
|
order_text: string | null;
|
||||||
notes: string | null;
|
notes: string | null;
|
||||||
internal_notes: string | null;
|
internal_notes: string | null;
|
||||||
items: IssuedOrderItem[];
|
items: IssuedOrderItem[];
|
||||||
customer: Record<string, unknown> | null;
|
supplier: Record<string, unknown> | null;
|
||||||
valid_transitions: string[];
|
valid_transitions: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Suppliers lookup for the PO supplier picker — hits the issued-orders-scoped
|
||||||
|
// endpoint (orders permissions), NOT the warehouse.manage-guarded CRUD list.
|
||||||
|
export const issuedOrderSuppliersOptions = () =>
|
||||||
|
queryOptions({
|
||||||
|
queryKey: ["issued-orders", "suppliers"],
|
||||||
|
queryFn: () => jsonQuery<Supplier[]>("/api/admin/issued-orders/suppliers"),
|
||||||
|
staleTime: 2 * 60_000,
|
||||||
|
});
|
||||||
|
|
||||||
export const issuedOrderListOptions = (filters: {
|
export const issuedOrderListOptions = (filters: {
|
||||||
search?: string;
|
search?: string;
|
||||||
sort?: string;
|
sort?: string;
|
||||||
|
|||||||
@@ -274,7 +274,8 @@ export default function Attendance() {
|
|||||||
>({
|
>({
|
||||||
url: () => `${API_BASE}/attendance`,
|
url: () => `${API_BASE}/attendance`,
|
||||||
method: () => "POST",
|
method: () => "POST",
|
||||||
invalidate: ["attendance"],
|
// dashboard included: the punch-button state + presence cards live there.
|
||||||
|
invalidate: ["attendance", "dashboard"],
|
||||||
});
|
});
|
||||||
|
|
||||||
const notesMutation = useApiMutation<{ notes: string }, { message?: string }>(
|
const notesMutation = useApiMutation<{ notes: string }, { message?: string }>(
|
||||||
@@ -300,7 +301,8 @@ export default function Attendance() {
|
|||||||
>({
|
>({
|
||||||
url: () => `${API_BASE}/leave-requests`,
|
url: () => `${API_BASE}/leave-requests`,
|
||||||
method: () => "POST",
|
method: () => "POST",
|
||||||
invalidate: ["attendance", "leave-requests", "leave", "users"],
|
// dashboard included: approvers see the pending-requests KPI there.
|
||||||
|
invalidate: ["attendance", "leave-requests", "leave", "users", "dashboard"],
|
||||||
});
|
});
|
||||||
|
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ export default function AttendanceCreate() {
|
|||||||
const createMutation = useApiMutation<CreatePayload, { message?: string }>({
|
const createMutation = useApiMutation<CreatePayload, { message?: string }>({
|
||||||
url: () => `${API_BASE}/attendance`,
|
url: () => `${API_BASE}/attendance`,
|
||||||
method: () => "POST",
|
method: () => "POST",
|
||||||
invalidate: ["attendance", "users"],
|
invalidate: ["attendance", "users", "dashboard"],
|
||||||
});
|
});
|
||||||
|
|
||||||
const [form, setForm] = useState<CreateForm>(() => {
|
const [form, setForm] = useState<CreateForm>(() => {
|
||||||
|
|||||||
@@ -41,8 +41,11 @@ import Forbidden from "../components/Forbidden";
|
|||||||
import RichEditor from "../components/RichEditor";
|
import RichEditor from "../components/RichEditor";
|
||||||
import apiFetch from "../utils/api";
|
import apiFetch from "../utils/api";
|
||||||
import { jsonQuery } from "../lib/apiAdapter";
|
import { jsonQuery } from "../lib/apiAdapter";
|
||||||
import { offerCustomersOptions, type Customer } from "../lib/queries/offers";
|
import {
|
||||||
import { issuedOrderDetailOptions } from "../lib/queries/issued-orders";
|
issuedOrderDetailOptions,
|
||||||
|
issuedOrderSuppliersOptions,
|
||||||
|
type Supplier,
|
||||||
|
} from "../lib/queries/issued-orders";
|
||||||
import { companySettingsOptions } from "../lib/queries/settings";
|
import { companySettingsOptions } from "../lib/queries/settings";
|
||||||
import { formatCurrency, numberOr, todayLocalStr } from "../utils/formatters";
|
import { formatCurrency, numberOr, todayLocalStr } from "../utils/formatters";
|
||||||
import { normalizeDateStr } from "../utils/attendanceHelpers";
|
import { normalizeDateStr } from "../utils/attendanceHelpers";
|
||||||
@@ -58,7 +61,7 @@ import {
|
|||||||
ConfirmDialog,
|
ConfirmDialog,
|
||||||
LoadingState,
|
LoadingState,
|
||||||
PageEnter,
|
PageEnter,
|
||||||
CustomerPicker,
|
SupplierPicker,
|
||||||
headerActionsSx,
|
headerActionsSx,
|
||||||
} from "../ui";
|
} from "../ui";
|
||||||
import {
|
import {
|
||||||
@@ -157,8 +160,8 @@ interface OrderItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface OrderForm {
|
interface OrderForm {
|
||||||
customer_id: number | null;
|
supplier_id: number | null;
|
||||||
customer_name: string;
|
supplier_name: string;
|
||||||
currency: string;
|
currency: string;
|
||||||
apply_vat: boolean;
|
apply_vat: boolean;
|
||||||
vat_rate: number;
|
vat_rate: number;
|
||||||
@@ -168,6 +171,7 @@ interface OrderForm {
|
|||||||
delivery_terms: string;
|
delivery_terms: string;
|
||||||
payment_terms: string;
|
payment_terms: string;
|
||||||
issued_by: string;
|
issued_by: string;
|
||||||
|
order_text: string;
|
||||||
notes: string;
|
notes: string;
|
||||||
internal_notes: string;
|
internal_notes: string;
|
||||||
status: string;
|
status: string;
|
||||||
@@ -506,8 +510,8 @@ export default function IssuedOrderDetail() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const [form, setForm] = useState<OrderForm>({
|
const [form, setForm] = useState<OrderForm>({
|
||||||
customer_id: null,
|
supplier_id: null,
|
||||||
customer_name: "",
|
supplier_name: "",
|
||||||
currency: "CZK",
|
currency: "CZK",
|
||||||
apply_vat: true,
|
apply_vat: true,
|
||||||
vat_rate: 21,
|
vat_rate: 21,
|
||||||
@@ -517,6 +521,7 @@ export default function IssuedOrderDetail() {
|
|||||||
delivery_terms: "",
|
delivery_terms: "",
|
||||||
payment_terms: "",
|
payment_terms: "",
|
||||||
issued_by: user?.fullName || "",
|
issued_by: user?.fullName || "",
|
||||||
|
order_text: "",
|
||||||
notes: "",
|
notes: "",
|
||||||
internal_notes: "",
|
internal_notes: "",
|
||||||
status: "draft",
|
status: "draft",
|
||||||
@@ -551,8 +556,34 @@ export default function IssuedOrderDetail() {
|
|||||||
const [deleting, setDeleting] = useState(false);
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
|
||||||
// ─── Queries ───
|
// ─── Queries ───
|
||||||
const customersQuery = useQuery(offerCustomersOptions());
|
const suppliersQuery = useQuery(issuedOrderSuppliersOptions());
|
||||||
const customers = customersQuery.data ?? [];
|
|
||||||
|
// The lookup returns ACTIVE suppliers only, but a saved order may reference
|
||||||
|
// a since-deactivated one — without a fallback option the picker would
|
||||||
|
// resolve to nothing and render the counterparty blank. The fallback is
|
||||||
|
// built from the hydrated form state (supplier_name comes from the detail
|
||||||
|
// response), so the name stays visible and a re-save keeps the same id.
|
||||||
|
const suppliers = useMemo<Supplier[]>(() => {
|
||||||
|
const activeSuppliers = suppliersQuery.data ?? [];
|
||||||
|
if (
|
||||||
|
form.supplier_id != null &&
|
||||||
|
!activeSuppliers.some((s: Supplier) => s.id === form.supplier_id)
|
||||||
|
) {
|
||||||
|
return [
|
||||||
|
...activeSuppliers,
|
||||||
|
{
|
||||||
|
id: form.supplier_id,
|
||||||
|
name: form.supplier_name || `Dodavatel #${form.supplier_id}`,
|
||||||
|
ico: null,
|
||||||
|
dic: null,
|
||||||
|
address: null,
|
||||||
|
email: null,
|
||||||
|
phone: null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return activeSuppliers;
|
||||||
|
}, [suppliersQuery.data, form.supplier_id, form.supplier_name]);
|
||||||
|
|
||||||
const companySettings = useQuery(companySettingsOptions()).data;
|
const companySettings = useQuery(companySettingsOptions()).data;
|
||||||
// Configurable currency list from company settings (falls back to the
|
// Configurable currency list from company settings (falls back to the
|
||||||
@@ -576,13 +607,13 @@ export default function IssuedOrderDetail() {
|
|||||||
// ─── Edit mode: hydrate form from detail (once) ───
|
// ─── Edit mode: hydrate form from detail (once) ───
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isEdit || dataReady) return;
|
if (!isEdit || dataReady) return;
|
||||||
if (detailQuery.isLoading || customersQuery.isLoading) return;
|
if (detailQuery.isLoading || suppliersQuery.isLoading) return;
|
||||||
if (!detailQuery.data) return;
|
if (!detailQuery.data) return;
|
||||||
|
|
||||||
const d = detailQuery.data;
|
const d = detailQuery.data;
|
||||||
setForm({
|
setForm({
|
||||||
customer_id: d.customer_id ?? null,
|
supplier_id: d.supplier_id ?? null,
|
||||||
customer_name: d.customer_name ?? "",
|
supplier_name: d.supplier_name ?? "",
|
||||||
currency: d.currency || "CZK",
|
currency: d.currency || "CZK",
|
||||||
apply_vat: d.apply_vat !== false,
|
apply_vat: d.apply_vat !== false,
|
||||||
vat_rate: numberOr(d.vat_rate, 21),
|
vat_rate: numberOr(d.vat_rate, 21),
|
||||||
@@ -592,6 +623,7 @@ export default function IssuedOrderDetail() {
|
|||||||
delivery_terms: d.delivery_terms || "",
|
delivery_terms: d.delivery_terms || "",
|
||||||
payment_terms: d.payment_terms || "",
|
payment_terms: d.payment_terms || "",
|
||||||
issued_by: d.issued_by || "",
|
issued_by: d.issued_by || "",
|
||||||
|
order_text: d.order_text || "",
|
||||||
notes: d.notes || "",
|
notes: d.notes || "",
|
||||||
internal_notes: d.internal_notes || "",
|
internal_notes: d.internal_notes || "",
|
||||||
status: d.status,
|
status: d.status,
|
||||||
@@ -619,13 +651,13 @@ export default function IssuedOrderDetail() {
|
|||||||
dataReady,
|
dataReady,
|
||||||
detailQuery.isLoading,
|
detailQuery.isLoading,
|
||||||
detailQuery.data,
|
detailQuery.data,
|
||||||
customersQuery.isLoading,
|
suppliersQuery.isLoading,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// ─── Create mode: set the previewed PO number + default issued_by ───
|
// ─── Create mode: set the previewed PO number + default issued_by ───
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isEdit || dataReady) return;
|
if (isEdit || dataReady) return;
|
||||||
if (nextNumberQuery.isLoading || customersQuery.isLoading) return;
|
if (nextNumberQuery.isLoading || suppliersQuery.isLoading) return;
|
||||||
if (nextNumberQuery.data) setPoNumber(nextNumberQuery.data);
|
if (nextNumberQuery.data) setPoNumber(nextNumberQuery.data);
|
||||||
setDataReady(true);
|
setDataReady(true);
|
||||||
}, [
|
}, [
|
||||||
@@ -633,7 +665,7 @@ export default function IssuedOrderDetail() {
|
|||||||
dataReady,
|
dataReady,
|
||||||
nextNumberQuery.isLoading,
|
nextNumberQuery.isLoading,
|
||||||
nextNumberQuery.data,
|
nextNumberQuery.data,
|
||||||
customersQuery.isLoading,
|
suppliersQuery.isLoading,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Keep the displayed PO number in sync once it becomes available — a draft
|
// Keep the displayed PO number in sync once it becomes available — a draft
|
||||||
@@ -715,20 +747,20 @@ export default function IssuedOrderDetail() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const selectCustomer = (id: number | null) => {
|
const selectSupplier = (id: number | null) => {
|
||||||
const c = id != null ? customers.find((x: Customer) => x.id === id) : null;
|
const s = id != null ? suppliers.find((x: Supplier) => x.id === id) : null;
|
||||||
setForm((prev) => ({
|
setForm((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
customer_id: id,
|
supplier_id: id,
|
||||||
customer_name: c?.name || "",
|
supplier_name: s?.name || "",
|
||||||
}));
|
}));
|
||||||
setErrors((prev) => ({ ...prev, customer_id: "" }));
|
setErrors((prev) => ({ ...prev, supplier_id: "" }));
|
||||||
};
|
};
|
||||||
|
|
||||||
// ─── Submit (create + edit) ───
|
// ─── Submit (create + edit) ───
|
||||||
const handleSubmit = async (targetStatus?: string) => {
|
const handleSubmit = async (targetStatus?: string) => {
|
||||||
const newErrors: Record<string, string> = {};
|
const newErrors: Record<string, string> = {};
|
||||||
if (!form.customer_id) newErrors.customer_id = "Vyberte dodavatele";
|
if (!form.supplier_id) newErrors.supplier_id = "Vyberte dodavatele";
|
||||||
if (!form.order_date) newErrors.order_date = "Zadejte datum";
|
if (!form.order_date) newErrors.order_date = "Zadejte datum";
|
||||||
if (items.length === 0 || items.every((i) => !i.description.trim())) {
|
if (items.length === 0 || items.every((i) => !i.description.trim())) {
|
||||||
newErrors.items = "Přidejte alespoň jednu položku";
|
newErrors.items = "Přidejte alespoň jednu položku";
|
||||||
@@ -740,7 +772,7 @@ export default function IssuedOrderDetail() {
|
|||||||
setSavingAction(targetStatus ?? "save");
|
setSavingAction(targetStatus ?? "save");
|
||||||
try {
|
try {
|
||||||
const payload: Record<string, unknown> = {
|
const payload: Record<string, unknown> = {
|
||||||
customer_id: form.customer_id,
|
supplier_id: form.supplier_id,
|
||||||
currency: form.currency,
|
currency: form.currency,
|
||||||
vat_rate: form.vat_rate,
|
vat_rate: form.vat_rate,
|
||||||
apply_vat: form.apply_vat,
|
apply_vat: form.apply_vat,
|
||||||
@@ -750,6 +782,7 @@ export default function IssuedOrderDetail() {
|
|||||||
delivery_terms: form.delivery_terms,
|
delivery_terms: form.delivery_terms,
|
||||||
payment_terms: form.payment_terms,
|
payment_terms: form.payment_terms,
|
||||||
issued_by: form.issued_by,
|
issued_by: form.issued_by,
|
||||||
|
order_text: form.order_text || null,
|
||||||
notes: form.notes,
|
notes: form.notes,
|
||||||
internal_notes: form.internal_notes,
|
internal_notes: form.internal_notes,
|
||||||
items: items
|
items: items
|
||||||
@@ -1108,13 +1141,13 @@ export default function IssuedOrderDetail() {
|
|||||||
gap: 2,
|
gap: 2,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Field label="Dodavatel" error={errors.customer_id} required>
|
<Field label="Dodavatel" error={errors.supplier_id} required>
|
||||||
<CustomerPicker
|
<SupplierPicker
|
||||||
customers={customers}
|
suppliers={suppliers}
|
||||||
value={form.customer_id}
|
value={form.supplier_id}
|
||||||
onChange={selectCustomer}
|
onChange={selectSupplier}
|
||||||
disabled={!editable}
|
disabled={!editable}
|
||||||
error={errors.customer_id}
|
error={errors.supplier_id}
|
||||||
placeholder="Vyberte dodavatele…"
|
placeholder="Vyberte dodavatele…"
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
@@ -1390,6 +1423,16 @@ export default function IssuedOrderDetail() {
|
|||||||
|
|
||||||
{/* Notes & terms */}
|
{/* Notes & terms */}
|
||||||
<Card sx={{ mb: 3 }}>
|
<Card sx={{ mb: 3 }}>
|
||||||
|
<Field label="Text objednávky (na PDF)">
|
||||||
|
<TextField
|
||||||
|
value={form.order_text}
|
||||||
|
disabled={!editable}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm((prev) => ({ ...prev, order_text: e.target.value }))
|
||||||
|
}
|
||||||
|
placeholder="Objednáváme si u Vás: (ponechte prázdné pro výchozí)"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
<Field label="Dodací podmínky">
|
<Field label="Dodací podmínky">
|
||||||
<TextField
|
<TextField
|
||||||
value={form.delivery_terms}
|
value={form.delivery_terms}
|
||||||
|
|||||||
@@ -229,10 +229,10 @@ export default function IssuedOrders({ month, year }: IssuedOrdersProps) {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "customer_name",
|
key: "supplier_name",
|
||||||
header: "Dodavatel",
|
header: "Dodavatel",
|
||||||
width: "24%",
|
width: "24%",
|
||||||
render: (o) => o.customer_name || "—",
|
render: (o) => o.supplier_name || "—",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "status",
|
key: "status",
|
||||||
|
|||||||
@@ -161,7 +161,7 @@ export default function LeaveApproval() {
|
|||||||
>({
|
>({
|
||||||
url: ({ id }) => `${API_BASE}/leave-requests/${id}`,
|
url: ({ id }) => `${API_BASE}/leave-requests/${id}`,
|
||||||
method: () => "PUT",
|
method: () => "PUT",
|
||||||
invalidate: ["leave-requests", "leave", "attendance", "users"],
|
invalidate: ["leave-requests", "leave", "attendance", "users", "dashboard"],
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
setApproveModal({ open: false, request: null });
|
setApproveModal({ open: false, request: null });
|
||||||
alert.success("Žádost byla schválena");
|
alert.success("Žádost byla schválena");
|
||||||
@@ -174,7 +174,7 @@ export default function LeaveApproval() {
|
|||||||
>({
|
>({
|
||||||
url: ({ id }) => `${API_BASE}/leave-requests/${id}`,
|
url: ({ id }) => `${API_BASE}/leave-requests/${id}`,
|
||||||
method: () => "PUT",
|
method: () => "PUT",
|
||||||
invalidate: ["leave-requests", "leave", "attendance", "users"],
|
invalidate: ["leave-requests", "leave", "attendance", "users", "dashboard"],
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
setRejectModal({ open: false, request: null });
|
setRejectModal({ open: false, request: null });
|
||||||
setRejectNote("");
|
setRejectNote("");
|
||||||
|
|||||||
@@ -137,7 +137,9 @@ export default function WarehouseSuppliers() {
|
|||||||
url: () =>
|
url: () =>
|
||||||
editingSupplier ? `${API_BASE}/${editingSupplier.id}` : API_BASE,
|
editingSupplier ? `${API_BASE}/${editingSupplier.id}` : API_BASE,
|
||||||
method: () => (editingSupplier ? "PUT" : "POST"),
|
method: () => (editingSupplier ? "PUT" : "POST"),
|
||||||
invalidate: ["warehouse"],
|
// issued-orders included: the PO form's supplier picker caches under
|
||||||
|
// ["issued-orders","suppliers"] and must see supplier CRUD immediately.
|
||||||
|
invalidate: ["warehouse", "issued-orders"],
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
setShowModal(false);
|
setShowModal(false);
|
||||||
alert.success(data?.message || "Dodavatel byl uložen");
|
alert.success(data?.message || "Dodavatel byl uložen");
|
||||||
@@ -147,7 +149,9 @@ export default function WarehouseSuppliers() {
|
|||||||
const deleteMutation = useApiMutation<number, { message?: string }>({
|
const deleteMutation = useApiMutation<number, { message?: string }>({
|
||||||
url: (id) => `${API_BASE}/${id}`,
|
url: (id) => `${API_BASE}/${id}`,
|
||||||
method: () => "DELETE",
|
method: () => "DELETE",
|
||||||
invalidate: ["warehouse"],
|
// issued-orders included: the PO form's supplier picker caches under
|
||||||
|
// ["issued-orders","suppliers"] and must see supplier CRUD immediately.
|
||||||
|
invalidate: ["warehouse", "issued-orders"],
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
setDeactivateConfirm({ show: false, supplier: null });
|
setDeactivateConfirm({ show: false, supplier: null });
|
||||||
alert.success(data?.message || "Dodavatel byl smazán");
|
alert.success(data?.message || "Dodavatel byl smazán");
|
||||||
@@ -163,7 +167,9 @@ export default function WarehouseSuppliers() {
|
|||||||
>({
|
>({
|
||||||
url: ({ id }) => `${API_BASE}/${id}`,
|
url: ({ id }) => `${API_BASE}/${id}`,
|
||||||
method: () => "PUT",
|
method: () => "PUT",
|
||||||
invalidate: ["warehouse"],
|
// issued-orders included: the PO form's supplier picker caches under
|
||||||
|
// ["issued-orders","suppliers"] and must see supplier CRUD immediately.
|
||||||
|
invalidate: ["warehouse", "issued-orders"],
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!hasPermission("warehouse.manage")) return <Forbidden />;
|
if (!hasPermission("warehouse.manage")) return <Forbidden />;
|
||||||
|
|||||||
88
src/admin/ui/SupplierPicker.tsx
Normal file
88
src/admin/ui/SupplierPicker.tsx
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import Autocomplete, { createFilterOptions } from "@mui/material/Autocomplete";
|
||||||
|
import MuiTextField from "@mui/material/TextField";
|
||||||
|
import Box from "@mui/material/Box";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
|
import type { Supplier } from "../lib/queries/issued-orders";
|
||||||
|
|
||||||
|
interface SupplierPickerProps {
|
||||||
|
suppliers: Supplier[];
|
||||||
|
/** Selected supplier id (FK), or null when none chosen. */
|
||||||
|
value: number | null;
|
||||||
|
onChange: (id: number | null) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
/**
|
||||||
|
* When set, draws the input in its error state (red border). The error TEXT
|
||||||
|
* is owned by the surrounding <Field> wrapper, so we deliberately do NOT
|
||||||
|
* render helperText here to avoid duplicating the message.
|
||||||
|
*/
|
||||||
|
error?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
/** Optional id forwarded by <Field> for label association. */
|
||||||
|
id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search matches the supplier name AND the IČO, so typing either finds the
|
||||||
|
// record. Match on a stringified "name + ico" so MUI's default
|
||||||
|
// case-insensitive substring filtering covers both.
|
||||||
|
const filterSuppliers = createFilterOptions<Supplier>({
|
||||||
|
stringify: (s) => `${s.name} ${s.ico ?? ""}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Searchable supplier picker for issued orders (purchase orders) — a
|
||||||
|
* controlled MUI Autocomplete over the sklad_suppliers lookup list, bound to
|
||||||
|
* the supplier id. Modeled on CustomerPicker (offers/invoices keep that one);
|
||||||
|
* renders bare (no label/error text); wrap it in the page's
|
||||||
|
* <Field label error> for the label + error line.
|
||||||
|
*/
|
||||||
|
export default function SupplierPicker({
|
||||||
|
suppliers,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
disabled,
|
||||||
|
error,
|
||||||
|
placeholder,
|
||||||
|
id,
|
||||||
|
}: SupplierPickerProps) {
|
||||||
|
const selected = suppliers.find((s) => s.id === value) ?? null;
|
||||||
|
return (
|
||||||
|
<Autocomplete<Supplier>
|
||||||
|
options={suppliers}
|
||||||
|
value={selected}
|
||||||
|
onChange={(_, opt) => onChange(opt ? opt.id : null)}
|
||||||
|
getOptionLabel={(s) => s?.name ?? ""}
|
||||||
|
isOptionEqualToValue={(o, v) => o.id === v.id}
|
||||||
|
filterOptions={filterSuppliers}
|
||||||
|
disabled={disabled}
|
||||||
|
size="small"
|
||||||
|
fullWidth
|
||||||
|
autoHighlight
|
||||||
|
renderOption={(props, s) => {
|
||||||
|
const { key, ...rest } = props as typeof props & { key: string };
|
||||||
|
return (
|
||||||
|
<Box component="li" key={key} {...rest}>
|
||||||
|
<Box>
|
||||||
|
{s.name}
|
||||||
|
{s.ico && (
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
sx={{ display: "block", color: "text.secondary" }}
|
||||||
|
>
|
||||||
|
IČ: {s.ico}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
renderInput={(params) => (
|
||||||
|
<MuiTextField
|
||||||
|
{...params}
|
||||||
|
id={id}
|
||||||
|
placeholder={placeholder ?? "Vyberte dodavatele…"}
|
||||||
|
error={!!error}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ export { Field, SwitchField } from "./Field";
|
|||||||
export { default as Select } from "./Select";
|
export { default as Select } from "./Select";
|
||||||
export type { SelectOption } from "./Select";
|
export type { SelectOption } from "./Select";
|
||||||
export { default as CustomerPicker } from "./CustomerPicker";
|
export { default as CustomerPicker } from "./CustomerPicker";
|
||||||
|
export { default as SupplierPicker } from "./SupplierPicker";
|
||||||
export { default as StatusChip } from "./StatusChip";
|
export { default as StatusChip } from "./StatusChip";
|
||||||
export { CheckboxField } from "./Checkbox";
|
export { CheckboxField } from "./Checkbox";
|
||||||
export { default as Alert } from "./Alert";
|
export { default as Alert } from "./Alert";
|
||||||
|
|||||||
@@ -11,15 +11,16 @@ export default async function dashboardRoutes(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
fastify.get("/", { preHandler: requireAuth }, async (request, reply) => {
|
fastify.get("/", { preHandler: requireAuth }, async (request, reply) => {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
// shift_date is @db.Date: Prisma truncates a filter Date to its UTC date
|
||||||
|
// part, so these boundaries MUST be UTC-midnight instants of the LOCAL
|
||||||
|
// (Prague) calendar day. A local-midnight Date (new Date(y, m, d)) is
|
||||||
|
// 22:00/23:00 UTC of the PREVIOUS day and silently shifts the window to
|
||||||
|
// [yesterday, today) — the "Docházka dnes" card then matches zero rows.
|
||||||
const todayStart = new Date(
|
const todayStart = new Date(
|
||||||
now.getFullYear(),
|
Date.UTC(now.getFullYear(), now.getMonth(), now.getDate()),
|
||||||
now.getMonth(),
|
|
||||||
now.getDate(),
|
|
||||||
);
|
);
|
||||||
const todayEnd = new Date(
|
const todayEnd = new Date(
|
||||||
now.getFullYear(),
|
Date.UTC(now.getFullYear(), now.getMonth(), now.getDate() + 1),
|
||||||
now.getMonth(),
|
|
||||||
now.getDate() + 1,
|
|
||||||
);
|
);
|
||||||
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1);
|
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||||
const monthEnd = new Date(now.getFullYear(), now.getMonth() + 1, 1);
|
const monthEnd = new Date(now.getFullYear(), now.getMonth() + 1, 1);
|
||||||
|
|||||||
@@ -156,6 +156,31 @@ function buildAddressLines(
|
|||||||
return { name, lines };
|
return { name, lines };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Address block for the sklad_suppliers counterparty. Unlike customers (which
|
||||||
|
* have structured street/city/postal columns), suppliers.address is a single
|
||||||
|
* Text blob — split it on newlines into one rendered line each (a blob without
|
||||||
|
* newlines renders as one line). IČO/DIČ come from the supplier's ico/dic
|
||||||
|
* columns, prefixed with the same translated labels the customer block used.
|
||||||
|
*/
|
||||||
|
function buildSupplierLines(
|
||||||
|
supplier: Record<string, unknown> | null,
|
||||||
|
tObj: Record<string, string>,
|
||||||
|
): AddressResult {
|
||||||
|
if (!supplier) return { name: "", lines: [] };
|
||||||
|
const name = String(supplier.name || "");
|
||||||
|
const lines: string[] = [];
|
||||||
|
if (supplier.address) {
|
||||||
|
for (const part of String(supplier.address).split(/\r?\n/)) {
|
||||||
|
const line = part.trim();
|
||||||
|
if (line) lines.push(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (supplier.ico) lines.push(`${tObj.ico}${supplier.ico}`);
|
||||||
|
if (supplier.dic) lines.push(`${tObj.dic}${supplier.dic}`);
|
||||||
|
return { name, lines };
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Translations ────────────────────────────────────────────────── */
|
/* ── Translations ────────────────────────────────────────────────── */
|
||||||
|
|
||||||
type Lang = "cs" | "en";
|
type Lang = "cs" | "en";
|
||||||
@@ -165,12 +190,12 @@ const translations: Record<Lang, Record<string, string>> = {
|
|||||||
title: "OBJEDNÁVKA",
|
title: "OBJEDNÁVKA",
|
||||||
heading: "OBJEDNÁVKA č.",
|
heading: "OBJEDNÁVKA č.",
|
||||||
// PO direction: WE (company) are the buyer (Odběratel), the
|
// PO direction: WE (company) are the buyer (Odběratel), the
|
||||||
// selected customer record is the supplier (Dodavatel).
|
// selected sklad_suppliers record is the supplier (Dodavatel).
|
||||||
supplier: "Dodavatel",
|
supplier: "Dodavatel",
|
||||||
buyer: "Odběratel",
|
buyer: "Odběratel",
|
||||||
issue_date: "Datum vystavení:",
|
issue_date: "Datum vystavení:",
|
||||||
delivery_date: "Požadované dodání:",
|
delivery_date: "Požadované dodání:",
|
||||||
billing: "Objednáváme u Vás:",
|
billing: "Objednáváme si u Vás:",
|
||||||
col_no: "Č.",
|
col_no: "Č.",
|
||||||
col_desc: "Popis",
|
col_desc: "Popis",
|
||||||
col_qty: "Množství",
|
col_qty: "Množství",
|
||||||
@@ -182,6 +207,7 @@ const translations: Record<Lang, Record<string, string>> = {
|
|||||||
subtotal: "Mezisoučet:",
|
subtotal: "Mezisoučet:",
|
||||||
vat_label: "DPH",
|
vat_label: "DPH",
|
||||||
total: "Celkem",
|
total: "Celkem",
|
||||||
|
total_no_vat: "Celkem bez DPH",
|
||||||
amounts_in: "Částky jsou uvedeny v",
|
amounts_in: "Částky jsou uvedeny v",
|
||||||
notes: "Poznámky",
|
notes: "Poznámky",
|
||||||
delivery_terms: "Dodací podmínky:",
|
delivery_terms: "Dodací podmínky:",
|
||||||
@@ -209,6 +235,7 @@ const translations: Record<Lang, Record<string, string>> = {
|
|||||||
subtotal: "Subtotal:",
|
subtotal: "Subtotal:",
|
||||||
vat_label: "VAT",
|
vat_label: "VAT",
|
||||||
total: "Total",
|
total: "Total",
|
||||||
|
total_no_vat: "Total excl. VAT",
|
||||||
amounts_in: "Amounts are in",
|
amounts_in: "Amounts are in",
|
||||||
notes: "Notes",
|
notes: "Notes",
|
||||||
delivery_terms: "Delivery terms:",
|
delivery_terms: "Delivery terms:",
|
||||||
@@ -230,6 +257,8 @@ interface IssuedOrderPdfData {
|
|||||||
delivery_terms: string | null;
|
delivery_terms: string | null;
|
||||||
payment_terms: string | null;
|
payment_terms: string | null;
|
||||||
issued_by: string | null;
|
issued_by: string | null;
|
||||||
|
// Editable heading above the items table; null → t.billing default.
|
||||||
|
order_text?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface IssuedOrderPdfItem {
|
interface IssuedOrderPdfItem {
|
||||||
@@ -244,7 +273,7 @@ interface IssuedOrderPdfItem {
|
|||||||
export function renderIssuedOrderHtml(
|
export function renderIssuedOrderHtml(
|
||||||
order: IssuedOrderPdfData,
|
order: IssuedOrderPdfData,
|
||||||
items: IssuedOrderPdfItem[],
|
items: IssuedOrderPdfItem[],
|
||||||
customer: Record<string, unknown> | null,
|
supplier: Record<string, unknown> | null,
|
||||||
settings: Record<string, unknown> | null,
|
settings: Record<string, unknown> | null,
|
||||||
lang: Lang,
|
lang: Lang,
|
||||||
issuer: { name: string },
|
issuer: { name: string },
|
||||||
@@ -268,14 +297,14 @@ export function renderIssuedOrderHtml(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// PO direction: our company (settings) = Odběratel (buyer);
|
// PO direction: our company (settings) = Odběratel (buyer);
|
||||||
// the customer record = Dodavatel (supplier).
|
// the sklad_suppliers record = Dodavatel (supplier).
|
||||||
const buyer = buildAddressLines(settings, true, t); // company → Odběratel
|
const buyer = buildAddressLines(settings, true, t); // company → Odběratel
|
||||||
const supplier = buildAddressLines(customer, false, t); // customer → Dodavatel
|
const supplierAddr = buildSupplierLines(supplier, t); // supplier → Dodavatel
|
||||||
|
|
||||||
const buyerLinesHtml = buyer.lines
|
const buyerLinesHtml = buyer.lines
|
||||||
.map((l) => `<div class="address-line">${escapeHtml(l)}</div>`)
|
.map((l) => `<div class="address-line">${escapeHtml(l)}</div>`)
|
||||||
.join("");
|
.join("");
|
||||||
const supplierLinesHtml = supplier.lines
|
const supplierLinesHtml = supplierAddr.lines
|
||||||
.map((l) => `<div class="address-line">${escapeHtml(l)}</div>`)
|
.map((l) => `<div class="address-line">${escapeHtml(l)}</div>`)
|
||||||
.join("");
|
.join("");
|
||||||
|
|
||||||
@@ -311,14 +340,19 @@ export function renderIssuedOrderHtml(
|
|||||||
? `<div class="item-sub">${escapeHtml(it.item_description)}</div>`
|
? `<div class="item-sub">${escapeHtml(it.item_description)}</div>`
|
||||||
: ""
|
: ""
|
||||||
}`;
|
}`;
|
||||||
|
// Without "Uplatnit DPH" the VAT columns are dropped entirely (the
|
||||||
|
// header does the same) instead of printing meaningless 0% / 0.00.
|
||||||
|
const vatCells = applyVat
|
||||||
|
? `
|
||||||
|
<td class="center">${Math.floor(rate)}%</td>
|
||||||
|
<td class="right">${formatNum(lineVat)}</td>`
|
||||||
|
: "";
|
||||||
return `<tr>
|
return `<tr>
|
||||||
<td class="row-num">${i + 1}</td>
|
<td class="row-num">${i + 1}</td>
|
||||||
<td class="desc">${descHtml}</td>
|
<td class="desc">${descHtml}</td>
|
||||||
<td class="center">${formatNum(qty, qtyDecimals)}${it.unit ? ` / ${escapeHtml(it.unit)}` : ""}</td>
|
<td class="center">${formatNum(qty, qtyDecimals)}${it.unit ? ` / ${escapeHtml(it.unit)}` : ""}</td>
|
||||||
<td class="right">${formatNum(unitPrice)}</td>
|
<td class="right">${formatNum(unitPrice)}</td>
|
||||||
<td class="right">${formatNum(lineSubtotal)}</td>
|
<td class="right">${formatNum(lineSubtotal)}</td>${vatCells}
|
||||||
<td class="center">${applyVat ? Math.floor(rate) : 0}%</td>
|
|
||||||
<td class="right">${formatNum(lineVat)}</td>
|
|
||||||
<td class="right total-cell">${formatNum(lineTotal)}</td>
|
<td class="right total-cell">${formatNum(lineTotal)}</td>
|
||||||
</tr>`;
|
</tr>`;
|
||||||
})
|
})
|
||||||
@@ -450,7 +484,7 @@ export function renderIssuedOrderHtml(
|
|||||||
vertical-align: top;
|
vertical-align: top;
|
||||||
width: 50%;
|
width: 50%;
|
||||||
}
|
}
|
||||||
.header-grid td.addr-customer {
|
.header-grid td.addr-supplier {
|
||||||
background: #f5f5f5;
|
background: #f5f5f5;
|
||||||
}
|
}
|
||||||
.header-grid td.details-bank {
|
.header-grid td.details-bank {
|
||||||
@@ -715,7 +749,7 @@ ${indentCSS}
|
|||||||
<div class="invoice-title">${escapeHtml(t.heading)} ${poNumber}</div>
|
<div class="invoice-title">${escapeHtml(t.heading)} ${poNumber}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Odberatel (nase firma) / Dodavatel (zakaznik) + Detaily -->
|
<!-- Odberatel (nase firma) / Dodavatel (sklad_suppliers) + Detaily -->
|
||||||
<table class="header-grid" cellspacing="0">
|
<table class="header-grid" cellspacing="0">
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
@@ -723,9 +757,9 @@ ${indentCSS}
|
|||||||
<div class="address-name">${escapeHtml(buyer.name)}</div>
|
<div class="address-name">${escapeHtml(buyer.name)}</div>
|
||||||
${buyerLinesHtml}
|
${buyerLinesHtml}
|
||||||
</td>
|
</td>
|
||||||
<td class="addr-customer">
|
<td class="addr-supplier">
|
||||||
<div class="address-label">${escapeHtml(t.supplier)}</div>
|
<div class="address-label">${escapeHtml(t.supplier)}</div>
|
||||||
<div class="address-name">${escapeHtml(supplier.name)}</div>
|
<div class="address-name">${escapeHtml(supplierAddr.name)}</div>
|
||||||
${supplierLinesHtml}
|
${supplierLinesHtml}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -739,18 +773,22 @@ ${indentCSS}
|
|||||||
</table>
|
</table>
|
||||||
|
|
||||||
<!-- Polozky -->
|
<!-- Polozky -->
|
||||||
<div class="billing-label">${escapeHtml(t.billing)}</div>
|
<div class="billing-label">${escapeHtml(order.order_text || t.billing)}</div>
|
||||||
<table class="items">
|
<table class="items">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th class="center" style="width:3%">${escapeHtml(t.col_no)}</th>
|
<th class="center" style="width:3%">${escapeHtml(t.col_no)}</th>
|
||||||
<th style="width:36%">${escapeHtml(t.col_desc)}</th>
|
<th style="width:${applyVat ? 36 : 46}%">${escapeHtml(t.col_desc)}</th>
|
||||||
<th class="center" style="width:10%">${escapeHtml(t.col_qty)}</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_unit_price)}</th>
|
||||||
<th class="right" style="width:10%">${escapeHtml(t.col_price)}</th>
|
<th class="right" style="width:10%">${escapeHtml(t.col_price)}</th>${
|
||||||
|
applyVat
|
||||||
|
? `
|
||||||
<th class="center" style="width:5%">${escapeHtml(t.col_vat_pct)}</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:10%">${escapeHtml(t.col_vat)}</th>`
|
||||||
<th class="right" style="width:16%">${escapeHtml(t.col_total)}</th>
|
: ""
|
||||||
|
}
|
||||||
|
<th class="right" style="width:${applyVat ? 16 : 21}%">${escapeHtml(t.col_total)}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -758,17 +796,21 @@ ${indentCSS}
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
<!-- Soucty -->
|
<!-- Soucty (bez DPH jen souhrnny radek - mezisoucet by ho jen opakoval) -->
|
||||||
<div class="totals-wrapper">
|
<div class="totals-wrapper">
|
||||||
<div class="totals">
|
<div class="totals">${
|
||||||
|
applyVat
|
||||||
|
? `
|
||||||
<div class="detail-rows">
|
<div class="detail-rows">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<span class="label">${escapeHtml(t.subtotal)}</span>
|
<span class="label">${escapeHtml(t.subtotal)}</span>
|
||||||
<span class="value">${formatNum(subtotal)} ${escapeHtml(currency)}</span>
|
<span class="value">${formatNum(subtotal)} ${escapeHtml(currency)}</span>
|
||||||
</div>${vatDetailHtml}
|
</div>${vatDetailHtml}
|
||||||
</div>
|
</div>`
|
||||||
|
: ""
|
||||||
|
}
|
||||||
<div class="grand">
|
<div class="grand">
|
||||||
<span class="label">${escapeHtml(t.total)}</span>
|
<span class="label">${escapeHtml(applyVat ? t.total : t.total_no_vat)}</span>
|
||||||
<span class="value">${formatNum(totalToPay)} ${escapeHtml(currency)}</span>
|
<span class="value">${formatNum(totalToPay)} ${escapeHtml(currency)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="currency-note">${escapeHtml(t.amounts_in)} ${escapeHtml(currency)}</div>
|
<div class="currency-note">${escapeHtml(t.amounts_in)} ${escapeHtml(currency)}</div>
|
||||||
@@ -818,9 +860,9 @@ export default async function issuedOrdersPdfRoutes(fastify: FastifyInstance) {
|
|||||||
where: { issued_order_id: id },
|
where: { issued_order_id: id },
|
||||||
orderBy: { position: "asc" },
|
orderBy: { position: "asc" },
|
||||||
});
|
});
|
||||||
const customer = order.customer_id
|
const supplier = order.supplier_id
|
||||||
? ((await prisma.customers.findUnique({
|
? ((await prisma.sklad_suppliers.findUnique({
|
||||||
where: { id: order.customer_id },
|
where: { id: order.supplier_id },
|
||||||
})) as Record<string, unknown> | null)
|
})) as Record<string, unknown> | null)
|
||||||
: null;
|
: null;
|
||||||
const settings = (await prisma.company_settings.findFirst()) as Record<
|
const settings = (await prisma.company_settings.findFirst()) as Record<
|
||||||
@@ -838,7 +880,7 @@ export default async function issuedOrdersPdfRoutes(fastify: FastifyInstance) {
|
|||||||
const html = renderIssuedOrderHtml(
|
const html = renderIssuedOrderHtml(
|
||||||
order,
|
order,
|
||||||
items,
|
items,
|
||||||
customer,
|
supplier,
|
||||||
settings,
|
settings,
|
||||||
lang,
|
lang,
|
||||||
issuer,
|
issuer,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { FastifyInstance } from "fastify";
|
import { FastifyInstance } from "fastify";
|
||||||
import { requirePermission } from "../../middleware/auth";
|
import prisma from "../../config/database";
|
||||||
|
import { requirePermission, requireAnyPermission } from "../../middleware/auth";
|
||||||
import { logAudit } from "../../services/audit";
|
import { logAudit } from "../../services/audit";
|
||||||
import { success, error, parseId, paginated } from "../../utils/response";
|
import { success, error, parseId, paginated } from "../../utils/response";
|
||||||
import { parsePagination, buildPaginationMeta } from "../../utils/pagination";
|
import { parsePagination, buildPaginationMeta } from "../../utils/pagination";
|
||||||
@@ -34,7 +35,7 @@ export default async function issuedOrdersRoutes(fastify: FastifyInstance) {
|
|||||||
order,
|
order,
|
||||||
search,
|
search,
|
||||||
status: query.status ? String(query.status) : undefined,
|
status: query.status ? String(query.status) : undefined,
|
||||||
customer_id: query.customer_id ? Number(query.customer_id) : undefined,
|
supplier_id: query.supplier_id ? Number(query.supplier_id) : undefined,
|
||||||
month: query.month ? Number(query.month) : undefined,
|
month: query.month ? Number(query.month) : undefined,
|
||||||
year: query.year ? Number(query.year) : undefined,
|
year: query.year ? Number(query.year) : undefined,
|
||||||
});
|
});
|
||||||
@@ -66,7 +67,7 @@ export default async function issuedOrdersRoutes(fastify: FastifyInstance) {
|
|||||||
const result = await getIssuedOrderTotals({
|
const result = await getIssuedOrderTotals({
|
||||||
search: query.search ? String(query.search) : undefined,
|
search: query.search ? String(query.search) : undefined,
|
||||||
status: query.status ? String(query.status) : undefined,
|
status: query.status ? String(query.status) : undefined,
|
||||||
customer_id: query.customer_id ? Number(query.customer_id) : undefined,
|
supplier_id: query.supplier_id ? Number(query.supplier_id) : undefined,
|
||||||
month: query.month ? Number(query.month) : undefined,
|
month: query.month ? Number(query.month) : undefined,
|
||||||
year: query.year ? Number(query.year) : undefined,
|
year: query.year ? Number(query.year) : undefined,
|
||||||
});
|
});
|
||||||
@@ -74,6 +75,40 @@ export default async function issuedOrdersRoutes(fastify: FastifyInstance) {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// GET /api/admin/issued-orders/suppliers — lightweight supplier lookup for
|
||||||
|
// the PO supplier picker. Guarded by the ORDERS permissions (any of
|
||||||
|
// view/create/edit), NOT warehouse.manage: the warehouse suppliers CRUD is
|
||||||
|
// gated by warehouse.manage, which orders users typically lack — without
|
||||||
|
// this endpoint they couldn't populate the picker. Registered BEFORE "/:id"
|
||||||
|
// so the literal "suppliers" path isn't captured as an order id.
|
||||||
|
fastify.get(
|
||||||
|
"/suppliers",
|
||||||
|
{
|
||||||
|
preHandler: requireAnyPermission(
|
||||||
|
"orders.view",
|
||||||
|
"orders.create",
|
||||||
|
"orders.edit",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
async (_request, reply) => {
|
||||||
|
const suppliers = await prisma.sklad_suppliers.findMany({
|
||||||
|
where: { is_active: true },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
ico: true,
|
||||||
|
dic: true,
|
||||||
|
address: true,
|
||||||
|
email: true,
|
||||||
|
phone: true,
|
||||||
|
},
|
||||||
|
// id tiebreak so same-name suppliers sort deterministically.
|
||||||
|
orderBy: [{ name: "asc" }, { id: "asc" }],
|
||||||
|
});
|
||||||
|
return success(reply, suppliers);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// GET /api/admin/issued-orders/:id
|
// GET /api/admin/issued-orders/:id
|
||||||
fastify.get<{ Params: { id: string } }>(
|
fastify.get<{ Params: { id: string } }>(
|
||||||
"/:id",
|
"/:id",
|
||||||
@@ -95,6 +130,11 @@ export default async function issuedOrdersRoutes(fastify: FastifyInstance) {
|
|||||||
const parsed = parseBody(CreateIssuedOrderSchema, request.body);
|
const parsed = parseBody(CreateIssuedOrderSchema, request.body);
|
||||||
if ("error" in parsed) return error(reply, parsed.error, 400);
|
if ("error" in parsed) return error(reply, parsed.error, 400);
|
||||||
const order = await createIssuedOrder(parsed.data);
|
const order = await createIssuedOrder(parsed.data);
|
||||||
|
if ("error" in order) {
|
||||||
|
if (order.error === "supplier_not_found")
|
||||||
|
return error(reply, "Dodavatel nenalezen", 400);
|
||||||
|
return error(reply, "Neznámá chyba", 500);
|
||||||
|
}
|
||||||
await logAudit({
|
await logAudit({
|
||||||
request,
|
request,
|
||||||
authData: request.authData,
|
authData: request.authData,
|
||||||
@@ -125,6 +165,8 @@ export default async function issuedOrdersRoutes(fastify: FastifyInstance) {
|
|||||||
if ("error" in result) {
|
if ("error" in result) {
|
||||||
if (result.error === "not_found")
|
if (result.error === "not_found")
|
||||||
return error(reply, "Objednávka nenalezena", 404);
|
return error(reply, "Objednávka nenalezena", 404);
|
||||||
|
if (result.error === "supplier_not_found")
|
||||||
|
return error(reply, "Dodavatel nenalezen", 400);
|
||||||
if (result.error === "invalid_transition")
|
if (result.error === "invalid_transition")
|
||||||
return error(
|
return error(
|
||||||
reply,
|
reply,
|
||||||
|
|||||||
@@ -200,6 +200,7 @@ const translations: Record<string, Record<string, string>> = {
|
|||||||
subtotal: "Mezisoučet:",
|
subtotal: "Mezisoučet:",
|
||||||
vat_label: "DPH",
|
vat_label: "DPH",
|
||||||
total: "Celkem",
|
total: "Celkem",
|
||||||
|
total_no_vat: "Celkem bez DPH",
|
||||||
amounts_in: "Částky jsou uvedeny v",
|
amounts_in: "Částky jsou uvedeny v",
|
||||||
notes: "Poznámky",
|
notes: "Poznámky",
|
||||||
issued_by: "Vystavil:",
|
issued_by: "Vystavil:",
|
||||||
@@ -228,6 +229,7 @@ const translations: Record<string, Record<string, string>> = {
|
|||||||
subtotal: "Subtotal:",
|
subtotal: "Subtotal:",
|
||||||
vat_label: "VAT",
|
vat_label: "VAT",
|
||||||
total: "Total",
|
total: "Total",
|
||||||
|
total_no_vat: "Total excl. VAT",
|
||||||
amounts_in: "Amounts are in",
|
amounts_in: "Amounts are in",
|
||||||
notes: "Notes",
|
notes: "Notes",
|
||||||
issued_by: "Issued by:",
|
issued_by: "Issued by:",
|
||||||
@@ -388,14 +390,19 @@ export default async function ordersPdfRoutes(
|
|||||||
const lineTotal = lineSubtotal + lineVat;
|
const lineTotal = lineSubtotal + lineVat;
|
||||||
const qtyDecimals =
|
const qtyDecimals =
|
||||||
Math.floor(item.quantity) === item.quantity ? 0 : 2;
|
Math.floor(item.quantity) === item.quantity ? 0 : 2;
|
||||||
|
// Without "Uplatnit DPH" the VAT columns are dropped entirely
|
||||||
|
// (the header does the same) instead of printing 0% / 0.00.
|
||||||
|
const vatCells = applyVat
|
||||||
|
? `
|
||||||
|
<td class="center">${Math.floor(item.vat_rate)}%</td>
|
||||||
|
<td class="right">${formatNum(lineVat)}</td>`
|
||||||
|
: "";
|
||||||
return `<tr>
|
return `<tr>
|
||||||
<td class="row-num">${i + 1}</td>
|
<td class="row-num">${i + 1}</td>
|
||||||
<td class="desc">${escapeHtml(item.description)}</td>
|
<td class="desc">${escapeHtml(item.description)}</td>
|
||||||
<td class="center">${formatNum(item.quantity, qtyDecimals)}${item.unit ? ` / ${escapeHtml(item.unit)}` : ""}</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(item.unit_price)}</td>
|
||||||
<td class="right">${formatNum(lineSubtotal)}</td>
|
<td class="right">${formatNum(lineSubtotal)}</td>${vatCells}
|
||||||
<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>
|
<td class="right total-cell">${formatNum(lineTotal)}</td>
|
||||||
</tr>`;
|
</tr>`;
|
||||||
})
|
})
|
||||||
@@ -848,13 +855,17 @@ ${indentCSS}
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th class="center" style="width:3%">${escapeHtml(t.col_no)}</th>
|
<th class="center" style="width:3%">${escapeHtml(t.col_no)}</th>
|
||||||
<th style="width:36%">${escapeHtml(t.col_desc)}</th>
|
<th style="width:${applyVat ? 36 : 46}%">${escapeHtml(t.col_desc)}</th>
|
||||||
<th class="center" style="width:10%">${escapeHtml(t.col_qty)}</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_unit_price)}</th>
|
||||||
<th class="right" style="width:10%">${escapeHtml(t.col_price)}</th>
|
<th class="right" style="width:10%">${escapeHtml(t.col_price)}</th>${
|
||||||
|
applyVat
|
||||||
|
? `
|
||||||
<th class="center" style="width:5%">${escapeHtml(t.col_vat_pct)}</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:10%">${escapeHtml(t.col_vat)}</th>`
|
||||||
<th class="right" style="width:16%">${escapeHtml(t.col_total)}</th>
|
: ""
|
||||||
|
}
|
||||||
|
<th class="right" style="width:${applyVat ? 16 : 21}%">${escapeHtml(t.col_total)}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -862,17 +873,21 @@ ${indentCSS}
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
<!-- Soucty -->
|
<!-- Soucty (bez DPH jen souhrnny radek - mezisoucet by ho jen opakoval) -->
|
||||||
<div class="totals-wrapper">
|
<div class="totals-wrapper">
|
||||||
<div class="totals">
|
<div class="totals">${
|
||||||
|
applyVat
|
||||||
|
? `
|
||||||
<div class="detail-rows">
|
<div class="detail-rows">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<span class="label">${escapeHtml(t.subtotal)}</span>
|
<span class="label">${escapeHtml(t.subtotal)}</span>
|
||||||
<span class="value">${formatNum(subtotal)} ${escapeHtml(currency)}</span>
|
<span class="value">${formatNum(subtotal)} ${escapeHtml(currency)}</span>
|
||||||
</div>${vatDetailHtml}
|
</div>${vatDetailHtml}
|
||||||
</div>
|
</div>`
|
||||||
|
: ""
|
||||||
|
}
|
||||||
<div class="grand">
|
<div class="grand">
|
||||||
<span class="label">${escapeHtml(t.total)}</span>
|
<span class="label">${escapeHtml(applyVat ? t.total : t.total_no_vat)}</span>
|
||||||
<span class="value">${formatNum(totalToPay)} ${escapeHtml(currency)}</span>
|
<span class="value">${formatNum(totalToPay)} ${escapeHtml(currency)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="currency-note">${escapeHtml(t.amounts_in)} ${escapeHtml(currency)}</div>
|
<div class="currency-note">${escapeHtml(t.amounts_in)} ${escapeHtml(currency)}</div>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
} from "../../schemas/received-invoices.schema";
|
} from "../../schemas/received-invoices.schema";
|
||||||
import { nasInvoicesManager } from "../../services/nas-financials-manager";
|
import { nasInvoicesManager } from "../../services/nas-financials-manager";
|
||||||
import { toCzk } from "../../services/exchange-rates";
|
import { toCzk } from "../../services/exchange-rates";
|
||||||
|
import { utcMidnightOfLocalDay } from "../../utils/date";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
|
|
||||||
const VALID_STATUSES = ["unpaid", "paid"] as const;
|
const VALID_STATUSES = ["unpaid", "paid"] as const;
|
||||||
@@ -561,14 +562,17 @@ export default async function receivedInvoicesRoutes(
|
|||||||
// `amount` is the GROSS total (VAT included); VAT is the portion within it.
|
// `amount` is the GROSS total (VAT included); VAT is the portion within it.
|
||||||
const computedVat = vatFromGross(finalAmount, finalVatRate);
|
const computedVat = vatFromGross(finalAmount, finalVatRate);
|
||||||
|
|
||||||
// Auto-set paid_date when status transitions to paid (matching PHP)
|
// Auto-set paid_date when status transitions to paid (matching PHP).
|
||||||
|
// paid_date is @db.Date (truncated to the UTC date part) —
|
||||||
|
// utcMidnightOfLocalDay keeps the LOCAL calendar day even during the
|
||||||
|
// 00:00–02:00 Prague window (a bare new Date() stored yesterday there).
|
||||||
const newStatus =
|
const newStatus =
|
||||||
body.status !== undefined
|
body.status !== undefined
|
||||||
? String(body.status)
|
? String(body.status)
|
||||||
: String(existing.status);
|
: String(existing.status);
|
||||||
const paidDate =
|
const paidDate =
|
||||||
newStatus === "paid" && String(existing.status) !== "paid"
|
newStatus === "paid" && String(existing.status) !== "paid"
|
||||||
? new Date()
|
? utcMidnightOfLocalDay()
|
||||||
: body.paid_date !== undefined
|
: body.paid_date !== undefined
|
||||||
? body.paid_date
|
? body.paid_date
|
||||||
? new Date(String(body.paid_date))
|
? new Date(String(body.paid_date))
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import {
|
import {
|
||||||
intIdFromForm,
|
intIdFromForm,
|
||||||
|
isoDateString,
|
||||||
nullableDateTimeString,
|
nullableDateTimeString,
|
||||||
nullableIntIdFromForm,
|
nullableIntIdFromForm,
|
||||||
numberFromForm,
|
numberFromForm,
|
||||||
@@ -73,7 +74,10 @@ export const AttendancePunchSchema = z.object({
|
|||||||
// NOT bare HH:MM times.
|
// NOT bare HH:MM times.
|
||||||
export const CreateAttendanceSchema = z.object({
|
export const CreateAttendanceSchema = z.object({
|
||||||
user_id: intIdFromForm.optional(),
|
user_id: intIdFromForm.optional(),
|
||||||
shift_date: z.string(),
|
// Must be a bare "YYYY-MM-DD" (parses to UTC midnight) — the service's
|
||||||
|
// duplicate-validation window and the stored @db.Date both depend on it.
|
||||||
|
// isoDateString leniently strips a trailing time component.
|
||||||
|
shift_date: isoDateString,
|
||||||
arrival_time: nullableDateTimeString.optional(),
|
arrival_time: nullableDateTimeString.optional(),
|
||||||
arrival_lat: numberFromForm.nullish(),
|
arrival_lat: numberFromForm.nullish(),
|
||||||
arrival_lng: numberFromForm.nullish(),
|
arrival_lng: numberFromForm.nullish(),
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ const ISSUED_ORDER_STATUSES = [
|
|||||||
|
|
||||||
export const CreateIssuedOrderSchema = z.object({
|
export const CreateIssuedOrderSchema = z.object({
|
||||||
po_number: z.string().max(50).nullish(),
|
po_number: z.string().max(50).nullish(),
|
||||||
customer_id: nullableIntIdFromForm.nullish(),
|
supplier_id: nullableIntIdFromForm.nullish(),
|
||||||
status: z.enum(ISSUED_ORDER_STATUSES).optional(),
|
status: z.enum(ISSUED_ORDER_STATUSES).optional(),
|
||||||
currency: z.string().max(10).optional(),
|
currency: z.string().max(10).optional(),
|
||||||
vat_rate: numberInRange(0, 100).optional(),
|
vat_rate: numberInRange(0, 100).optional(),
|
||||||
@@ -40,6 +40,9 @@ export const CreateIssuedOrderSchema = z.object({
|
|||||||
delivery_terms: z.string().max(500).nullish(),
|
delivery_terms: z.string().max(500).nullish(),
|
||||||
payment_terms: z.string().max(500).nullish(),
|
payment_terms: z.string().max(500).nullish(),
|
||||||
issued_by: z.string().max(255).nullish(),
|
issued_by: z.string().max(255).nullish(),
|
||||||
|
// Editable heading above the PDF items table; empty/null falls back to the
|
||||||
|
// default "Objednáváme si u Vás:" (issued-orders-pdf t.billing).
|
||||||
|
order_text: z.string().max(500).nullish(),
|
||||||
notes: z.string().nullish(),
|
notes: z.string().nullish(),
|
||||||
internal_notes: z.string().nullish(),
|
internal_notes: z.string().nullish(),
|
||||||
items: z.array(IssuedOrderItemSchema).optional(),
|
items: z.array(IssuedOrderItemSchema).optional(),
|
||||||
|
|||||||
@@ -189,12 +189,16 @@ export async function getStatus(userId: number) {
|
|||||||
const y = now.getFullYear(),
|
const y = now.getFullYear(),
|
||||||
m = now.getMonth(),
|
m = now.getMonth(),
|
||||||
d = now.getDate();
|
d = now.getDate();
|
||||||
const todayStart = new Date(y, m, d, 0, 0, 0);
|
// shift_date is @db.Date: Prisma compares it by its UTC date part, so the
|
||||||
const todayEnd = new Date(y, m, d, 23, 59, 59);
|
// range boundaries must be UTC midnights of the LOCAL calendar day. A local
|
||||||
|
// midnight (= 22:00/23:00 UTC of the previous day) shifts the whole window
|
||||||
|
// one day back. Half-open [gte, lt) ranges.
|
||||||
|
const todayStart = new Date(Date.UTC(y, m, d));
|
||||||
|
const todayEnd = new Date(Date.UTC(y, m, d + 1));
|
||||||
|
|
||||||
// Monthly fund range (used by query #4)
|
// Monthly fund range (used by query #4)
|
||||||
const monthStart = new Date(y, m, 1);
|
const monthStart = new Date(Date.UTC(y, m, 1));
|
||||||
const monthEnd = new Date(y, m + 1, 0, 23, 59, 59);
|
const monthEnd = new Date(Date.UTC(y, m + 1, 1));
|
||||||
|
|
||||||
// Queries 1-4 are independent of one another → run them in parallel.
|
// Queries 1-4 are independent of one another → run them in parallel.
|
||||||
const [ongoingShift, todayShiftsRaw, balance, monthRecords] =
|
const [ongoingShift, todayShiftsRaw, balance, monthRecords] =
|
||||||
@@ -212,7 +216,7 @@ export async function getStatus(userId: number) {
|
|||||||
prisma.attendance.findMany({
|
prisma.attendance.findMany({
|
||||||
where: {
|
where: {
|
||||||
user_id: userId,
|
user_id: userId,
|
||||||
shift_date: { gte: todayStart, lte: todayEnd },
|
shift_date: { gte: todayStart, lt: todayEnd },
|
||||||
departure_time: { not: null },
|
departure_time: { not: null },
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
@@ -228,7 +232,7 @@ export async function getStatus(userId: number) {
|
|||||||
prisma.attendance.findMany({
|
prisma.attendance.findMany({
|
||||||
where: {
|
where: {
|
||||||
user_id: userId,
|
user_id: userId,
|
||||||
shift_date: { gte: monthStart, lte: monthEnd },
|
shift_date: { gte: monthStart, lt: monthEnd },
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
@@ -585,10 +589,13 @@ export async function getWorkfund(year: number) {
|
|||||||
: bizDays;
|
: bizDays;
|
||||||
const fund = bizDays * 8;
|
const fund = bizDays * 8;
|
||||||
const fundToDate = bizDaysToDate * 8;
|
const fundToDate = bizDaysToDate * 8;
|
||||||
const monthStart = new Date(year, m, 1);
|
// shift_date is @db.Date (compared by UTC date part) → UTC-midnight
|
||||||
const monthEnd = new Date(year, m + 1, 0, 23, 59, 59);
|
// month boundaries, half-open range. Local midnights would double-count
|
||||||
|
// each previous month's last day.
|
||||||
|
const monthStart = new Date(Date.UTC(year, m, 1));
|
||||||
|
const monthEnd = new Date(Date.UTC(year, m + 1, 1));
|
||||||
const monthRecords = await prisma.attendance.findMany({
|
const monthRecords = await prisma.attendance.findMany({
|
||||||
where: { shift_date: { gte: monthStart, lte: monthEnd } },
|
where: { shift_date: { gte: monthStart, lt: monthEnd } },
|
||||||
select: {
|
select: {
|
||||||
user_id: true,
|
user_id: true,
|
||||||
shift_date: true,
|
shift_date: true,
|
||||||
@@ -846,13 +853,16 @@ export async function getPrintData(
|
|||||||
const yr = Number(yearStr);
|
const yr = Number(yearStr);
|
||||||
const mo = Number(monthNumStr);
|
const mo = Number(monthNumStr);
|
||||||
|
|
||||||
const monthStart = new Date(yr, mo - 1, 1);
|
// shift_date is @db.Date (compared by UTC date part) → UTC-midnight month
|
||||||
const monthEnd = new Date(yr, mo, 0, 23, 59, 59);
|
// boundaries, half-open range (local midnights pulled in the previous
|
||||||
|
// month's last day).
|
||||||
|
const monthStart = new Date(Date.UTC(yr, mo - 1, 1));
|
||||||
|
const monthEnd = new Date(Date.UTC(yr, mo, 1));
|
||||||
|
|
||||||
const users = await getAttendanceUsers();
|
const users = await getAttendanceUsers();
|
||||||
|
|
||||||
const where: Record<string, unknown> = {
|
const where: Record<string, unknown> = {
|
||||||
shift_date: { gte: monthStart, lte: monthEnd },
|
shift_date: { gte: monthStart, lt: monthEnd },
|
||||||
};
|
};
|
||||||
if (filterUserId) where.user_id = filterUserId;
|
if (filterUserId) where.user_id = filterUserId;
|
||||||
|
|
||||||
@@ -1043,9 +1053,12 @@ export async function listAttendance(params: ListAttendanceParams) {
|
|||||||
where.user_id = params.userId;
|
where.user_id = params.userId;
|
||||||
}
|
}
|
||||||
if (params.month && params.year) {
|
if (params.month && params.year) {
|
||||||
|
// shift_date is @db.Date: Prisma compares by UTC date part, so the month
|
||||||
|
// boundaries must be UTC midnights. Local midnights shifted the window a
|
||||||
|
// day back (included prev-month's last day, dropped this month's last day).
|
||||||
where.shift_date = {
|
where.shift_date = {
|
||||||
gte: new Date(params.year, params.month - 1, 1),
|
gte: new Date(Date.UTC(params.year, params.month - 1, 1)),
|
||||||
lt: new Date(params.year, params.month, 1),
|
lt: new Date(Date.UTC(params.year, params.month, 1)),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1527,15 +1540,25 @@ export async function createAttendance(
|
|||||||
) {
|
) {
|
||||||
const userId = data.user_id ?? authUserId;
|
const userId = data.user_id ?? authUserId;
|
||||||
const shiftDate = new Date(data.shift_date);
|
const shiftDate = new Date(data.shift_date);
|
||||||
|
// shift_date arrives as "YYYY-MM-DD" → parsed as UTC midnight, and the
|
||||||
|
// @db.Date column is compared by UTC date part. The duplicate/overlap
|
||||||
|
// window must therefore be the UTC day [shiftDate, shiftDate+1) — building
|
||||||
|
// it from LOCAL getters made both bounds 22:00/23:00 UTC of the previous
|
||||||
|
// day, so the validation queried ONLY yesterday's records (same-day
|
||||||
|
// overlaps undetected, false duplicates when yesterday had records).
|
||||||
const startOfDay = new Date(
|
const startOfDay = new Date(
|
||||||
shiftDate.getFullYear(),
|
Date.UTC(
|
||||||
shiftDate.getMonth(),
|
shiftDate.getUTCFullYear(),
|
||||||
shiftDate.getDate(),
|
shiftDate.getUTCMonth(),
|
||||||
|
shiftDate.getUTCDate(),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
const endOfDay = new Date(
|
const endOfDay = new Date(
|
||||||
shiftDate.getFullYear(),
|
Date.UTC(
|
||||||
shiftDate.getMonth(),
|
shiftDate.getUTCFullYear(),
|
||||||
shiftDate.getDate() + 1,
|
shiftDate.getUTCMonth(),
|
||||||
|
shiftDate.getUTCDate() + 1,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Multiple work shifts per day are allowed — only block when the new
|
// Multiple work shifts per day are allowed — only block when the new
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import prisma from "../config/database";
|
import prisma from "../config/database";
|
||||||
import { config } from "../config/env";
|
import { config } from "../config/env";
|
||||||
import { sendMail } from "./mailer";
|
import { sendMail } from "./mailer";
|
||||||
import { localDateCzStr, localDateStr } from "../utils/date";
|
import {
|
||||||
|
localDateCzStr,
|
||||||
|
localDateStr,
|
||||||
|
utcMidnightOfLocalDay,
|
||||||
|
} from "../utils/date";
|
||||||
import { getSystemSettings } from "./system-settings";
|
import { getSystemSettings } from "./system-settings";
|
||||||
|
|
||||||
interface AlertInvoice {
|
interface AlertInvoice {
|
||||||
@@ -33,17 +37,35 @@ function formatAmount(n: number | { toNumber?: () => number }): string {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alert window [today, today+3] for the due-date queries + the local date
|
||||||
|
* strings the classifier matches against.
|
||||||
|
*
|
||||||
|
* due_date is @db.Date: Prisma truncates the filter Dates to their UTC date
|
||||||
|
* part, so the window boundaries must be UTC midnights of the LOCAL calendar
|
||||||
|
* day. A local midnight (22:00/23:00 UTC of the previous day) shifted the
|
||||||
|
* whole window a day early — the "splatnost za 3 dny" advance alert
|
||||||
|
* (due == today+3) could then never fire.
|
||||||
|
*
|
||||||
|
* Exported (with an injectable `now`) so the window math is unit-testable.
|
||||||
|
*/
|
||||||
|
export function computeAlertWindow(now: Date = new Date()) {
|
||||||
|
const today = utcMidnightOfLocalDay(now);
|
||||||
|
const todayStr = localDateStr(now);
|
||||||
|
const in3days = new Date(today);
|
||||||
|
in3days.setUTCDate(in3days.getUTCDate() + 3);
|
||||||
|
const in3daysStr = localDateStr(
|
||||||
|
new Date(now.getFullYear(), now.getMonth(), now.getDate() + 3),
|
||||||
|
);
|
||||||
|
return { today, todayStr, in3days, in3daysStr };
|
||||||
|
}
|
||||||
|
|
||||||
export async function checkInvoiceAlerts(): Promise<void> {
|
export async function checkInvoiceAlerts(): Promise<void> {
|
||||||
const settings = await getSystemSettings();
|
const settings = await getSystemSettings();
|
||||||
const alertEmail = settings.invoice_alert_email || config.email.invoiceAlert;
|
const alertEmail = settings.invoice_alert_email || config.email.invoiceAlert;
|
||||||
if (!alertEmail) return;
|
if (!alertEmail) return;
|
||||||
|
|
||||||
const today = new Date();
|
const { today, todayStr, in3days, in3daysStr } = computeAlertWindow();
|
||||||
today.setHours(0, 0, 0, 0);
|
|
||||||
const todayStr = localDateStr(today);
|
|
||||||
const in3days = new Date(today);
|
|
||||||
in3days.setDate(in3days.getDate() + 3);
|
|
||||||
const in3daysStr = localDateStr(in3days);
|
|
||||||
|
|
||||||
// Classify a due date into an alert type/label, or null if it doesn't match.
|
// Classify a due date into an alert type/label, or null if it doesn't match.
|
||||||
const classify = (
|
const classify = (
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import prisma from "../config/database";
|
import prisma from "../config/database";
|
||||||
|
import { utcMidnightOfLocalDay } from "../utils/date";
|
||||||
import { toCzk } from "./exchange-rates";
|
import { toCzk } from "./exchange-rates";
|
||||||
import {
|
import {
|
||||||
generateInvoiceNumber,
|
generateInvoiceNumber,
|
||||||
@@ -139,8 +140,12 @@ function buildInvoiceWhere(
|
|||||||
if (status) where.status = status;
|
if (status) where.status = status;
|
||||||
if (customer_id) where.customer_id = customer_id;
|
if (customer_id) where.customer_id = customer_id;
|
||||||
if (month && year) {
|
if (month && year) {
|
||||||
const from = new Date(year, month - 1, 1);
|
// issue_date is @db.Date: Prisma compares by UTC date part, so the month
|
||||||
const to = new Date(year, month, 1);
|
// boundaries must be UTC midnights. Local midnights shifted the window a
|
||||||
|
// day back — the filter included the previous month's last day and
|
||||||
|
// DROPPED invoices issued on the selected month's last day.
|
||||||
|
const from = new Date(Date.UTC(year, month - 1, 1));
|
||||||
|
const to = new Date(Date.UTC(year, month, 1));
|
||||||
where.issue_date = { gte: from, lt: to };
|
where.issue_date = { gte: from, lt: to };
|
||||||
}
|
}
|
||||||
if (search) {
|
if (search) {
|
||||||
@@ -180,13 +185,18 @@ function computeInvoiceTotals(
|
|||||||
|
|
||||||
export async function markOverdueInvoices() {
|
export async function markOverdueInvoices() {
|
||||||
try {
|
try {
|
||||||
|
// due_date is @db.Date (UTC midnight of the calendar day). Compare
|
||||||
|
// against UTC midnight of the LOCAL today — a bare new Date() has
|
||||||
|
// yesterday's UTC date during the 00:00–02:00 Prague window, so the
|
||||||
|
// overdue flip lagged a day there. Due TODAY = not yet overdue.
|
||||||
|
const today = utcMidnightOfLocalDay();
|
||||||
await prisma.invoices.updateMany({
|
await prisma.invoices.updateMany({
|
||||||
where: { status: "issued", due_date: { lt: new Date() } },
|
where: { status: "issued", due_date: { lt: today } },
|
||||||
data: { status: "overdue" },
|
data: { status: "overdue" },
|
||||||
});
|
});
|
||||||
// Reverse: if due_date was changed to future, set back to issued
|
// Reverse: if due_date was changed to today/future, set back to issued
|
||||||
await prisma.invoices.updateMany({
|
await prisma.invoices.updateMany({
|
||||||
where: { status: "overdue", due_date: { gte: new Date() } },
|
where: { status: "overdue", due_date: { gte: today } },
|
||||||
data: { status: "issued" },
|
data: { status: "issued" },
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -312,10 +322,13 @@ export async function getInvoiceStats(queryMonth?: number, queryYear?: number) {
|
|||||||
const year = queryYear || now.getFullYear();
|
const year = queryYear || now.getFullYear();
|
||||||
const month = queryMonth || now.getMonth() + 1;
|
const month = queryMonth || now.getMonth() + 1;
|
||||||
|
|
||||||
const monthStart = new Date(year, month - 1, 1);
|
// issue_date is @db.Date (compared by UTC date part) → UTC-midnight period
|
||||||
const monthEnd = new Date(year, month, 0, 23, 59, 59);
|
// boundaries, half-open [gte, lt) ranges. Local-midnight bounds included
|
||||||
const startOfYear = new Date(year, 0, 1);
|
// the previous period's boundary day in the stats.
|
||||||
const endOfYear = new Date(year, 11, 31, 23, 59, 59);
|
const monthStart = new Date(Date.UTC(year, month - 1, 1));
|
||||||
|
const nextMonthStart = new Date(Date.UTC(year, month, 1));
|
||||||
|
const startOfYear = new Date(Date.UTC(year, 0, 1));
|
||||||
|
const startOfNextYear = new Date(Date.UTC(year + 1, 0, 1));
|
||||||
|
|
||||||
const [monthInvoices, awaitingInvoices, overdueInvoices] = await Promise.all([
|
const [monthInvoices, awaitingInvoices, overdueInvoices] = await Promise.all([
|
||||||
prisma.invoices.findMany({
|
prisma.invoices.findMany({
|
||||||
@@ -323,21 +336,21 @@ export async function getInvoiceStats(queryMonth?: number, queryYear?: number) {
|
|||||||
// Drafts are not real financial documents — exclude them so their
|
// Drafts are not real financial documents — exclude them so their
|
||||||
// VAT/amounts never inflate the monthly stats (vat_month etc.).
|
// VAT/amounts never inflate the monthly stats (vat_month etc.).
|
||||||
status: { not: "draft" },
|
status: { not: "draft" },
|
||||||
issue_date: { gte: monthStart, lte: monthEnd },
|
issue_date: { gte: monthStart, lt: nextMonthStart },
|
||||||
},
|
},
|
||||||
include: { invoice_items: true },
|
include: { invoice_items: true },
|
||||||
}),
|
}),
|
||||||
prisma.invoices.findMany({
|
prisma.invoices.findMany({
|
||||||
where: {
|
where: {
|
||||||
status: "issued",
|
status: "issued",
|
||||||
issue_date: { gte: startOfYear, lte: endOfYear },
|
issue_date: { gte: startOfYear, lt: startOfNextYear },
|
||||||
},
|
},
|
||||||
include: { invoice_items: true },
|
include: { invoice_items: true },
|
||||||
}),
|
}),
|
||||||
prisma.invoices.findMany({
|
prisma.invoices.findMany({
|
||||||
where: {
|
where: {
|
||||||
status: "overdue",
|
status: "overdue",
|
||||||
issue_date: { gte: startOfYear, lte: endOfYear },
|
issue_date: { gte: startOfYear, lt: startOfNextYear },
|
||||||
},
|
},
|
||||||
include: { invoice_items: true },
|
include: { invoice_items: true },
|
||||||
}),
|
}),
|
||||||
@@ -601,9 +614,11 @@ export async function updateInvoice(id: number, body: InvoiceInput) {
|
|||||||
// Status change
|
// Status change
|
||||||
if (body.status !== undefined) {
|
if (body.status !== undefined) {
|
||||||
data.status = String(body.status);
|
data.status = String(body.status);
|
||||||
// Auto-set paid_date when transitioning to paid
|
// Auto-set paid_date when transitioning to paid. paid_date is @db.Date
|
||||||
|
// (truncated to the UTC date part) — utcMidnightOfLocalDay keeps the
|
||||||
|
// LOCAL calendar day even during the 00:00–02:00 Prague window.
|
||||||
if (String(body.status) === "paid" && !existing.paid_date) {
|
if (String(body.status) === "paid" && !existing.paid_date) {
|
||||||
data.paid_date = new Date();
|
data.paid_date = utcMidnightOfLocalDay();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { $Enums } from "@prisma/client";
|
import type { $Enums } from "@prisma/client";
|
||||||
import prisma from "../config/database";
|
import prisma from "../config/database";
|
||||||
|
import { utcMidnightOfLocalDay } from "../utils/date";
|
||||||
import {
|
import {
|
||||||
generateIssuedOrderNumber,
|
generateIssuedOrderNumber,
|
||||||
previewIssuedOrderNumber,
|
previewIssuedOrderNumber,
|
||||||
@@ -19,7 +20,7 @@ export interface IssuedOrderItemInput {
|
|||||||
|
|
||||||
export interface IssuedOrderInput {
|
export interface IssuedOrderInput {
|
||||||
po_number?: string | number | null;
|
po_number?: string | number | null;
|
||||||
customer_id?: number | string | null;
|
supplier_id?: number | string | null;
|
||||||
status?: string;
|
status?: string;
|
||||||
currency?: string;
|
currency?: string;
|
||||||
vat_rate?: number | string | null;
|
vat_rate?: number | string | null;
|
||||||
@@ -31,6 +32,7 @@ export interface IssuedOrderInput {
|
|||||||
delivery_terms?: string | null;
|
delivery_terms?: string | null;
|
||||||
payment_terms?: string | null;
|
payment_terms?: string | null;
|
||||||
issued_by?: string | null;
|
issued_by?: string | null;
|
||||||
|
order_text?: string | null;
|
||||||
notes?: string | null;
|
notes?: string | null;
|
||||||
internal_notes?: string | null;
|
internal_notes?: string | null;
|
||||||
items?: IssuedOrderItemInput[];
|
items?: IssuedOrderItemInput[];
|
||||||
@@ -40,7 +42,7 @@ export interface IssuedOrderInput {
|
|||||||
interface IssuedOrderFilterParams {
|
interface IssuedOrderFilterParams {
|
||||||
search?: string;
|
search?: string;
|
||||||
status?: string;
|
status?: string;
|
||||||
customer_id?: number;
|
supplier_id?: number;
|
||||||
month?: number;
|
month?: number;
|
||||||
year?: number;
|
year?: number;
|
||||||
}
|
}
|
||||||
@@ -66,20 +68,23 @@ export interface CurrencyAmount {
|
|||||||
function buildIssuedOrderWhere(
|
function buildIssuedOrderWhere(
|
||||||
params: IssuedOrderFilterParams,
|
params: IssuedOrderFilterParams,
|
||||||
): Record<string, unknown> {
|
): Record<string, unknown> {
|
||||||
const { search, status, customer_id, month, year } = params;
|
const { search, status, supplier_id, month, year } = params;
|
||||||
const where: Record<string, unknown> = {};
|
const where: Record<string, unknown> = {};
|
||||||
if (status) where.status = status;
|
if (status) where.status = status;
|
||||||
if (customer_id) where.customer_id = customer_id;
|
if (supplier_id) where.supplier_id = supplier_id;
|
||||||
if (search) {
|
if (search) {
|
||||||
where.OR = [
|
where.OR = [
|
||||||
{ po_number: { contains: search } },
|
{ po_number: { contains: search } },
|
||||||
{ customers: { name: { contains: search } } },
|
{ suppliers: { name: { contains: search } } },
|
||||||
{ customers: { company_id: { contains: search } } },
|
{ suppliers: { ico: { contains: search } } },
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
if (month && year) {
|
if (month && year) {
|
||||||
const from = new Date(year, month - 1, 1);
|
// order_date is @db.Date: Prisma compares by UTC date part, so the month
|
||||||
const to = new Date(year, month, 1);
|
// boundaries must be UTC midnights. Local midnights shifted the window a
|
||||||
|
// day back (included prev-month's last day, dropped this month's last day).
|
||||||
|
const from = new Date(Date.UTC(year, month - 1, 1));
|
||||||
|
const to = new Date(Date.UTC(year, month, 1));
|
||||||
where.order_date = { gte: from, lt: to };
|
where.order_date = { gte: from, lt: to };
|
||||||
}
|
}
|
||||||
return where;
|
return where;
|
||||||
@@ -150,7 +155,7 @@ export async function listIssuedOrders(params: ListIssuedOrdersParams) {
|
|||||||
take: limit,
|
take: limit,
|
||||||
orderBy,
|
orderBy,
|
||||||
include: {
|
include: {
|
||||||
customers: { select: { id: true, name: true } },
|
suppliers: { select: { id: true, name: true } },
|
||||||
issued_order_items: true,
|
issued_order_items: true,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@@ -167,7 +172,7 @@ export async function listIssuedOrders(params: ListIssuedOrdersParams) {
|
|||||||
return {
|
return {
|
||||||
...rest,
|
...rest,
|
||||||
items: issued_order_items,
|
items: issued_order_items,
|
||||||
customer_name: o.customers?.name || null,
|
supplier_name: o.suppliers?.name || null,
|
||||||
...totals,
|
...totals,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -217,7 +222,19 @@ export async function getIssuedOrder(id: number) {
|
|||||||
const order = await prisma.issued_orders.findUnique({
|
const order = await prisma.issued_orders.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
include: {
|
include: {
|
||||||
customers: true,
|
// Same minimal field set as the picker lookup endpoint — the full row
|
||||||
|
// would expose internal notes/contact to any orders.view holder.
|
||||||
|
suppliers: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
ico: true,
|
||||||
|
dic: true,
|
||||||
|
address: true,
|
||||||
|
email: true,
|
||||||
|
phone: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
issued_order_items: { orderBy: { position: "asc" } },
|
issued_order_items: { orderBy: { position: "asc" } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -226,8 +243,8 @@ export async function getIssuedOrder(id: number) {
|
|||||||
return {
|
return {
|
||||||
...rest,
|
...rest,
|
||||||
items: issued_order_items,
|
items: issued_order_items,
|
||||||
customer: order.customers,
|
supplier: order.suppliers,
|
||||||
customer_name: order.customers?.name || null,
|
supplier_name: order.suppliers?.name || null,
|
||||||
valid_transitions: VALID_TRANSITIONS[order.status as string] || [],
|
valid_transitions: VALID_TRANSITIONS[order.status as string] || [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -236,6 +253,17 @@ export async function createIssuedOrder(body: IssuedOrderInput) {
|
|||||||
return prisma.$transaction(async (tx) => {
|
return prisma.$transaction(async (tx) => {
|
||||||
const status = body.status ? String(body.status) : "draft";
|
const status = body.status ? String(body.status) : "draft";
|
||||||
|
|
||||||
|
// Validate the referenced supplier exists BEFORE the insert — a dangling
|
||||||
|
// FK would otherwise surface as a P2003 500 instead of a clean 400.
|
||||||
|
const supplierId = body.supplier_id ? Number(body.supplier_id) : null;
|
||||||
|
if (supplierId) {
|
||||||
|
const supplier = await tx.sklad_suppliers.findUnique({
|
||||||
|
where: { id: supplierId },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!supplier) return { error: "supplier_not_found" as const };
|
||||||
|
}
|
||||||
|
|
||||||
// Deferred numbering: a draft carries NO po_number (the column is
|
// Deferred numbering: a draft carries NO po_number (the column is
|
||||||
// nullable-unique so many drafts coexist). The official number is consumed
|
// nullable-unique so many drafts coexist). The official number is consumed
|
||||||
// only when the draft is finalized (assignIssuedOrderNumber on draft->sent).
|
// only when the draft is finalized (assignIssuedOrderNumber on draft->sent).
|
||||||
@@ -255,16 +283,19 @@ export async function createIssuedOrder(body: IssuedOrderInput) {
|
|||||||
const order = await tx.issued_orders.create({
|
const order = await tx.issued_orders.create({
|
||||||
data: {
|
data: {
|
||||||
po_number: poNumber,
|
po_number: poNumber,
|
||||||
customer_id: body.customer_id ? Number(body.customer_id) : null,
|
supplier_id: supplierId,
|
||||||
status: status as $Enums.issued_orders_status,
|
status: status as $Enums.issued_orders_status,
|
||||||
currency: body.currency ? String(body.currency) : "CZK",
|
currency: body.currency ? String(body.currency) : "CZK",
|
||||||
vat_rate: body.vat_rate != null ? Number(body.vat_rate) : 21.0,
|
vat_rate: body.vat_rate != null ? Number(body.vat_rate) : 21.0,
|
||||||
apply_vat: body.apply_vat !== false,
|
apply_vat: body.apply_vat !== false,
|
||||||
exchange_rate:
|
exchange_rate:
|
||||||
body.exchange_rate != null ? Number(body.exchange_rate) : 1.0,
|
body.exchange_rate != null ? Number(body.exchange_rate) : 1.0,
|
||||||
|
// order_date is @db.Date (truncated to the UTC date part) — the
|
||||||
|
// "today" default must be utcMidnightOfLocalDay, or it stores
|
||||||
|
// yesterday during the 00:00–02:00 Prague window.
|
||||||
order_date: body.order_date
|
order_date: body.order_date
|
||||||
? new Date(String(body.order_date))
|
? new Date(String(body.order_date))
|
||||||
: new Date(),
|
: utcMidnightOfLocalDay(),
|
||||||
delivery_date: body.delivery_date
|
delivery_date: body.delivery_date
|
||||||
? new Date(String(body.delivery_date))
|
? new Date(String(body.delivery_date))
|
||||||
: null,
|
: null,
|
||||||
@@ -274,6 +305,7 @@ export async function createIssuedOrder(body: IssuedOrderInput) {
|
|||||||
: null,
|
: null,
|
||||||
payment_terms: body.payment_terms ? String(body.payment_terms) : null,
|
payment_terms: body.payment_terms ? String(body.payment_terms) : null,
|
||||||
issued_by: body.issued_by ? String(body.issued_by) : null,
|
issued_by: body.issued_by ? String(body.issued_by) : null,
|
||||||
|
order_text: body.order_text ? String(body.order_text) : null,
|
||||||
notes: body.notes ? String(body.notes) : null,
|
notes: body.notes ? String(body.notes) : null,
|
||||||
internal_notes: body.internal_notes
|
internal_notes: body.internal_notes
|
||||||
? String(body.internal_notes)
|
? String(body.internal_notes)
|
||||||
@@ -324,12 +356,23 @@ export async function updateIssuedOrder(id: number, body: IssuedOrderInput) {
|
|||||||
"delivery_terms",
|
"delivery_terms",
|
||||||
"payment_terms",
|
"payment_terms",
|
||||||
"issued_by",
|
"issued_by",
|
||||||
|
"order_text",
|
||||||
];
|
];
|
||||||
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.supplier_id !== undefined) {
|
||||||
data.customer_id = body.customer_id ? Number(body.customer_id) : null;
|
const supplierId = body.supplier_id ? Number(body.supplier_id) : null;
|
||||||
|
if (supplierId) {
|
||||||
|
// Same dangling-FK guard as create: 400, not a P2003 500.
|
||||||
|
const supplier = await prisma.sklad_suppliers.findUnique({
|
||||||
|
where: { id: supplierId },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!supplier) return { error: "supplier_not_found" as const };
|
||||||
|
}
|
||||||
|
data.supplier_id = supplierId;
|
||||||
|
}
|
||||||
if (body.vat_rate !== undefined) data.vat_rate = Number(body.vat_rate);
|
if (body.vat_rate !== undefined) data.vat_rate = Number(body.vat_rate);
|
||||||
if (body.apply_vat !== undefined)
|
if (body.apply_vat !== undefined)
|
||||||
data.apply_vat =
|
data.apply_vat =
|
||||||
|
|||||||
@@ -8,6 +8,22 @@
|
|||||||
* of JSON serialization (e.g., building lookup keys, shift_date strings).
|
* of JSON serialization (e.g., building lookup keys, shift_date strings).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UTC midnight of the LOCAL calendar day of `d` (default: now).
|
||||||
|
*
|
||||||
|
* Prisma truncates a JS Date used against a `@db.Date` column (filter or
|
||||||
|
* write) to its **UTC** date part. With TZ=Europe/Prague, a local-midnight
|
||||||
|
* Date — `new Date(y, m, d)` — is 22:00/23:00 UTC of the PREVIOUS day, so it
|
||||||
|
* filters/stores as the previous calendar date; and a bare `new Date()`
|
||||||
|
* written to a `@db.Date` column stores yesterday during the 00:00–02:00
|
||||||
|
* Prague window. Always build `@db.Date` values and range boundaries from
|
||||||
|
* `Date.UTC(...)` of the LOCAL calendar components — this helper does exactly
|
||||||
|
* that for "today" (or any reference Date).
|
||||||
|
*/
|
||||||
|
export function utcMidnightOfLocalDay(d: Date = new Date()): Date {
|
||||||
|
return new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()));
|
||||||
|
}
|
||||||
|
|
||||||
/** YYYY-MM-DD in local time */
|
/** YYYY-MM-DD in local time */
|
||||||
export function localDateStr(d: Date): string {
|
export function localDateStr(d: Date): string {
|
||||||
const y = d.getFullYear();
|
const y = d.getFullYear();
|
||||||
|
|||||||
Reference in New Issue
Block a user