Compare commits
15 Commits
v2.4.22
...
bf33d0a543
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf33d0a543 | ||
|
|
64bb7f9c37 | ||
|
|
6f3f1c40e7 | ||
|
|
164ce54adf | ||
|
|
6877919133 | ||
|
|
5ec70599aa | ||
|
|
7e4469b29a | ||
|
|
08af4cd0ae | ||
|
|
eb279a87a9 | ||
|
|
ea3b595b32 | ||
|
|
e9b432bec6 | ||
|
|
741c8109fa | ||
|
|
07d9c8d9f7 | ||
|
|
0928b3636e | ||
|
|
738f852168 |
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "app-ts",
|
||||
"version": "2.4.22",
|
||||
"version": "2.4.29",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "app-ts",
|
||||
"version": "2.4.22",
|
||||
"version": "2.4.29",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.102.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "app-ts",
|
||||
"version": "2.4.22",
|
||||
"version": "2.4.29",
|
||||
"description": "",
|
||||
"main": "dist/server.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE `project_notes` ADD COLUMN `edited_at` DATETIME(0) NULL;
|
||||
|
||||
@@ -457,6 +457,7 @@ model project_notes {
|
||||
user_name String? @db.VarChar(100)
|
||||
content String? @db.Text
|
||||
created_at DateTime? @default(now()) @db.DateTime(0)
|
||||
edited_at DateTime? @db.DateTime(0)
|
||||
projects projects @relation(fields: [project_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "project_notes_ibfk_1")
|
||||
|
||||
@@index([project_id], map: "project_id")
|
||||
|
||||
@@ -112,6 +112,16 @@ beforeAll(async () => {
|
||||
},
|
||||
});
|
||||
fixUser2Id = u2.id;
|
||||
// Leave balance for the HR tools (far-future year per fixture hygiene).
|
||||
await prisma.leave_balances.create({
|
||||
data: {
|
||||
user_id: fixUserId,
|
||||
year: 2098,
|
||||
vacation_total: 25,
|
||||
vacation_used: 5,
|
||||
sick_used: 2,
|
||||
},
|
||||
});
|
||||
await prisma.attendance.create({
|
||||
data: {
|
||||
user_id: fixUserId,
|
||||
@@ -177,12 +187,57 @@ beforeAll(async () => {
|
||||
});
|
||||
|
||||
const FIX_OFFER_NUMBER = "2098/NA/99999";
|
||||
const FIX_SUPPLIER = "AiTest Dodavatel s.r.o.";
|
||||
const FIX_RI_SUPPLIER = "AiTest Supplier s.r.o.";
|
||||
const FIX_CUSTOMER = "AiTest Zákazník s.r.o.";
|
||||
let fixOfferId = 0;
|
||||
let fixSupplierId = 0;
|
||||
let fixRiId = 0;
|
||||
let fixCustomerId = 0;
|
||||
|
||||
beforeAll(async () => {
|
||||
await prisma.quotations.deleteMany({
|
||||
where: { quotation_number: FIX_OFFER_NUMBER },
|
||||
});
|
||||
await prisma.sklad_suppliers.deleteMany({ where: { name: FIX_SUPPLIER } });
|
||||
await prisma.received_invoices.deleteMany({
|
||||
where: { supplier_name: FIX_RI_SUPPLIER },
|
||||
});
|
||||
const sup = await prisma.sklad_suppliers.create({
|
||||
data: { name: FIX_SUPPLIER, ico: "99999998", city: "Brno" },
|
||||
});
|
||||
fixSupplierId = sup.id;
|
||||
// Customer + two projects for the per-customer aggregation tool.
|
||||
await prisma.projects.deleteMany({
|
||||
where: { name: { startsWith: "AiTest Projekt" } },
|
||||
});
|
||||
await prisma.customers.deleteMany({ where: { name: FIX_CUSTOMER } });
|
||||
const cust = await prisma.customers.create({
|
||||
data: { name: FIX_CUSTOMER },
|
||||
});
|
||||
fixCustomerId = cust.id;
|
||||
await prisma.projects.createMany({
|
||||
data: [
|
||||
{ name: "AiTest Projekt 1", customer_id: fixCustomerId },
|
||||
{ name: "AiTest Projekt 2", customer_id: fixCustomerId },
|
||||
],
|
||||
});
|
||||
const ri = await prisma.received_invoices.create({
|
||||
data: {
|
||||
month: 6,
|
||||
year: 2098,
|
||||
supplier_name: FIX_RI_SUPPLIER,
|
||||
invoice_number: "AITEST-RI-1",
|
||||
amount: 1210,
|
||||
vat_rate: 21,
|
||||
vat_amount: 210,
|
||||
currency: "CZK",
|
||||
issue_date: new Date(Date.UTC(2098, 5, 1)),
|
||||
due_date: new Date(Date.UTC(2098, 5, 15)),
|
||||
status: "unpaid",
|
||||
},
|
||||
});
|
||||
fixRiId = ri.id;
|
||||
const q = await prisma.quotations.create({
|
||||
data: {
|
||||
quotation_number: FIX_OFFER_NUMBER,
|
||||
@@ -225,6 +280,11 @@ beforeAll(async () => {
|
||||
afterAll(async () => {
|
||||
// Items/sections cascade with the quotation.
|
||||
await prisma.quotations.deleteMany({ where: { id: fixOfferId } });
|
||||
await prisma.sklad_suppliers.deleteMany({ where: { id: fixSupplierId } });
|
||||
await prisma.received_invoices.deleteMany({ where: { id: fixRiId } });
|
||||
// Projects before the customer (Restrict FK on projects.customer_id).
|
||||
await prisma.projects.deleteMany({ where: { customer_id: fixCustomerId } });
|
||||
await prisma.customers.deleteMany({ where: { id: fixCustomerId } });
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -511,6 +571,298 @@ describe("ai-tools — employees, attendance detail, trips, work plan", () => {
|
||||
expect(denied.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("get_leave_balance: own for a recorder; foreign needs balances/manage", async () => {
|
||||
const own = await executeTool(
|
||||
"get_leave_balance",
|
||||
{ year: 2098 },
|
||||
{
|
||||
userId: fixUserId,
|
||||
roleName: "viewer",
|
||||
permissions: ["attendance.record"],
|
||||
},
|
||||
);
|
||||
expect(own.ok).toBe(true);
|
||||
expect(own.result).toMatchObject({
|
||||
user_id: fixUserId,
|
||||
year: 2098,
|
||||
vacation_total: 25,
|
||||
vacation_used: 5,
|
||||
vacation_remaining: 20,
|
||||
sick_used: 2,
|
||||
});
|
||||
|
||||
// Bare attendance.record asking about someone else → denied.
|
||||
const denied = await executeTool(
|
||||
"get_leave_balance",
|
||||
{ user_id: fixUserId, year: 2098 },
|
||||
RECORDER,
|
||||
);
|
||||
expect(denied.ok).toBe(false);
|
||||
|
||||
// attendance.balances (the overview route's permission) unlocks others.
|
||||
const hr = await executeTool(
|
||||
"get_leave_balance",
|
||||
{ user_id: fixUserId, year: 2098 },
|
||||
{
|
||||
userId: 999999998,
|
||||
roleName: "viewer",
|
||||
permissions: ["attendance.balances"],
|
||||
},
|
||||
);
|
||||
expect(hr.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("get_work_fund returns the current-month fund; foreign needs manage", async () => {
|
||||
const res = await executeTool(
|
||||
"get_work_fund",
|
||||
{ user_id: fixUserId },
|
||||
ADMIN,
|
||||
);
|
||||
expect(res.ok).toBe(true);
|
||||
const r = res.result as {
|
||||
fund_hours: number;
|
||||
worked_hours: number;
|
||||
business_days: number;
|
||||
};
|
||||
expect(r.fund_hours).toBeGreaterThan(0);
|
||||
expect(r.business_days).toBeGreaterThan(0);
|
||||
expect(r.worked_hours).toBe(0); // fixture attendance lives in 2098
|
||||
|
||||
const denied = await executeTool(
|
||||
"get_work_fund",
|
||||
{ user_id: fixUserId },
|
||||
RECORDER,
|
||||
);
|
||||
expect(denied.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("get_project_hours_report is manage-gated and aggregates the fixture shift", async () => {
|
||||
const denied = await executeTool("get_project_hours_report", {}, RECORDER);
|
||||
expect(denied.ok).toBe(false);
|
||||
|
||||
// The 2098 fixture shift (8.00 h, no project) lands in the labeled
|
||||
// no-project bucket with per-user hours.
|
||||
const res = await executeTool(
|
||||
"get_project_hours_report",
|
||||
{ year: 2098 },
|
||||
ADMIN,
|
||||
);
|
||||
expect(res.ok).toBe(true);
|
||||
const r = res.result as {
|
||||
year: number;
|
||||
projects: {
|
||||
label: string;
|
||||
hours: number;
|
||||
by_user: Record<string, number>;
|
||||
}[];
|
||||
};
|
||||
expect(r.year).toBe(2098);
|
||||
const bucket = r.projects.find((p) => p.label === "(bez projektu)");
|
||||
expect(bucket).toBeDefined();
|
||||
expect(bucket!.hours).toBe(8);
|
||||
expect(bucket!.by_user[`${FIX.first} ${FIX.last}`]).toBe(8);
|
||||
});
|
||||
|
||||
it("find_supplier + list_vehicles return their compact shapes", async () => {
|
||||
const sup = await executeTool(
|
||||
"find_supplier",
|
||||
{ search: "AiTest Dodavatel" },
|
||||
{ userId: 999999998, roleName: "viewer", permissions: ["orders.view"] },
|
||||
);
|
||||
expect(sup.ok).toBe(true);
|
||||
const s = (
|
||||
sup.result as { suppliers: { name: string; ico: string | null }[] }
|
||||
).suppliers.find((x) => x.name === FIX_SUPPLIER);
|
||||
expect(s).toMatchObject({ ico: "99999998" });
|
||||
|
||||
// IČO typed with spaces finds the space-less stored value.
|
||||
const spacedIco = await executeTool(
|
||||
"find_supplier",
|
||||
{ search: "999 99 998" },
|
||||
{ userId: 999999998, roleName: "viewer", permissions: ["orders.view"] },
|
||||
);
|
||||
expect(
|
||||
(spacedIco.result as { suppliers: { name: string }[] }).suppliers.some(
|
||||
(x) => x.name === FIX_SUPPLIER,
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
const veh = await executeTool(
|
||||
"list_vehicles",
|
||||
{ search: FIX.spz },
|
||||
{ userId: fixUserId, roleName: "viewer", permissions: ["trips.history"] },
|
||||
);
|
||||
expect(veh.ok).toBe(true);
|
||||
const v = (
|
||||
veh.result as {
|
||||
vehicles: { spz: string; current_km: number; trip_count: number }[];
|
||||
}
|
||||
).vehicles[0];
|
||||
// Odometer rule: max trip end_km (1050) over initial_km; 3 fixture trips.
|
||||
expect(v).toMatchObject({ spz: FIX.spz, current_km: 1050, trip_count: 3 });
|
||||
|
||||
// SPZ spacing is normalized: "AITEST 999" finds the stored "AITEST999",
|
||||
// in list_vehicles AND in the list_trips vehicle filter.
|
||||
const spaced = await executeTool(
|
||||
"list_vehicles",
|
||||
{ search: "aitest 999" },
|
||||
{ userId: fixUserId, roleName: "viewer", permissions: ["trips.history"] },
|
||||
);
|
||||
expect(
|
||||
(spaced.result as { vehicles: { spz: string }[] }).vehicles[0]?.spz,
|
||||
).toBe(FIX.spz);
|
||||
const tripsByPlate = await executeTool(
|
||||
"list_trips",
|
||||
{ vehicle: "AITEST 999", date_from: "2098-06-15", date_to: "2098-06-16" },
|
||||
{ userId: fixUserId, roleName: "viewer", permissions: ["trips.history"] },
|
||||
);
|
||||
expect(
|
||||
(tripsByPlate.result as { total_matching: number }).total_matching,
|
||||
).toBe(2);
|
||||
|
||||
expect(
|
||||
(await executeTool("find_supplier", { search: "x" }, NOBODY)).ok,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("get_document_totals sums the WHOLE filtered set per currency", async () => {
|
||||
const res = await executeTool(
|
||||
"get_document_totals",
|
||||
{ type: "offers", search: FIX_OFFER_NUMBER },
|
||||
ADMIN,
|
||||
);
|
||||
expect(res.ok).toBe(true);
|
||||
expect(
|
||||
(res.result as { totals: { currency: string; amount: number }[] }).totals,
|
||||
).toEqual([{ currency: "EUR", amount: 2000 }]); // excluded row not counted
|
||||
|
||||
// orders.view alone cannot read offer totals (per-type re-check).
|
||||
const denied = await executeTool(
|
||||
"get_document_totals",
|
||||
{ type: "offers" },
|
||||
{ userId: 999999998, roleName: "viewer", permissions: ["orders.view"] },
|
||||
);
|
||||
expect(denied.ok).toBe(false);
|
||||
|
||||
// Offers have no period filter — month/year must error, not be ignored.
|
||||
const offersMonth = await executeTool(
|
||||
"get_document_totals",
|
||||
{ type: "offers", month: 6, year: 2026 },
|
||||
ADMIN,
|
||||
);
|
||||
expect(offersMonth.ok).toBe(false);
|
||||
|
||||
// A bare month defaults the year (never an unmarked all-time sum) and
|
||||
// the applied period is echoed back.
|
||||
const bareMonth = await executeTool(
|
||||
"get_document_totals",
|
||||
{ type: "orders", month: 6 },
|
||||
ADMIN,
|
||||
);
|
||||
expect(bareMonth.ok).toBe(true);
|
||||
expect((bareMonth.result as { period: string }).period).toBe(
|
||||
`6/${new Date().getFullYear()}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("get_document_totals: received_invoices sums gross + converts to CZK", async () => {
|
||||
// invoices.view alone unlocks the received-invoices branch.
|
||||
const res = await executeTool(
|
||||
"get_document_totals",
|
||||
{ type: "received_invoices", month: 6, year: 2098 },
|
||||
VIEWER_INVOICES,
|
||||
);
|
||||
expect(res.ok).toBe(true);
|
||||
const r = res.result as {
|
||||
period: string;
|
||||
invoice_count: number;
|
||||
totals: { currency: string; amount: number }[];
|
||||
total_czk: number;
|
||||
};
|
||||
expect(r.period).toBe("6/2098");
|
||||
expect(r.invoice_count).toBe(1);
|
||||
expect(r.totals).toEqual([{ currency: "CZK", amount: 1210 }]);
|
||||
expect(r.total_czk).toBe(1210); // CZK rows convert 1:1, no rate fetch
|
||||
|
||||
// orders.view alone cannot read expense totals.
|
||||
const denied = await executeTool(
|
||||
"get_document_totals",
|
||||
{ type: "received_invoices" },
|
||||
{ userId: 999999998, roleName: "viewer", permissions: ["orders.view"] },
|
||||
);
|
||||
expect(denied.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("get_top_customers aggregates per customer across the whole table", async () => {
|
||||
const res = await executeTool(
|
||||
"get_top_customers",
|
||||
{ type: "projects" },
|
||||
ADMIN,
|
||||
);
|
||||
expect(res.ok).toBe(true);
|
||||
const r = res.result as {
|
||||
customers_with_records: number;
|
||||
top: { customer: string; count: number }[];
|
||||
};
|
||||
const mine = r.top.find((t) => t.customer === FIX_CUSTOMER);
|
||||
expect(mine).toBeDefined();
|
||||
expect(mine!.count).toBe(2);
|
||||
expect(r.customers_with_records).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// invoices.view alone cannot aggregate projects (per-type re-check).
|
||||
const denied = await executeTool(
|
||||
"get_top_customers",
|
||||
{ type: "projects" },
|
||||
VIEWER_INVOICES,
|
||||
);
|
||||
expect(denied.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("find_employee and find_supplier report total_matching", async () => {
|
||||
const emp = await executeTool(
|
||||
"find_employee",
|
||||
{ search: FIX.last },
|
||||
RECORDER,
|
||||
);
|
||||
expect(
|
||||
(emp.result as { total_matching: number }).total_matching,
|
||||
).toBeGreaterThanOrEqual(1);
|
||||
const sup = await executeTool(
|
||||
"find_supplier",
|
||||
{ search: FIX_SUPPLIER },
|
||||
ADMIN,
|
||||
);
|
||||
expect(
|
||||
(sup.result as { total_matching: number }).total_matching,
|
||||
).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("get_received_invoice_detail returns the gross-amount detail", async () => {
|
||||
const res = await executeTool(
|
||||
"get_received_invoice_detail",
|
||||
{ search: "AiTest Supplier" },
|
||||
VIEWER_INVOICES,
|
||||
);
|
||||
expect(res.ok).toBe(true);
|
||||
expect(res.result).toMatchObject({
|
||||
supplier: FIX_RI_SUPPLIER,
|
||||
number: "AITEST-RI-1",
|
||||
amount_with_vat: 1210,
|
||||
vat_amount: 210,
|
||||
status: "unpaid",
|
||||
});
|
||||
|
||||
expect(
|
||||
(
|
||||
await executeTool(
|
||||
"get_received_invoice_detail",
|
||||
{ search: "x" },
|
||||
NOBODY,
|
||||
)
|
||||
).ok,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("get_work_plan resolves the range-entry into per-day rows", async () => {
|
||||
const res = await executeTool(
|
||||
"get_work_plan",
|
||||
|
||||
55
src/__tests__/pdf-shared.test.ts
Normal file
55
src/__tests__/pdf-shared.test.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { cleanQuillHtml } from "../utils/pdf-shared";
|
||||
|
||||
describe("cleanQuillHtml — inline-style whitelist", () => {
|
||||
it("keeps Quill text and background colors", () => {
|
||||
const html =
|
||||
'<p><span style="color: rgb(230, 0, 0);">červeně</span> ' +
|
||||
'<span style="background-color: #ffff00;">zvýrazněně</span> ' +
|
||||
'<span style="color: #06c;">modře</span></p>';
|
||||
const out = cleanQuillHtml(html);
|
||||
expect(out).toContain('style="color: rgb(230, 0, 0)"');
|
||||
expect(out).toContain('style="background-color: #ffff00"');
|
||||
expect(out).toContain('style="color: #06c"');
|
||||
});
|
||||
|
||||
it("drops every non-color style declaration", () => {
|
||||
const out = cleanQuillHtml(
|
||||
'<span style="position: fixed; color: red; font-size: 99px;">x</span>',
|
||||
);
|
||||
expect(out).toBe('<span style="color: red">x</span>');
|
||||
});
|
||||
|
||||
it("drops unsafe color values (url, expression, quotes)", () => {
|
||||
expect(
|
||||
cleanQuillHtml('<span style="color: url(javascript:alert(1))">x</span>'),
|
||||
).toBe("<span>x</span>");
|
||||
expect(
|
||||
cleanQuillHtml('<span style="color: expression(alert(1))">x</span>'),
|
||||
).toBe("<span>x</span>");
|
||||
expect(cleanQuillHtml("<span style='color: \"red\"'>x</span>")).toBe(
|
||||
"<span>x</span>",
|
||||
);
|
||||
});
|
||||
|
||||
it("restores Quill 2's empty <p></p> blank lines to <p><br></p>", () => {
|
||||
// Quill 2's semantic HTML drops the <br> placeholder from blank lines.
|
||||
expect(cleanQuillHtml("<p>A</p><p></p><p>B</p>")).toBe(
|
||||
"<p>A</p><p><br></p><p>B</p>",
|
||||
);
|
||||
// Attribute-carrying and whitespace-only empties too.
|
||||
expect(cleanQuillHtml('<p class="ql-align-center"> </p>')).toBe(
|
||||
'<p class="ql-align-center"><br></p>',
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps stripping scripts, event handlers and js: URLs", () => {
|
||||
const out = cleanQuillHtml(
|
||||
'<p onclick="alert(1)"><script>alert(1)</script>' +
|
||||
'<a href="javascript:alert(1)">x</a></p>',
|
||||
);
|
||||
expect(out).not.toContain("script");
|
||||
expect(out).not.toContain("onclick");
|
||||
expect(out).not.toContain("javascript:");
|
||||
});
|
||||
});
|
||||
227
src/__tests__/project-notes.test.ts
Normal file
227
src/__tests__/project-notes.test.ts
Normal file
@@ -0,0 +1,227 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||
import Fastify from "fastify";
|
||||
import cookie from "@fastify/cookie";
|
||||
import rateLimit from "@fastify/rate-limit";
|
||||
import jwt from "jsonwebtoken";
|
||||
import prisma from "../config/database";
|
||||
import { config } from "../config/env";
|
||||
import { securityHeaders } from "../middleware/security";
|
||||
import projectsRoutes from "../routes/admin/projects";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Project notes: author-only editing with a visible edited_at stamp, and
|
||||
// admin-only deletion (a projects.edit holder may add + edit their own notes
|
||||
// but never delete — user decision 2026-06-12).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const N = "projnotes_test_";
|
||||
|
||||
let app: Awaited<ReturnType<typeof buildApp>>;
|
||||
let adminToken: string;
|
||||
let userAId: number;
|
||||
let userAToken: string;
|
||||
let userBToken: string;
|
||||
let roleId: number;
|
||||
let projectId: number;
|
||||
let noteId: number;
|
||||
|
||||
async function buildApp() {
|
||||
const a = Fastify({ logger: false });
|
||||
await a.register(cookie);
|
||||
await a.register(rateLimit, { max: 1000, timeWindow: "1 minute" });
|
||||
a.addHook("onRequest", securityHeaders);
|
||||
await a.register(projectsRoutes, { prefix: "/api/admin/projects" });
|
||||
return a;
|
||||
}
|
||||
|
||||
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" },
|
||||
);
|
||||
}
|
||||
|
||||
function inject(
|
||||
method: "POST" | "PUT" | "DELETE",
|
||||
path: string,
|
||||
token: string,
|
||||
body?: unknown,
|
||||
) {
|
||||
return app.inject({
|
||||
method,
|
||||
url: path,
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
// Content-Type only with a body — Fastify 400s an empty JSON body.
|
||||
...(body !== undefined ? { "Content-Type": "application/json" } : {}),
|
||||
},
|
||||
...(body !== undefined ? { payload: body as object } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await buildApp();
|
||||
|
||||
// Defensive pre-clean of a previously failed run.
|
||||
await prisma.project_notes.deleteMany({
|
||||
where: { content: { contains: N } },
|
||||
});
|
||||
await prisma.projects.deleteMany({ where: { name: { startsWith: N } } });
|
||||
await prisma.users.deleteMany({ where: { username: { startsWith: N } } });
|
||||
await prisma.roles.deleteMany({ where: { name: `${N}role` } });
|
||||
|
||||
const admin = await prisma.users.findFirst({
|
||||
where: { roles: { name: "admin" } },
|
||||
include: { roles: true },
|
||||
});
|
||||
if (!admin) throw new Error("Test setup: admin user not found");
|
||||
adminToken = generateToken({
|
||||
id: admin.id,
|
||||
username: admin.username,
|
||||
roleName: admin.roles?.name ?? null,
|
||||
});
|
||||
|
||||
// Non-admin role with projects.view + projects.edit.
|
||||
const role = await prisma.roles.create({
|
||||
data: { name: `${N}role`, display_name: "ProjNotes test role" },
|
||||
});
|
||||
roleId = role.id;
|
||||
const perms = await prisma.permissions.findMany({
|
||||
where: { name: { in: ["projects.view", "projects.edit"] } },
|
||||
select: { id: true },
|
||||
});
|
||||
if (perms.length !== 2) {
|
||||
throw new Error("Test setup: projects.view/projects.edit missing");
|
||||
}
|
||||
await prisma.role_permissions.createMany({
|
||||
data: perms.map((p) => ({ role_id: roleId, permission_id: p.id })),
|
||||
});
|
||||
|
||||
const mkUser = async (suffix: string) =>
|
||||
prisma.users.create({
|
||||
data: {
|
||||
username: `${N}${suffix}`,
|
||||
email: `${N}${suffix}@test.local`,
|
||||
password_hash: "x",
|
||||
first_name: "Projnotes",
|
||||
last_name: suffix.toUpperCase(),
|
||||
is_active: true,
|
||||
role_id: roleId,
|
||||
},
|
||||
});
|
||||
const userA = await mkUser("a");
|
||||
const userB = await mkUser("b");
|
||||
userAId = userA.id;
|
||||
userAToken = generateToken({
|
||||
id: userA.id,
|
||||
username: userA.username,
|
||||
roleName: `${N}role`,
|
||||
});
|
||||
userBToken = generateToken({
|
||||
id: userB.id,
|
||||
username: userB.username,
|
||||
roleName: `${N}role`,
|
||||
});
|
||||
|
||||
const project = await prisma.projects.create({
|
||||
data: { name: `${N}project`, status: "aktivni" },
|
||||
});
|
||||
projectId = project.id;
|
||||
|
||||
// Note authored by user A (through the route, like the UI does).
|
||||
const res = await inject(
|
||||
"POST",
|
||||
`/api/admin/projects/${projectId}/notes`,
|
||||
userAToken,
|
||||
{ content: `${N}original` },
|
||||
);
|
||||
expect(res.statusCode).toBe(201);
|
||||
noteId = res.json().data.note.id;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await prisma.project_notes.deleteMany({ where: { project_id: projectId } });
|
||||
await prisma.projects.deleteMany({ where: { id: projectId } });
|
||||
await prisma.users.deleteMany({ where: { username: { startsWith: N } } });
|
||||
await prisma.roles.deleteMany({ where: { id: roleId } });
|
||||
await app.close();
|
||||
});
|
||||
|
||||
describe("project notes — author-only edit with edited_at stamp", () => {
|
||||
it("the author edits their own note and the edit is stamped", async () => {
|
||||
const res = await inject(
|
||||
"PUT",
|
||||
`/api/admin/projects/${projectId}/notes/${noteId}`,
|
||||
userAToken,
|
||||
{ content: `${N}edited` },
|
||||
);
|
||||
expect(res.statusCode).toBe(200);
|
||||
const row = await prisma.project_notes.findUnique({
|
||||
where: { id: noteId },
|
||||
});
|
||||
expect(row?.content).toBe(`${N}edited`);
|
||||
expect(row?.user_id).toBe(userAId);
|
||||
expect(row?.edited_at).not.toBeNull();
|
||||
});
|
||||
|
||||
it("another user cannot edit someone else's note", async () => {
|
||||
const res = await inject(
|
||||
"PUT",
|
||||
`/api/admin/projects/${projectId}/notes/${noteId}`,
|
||||
userBToken,
|
||||
{ content: `${N}hijack` },
|
||||
);
|
||||
expect(res.statusCode).toBe(403);
|
||||
const row = await prisma.project_notes.findUnique({
|
||||
where: { id: noteId },
|
||||
});
|
||||
expect(row?.content).toBe(`${N}edited`); // unchanged
|
||||
});
|
||||
|
||||
it("rejects empty content and unknown note ids", async () => {
|
||||
const empty = await inject(
|
||||
"PUT",
|
||||
`/api/admin/projects/${projectId}/notes/${noteId}`,
|
||||
userAToken,
|
||||
{ content: " " },
|
||||
);
|
||||
expect(empty.statusCode).toBe(400);
|
||||
|
||||
const missing = await inject(
|
||||
"PUT",
|
||||
`/api/admin/projects/${projectId}/notes/999999999`,
|
||||
userAToken,
|
||||
{ content: "x" },
|
||||
);
|
||||
expect(missing.statusCode).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe("project notes — admin-only deletion", () => {
|
||||
it("a projects.edit holder cannot delete (even their own note)", async () => {
|
||||
const res = await inject(
|
||||
"DELETE",
|
||||
`/api/admin/projects/${projectId}/notes/${noteId}`,
|
||||
userAToken,
|
||||
);
|
||||
expect(res.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
it("an admin deletes anybody's note", async () => {
|
||||
const res = await inject(
|
||||
"DELETE",
|
||||
`/api/admin/projects/${projectId}/notes/${noteId}`,
|
||||
adminToken,
|
||||
);
|
||||
expect(res.statusCode).toBe(200);
|
||||
const row = await prisma.project_notes.findUnique({
|
||||
where: { id: noteId },
|
||||
});
|
||||
expect(row).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -162,7 +162,7 @@ export default function BulkPlanModal({
|
||||
value={form.project_id}
|
||||
onChange={(val) => setForm({ ...form, project_id: val })}
|
||||
options={[
|
||||
{ value: "", label: "— bez projektu —" },
|
||||
{ value: "", label: "Vyberte projekt" },
|
||||
...projects.map((p) => ({ value: String(p.id), label: p.name })),
|
||||
]}
|
||||
/>
|
||||
|
||||
249
src/admin/components/NoteCard.tsx
Normal file
249
src/admin/components/NoteCard.tsx
Normal file
@@ -0,0 +1,249 @@
|
||||
import { useState } from "react";
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import Button from "@mui/material/Button";
|
||||
|
||||
/**
|
||||
* Chat-bubble style note row (author avatar + bubble), shared by the
|
||||
* project notes list. Mobile-first: the header wraps (name / timestamp),
|
||||
* the action icons live in the bubble's top-right corner so they never
|
||||
* squeeze the content column, and editing happens inline in the bubble.
|
||||
*/
|
||||
|
||||
const initialsOf = (name: string): string =>
|
||||
name
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.map((p) => p[0]?.toUpperCase() ?? "")
|
||||
.join("");
|
||||
|
||||
// Same edit/delete glyphs as the data tables (AttendanceShiftTable & co.).
|
||||
const EditIcon = (
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const DeleteIcon = (
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<polyline points="3 6 5 6 21 6" />
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
function formatNoteDate(dateStr: string | null | undefined): string {
|
||||
if (!dateStr) return "";
|
||||
const d = new Date(dateStr);
|
||||
if (isNaN(d.getTime())) return "";
|
||||
const hours = String(d.getHours()).padStart(2, "0");
|
||||
const mins = String(d.getMinutes()).padStart(2, "0");
|
||||
return `${d.getDate()}. ${d.getMonth() + 1}. ${d.getFullYear()} ${hours}:${mins}`;
|
||||
}
|
||||
|
||||
export interface NoteCardProps {
|
||||
authorName: string;
|
||||
createdAt: string;
|
||||
editedAt?: string | null;
|
||||
content: string;
|
||||
canEdit?: boolean;
|
||||
canDelete?: boolean;
|
||||
deleting?: boolean;
|
||||
/** Reject (throw) to keep the editor open — the caller shows the alert. */
|
||||
onSave?: (content: string) => Promise<void>;
|
||||
onDelete?: () => void;
|
||||
}
|
||||
|
||||
export default function NoteCard({
|
||||
authorName,
|
||||
createdAt,
|
||||
editedAt,
|
||||
content,
|
||||
canEdit = false,
|
||||
canDelete = false,
|
||||
deleting = false,
|
||||
onSave,
|
||||
onDelete,
|
||||
}: NoteCardProps) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const startEdit = () => {
|
||||
setDraft(content);
|
||||
setEditing(true);
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
if (!onSave || !draft.trim() || saving) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await onSave(draft.trim());
|
||||
setEditing(false);
|
||||
} catch {
|
||||
// Caller already alerted; keep the editor open with the draft.
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ display: "flex", gap: 1, alignItems: "flex-start" }}>
|
||||
<Box
|
||||
aria-hidden
|
||||
sx={(t) => ({
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: "50%",
|
||||
flexShrink: 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontSize: "0.75rem",
|
||||
fontWeight: 700,
|
||||
bgcolor: `rgba(${t.vars!.palette.primary.mainChannel} / 0.12)`,
|
||||
color: "primary.main",
|
||||
})}
|
||||
>
|
||||
{initialsOf(authorName)}
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
bgcolor: "action.hover",
|
||||
borderRadius: 2,
|
||||
borderTopLeftRadius: 4,
|
||||
p: 1.5,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{ display: "flex", alignItems: "flex-start", gap: 1, mb: 0.5 }}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
display: "flex",
|
||||
alignItems: "baseline",
|
||||
columnGap: 1,
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||
{authorName}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{formatNoteDate(createdAt)}
|
||||
{editedAt && (
|
||||
<Box component="span" sx={{ fontStyle: "italic" }}>
|
||||
{" "}
|
||||
· upraveno {formatNoteDate(editedAt)}
|
||||
</Box>
|
||||
)}
|
||||
</Typography>
|
||||
</Box>
|
||||
{!editing && (canEdit || canDelete) && (
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
gap: 0.25,
|
||||
flexShrink: 0,
|
||||
mt: -0.5,
|
||||
mr: -0.5,
|
||||
}}
|
||||
>
|
||||
{canEdit && (
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={startEdit}
|
||||
title="Upravit poznámku"
|
||||
aria-label="Upravit poznámku"
|
||||
>
|
||||
{EditIcon}
|
||||
</IconButton>
|
||||
)}
|
||||
{canDelete && (
|
||||
<IconButton
|
||||
size="small"
|
||||
color="error"
|
||||
onClick={onDelete}
|
||||
title="Smazat poznámku"
|
||||
aria-label="Smazat poznámku"
|
||||
disabled={deleting}
|
||||
>
|
||||
{DeleteIcon}
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{editing ? (
|
||||
<Box>
|
||||
<TextField
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
multiline
|
||||
minRows={2}
|
||||
maxRows={16}
|
||||
fullWidth
|
||||
autoFocus
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && e.ctrlKey && draft.trim()) save();
|
||||
if (e.key === "Escape") setEditing(false);
|
||||
}}
|
||||
/>
|
||||
<Box sx={{ mt: 1, display: "flex", gap: 1 }}>
|
||||
<Button
|
||||
size="small"
|
||||
variant="contained"
|
||||
onClick={save}
|
||||
disabled={saving || !draft.trim()}
|
||||
>
|
||||
{saving ? "Ukládání…" : "Uložit"}
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
color="inherit"
|
||||
onClick={() => setEditing(false)}
|
||||
disabled={saving}
|
||||
>
|
||||
Zrušit
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
) : (
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{ whiteSpace: "pre-wrap", lineHeight: 1.5 }}
|
||||
>
|
||||
{content}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -392,7 +392,7 @@ function EditForm(props: Props) {
|
||||
value={projectId === null ? "" : String(projectId)}
|
||||
onChange={(val) => setProjectId(val ? Number(val) : null)}
|
||||
options={[
|
||||
{ value: "", label: "— bez projektu —" },
|
||||
{ value: "", label: "Vyberte projekt" },
|
||||
...projects.map((p) => ({
|
||||
value: String(p.id),
|
||||
label: p.name,
|
||||
|
||||
@@ -157,7 +157,7 @@ function ProjectLogRow({
|
||||
value={String(log.project_id)}
|
||||
onChange={(val) => onUpdate(index, "project_id", val)}
|
||||
options={[
|
||||
{ value: "", label: "— Projekt —" },
|
||||
{ value: "", label: "Vyberte projekt" },
|
||||
...projectList.map((p) => ({
|
||||
value: String(p.id),
|
||||
label: `${p.project_number} – ${p.name}`,
|
||||
|
||||
@@ -214,7 +214,8 @@ function SortableItemRow({
|
||||
placeholder="Volitelný"
|
||||
InputProps={{ readOnly }}
|
||||
multiline
|
||||
rows={2}
|
||||
minRows={2}
|
||||
maxRows={16}
|
||||
slotProps={{ htmlInput: { maxLength: itemDescriptionMaxLength } }}
|
||||
sx={{ "& textarea": { resize: "vertical" } }}
|
||||
/>
|
||||
@@ -352,7 +353,8 @@ function SortableItemRow({
|
||||
placeholder="Podrobný popis (volitelný)"
|
||||
InputProps={{ readOnly }}
|
||||
multiline
|
||||
rows={2}
|
||||
minRows={2}
|
||||
maxRows={16}
|
||||
slotProps={{ htmlInput: { maxLength: itemDescriptionMaxLength } }}
|
||||
sx={{
|
||||
"& textarea": {
|
||||
|
||||
@@ -5,8 +5,10 @@ import apiFetch from "../../utils/api";
|
||||
export interface ProjectNote {
|
||||
id: number;
|
||||
content: string;
|
||||
user_id: number | null;
|
||||
user_name: string;
|
||||
created_at: string;
|
||||
edited_at: string | null;
|
||||
}
|
||||
|
||||
export interface ProjectData {
|
||||
|
||||
@@ -835,7 +835,7 @@ export default function Attendance() {
|
||||
onChange={(val) => handleSwitchProject(val || null)}
|
||||
disabled={switchingProject}
|
||||
options={[
|
||||
{ value: "", label: "— Bez projektu —" },
|
||||
{ value: "", label: "Vyberte projekt" },
|
||||
...projects.map((p) => ({
|
||||
value: String(p.id),
|
||||
label: `${p.project_number} – ${p.name}`,
|
||||
|
||||
@@ -58,6 +58,7 @@ import {
|
||||
formatCurrency,
|
||||
formatDate,
|
||||
numberOr,
|
||||
restoreQuillBlankLines,
|
||||
todayLocalStr,
|
||||
} from "../utils/formatters";
|
||||
import { normalizeDateStr } from "../utils/attendanceHelpers";
|
||||
@@ -317,7 +318,8 @@ function SortableInvoiceRow({
|
||||
onChange={(e) => onUpdate(index, "item_description", e.target.value)}
|
||||
placeholder="Volitelný"
|
||||
multiline
|
||||
rows={2}
|
||||
minRows={2}
|
||||
maxRows={16}
|
||||
sx={{ "& textarea": { resize: "vertical" } }}
|
||||
/>
|
||||
<Box
|
||||
@@ -418,7 +420,8 @@ function SortableInvoiceRow({
|
||||
}
|
||||
placeholder="Podrobný popis (volitelný)"
|
||||
multiline
|
||||
rows={2}
|
||||
minRows={2}
|
||||
maxRows={16}
|
||||
sx={{
|
||||
"& textarea": {
|
||||
fontSize: "0.8rem",
|
||||
@@ -1668,7 +1671,9 @@ export default function InvoiceDetail() {
|
||||
{(s.content || "").replace(/<[^>]*>/g, "").trim() && (
|
||||
<RichTextView
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: DOMPurify.sanitize(s.content || ""),
|
||||
__html: DOMPurify.sanitize(
|
||||
restoreQuillBlankLines(s.content),
|
||||
),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -1688,7 +1693,9 @@ export default function InvoiceDetail() {
|
||||
invoice.internal_notes !== "<p><br></p>" ? (
|
||||
<RichTextView
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: DOMPurify.sanitize(invoice.internal_notes),
|
||||
__html: DOMPurify.sanitize(
|
||||
restoreQuillBlankLines(invoice.internal_notes),
|
||||
),
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -18,7 +18,11 @@ import Typography from "@mui/material/Typography";
|
||||
import OrderConfirmationModal from "../components/OrderConfirmationModal";
|
||||
import Forbidden from "../components/Forbidden";
|
||||
import apiFetch from "../utils/api";
|
||||
import { formatCurrency, formatDate } from "../utils/formatters";
|
||||
import {
|
||||
formatCurrency,
|
||||
formatDate,
|
||||
restoreQuillBlankLines,
|
||||
} from "../utils/formatters";
|
||||
import { ORDER_STATUS, statusLabel, statusColor } from "../lib/documentStatus";
|
||||
import {
|
||||
Button,
|
||||
@@ -713,7 +717,9 @@ export default function OrderDetail() {
|
||||
<RichTextView
|
||||
sx={{ p: 2 }}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: DOMPurify.sanitize(section.content),
|
||||
__html: DOMPurify.sanitize(
|
||||
restoreQuillBlankLines(section.content),
|
||||
),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useApiMutation } from "../lib/queries/mutations";
|
||||
import apiFetch from "../utils/api";
|
||||
import { useAlert } from "../context/AlertContext";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import { useParams, useNavigate, Link as RouterLink } from "react-router-dom";
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
|
||||
import {
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
import { userListOptions, type User as ApiUser } from "../lib/queries/users";
|
||||
import Forbidden from "../components/Forbidden";
|
||||
import ProjectFileManager from "../components/ProjectFileManager";
|
||||
import NoteCard from "../components/NoteCard";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
@@ -49,17 +50,6 @@ const STATUS_COLORS: Record<
|
||||
zruseny: "default",
|
||||
};
|
||||
|
||||
function formatNoteDate(dateStr: string) {
|
||||
if (!dateStr) return "";
|
||||
const d = new Date(dateStr);
|
||||
const day = d.getDate();
|
||||
const month = d.getMonth() + 1;
|
||||
const year = d.getFullYear();
|
||||
const hours = String(d.getHours()).padStart(2, "0");
|
||||
const mins = String(d.getMinutes()).padStart(2, "0");
|
||||
return `${day}. ${month}. ${year} ${hours}:${mins}`;
|
||||
}
|
||||
|
||||
interface User {
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -86,28 +76,12 @@ const BackIcon = (
|
||||
</svg>
|
||||
);
|
||||
|
||||
const TrashIcon = (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<polyline points="3 6 5 6 21 6" />
|
||||
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
|
||||
<path d="M10 11v6M14 11v6" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default function ProjectDetail() {
|
||||
const { id } = useParams();
|
||||
const alert = useAlert();
|
||||
const { hasPermission, isAdmin } = useAuth();
|
||||
const { hasPermission, isAdmin, user } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const qc = useQueryClient();
|
||||
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [form, setForm] = useState<ProjectForm>({
|
||||
@@ -278,6 +252,30 @@ export default function ProjectDetail() {
|
||||
}
|
||||
};
|
||||
|
||||
// Throws on failure so NoteCard keeps its editor open with the draft.
|
||||
const saveNoteEdit = async (noteId: number, content: string) => {
|
||||
// Raw apiFetch: the body must carry ONLY content (strict schema), the
|
||||
// note id lives in the URL — useApiMutation sends its whole input.
|
||||
let res: Response;
|
||||
try {
|
||||
res = await apiFetch(`${API_BASE}/projects/${id}/notes/${noteId}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ content }),
|
||||
});
|
||||
} catch {
|
||||
alert.error("Chyba připojení");
|
||||
throw new Error("network");
|
||||
}
|
||||
const body = await res.json().catch(() => null);
|
||||
if (!res.ok) {
|
||||
alert.error(body?.error || "Uložení selhalo");
|
||||
throw new Error(body?.error || "Uložení selhalo");
|
||||
}
|
||||
qc.invalidateQueries({ queryKey: ["projects"] });
|
||||
alert.success("Poznámka upravena");
|
||||
};
|
||||
|
||||
if (isPending) {
|
||||
return <LoadingState />;
|
||||
}
|
||||
@@ -493,61 +491,18 @@ export default function ProjectDetail() {
|
||||
{(notes.length > 0 || project.notes) && (
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
|
||||
{notes.map((note) => (
|
||||
<Box
|
||||
<NoteCard
|
||||
key={note.id}
|
||||
sx={{
|
||||
p: 1.5,
|
||||
bgcolor: "action.hover",
|
||||
borderRadius: 2,
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "flex-start",
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 1,
|
||||
mb: 0.5,
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||
{note.user_name}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{formatNoteDate(note.created_at)}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{ whiteSpace: "pre-wrap", lineHeight: 1.5 }}
|
||||
>
|
||||
{note.content}
|
||||
</Typography>
|
||||
</Box>
|
||||
{isAdmin && (
|
||||
<IconButton
|
||||
size="small"
|
||||
color="error"
|
||||
onClick={() => handleDeleteNote(note.id)}
|
||||
title="Smazat poznámku"
|
||||
aria-label="Smazat poznámku"
|
||||
disabled={deletingNoteId === note.id}
|
||||
sx={{ flexShrink: 0 }}
|
||||
>
|
||||
{TrashIcon}
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
authorName={note.user_name}
|
||||
createdAt={note.created_at}
|
||||
editedAt={note.edited_at}
|
||||
content={note.content || ""}
|
||||
canEdit={user?.id === note.user_id}
|
||||
canDelete={isAdmin}
|
||||
deleting={deletingNoteId === note.id}
|
||||
onSave={(content) => saveNoteEdit(note.id, content)}
|
||||
onDelete={() => handleDeleteNote(note.id)}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
@@ -554,7 +554,7 @@ export default function Projects() {
|
||||
setCreateForm({ ...createForm, customer_id: value })
|
||||
}
|
||||
options={[
|
||||
{ value: "", label: "— bez zákazníka —" },
|
||||
{ value: "", label: "Vyberte zákaznika" },
|
||||
...(customersQuery.data ?? []).map((c) => ({
|
||||
value: String(c.id),
|
||||
label: c.name,
|
||||
@@ -571,7 +571,7 @@ export default function Projects() {
|
||||
setCreateForm({ ...createForm, responsible_user_id: value })
|
||||
}
|
||||
options={[
|
||||
{ value: "", label: "— nepřiřazeno —" },
|
||||
{ value: "", label: "Vyberte zaměstnance" },
|
||||
...(usersQuery.data ?? [])
|
||||
.filter((u) => u.is_active)
|
||||
.map((u) => ({
|
||||
|
||||
@@ -625,7 +625,7 @@ export default function OrdersReceived({
|
||||
setCreateForm({ ...createForm, customer_id: value })
|
||||
}
|
||||
options={[
|
||||
{ value: "", label: "— bez zákazníka —" },
|
||||
{ value: "", label: "Vyberte zákazníka" },
|
||||
...(customersQuery.data ?? []).map((c) => ({
|
||||
value: String(c.id),
|
||||
label: c.name,
|
||||
|
||||
@@ -76,3 +76,15 @@ export function czechPlural(
|
||||
if (n >= 2 && n <= 4) return few;
|
||||
return many;
|
||||
}
|
||||
|
||||
/**
|
||||
* Quill 2's semantic HTML saves blank lines as truly empty <p></p>, which
|
||||
* collapse to zero height outside the editor — restore the <br> placeholder
|
||||
* before rendering stored rich text read-only. (The PDF path does the same
|
||||
* in cleanQuillHtml.)
|
||||
*/
|
||||
export function restoreQuillBlankLines(
|
||||
html: string | null | undefined,
|
||||
): string {
|
||||
return (html || "").replace(/<p([^>]*)>\s*<\/p>/gi, "<p$1><br></p>");
|
||||
}
|
||||
|
||||
@@ -253,7 +253,7 @@ export function buildInvoicePdfFooter(
|
||||
const t = translations[lang] || translations.cs;
|
||||
const pageWord = lang === "cs" ? "Strana" : "Page";
|
||||
const ofWord = lang === "cs" ? "z" : "of";
|
||||
return `<div style="width:100%; font-size:8pt; font-family:Tahoma, sans-serif; color:#646464; padding:0 12mm; box-sizing:border-box; display:flex; align-items:center;">
|
||||
return `<div style="width:100%; font-size:8pt; font-family:'Segoe UI', Tahoma, Arial, sans-serif; color:#646464; padding:0 12mm; box-sizing:border-box; display:flex; align-items:center;">
|
||||
<div style="flex:1; text-align:left;"><span style="font-weight:600;">${escapeHtml(t.issued_by)}</span> ${escapeHtml(issuerLine)}</div>
|
||||
<div style="flex:1; text-align:center;">${pageWord} <span class="pageNumber"></span> ${ofWord} <span class="totalPages"></span></div>
|
||||
<div style="flex:1;"></div>
|
||||
@@ -898,7 +898,7 @@ export default async function invoicesPdfRoutes(
|
||||
}
|
||||
.section-content,
|
||||
.section-content * {
|
||||
font-family: Tahoma, sans-serif !important;
|
||||
font-family: "Segoe UI", Tahoma, Arial, sans-serif !important;
|
||||
}
|
||||
.section-content { font-size: 14px; }
|
||||
.section-content h1 { font-size: 20px; }
|
||||
@@ -910,7 +910,7 @@ export default async function invoicesPdfRoutes(
|
||||
.section-content li { margin-bottom: 0.2em; }
|
||||
|
||||
/* Quill fonty – v PDF vynuceno Tahoma */
|
||||
[class*="ql-font-"] { font-family: Tahoma, sans-serif !important; }
|
||||
[class*="ql-font-"] { font-family: "Segoe UI", Tahoma, Arial, sans-serif !important; }
|
||||
.ql-align-center { text-align: center; }
|
||||
.ql-align-right { text-align: right; }
|
||||
.ql-align-justify { text-align: justify; }
|
||||
|
||||
@@ -267,7 +267,7 @@ export function buildIssuedOrderPdfFooter(
|
||||
const t = translations[lang];
|
||||
const pageWord = lang === "cs" ? "Strana" : "Page";
|
||||
const ofWord = lang === "cs" ? "z" : "of";
|
||||
return `<div style="width:100%; font-size:8pt; font-family:Tahoma, sans-serif; color:#646464; padding:0 12mm; box-sizing:border-box; display:flex; align-items:center;">
|
||||
return `<div style="width:100%; font-size:8pt; font-family:'Segoe UI', Tahoma, Arial, sans-serif; color:#646464; padding:0 12mm; box-sizing:border-box; display:flex; align-items:center;">
|
||||
<div style="flex:1; text-align:left;"><span style="font-weight:600;">${escapeHtml(t.issued_by)}</span> ${escapeHtml(issuerName)}</div>
|
||||
<div style="flex:1; text-align:center;">${pageWord} <span class="pageNumber"></span> ${ofWord} <span class="totalPages"></span></div>
|
||||
<div style="flex:1;"></div>
|
||||
@@ -655,7 +655,7 @@ export function renderIssuedOrderHtml(
|
||||
}
|
||||
.section-content,
|
||||
.section-content * {
|
||||
font-family: Tahoma, sans-serif !important;
|
||||
font-family: "Segoe UI", Tahoma, Arial, sans-serif !important;
|
||||
}
|
||||
.section-content { font-size: 14px; }
|
||||
.section-content h1 { font-size: 20px; }
|
||||
@@ -667,7 +667,7 @@ export function renderIssuedOrderHtml(
|
||||
.section-content li { margin-bottom: 0.2em; }
|
||||
|
||||
/* Quill fonty – v PDF vynuceno Tahoma */
|
||||
[class*="ql-font-"] { font-family: Tahoma, sans-serif !important; }
|
||||
[class*="ql-font-"] { font-family: "Segoe UI", Tahoma, Arial, sans-serif !important; }
|
||||
.ql-align-center { text-align: center; }
|
||||
.ql-align-right { text-align: right; }
|
||||
.ql-align-justify { text-align: justify; }
|
||||
|
||||
@@ -293,7 +293,7 @@ export function renderOfferHtml(
|
||||
img, table, pre, code { max-width: 100%; }
|
||||
|
||||
/* ---- Quill font classes – v PDF vynuceno Tahoma ---- */
|
||||
[class*="ql-font-"] { font-family: Tahoma, sans-serif !important; }
|
||||
[class*="ql-font-"] { font-family: "Segoe UI", Tahoma, Arial, sans-serif !important; }
|
||||
|
||||
/* ---- Quill alignment ---- */
|
||||
.ql-align-center { text-align: center; }
|
||||
@@ -502,7 +502,7 @@ ${indentCSS}
|
||||
}
|
||||
.section-content,
|
||||
.section-content * {
|
||||
font-family: Tahoma, sans-serif !important;
|
||||
font-family: "Segoe UI", Tahoma, Arial, sans-serif !important;
|
||||
}
|
||||
.section-content { font-size: 14px; }
|
||||
.section-content h1 { font-size: 20px; }
|
||||
|
||||
@@ -570,7 +570,7 @@ export function renderOrderConfirmationHtml(
|
||||
.invoice-notes-content li { margin-bottom: 0.2em; }
|
||||
.invoice-notes-content,
|
||||
.invoice-notes-content * {
|
||||
font-family: Tahoma, sans-serif !important;
|
||||
font-family: "Segoe UI", Tahoma, Arial, sans-serif !important;
|
||||
}
|
||||
.invoice-notes-content { font-size: 14px; }
|
||||
.invoice-notes-content h1 { font-size: 20px; }
|
||||
@@ -579,7 +579,7 @@ export function renderOrderConfirmationHtml(
|
||||
.invoice-notes-content h4 { font-size: 15px; }
|
||||
|
||||
/* Quill fonty – v PDF vynuceno Tahoma */
|
||||
[class*="ql-font-"] { font-family: Tahoma, sans-serif !important; }
|
||||
[class*="ql-font-"] { font-family: "Segoe UI", Tahoma, Arial, sans-serif !important; }
|
||||
.ql-align-center { text-align: center; }
|
||||
.ql-align-right { text-align: right; }
|
||||
.ql-align-justify { text-align: justify; }
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
UpdateProjectSchema,
|
||||
CreateProjectSchema,
|
||||
CreateProjectNoteSchema,
|
||||
UpdateProjectNoteSchema,
|
||||
} from "../../schemas/projects.schema";
|
||||
import {
|
||||
listProjects,
|
||||
@@ -19,6 +20,7 @@ import {
|
||||
deleteProject,
|
||||
createProjectNote,
|
||||
deleteProjectNote,
|
||||
updateProjectNote,
|
||||
} from "../../services/projects.service";
|
||||
|
||||
// Body for DELETE /:id — optional delete_files flag. The body may be absent on
|
||||
@@ -174,7 +176,47 @@ export default async function projectsRoutes(
|
||||
},
|
||||
);
|
||||
|
||||
// DELETE /api/admin/projects/:id/notes/:noteId
|
||||
// PUT /api/admin/projects/:id/notes/:noteId — author-only edit; the edit
|
||||
// is visibly stamped (edited_at) so a changed note can't pose as original.
|
||||
fastify.put<{ Params: { id: string; noteId: string } }>(
|
||||
"/:id/notes/:noteId",
|
||||
{ preHandler: requirePermission("projects.edit") },
|
||||
async (request, reply) => {
|
||||
const noteId = parseId(request.params.noteId, reply);
|
||||
if (noteId === null) return;
|
||||
const projectId = parseId(request.params.id, reply);
|
||||
if (projectId === null) return;
|
||||
const parsed = parseBody(UpdateProjectNoteSchema, request.body);
|
||||
if ("error" in parsed) return error(reply, parsed.error, 400);
|
||||
const authData = request.authData!;
|
||||
|
||||
const result = await updateProjectNote(
|
||||
projectId,
|
||||
noteId,
|
||||
authData.userId,
|
||||
parsed.data.content,
|
||||
);
|
||||
if (!result) return error(reply, "Poznámka nenalezena", 404);
|
||||
if (result.error !== undefined) {
|
||||
return error(reply, result.error, result.status ?? 403);
|
||||
}
|
||||
|
||||
await logAudit({
|
||||
request,
|
||||
authData,
|
||||
action: "update",
|
||||
entityType: "project",
|
||||
entityId: projectId,
|
||||
description: `Upravena poznámka projektu`,
|
||||
oldValues: { content: result.previousContent },
|
||||
newValues: { content: result.note.content },
|
||||
});
|
||||
return success(reply, { note: result.note }, 200, "Poznámka upravena");
|
||||
},
|
||||
);
|
||||
|
||||
// DELETE /api/admin/projects/:id/notes/:noteId — admin only (user
|
||||
// decision: authors edit their notes, only an admin removes anyone's).
|
||||
fastify.delete<{ Params: { id: string; noteId: string } }>(
|
||||
"/:id/notes/:noteId",
|
||||
{ preHandler: requirePermission("projects.edit") },
|
||||
@@ -183,6 +225,9 @@ export default async function projectsRoutes(
|
||||
if (noteId === null) return;
|
||||
const projectId = parseId(request.params.id, reply);
|
||||
if (projectId === null) return;
|
||||
if (request.authData!.roleName !== "admin") {
|
||||
return error(reply, "Poznámky může mazat pouze administrátor", 403);
|
||||
}
|
||||
|
||||
const note = await deleteProjectNote(projectId, noteId);
|
||||
if (!note) return error(reply, "Poznámka nenalezena", 404);
|
||||
|
||||
@@ -27,6 +27,14 @@ export const CreateProjectNoteSchema = z.object({
|
||||
content: z.string().nullish(),
|
||||
});
|
||||
|
||||
export const UpdateProjectNoteSchema = z.strictObject({
|
||||
content: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, "Poznámka nesmí být prázdná")
|
||||
.max(5000, "Poznámka může mít maximálně 5000 znaků"),
|
||||
});
|
||||
|
||||
export type CreateProjectInput = z.infer<typeof CreateProjectSchema>;
|
||||
export type UpdateProjectInput = z.infer<typeof UpdateProjectSchema>;
|
||||
export type CreateProjectNoteInput = z.infer<typeof CreateProjectNoteSchema>;
|
||||
|
||||
@@ -1,12 +1,22 @@
|
||||
import type Anthropic from "@anthropic-ai/sdk";
|
||||
import prisma from "../config/database";
|
||||
import { listInvoices, getInvoiceStats, getInvoice } from "./invoices.service";
|
||||
import { listOffers, getOffer } from "./offers.service";
|
||||
import { listOrders, getOrder } from "./orders.service";
|
||||
import { listIssuedOrders, getIssuedOrder } from "./issued-orders.service";
|
||||
import { listOffers, getOffer, getOfferTotals } from "./offers.service";
|
||||
import { listOrders, getOrder, getOrderTotals } from "./orders.service";
|
||||
import {
|
||||
listIssuedOrders,
|
||||
getIssuedOrder,
|
||||
getIssuedOrderTotals,
|
||||
} from "./issued-orders.service";
|
||||
import { listProjects, getProject } from "./projects.service";
|
||||
import { getBelowMinimumItems } from "./warehouse.service";
|
||||
import { calcWorkedHours } from "./attendance.service";
|
||||
import {
|
||||
calcWorkedHours,
|
||||
getBalances,
|
||||
getWorkfund,
|
||||
getProjectReport,
|
||||
} from "./attendance.service";
|
||||
import { toCzk } from "./exchange-rates";
|
||||
import { resolveGrid, listPlanUsers } from "./plan.service";
|
||||
|
||||
/**
|
||||
@@ -85,6 +95,14 @@ const hhmm = (d: Date | null): string | null =>
|
||||
? `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`
|
||||
: null;
|
||||
const round2 = (n: number): number => Math.round(n * 100) / 100;
|
||||
/** SPZ normalization: users type plates with/without spaces or dashes. */
|
||||
const normPlate = (s: string): string => s.replace(/[\s-]/g, "").toUpperCase();
|
||||
/**
|
||||
* Diacritic-folded lowercase for JS-side matching — parity with the DB's
|
||||
* utf8mb4_unicode_ci contains ("dodavka" must match "Dodávka").
|
||||
*/
|
||||
const normText = (s: string): string =>
|
||||
s.normalize("NFD").replace(/[̀-ͯ]/g, "").toLowerCase();
|
||||
|
||||
/** Quill/HTML rich text → compact plain text for model context. */
|
||||
const stripHtml = (html: string | null | undefined): string =>
|
||||
@@ -463,7 +481,7 @@ const TOOLS: ToolSpec[] = [
|
||||
definition: {
|
||||
name: "list_customers",
|
||||
description:
|
||||
"Zákazníci firmy (adresy, IČO, DIČ). Vrací max 20 odpovídajících.",
|
||||
"Zákazníci firmy (adresy, IČO, DIČ). Vrací max 20 odpovídajících; total_matching = CELKOVÝ počet zákazníků odpovídajících filtru (bez search = všichni).",
|
||||
input_schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
@@ -476,12 +494,13 @@ const TOOLS: ToolSpec[] = [
|
||||
},
|
||||
handler: async (input) => {
|
||||
// Mirrors the customers route's read (search on name/company_id).
|
||||
// IČO is stored without spaces but users type "123 45 678".
|
||||
const search = str(input.search);
|
||||
const where = search
|
||||
? {
|
||||
OR: [
|
||||
{ name: { contains: search } },
|
||||
{ company_id: { contains: search } },
|
||||
{ company_id: { contains: search.replace(/\s+/g, "") } },
|
||||
],
|
||||
}
|
||||
: {};
|
||||
@@ -545,26 +564,32 @@ const TOOLS: ToolSpec[] = [
|
||||
}
|
||||
// Stock math mirrors the service's total-stock rule: sum of
|
||||
// unconsumed batch quantities.
|
||||
const rows = await prisma.sklad_items.findMany({
|
||||
where: {
|
||||
is_active: true,
|
||||
OR: [
|
||||
{ name: { contains: search } },
|
||||
{ item_number: { contains: search } },
|
||||
],
|
||||
},
|
||||
take: 20,
|
||||
select: {
|
||||
item_number: true,
|
||||
name: true,
|
||||
unit: true,
|
||||
batches: {
|
||||
where: { is_consumed: false },
|
||||
select: { quantity: true },
|
||||
const where = {
|
||||
is_active: true,
|
||||
OR: [
|
||||
{ name: { contains: search } },
|
||||
{ item_number: { contains: search } },
|
||||
],
|
||||
};
|
||||
const [rows, total] = await Promise.all([
|
||||
prisma.sklad_items.findMany({
|
||||
where,
|
||||
orderBy: [{ name: "asc" }, { id: "asc" }],
|
||||
take: 20,
|
||||
select: {
|
||||
item_number: true,
|
||||
name: true,
|
||||
unit: true,
|
||||
batches: {
|
||||
where: { is_consumed: false },
|
||||
select: { quantity: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}),
|
||||
prisma.sklad_items.count({ where }),
|
||||
]);
|
||||
return {
|
||||
total_matching: total,
|
||||
shown: rows.length,
|
||||
items: rows.map((r) => ({
|
||||
item_number: r.item_number,
|
||||
@@ -780,20 +805,24 @@ const TOOLS: ToolSpec[] = [
|
||||
if (!fullDirectory && populations.length === 0) {
|
||||
return { error: DENIED("seznam zaměstnanců") };
|
||||
}
|
||||
const rows = await prisma.users.findMany({
|
||||
where: fullDirectory
|
||||
? { OR: or }
|
||||
: { AND: [{ OR: or }, { is_active: true }, { OR: populations }] },
|
||||
orderBy: [{ is_active: "desc" }, { last_name: "asc" }, { id: "asc" }],
|
||||
take: 10,
|
||||
select: {
|
||||
id: true,
|
||||
first_name: true,
|
||||
last_name: true,
|
||||
username: true,
|
||||
is_active: true,
|
||||
},
|
||||
});
|
||||
const where = fullDirectory
|
||||
? { OR: or }
|
||||
: { AND: [{ OR: or }, { is_active: true }, { OR: populations }] };
|
||||
const [rows, total] = await Promise.all([
|
||||
prisma.users.findMany({
|
||||
where,
|
||||
orderBy: [{ is_active: "desc" }, { last_name: "asc" }, { id: "asc" }],
|
||||
take: 10,
|
||||
select: {
|
||||
id: true,
|
||||
first_name: true,
|
||||
last_name: true,
|
||||
username: true,
|
||||
is_active: true,
|
||||
},
|
||||
}),
|
||||
prisma.users.count({ where }),
|
||||
]);
|
||||
// GET /trips/users deliberately omits usernames (login identifiers);
|
||||
// only the attendance picker and the users admin expose them.
|
||||
const showUsername =
|
||||
@@ -801,6 +830,7 @@ const TOOLS: ToolSpec[] = [
|
||||
ctxCan(ctx, "attendance.record") ||
|
||||
ctxCan(ctx, "attendance.manage");
|
||||
return {
|
||||
total_matching: total,
|
||||
shown: rows.length,
|
||||
employees: rows.map((u) => ({
|
||||
user_id: u.id,
|
||||
@@ -874,14 +904,28 @@ const TOOLS: ToolSpec[] = [
|
||||
}
|
||||
const vehicle = str(input.vehicle);
|
||||
if (vehicle) {
|
||||
where.vehicles = {
|
||||
is: {
|
||||
OR: [
|
||||
{ name: { contains: vehicle } },
|
||||
{ spz: { contains: vehicle } },
|
||||
],
|
||||
},
|
||||
};
|
||||
// Resolve in JS so SPZ spacing is normalized on both sides
|
||||
// ("4SY7039" must match the stored "4SY 7039") — SQL contains is
|
||||
// literal. The vehicles table is tiny.
|
||||
const allVehicles = await prisma.vehicles.findMany({
|
||||
select: { id: true, name: true, spz: true },
|
||||
});
|
||||
const ids = allVehicles
|
||||
.filter(
|
||||
(v) =>
|
||||
normText(v.name).includes(normText(vehicle)) ||
|
||||
normPlate(v.spz).includes(normPlate(vehicle)),
|
||||
)
|
||||
.map((v) => v.id);
|
||||
if (ids.length === 0) {
|
||||
return {
|
||||
total_matching: 0,
|
||||
shown: 0,
|
||||
trips: [],
|
||||
note: "Žádné vozidlo neodpovídá hledání (zkontroluj název nebo SPZ).",
|
||||
};
|
||||
}
|
||||
where.vehicle_id = { in: ids };
|
||||
}
|
||||
|
||||
const [rows, all] = await Promise.all([
|
||||
@@ -1276,11 +1320,690 @@ const TOOLS: ToolSpec[] = [
|
||||
recent_notes: p.project_notes.slice(0, 5).map((n) => ({
|
||||
author: n.user_name,
|
||||
date: n.created_at,
|
||||
...(n.edited_at ? { edited_at: n.edited_at } : {}),
|
||||
text: stripHtml(n.content).slice(0, 300),
|
||||
})),
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
// Own balance parity: getStatus exposes it to every attendance.record
|
||||
// holder; the all-users overview route requires attendance.balances.
|
||||
// attendance.manage also unlocks foreign balances — DELIBERATE widening:
|
||||
// the manage-gated print route (getPrintData) already returns
|
||||
// vacation_total/remaining for every user, so manage holders gain
|
||||
// nothing new here.
|
||||
permission: [
|
||||
"attendance.record",
|
||||
"attendance.manage",
|
||||
"attendance.balances",
|
||||
],
|
||||
definition: {
|
||||
name: "get_leave_balance",
|
||||
description:
|
||||
"Zůstatek dovolené a čerpání nemocenské za rok (nárok, vyčerpáno, zbývá). Bez user_id vrací zůstatek PŘIHLÁŠENÉHO uživatele; cizí zůstatky vyžadují oprávnění na zůstatky/správu docházky.",
|
||||
input_schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
user_id: {
|
||||
type: "number",
|
||||
description: "ID jiného uživatele (zjistíš přes find_employee)",
|
||||
},
|
||||
year: { type: "number", description: "Rok (výchozí aktuální)" },
|
||||
},
|
||||
},
|
||||
},
|
||||
handler: async (input, ctx) => {
|
||||
const targetUserId = num(input.user_id) ?? ctx.userId;
|
||||
if (
|
||||
targetUserId !== ctx.userId &&
|
||||
!ctxCan(ctx, "attendance.balances") &&
|
||||
!ctxCan(ctx, "attendance.manage")
|
||||
) {
|
||||
return { error: DENIED("zůstatky dovolené jiných uživatelů") };
|
||||
}
|
||||
const year = num(input.year) ?? new Date().getFullYear();
|
||||
const { balances } = await getBalances(year);
|
||||
const b = balances[String(targetUserId)];
|
||||
if (!b) {
|
||||
return {
|
||||
error:
|
||||
"Uživatel není v přehledu docházky (neaktivní nebo bez docházky).",
|
||||
};
|
||||
}
|
||||
return { user_id: targetUserId, year, ...b };
|
||||
},
|
||||
},
|
||||
{
|
||||
// Own month parity: getStatus exposes the fund to the user; the
|
||||
// all-users workfund route requires attendance.manage.
|
||||
permission: ["attendance.record", "attendance.manage"],
|
||||
definition: {
|
||||
name: "get_work_fund",
|
||||
description:
|
||||
"Fond pracovní doby za měsíc: fond hodin (i k dnešnímu dni), odpracováno, pokryto (práce + dovolená/nemoc), přesčas a chybějící hodiny. Bez user_id vrací PŘIHLÁŠENÉHO uživatele; cizí fond vyžaduje správu docházky.",
|
||||
input_schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
user_id: {
|
||||
type: "number",
|
||||
description: "ID jiného uživatele (jen se správou docházky)",
|
||||
},
|
||||
month: {
|
||||
type: "number",
|
||||
description: "Měsíc 1-12 (výchozí aktuální)",
|
||||
},
|
||||
year: { type: "number", description: "Rok (výchozí aktuální)" },
|
||||
},
|
||||
},
|
||||
},
|
||||
handler: async (input, ctx) => {
|
||||
const targetUserId = num(input.user_id) ?? ctx.userId;
|
||||
if (targetUserId !== ctx.userId && !ctxCan(ctx, "attendance.manage")) {
|
||||
return { error: DENIED("fond pracovní doby jiných uživatelů") };
|
||||
}
|
||||
const now = new Date();
|
||||
const month = num(input.month) ?? now.getMonth() + 1;
|
||||
const year = num(input.year) ?? now.getFullYear();
|
||||
if (!Number.isInteger(month) || month > 12) {
|
||||
return { error: "Měsíc musí být celé číslo 1-12." };
|
||||
}
|
||||
const fund = await getWorkfund(year);
|
||||
// getWorkfund's future-year early return types months as `{}` — pin
|
||||
// the indexable shape.
|
||||
const months: Record<
|
||||
string,
|
||||
{
|
||||
month_name: string;
|
||||
fund: number;
|
||||
fund_to_date: number;
|
||||
business_days: number;
|
||||
users: Record<
|
||||
string,
|
||||
{
|
||||
name: string;
|
||||
worked: number;
|
||||
covered: number;
|
||||
overtime: number;
|
||||
missing: number;
|
||||
}
|
||||
>;
|
||||
}
|
||||
> = fund.months;
|
||||
const m = months[String(month)];
|
||||
if (!m) {
|
||||
return {
|
||||
error: "Pro tento měsíc zatím nejsou data (budoucí období).",
|
||||
};
|
||||
}
|
||||
const u = m.users[String(targetUserId)];
|
||||
if (!u) {
|
||||
return {
|
||||
error:
|
||||
"Uživatel není v přehledu docházky (neaktivní nebo bez docházky).",
|
||||
};
|
||||
}
|
||||
return {
|
||||
user_id: targetUserId,
|
||||
month,
|
||||
year,
|
||||
month_name: m.month_name,
|
||||
business_days: m.business_days,
|
||||
fund_hours: m.fund,
|
||||
fund_hours_to_date: m.fund_to_date,
|
||||
worked_hours: u.worked,
|
||||
covered_hours: u.covered,
|
||||
overtime_hours: u.overtime,
|
||||
missing_hours: u.missing,
|
||||
note: "Pokryto = odpracováno + dovolená/nemoc; fond k dnešnímu dni počítá jen uplynulé pracovní dny.",
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
// Route parity: action=project_report requires attendance.manage.
|
||||
permission: "attendance.manage",
|
||||
definition: {
|
||||
name: "get_project_hours_report",
|
||||
description:
|
||||
"Report odpracovaných hodin podle PROJEKTŮ za rok (z docházky). Volitelně filtr na jeden projekt (číslo nebo část názvu) a měsíc; vrací hodiny celkem, po měsících a po lidech. Volej na dotazy typu 'kolik hodin jsme strávili na projektu X'.",
|
||||
input_schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
year: { type: "number", description: "Rok (výchozí aktuální)" },
|
||||
month: {
|
||||
type: "number",
|
||||
description: "Jen měsíc 1-12 (vynech pro celý rok)",
|
||||
},
|
||||
project: {
|
||||
type: "string",
|
||||
description:
|
||||
"Číslo nebo část názvu projektu (vynech pro přehled všech)",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
handler: async (input) => {
|
||||
const year = num(input.year) ?? new Date().getFullYear();
|
||||
const monthFilter = num(input.month);
|
||||
const projectFilter = str(input.project)?.toLowerCase();
|
||||
const report = await getProjectReport(year);
|
||||
|
||||
type Agg = {
|
||||
label: string;
|
||||
project_number?: string;
|
||||
project_name?: string;
|
||||
hours: number;
|
||||
by_month: Record<string, number>;
|
||||
by_user: Record<string, number>;
|
||||
};
|
||||
const byProject = new Map<string, Agg>();
|
||||
for (const [monthKey, m] of Object.entries(report.months)) {
|
||||
if (monthFilter && Number(monthKey) !== monthFilter) continue;
|
||||
for (const p of m.projects) {
|
||||
const label = p.project_number || p.project_name || "(bez projektu)";
|
||||
if (
|
||||
projectFilter &&
|
||||
!`${p.project_number ?? ""} ${p.project_name ?? ""}`
|
||||
.toLowerCase()
|
||||
.includes(projectFilter)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const agg = byProject.get(label) ?? {
|
||||
// Always-present label: the no-project bucket has undefined
|
||||
// number/name, which JSON.stringify would drop entirely.
|
||||
label,
|
||||
project_number: p.project_number,
|
||||
project_name: p.project_name,
|
||||
hours: 0,
|
||||
by_month: {},
|
||||
by_user: {},
|
||||
};
|
||||
agg.hours += p.hours;
|
||||
agg.by_month[monthKey] = round2(
|
||||
(agg.by_month[monthKey] || 0) + p.hours,
|
||||
);
|
||||
for (const u of p.users) {
|
||||
agg.by_user[u.user_name] = round2(
|
||||
(agg.by_user[u.user_name] || 0) + u.hours,
|
||||
);
|
||||
}
|
||||
byProject.set(label, agg);
|
||||
}
|
||||
}
|
||||
const projects = [...byProject.values()]
|
||||
.sort((a, b) => b.hours - a.hours)
|
||||
.slice(0, 20)
|
||||
.map((p) => ({ ...p, hours: round2(p.hours) }));
|
||||
return {
|
||||
year,
|
||||
...(monthFilter ? { month: monthFilter } : {}),
|
||||
projects_matched: byProject.size,
|
||||
projects,
|
||||
note: "Hodiny pocházejí z docházky (záznamy práce s projektem).",
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
// The issued-orders supplier picker serves orders.view; the warehouse
|
||||
// supplier admin serves warehouse.manage — the fields returned here are
|
||||
// the same set getIssuedOrder already exposes to orders.view holders.
|
||||
permission: ["warehouse.manage", "orders.view"],
|
||||
definition: {
|
||||
name: "find_supplier",
|
||||
description:
|
||||
"Najde dodavatele podle názvu, IČO nebo města (sklad + objednávky vydané). Vrací max 10 odpovídajících.",
|
||||
input_schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
search: {
|
||||
type: "string",
|
||||
description: "Název, IČO nebo město (stačí část)",
|
||||
},
|
||||
},
|
||||
required: ["search"],
|
||||
},
|
||||
},
|
||||
handler: async (input, ctx) => {
|
||||
const search = str(input.search);
|
||||
if (!search) return { error: "Zadej název, IČO nebo město dodavatele." };
|
||||
// IČO is stored without spaces but users type "123 45 678".
|
||||
const icoDigits = search.replace(/\s+/g, "");
|
||||
// Route parity: the orders.view picker (GET /issued-orders/suppliers)
|
||||
// filters is_active: true — only the warehouse.manage supplier admin
|
||||
// sees deactivated (soft-deleted) suppliers.
|
||||
const where = {
|
||||
...(ctxCan(ctx, "warehouse.manage") ? {} : { is_active: true }),
|
||||
OR: [
|
||||
{ name: { contains: search } },
|
||||
{ ico: { contains: icoDigits } },
|
||||
{ city: { contains: search } },
|
||||
],
|
||||
};
|
||||
const [rows, total] = await Promise.all([
|
||||
prisma.sklad_suppliers.findMany({
|
||||
where,
|
||||
orderBy: [{ is_active: "desc" }, { name: "asc" }, { id: "asc" }],
|
||||
take: 10,
|
||||
select: {
|
||||
name: true,
|
||||
city: true,
|
||||
ico: true,
|
||||
dic: true,
|
||||
is_active: true,
|
||||
},
|
||||
}),
|
||||
prisma.sklad_suppliers.count({ where }),
|
||||
]);
|
||||
return {
|
||||
total_matching: total,
|
||||
shown: rows.length,
|
||||
suppliers: rows.map((s) => ({
|
||||
name: s.name,
|
||||
city: s.city,
|
||||
ico: s.ico,
|
||||
dic: s.dic,
|
||||
active: s.is_active,
|
||||
})),
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
// Mirrors GET /vehicles: any trips permission or vehicle management.
|
||||
permission: [
|
||||
"vehicles.manage",
|
||||
"trips.record",
|
||||
"trips.manage",
|
||||
"trips.history",
|
||||
],
|
||||
definition: {
|
||||
name: "list_vehicles",
|
||||
description:
|
||||
"Firemní vozidla: SPZ, značka/model, aktuální stav tachometru a počet jízd. Volej na dotazy typu 'kolik má najeto dodávka'.",
|
||||
input_schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
search: {
|
||||
type: "string",
|
||||
description: "Hledání podle názvu nebo SPZ (vynech pro všechna)",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
handler: async (input) => {
|
||||
const search = str(input.search);
|
||||
// The vehicles table is tiny — fetch all and match in JS so SPZ
|
||||
// spacing can be normalized on BOTH sides ("4SY7039" must find the
|
||||
// stored "4SY 7039" and vice versa); a SQL contains is literal.
|
||||
const [allVehicles, tripStats] = await Promise.all([
|
||||
prisma.vehicles.findMany({
|
||||
orderBy: [{ name: "asc" }, { id: "asc" }],
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
spz: true,
|
||||
brand: true,
|
||||
model: true,
|
||||
initial_km: true,
|
||||
is_active: true,
|
||||
},
|
||||
}),
|
||||
prisma.trips.groupBy({
|
||||
by: ["vehicle_id"],
|
||||
_max: { end_km: true },
|
||||
_count: { id: true },
|
||||
}),
|
||||
]);
|
||||
const matched = search
|
||||
? allVehicles.filter(
|
||||
(v) =>
|
||||
normText(v.name).includes(normText(search)) ||
|
||||
normPlate(v.spz).includes(normPlate(search)),
|
||||
)
|
||||
: allVehicles;
|
||||
const total = matched.length;
|
||||
const vehicles = matched.slice(0, 20);
|
||||
const statsMap = new Map(
|
||||
tripStats.map((s) => [
|
||||
s.vehicle_id,
|
||||
{ maxKm: s._max.end_km ?? 0, count: s._count.id },
|
||||
]),
|
||||
);
|
||||
return {
|
||||
total_matching: total,
|
||||
shown: vehicles.length,
|
||||
vehicles: vehicles.map((v) => {
|
||||
const stats = statsMap.get(v.id);
|
||||
return {
|
||||
name: v.name,
|
||||
spz: v.spz,
|
||||
brand: [v.brand, v.model].filter(Boolean).join(" ") || null,
|
||||
current_km: stats
|
||||
? Math.max(v.initial_km, stats.maxKm)
|
||||
: v.initial_km,
|
||||
trip_count: stats?.count ?? 0,
|
||||
active: v.is_active,
|
||||
};
|
||||
}),
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
// Coarse gate any-of; the handler re-checks the exact permission per
|
||||
// document type (offers vs orders vs invoices families).
|
||||
permission: ["offers.view", "orders.view", "invoices.view"],
|
||||
definition: {
|
||||
name: "get_document_totals",
|
||||
description:
|
||||
"Součty hodnot dokumentů za CELÝ filtr (ne jen prvních 20 z listu!) po měnách. Typy: offers (nabídky, bez DPH), orders (objednávky přijaté, bez DPH), issued_orders (objednávky vydané, bez DPH), received_invoices (přijaté faktury = VÝDAJE, včetně DPH, s přepočtem do CZK). VŽDY použij tento nástroj místo sčítání řádků z list_* nástrojů. Na dotazy 'kolik jsme utratili' použij received_invoices a/nebo issued_orders.",
|
||||
input_schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
type: {
|
||||
type: "string",
|
||||
enum: ["offers", "orders", "issued_orders", "received_invoices"],
|
||||
description:
|
||||
"Typ dokumentů (received_invoices = přijaté faktury / výdaje, VČETNĚ DPH + přepočet do CZK)",
|
||||
},
|
||||
status: {
|
||||
type: "string",
|
||||
description:
|
||||
"Filtr stavu (vynech pro všechny; received_invoices: unpaid/paid)",
|
||||
},
|
||||
search: {
|
||||
type: "string",
|
||||
description:
|
||||
"Hledání jako v list_* nástroji (ne received_invoices)",
|
||||
},
|
||||
month: {
|
||||
type: "number",
|
||||
description: "Měsíc 1-12 (ne offers)",
|
||||
},
|
||||
year: {
|
||||
type: "number",
|
||||
description: "Rok (ne offers)",
|
||||
},
|
||||
},
|
||||
required: ["type"],
|
||||
},
|
||||
},
|
||||
handler: async (input, ctx) => {
|
||||
const type = str(input.type);
|
||||
const month = num(input.month);
|
||||
// A bare month means the current year — the where-builders apply the
|
||||
// period only when BOTH are set, and a silently dropped filter would
|
||||
// let all-time totals masquerade as the asked-about month.
|
||||
const year =
|
||||
num(input.year) ?? (month ? new Date().getFullYear() : undefined);
|
||||
if (month && (!Number.isInteger(month) || month > 12)) {
|
||||
return { error: "Měsíc musí být celé číslo 1-12." };
|
||||
}
|
||||
const filters = {
|
||||
search: str(input.search),
|
||||
status: str(input.status),
|
||||
month,
|
||||
year,
|
||||
};
|
||||
// Echoed so the model (and the user) always see which period the sum
|
||||
// actually covers.
|
||||
const period =
|
||||
month && year ? `${month}/${year}` : "vše (bez filtru období)";
|
||||
if (type === "offers") {
|
||||
if (!ctxCan(ctx, "offers.view")) return { error: DENIED("nabídky") };
|
||||
if (month || num(input.year)) {
|
||||
// OfferFilterParams has no month/year — they'd be silently ignored
|
||||
// and the all-time sum would pose as a monthly figure.
|
||||
return {
|
||||
error:
|
||||
"Nabídky nelze filtrovat podle měsíce/roku — souhrn nabídek je vždy za všechny odpovídající; vynech month/year.",
|
||||
};
|
||||
}
|
||||
const { totals } = await getOfferTotals(filters);
|
||||
return {
|
||||
type,
|
||||
period: "vše",
|
||||
totals,
|
||||
note: "Součty bez DPH, po měnách.",
|
||||
};
|
||||
}
|
||||
if (type === "orders") {
|
||||
if (!ctxCan(ctx, "orders.view")) return { error: DENIED("objednávky") };
|
||||
const { totals } = await getOrderTotals(filters);
|
||||
return { type, period, totals, note: "Součty bez DPH, po měnách." };
|
||||
}
|
||||
if (type === "received_invoices") {
|
||||
if (!ctxCan(ctx, "invoices.view")) {
|
||||
return { error: DENIED("přijaté faktury") };
|
||||
}
|
||||
// month/year are literal columns on received_invoices; gross sums
|
||||
// per currency + CZK conversion mirror GET /received-invoices/stats.
|
||||
const where: Record<string, unknown> = {};
|
||||
if (month && year) {
|
||||
where.month = month;
|
||||
where.year = year;
|
||||
} else if (year) {
|
||||
where.year = year;
|
||||
}
|
||||
const status = str(input.status);
|
||||
if (status === "unpaid" || status === "paid") where.status = status;
|
||||
const rows = await prisma.received_invoices.findMany({
|
||||
where,
|
||||
select: { amount: true, currency: true },
|
||||
});
|
||||
const byCurrency: Record<string, number> = {};
|
||||
for (const r of rows) {
|
||||
const cur = r.currency || "CZK";
|
||||
byCurrency[cur] = (byCurrency[cur] || 0) + (Number(r.amount) || 0);
|
||||
}
|
||||
const totals = Object.entries(byCurrency).map(([currency, amount]) => ({
|
||||
currency,
|
||||
amount: round2(amount),
|
||||
}));
|
||||
// CZK conversion mirrors the stats route: a row with an unknown
|
||||
// currency is skipped (logged), never fails the whole sum.
|
||||
let totalCzk = 0;
|
||||
let skipped = 0;
|
||||
for (const r of rows) {
|
||||
try {
|
||||
totalCzk += await toCzk(Number(r.amount) || 0, r.currency);
|
||||
} catch (e) {
|
||||
skipped += 1;
|
||||
console.error(
|
||||
`[ai-tools] toCzk failed for currency "${r.currency}"`,
|
||||
e,
|
||||
);
|
||||
}
|
||||
}
|
||||
return {
|
||||
type,
|
||||
period: month && year ? `${month}/${year}` : year ? `${year}` : "vše",
|
||||
invoice_count: rows.length,
|
||||
totals,
|
||||
total_czk: round2(totalCzk),
|
||||
...(skipped ? { czk_conversion_skipped_rows: skipped } : {}),
|
||||
note: "Částky jsou VČETNĚ DPH (gross); total_czk je přepočet aktuálním kurzem.",
|
||||
};
|
||||
}
|
||||
if (type === "issued_orders") {
|
||||
if (!ctxCan(ctx, "orders.view")) {
|
||||
return { error: DENIED("objednávky vydané") };
|
||||
}
|
||||
const { totals } = await getIssuedOrderTotals(filters);
|
||||
return { type, period, totals, note: "Součty bez DPH, po měnách." };
|
||||
}
|
||||
return { error: "Neznámý typ dokumentu." };
|
||||
},
|
||||
},
|
||||
{
|
||||
permission: "invoices.view",
|
||||
definition: {
|
||||
name: "get_received_invoice_detail",
|
||||
description:
|
||||
"Detail jedné přijaté faktury (od dodavatele): částka VČETNĚ DPH, stav, splatnost/uhrazení, popis a interní poznámky. Hledej podle čísla faktury nebo názvu dodavatele.",
|
||||
input_schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
search: {
|
||||
type: "string",
|
||||
description: "Číslo faktury nebo název dodavatele (stačí část)",
|
||||
},
|
||||
},
|
||||
required: ["search"],
|
||||
},
|
||||
},
|
||||
handler: async (input) => {
|
||||
const search = str(input.search);
|
||||
if (!search) return { error: "Zadej číslo faktury nebo dodavatele." };
|
||||
const where = {
|
||||
OR: [
|
||||
{ invoice_number: { contains: search } },
|
||||
{ supplier_name: { contains: search } },
|
||||
],
|
||||
};
|
||||
const [inv, matched] = await Promise.all([
|
||||
prisma.received_invoices.findFirst({
|
||||
where,
|
||||
orderBy: [{ issue_date: "desc" }, { id: "desc" }],
|
||||
}),
|
||||
prisma.received_invoices.count({ where }),
|
||||
]);
|
||||
if (!inv) {
|
||||
return { error: "Přijatá faktura odpovídající hledání neexistuje." };
|
||||
}
|
||||
return {
|
||||
total_matching: matched,
|
||||
supplier: inv.supplier_name,
|
||||
number: inv.invoice_number,
|
||||
description: inv.description,
|
||||
amount_with_vat: Number(inv.amount),
|
||||
vat_rate: Number(inv.vat_rate),
|
||||
vat_amount: Number(inv.vat_amount),
|
||||
currency: inv.currency,
|
||||
issue_date: inv.issue_date,
|
||||
due_date: inv.due_date,
|
||||
paid_date: inv.paid_date,
|
||||
status: inv.status,
|
||||
attachment: inv.file_name,
|
||||
internal_notes: stripHtml(inv.notes).slice(0, 300) || null,
|
||||
note: "Částka je včetně DPH (gross). Zobrazena nejnovější odpovídající faktura.",
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
// Coarse gate any-of; the handler re-checks the exact permission per
|
||||
// aggregation source. Counts only — no amounts beyond what the lists
|
||||
// already expose to the same permission.
|
||||
permission: ["invoices.view", "orders.view", "projects.view"],
|
||||
definition: {
|
||||
name: "get_top_customers",
|
||||
description:
|
||||
"Agregace podle ZÁKAZNÍKA přes celou databázi: počet vydaných faktur / přijatých objednávek / projektů na zákazníka (TOP 20 + počet zákazníků se záznamem). Volej na dotazy 'pro koho pracujeme nejvíc', 'největší zákazník'. Celkový počet zákazníků zjistíš z list_customers (total_matching).",
|
||||
input_schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
type: {
|
||||
type: "string",
|
||||
enum: ["invoices", "orders", "projects"],
|
||||
description: "Podle čeho agregovat",
|
||||
},
|
||||
year: {
|
||||
type: "number",
|
||||
description: "Jen daný rok (vynech pro celou historii)",
|
||||
},
|
||||
},
|
||||
required: ["type"],
|
||||
},
|
||||
},
|
||||
handler: async (input, ctx) => {
|
||||
const type = str(input.type);
|
||||
const year = num(input.year);
|
||||
// Year ranges as UTC midnights (issue_date is @db.Date; created_at
|
||||
// DateTime comparisons are unaffected by the choice).
|
||||
const range = year
|
||||
? {
|
||||
gte: new Date(Date.UTC(year, 0, 1)),
|
||||
lt: new Date(Date.UTC(year + 1, 0, 1)),
|
||||
}
|
||||
: undefined;
|
||||
// Normalized per branch — Prisma's groupBy generics don't survive a
|
||||
// shared pre-declared variable type.
|
||||
let groups: { customer_id: number | null; count: number }[];
|
||||
let note: string;
|
||||
if (type === "invoices") {
|
||||
if (!ctxCan(ctx, "invoices.view")) return { error: DENIED("faktury") };
|
||||
const g = await prisma.invoices.groupBy({
|
||||
by: ["customer_id"],
|
||||
where: {
|
||||
status: { not: "draft" },
|
||||
...(range ? { issue_date: range } : {}),
|
||||
},
|
||||
_count: { _all: true },
|
||||
});
|
||||
groups = g.map((x) => ({
|
||||
customer_id: x.customer_id,
|
||||
count: x._count._all,
|
||||
}));
|
||||
note = "Počty vydaných faktur (bez konceptů).";
|
||||
} else if (type === "orders") {
|
||||
if (!ctxCan(ctx, "orders.view")) {
|
||||
return { error: DENIED("objednávky") };
|
||||
}
|
||||
const g = await prisma.orders.groupBy({
|
||||
by: ["customer_id"],
|
||||
where: range ? { created_at: range } : {},
|
||||
_count: { _all: true },
|
||||
});
|
||||
groups = g.map((x) => ({
|
||||
customer_id: x.customer_id,
|
||||
count: x._count._all,
|
||||
}));
|
||||
note = "Počty přijatých objednávek (všechny stavy).";
|
||||
} else if (type === "projects") {
|
||||
if (!ctxCan(ctx, "projects.view")) {
|
||||
return { error: DENIED("projekty") };
|
||||
}
|
||||
const g = await prisma.projects.groupBy({
|
||||
by: ["customer_id"],
|
||||
where: range ? { created_at: range } : {},
|
||||
_count: { _all: true },
|
||||
});
|
||||
groups = g.map((x) => ({
|
||||
customer_id: x.customer_id,
|
||||
count: x._count._all,
|
||||
}));
|
||||
note = "Počty projektů (všechny stavy).";
|
||||
} else {
|
||||
return { error: "Neznámý typ agregace." };
|
||||
}
|
||||
const assigned = groups
|
||||
.filter((g) => g.customer_id != null)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
b.count - a.count || (a.customer_id ?? 0) - (b.customer_id ?? 0),
|
||||
);
|
||||
const unassigned = groups.find((g) => g.customer_id == null)?.count ?? 0;
|
||||
const top = assigned.slice(0, 20);
|
||||
const names = await prisma.customers.findMany({
|
||||
where: { id: { in: top.map((g) => g.customer_id as number) } },
|
||||
select: { id: true, name: true },
|
||||
});
|
||||
const nameById = new Map(names.map((c) => [c.id, c.name]));
|
||||
return {
|
||||
type,
|
||||
...(year ? { year } : {}),
|
||||
customers_with_records: assigned.length,
|
||||
shown: top.length,
|
||||
top: top.map((g) => ({
|
||||
customer:
|
||||
nameById.get(g.customer_id as number) ??
|
||||
`Zákazník #${g.customer_id}`,
|
||||
count: g.count,
|
||||
})),
|
||||
...(unassigned ? { records_without_customer: unassigned } : {}),
|
||||
note,
|
||||
};
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
/** Tool definitions the caller may use — filtered by their permissions. */
|
||||
@@ -1348,4 +2071,12 @@ export const TOOL_LABELS: Record<string, string> = {
|
||||
get_issued_order_detail: "Detail obj. vydané",
|
||||
get_invoice_detail: "Detail faktury",
|
||||
get_project_detail: "Detail projektu",
|
||||
get_leave_balance: "Zůstatek dovolené",
|
||||
get_work_fund: "Fond pracovní doby",
|
||||
get_project_hours_report: "Hodiny na projektech",
|
||||
find_supplier: "Dodavatelé",
|
||||
list_vehicles: "Vozidla",
|
||||
get_document_totals: "Součty dokumentů",
|
||||
get_received_invoice_detail: "Detail přijaté faktury",
|
||||
get_top_customers: "TOP zákazníci",
|
||||
};
|
||||
|
||||
@@ -291,6 +291,7 @@ function agentSystemPrompt(
|
||||
"Odpovědi piš jako PROSTÝ TEXT — žádný Markdown (žádné tabulky, **tučné**, nadpisy); výčty piš jako řádky s pomlčkou. " +
|
||||
(tools.length > 0
|
||||
? "Máš nástroje POUZE PRO ČTENÍ dat systému — používej je, kdykoli se dotaz týká firemních dat, a odpovídej výhradně z jejich výsledků (nikdy si firemní čísla nevymýšlej). " +
|
||||
"Seznamové nástroje zobrazují max ~20 řádků, ale total_matching je CELKOVÝ počet odpovídajících záznamů — na otázky 'kolik' odpovídej z total_matching. Na 'pro koho nejvíc' a součty používej agregační nástroje (get_top_customers, get_document_totals, get_invoice_stats) — nikdy nesčítej řádky ze seznamů. " +
|
||||
(hasFindEmployee
|
||||
? "Když se dotaz týká konkrétního zaměstnance (docházka, kniha jízd, plán práce), zjisti nejdřív jeho user_id nástrojem find_employee podle jména — neptej se uživatele na ID. "
|
||||
: "") +
|
||||
|
||||
@@ -283,3 +283,37 @@ export async function deleteProjectNote(projectId: number, noteId: number) {
|
||||
await prisma.project_notes.delete({ where: { id: noteId } });
|
||||
return note;
|
||||
}
|
||||
|
||||
/**
|
||||
* Author-only note edit: a note belongs to whoever wrote it (even admins
|
||||
* edit only their own — editing someone else's words would falsify
|
||||
* authorship; admins delete instead). Stamps edited_at for the UI marker.
|
||||
*/
|
||||
export async function updateProjectNote(
|
||||
projectId: number,
|
||||
noteId: number,
|
||||
userId: number,
|
||||
content: string,
|
||||
): Promise<
|
||||
| null
|
||||
| { error: string; status: number; note?: never }
|
||||
| {
|
||||
error?: never;
|
||||
status?: never;
|
||||
note: Awaited<ReturnType<typeof prisma.project_notes.update>>;
|
||||
previousContent: string | null;
|
||||
}
|
||||
> {
|
||||
const note = await prisma.project_notes.findFirst({
|
||||
where: { id: noteId, project_id: projectId },
|
||||
});
|
||||
if (!note) return null;
|
||||
if (note.user_id !== userId) {
|
||||
return { error: "Upravit lze pouze vlastní poznámku", status: 403 };
|
||||
}
|
||||
const updated = await prisma.project_notes.update({
|
||||
where: { id: noteId },
|
||||
data: { content, edited_at: new Date() },
|
||||
});
|
||||
return { note: updated, previousContent: note.content };
|
||||
}
|
||||
|
||||
@@ -94,7 +94,7 @@ export function buildPdfHeaderTemplate(
|
||||
logoDataUri: string,
|
||||
heading: string,
|
||||
): string {
|
||||
return `<div style="width:100%; font-family:Tahoma, sans-serif; padding:0 12mm; box-sizing:border-box;">
|
||||
return `<div style="width:100%; font-family:'Segoe UI', Tahoma, Arial, sans-serif; padding:0 12mm; box-sizing:border-box;">
|
||||
<div style="display:flex; justify-content:space-between; align-items:center; padding-bottom:1mm; border-bottom:2pt solid #de3a3a;">
|
||||
<div style="flex:0 0 auto;">${logoDataUri ? `<img src="${logoDataUri}" style="max-width:42mm; max-height:22mm; object-fit:contain;" />` : ""}</div>
|
||||
<div style="font-size:13pt; font-weight:700; color:#de3a3a; text-align:right; letter-spacing:0.03em;">${escapeHtml(heading)}</div>
|
||||
@@ -108,6 +108,13 @@ export function buildPdfHeaderTemplate(
|
||||
* and merge adjacent identical spans. Runs AFTER DOMPurify at every call site
|
||||
* (regex pass = defense-in-depth, not the primary sanitizer).
|
||||
*/
|
||||
/**
|
||||
* Literal CSS color values only: #hex, rgb()/rgba() with numeric components,
|
||||
* or a bare keyword. No quotes, parentheses beyond rgb(a), url(), variables.
|
||||
*/
|
||||
const SAFE_CSS_COLOR =
|
||||
/^(#[0-9a-f]{3,8}|rgba?\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}\s*(,\s*(0|1|0?\.\d+)\s*)?\)|[a-z]{3,20})$/i;
|
||||
|
||||
export function cleanQuillHtml(html: string | null | undefined): string {
|
||||
if (!html) return "";
|
||||
let s = html;
|
||||
@@ -134,7 +141,32 @@ export function cleanQuillHtml(html: string | null | undefined): string {
|
||||
);
|
||||
// Replace with regular space (outside of tags)
|
||||
s = s.replace(/( )/g, " ");
|
||||
s = s.replace(/\s+style\s*=\s*("[^"]*"|'[^']*'|[^\s>]*)/gi, "");
|
||||
// Quill 2's semantic HTML saves blank lines as truly empty <p></p>, which
|
||||
// collapse to zero height in the PDF — restore the <br> placeholder so an
|
||||
// editor blank line stays a blank line.
|
||||
s = s.replace(/<p([^>]*)>\s*<\/p>/gi, "<p$1><br></p>");
|
||||
// Inline styles: Quill writes text/background colors as style attributes.
|
||||
// Keep ONLY color/background-color with literal color values — everything
|
||||
// else (url(), expression(), positioning…) must never reach Puppeteer.
|
||||
s = s.replace(
|
||||
/\s+style\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/gi,
|
||||
(_m, dq, sq, bare) => {
|
||||
const kept = String(dq ?? sq ?? bare ?? "")
|
||||
.split(";")
|
||||
.map((decl) => {
|
||||
const i = decl.indexOf(":");
|
||||
if (i < 0) return null;
|
||||
const prop = decl.slice(0, i).trim().toLowerCase();
|
||||
const val = decl.slice(i + 1).trim();
|
||||
return (prop === "color" || prop === "background-color") &&
|
||||
SAFE_CSS_COLOR.test(val)
|
||||
? `${prop}: ${val}`
|
||||
: null;
|
||||
})
|
||||
.filter((d): d is string => d !== null);
|
||||
return kept.length ? ` style="${kept.join("; ")}"` : "";
|
||||
},
|
||||
);
|
||||
// Merge adjacent spans with same attributes
|
||||
let prev = "";
|
||||
while (prev !== s) {
|
||||
|
||||
Reference in New Issue
Block a user