Compare commits

..

13 Commits

Author SHA1 Message Date
BOHA
bf33d0a543 chore(release): v2.4.29 - editable project notes
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 16:26:38 +02:00
BOHA
64bb7f9c37 feat(projects): editable notes with edit stamp + NoteCard bubble component
- author-only note editing (PUT /projects/:id/notes/:noteId, strict schema,
  audit with old/new content); edits stamped via new edited_at column
  (migration 20260612160000) and shown as '· upraveno <datum>'
- note deletion tightened to admin-only server-side (was any projects.edit
  holder via direct API)
- new NoteCard component: chat-bubble note row (initials avatar, wrapping
  header, inline editor, actions in the bubble corner so they don't squeeze
  content on mobile), table-matching edit/delete icons
- Odin project detail carries the edited_at stamp; route-level tests cover
  author-edit, foreign-edit 403, validation, admin-only delete

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 16:26:38 +02:00
BOHA
6f3f1c40e7 fix(ui): zmeneny text - text - v select boxu 2026-06-12 14:56:52 +02:00
BOHA
164ce54adf chore(release): v2.4.28 - Odin expense totals
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 09:34:51 +02:00
BOHA
6877919133 feat(odin): received-invoices expense totals with CZK conversion
get_document_totals gains type=received_invoices: whole-set gross sums per
currency + total_czk via the same toCzk logic as the stats route (unknown
currencies skipped + reported), month/year via the literal columns (bare
year supported), unpaid/paid filter, period echo. invoices.view joined the
coarse gate (the per-type re-check keeps other families locked); the tool
description routes 'kolik jsme utratili' questions here.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 09:34:51 +02:00
BOHA
5ec70599aa chore(release): v2.4.27 - rich-text blank lines
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 08:35:46 +02:00
BOHA
7e4469b29a fix(richtext): blank lines survive into PDFs and read-only previews
Quill 2's semantic HTML saves a blank line as a truly empty <p></p> (no
<br> placeholder) — verified in the DB (invoice_sections hold <p></p>,
zero <p><br>) — which collapses to nothing outside the editor.
cleanQuillHtml (PDF path) and the new restoreQuillBlankLines (order/
invoice read-only previews) restore the placeholder at render time, so
existing stored content is covered retroactively.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 08:35:46 +02:00
BOHA
08af4cd0ae chore(release): v2.4.26 - PDF font unification + rich-text colors
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 08:27:47 +02:00
BOHA
eb279a87a9 fix(pdf): Segoe UI everywhere + rich-text colors survive sanitization
- section/Quill overrides, footers and the shared header now lead with
  'Segoe UI' (installed on prod) instead of Tahoma (not installed — prod
  silently rendered sections and footers in Noto Sans, mismatching the
  Segoe UI body; dev previews showed real Tahoma and never matched prod)
- cleanQuillHtml no longer deletes ALL style attributes: color and
  background-color survive with strictly validated literal values (hex,
  rgb/rgba, keyword) so editor font colors finally reach the PDF; url(),
  expression() and every other declaration are still stripped — new test
  file pins both the kept colors and the attack vectors

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 08:27:47 +02:00
BOHA
ea3b595b32 chore(release): v2.4.25 - autosize item descriptions
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 08:16:43 +02:00
BOHA
e9b432bec6 feat(documents): autosize item descriptions (minRows 2, maxRows 16)
The 'Podrobny popis' textareas in the shared items editor and the invoice
page now grow with content like the rich-text editor (fixed rows={2} ->
minRows/maxRows autosize), capped at 16 rows; the manual vertical resize
handle stays.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 08:16:43 +02:00
BOHA
741c8109fa chore(release): v2.4.24 - Odin lookup normalization + aggregations
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 15:07:58 +02:00
BOHA
07d9c8d9f7 feat(odin): forgiving lookups + per-customer aggregation + count awareness
- SPZ matching normalized on both sides (4SY7039 finds '4SY 7039') in
  list_vehicles and the list_trips vehicle filter; JS-side name matching
  diacritic-folded to keep utf8mb4_unicode_ci parity; ICO typed with spaces
  matches the space-less stored value (suppliers + customers)
- get_top_customers: whole-table per-customer counts of invoices (sans
  drafts) / orders / projects, top 20 + customers_with_records, optional
  year, per-type permission re-checks — answers 'pro koho pracujeme nejvic'
  exactly instead of refusing
- find_employee, find_supplier and stock search now return total_matching
  (stock search also gained its missing deterministic orderBy); system
  prompt teaches the model to answer counts from total_matching and use
  aggregation tools instead of summing 20-row list pages

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 15:07:58 +02:00
30 changed files with 1176 additions and 196 deletions

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "app-ts", "name": "app-ts",
"version": "2.4.23", "version": "2.4.29",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "app-ts", "name": "app-ts",
"version": "2.4.23", "version": "2.4.29",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@anthropic-ai/sdk": "^0.102.0", "@anthropic-ai/sdk": "^0.102.0",

View File

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

View File

@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE `project_notes` ADD COLUMN `edited_at` DATETIME(0) NULL;

View File

@@ -457,6 +457,7 @@ model project_notes {
user_name String? @db.VarChar(100) user_name String? @db.VarChar(100)
content String? @db.Text content String? @db.Text
created_at DateTime? @default(now()) @db.DateTime(0) 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") projects projects @relation(fields: [project_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "project_notes_ibfk_1")
@@index([project_id], map: "project_id") @@index([project_id], map: "project_id")

View File

@@ -189,9 +189,11 @@ beforeAll(async () => {
const FIX_OFFER_NUMBER = "2098/NA/99999"; const FIX_OFFER_NUMBER = "2098/NA/99999";
const FIX_SUPPLIER = "AiTest Dodavatel s.r.o."; const FIX_SUPPLIER = "AiTest Dodavatel s.r.o.";
const FIX_RI_SUPPLIER = "AiTest Supplier 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 fixOfferId = 0;
let fixSupplierId = 0; let fixSupplierId = 0;
let fixRiId = 0; let fixRiId = 0;
let fixCustomerId = 0;
beforeAll(async () => { beforeAll(async () => {
await prisma.quotations.deleteMany({ await prisma.quotations.deleteMany({
@@ -205,6 +207,21 @@ beforeAll(async () => {
data: { name: FIX_SUPPLIER, ico: "99999998", city: "Brno" }, data: { name: FIX_SUPPLIER, ico: "99999998", city: "Brno" },
}); });
fixSupplierId = sup.id; 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({ const ri = await prisma.received_invoices.create({
data: { data: {
month: 6, month: 6,
@@ -265,6 +282,9 @@ afterAll(async () => {
await prisma.quotations.deleteMany({ where: { id: fixOfferId } }); await prisma.quotations.deleteMany({ where: { id: fixOfferId } });
await prisma.sklad_suppliers.deleteMany({ where: { id: fixSupplierId } }); await prisma.sklad_suppliers.deleteMany({ where: { id: fixSupplierId } });
await prisma.received_invoices.deleteMany({ where: { id: fixRiId } }); 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 () => { afterAll(async () => {
@@ -655,6 +675,18 @@ describe("ai-tools — employees, attendance detail, trips, work plan", () => {
).suppliers.find((x) => x.name === FIX_SUPPLIER); ).suppliers.find((x) => x.name === FIX_SUPPLIER);
expect(s).toMatchObject({ ico: "99999998" }); 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( const veh = await executeTool(
"list_vehicles", "list_vehicles",
{ search: FIX.spz }, { search: FIX.spz },
@@ -669,6 +701,25 @@ describe("ai-tools — employees, attendance detail, trips, work plan", () => {
// Odometer rule: max trip end_km (1050) over initial_km; 3 fixture trips. // 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 }); 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( expect(
(await executeTool("find_supplier", { search: "x" }, NOBODY)).ok, (await executeTool("find_supplier", { search: "x" }, NOBODY)).ok,
).toBe(false); ).toBe(false);
@@ -714,6 +765,78 @@ describe("ai-tools — employees, attendance detail, trips, work plan", () => {
); );
}); });
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 () => { it("get_received_invoice_detail returns the gross-amount detail", async () => {
const res = await executeTool( const res = await executeTool(
"get_received_invoice_detail", "get_received_invoice_detail",

View 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:");
});
});

View 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();
});
});

View File

@@ -162,7 +162,7 @@ export default function BulkPlanModal({
value={form.project_id} value={form.project_id}
onChange={(val) => setForm({ ...form, project_id: val })} onChange={(val) => setForm({ ...form, project_id: val })}
options={[ options={[
{ value: "", label: "— bez projektu —" }, { value: "", label: "Vyberte projekt" },
...projects.map((p) => ({ value: String(p.id), label: p.name })), ...projects.map((p) => ({ value: String(p.id), label: p.name })),
]} ]}
/> />

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

View File

@@ -392,7 +392,7 @@ function EditForm(props: Props) {
value={projectId === null ? "" : String(projectId)} value={projectId === null ? "" : String(projectId)}
onChange={(val) => setProjectId(val ? Number(val) : null)} onChange={(val) => setProjectId(val ? Number(val) : null)}
options={[ options={[
{ value: "", label: "— bez projektu —" }, { value: "", label: "Vyberte projekt" },
...projects.map((p) => ({ ...projects.map((p) => ({
value: String(p.id), value: String(p.id),
label: p.name, label: p.name,

View File

@@ -157,7 +157,7 @@ function ProjectLogRow({
value={String(log.project_id)} value={String(log.project_id)}
onChange={(val) => onUpdate(index, "project_id", val)} onChange={(val) => onUpdate(index, "project_id", val)}
options={[ options={[
{ value: "", label: "— Projekt" }, { value: "", label: "Vyberte projekt" },
...projectList.map((p) => ({ ...projectList.map((p) => ({
value: String(p.id), value: String(p.id),
label: `${p.project_number} ${p.name}`, label: `${p.project_number} ${p.name}`,

View File

@@ -214,7 +214,8 @@ function SortableItemRow({
placeholder="Volitelný" placeholder="Volitelný"
InputProps={{ readOnly }} InputProps={{ readOnly }}
multiline multiline
rows={2} minRows={2}
maxRows={16}
slotProps={{ htmlInput: { maxLength: itemDescriptionMaxLength } }} slotProps={{ htmlInput: { maxLength: itemDescriptionMaxLength } }}
sx={{ "& textarea": { resize: "vertical" } }} sx={{ "& textarea": { resize: "vertical" } }}
/> />
@@ -352,7 +353,8 @@ function SortableItemRow({
placeholder="Podrobný popis (volitelný)" placeholder="Podrobný popis (volitelný)"
InputProps={{ readOnly }} InputProps={{ readOnly }}
multiline multiline
rows={2} minRows={2}
maxRows={16}
slotProps={{ htmlInput: { maxLength: itemDescriptionMaxLength } }} slotProps={{ htmlInput: { maxLength: itemDescriptionMaxLength } }}
sx={{ sx={{
"& textarea": { "& textarea": {

View File

@@ -5,8 +5,10 @@ import apiFetch from "../../utils/api";
export interface ProjectNote { export interface ProjectNote {
id: number; id: number;
content: string; content: string;
user_id: number | null;
user_name: string; user_name: string;
created_at: string; created_at: string;
edited_at: string | null;
} }
export interface ProjectData { export interface ProjectData {

View File

@@ -835,7 +835,7 @@ export default function Attendance() {
onChange={(val) => handleSwitchProject(val || null)} onChange={(val) => handleSwitchProject(val || null)}
disabled={switchingProject} disabled={switchingProject}
options={[ options={[
{ value: "", label: "— Bez projektu —" }, { value: "", label: "Vyberte projekt" },
...projects.map((p) => ({ ...projects.map((p) => ({
value: String(p.id), value: String(p.id),
label: `${p.project_number} ${p.name}`, label: `${p.project_number} ${p.name}`,

View File

@@ -58,6 +58,7 @@ import {
formatCurrency, formatCurrency,
formatDate, formatDate,
numberOr, numberOr,
restoreQuillBlankLines,
todayLocalStr, todayLocalStr,
} from "../utils/formatters"; } from "../utils/formatters";
import { normalizeDateStr } from "../utils/attendanceHelpers"; import { normalizeDateStr } from "../utils/attendanceHelpers";
@@ -317,7 +318,8 @@ function SortableInvoiceRow({
onChange={(e) => onUpdate(index, "item_description", e.target.value)} onChange={(e) => onUpdate(index, "item_description", e.target.value)}
placeholder="Volitelný" placeholder="Volitelný"
multiline multiline
rows={2} minRows={2}
maxRows={16}
sx={{ "& textarea": { resize: "vertical" } }} sx={{ "& textarea": { resize: "vertical" } }}
/> />
<Box <Box
@@ -418,7 +420,8 @@ function SortableInvoiceRow({
} }
placeholder="Podrobný popis (volitelný)" placeholder="Podrobný popis (volitelný)"
multiline multiline
rows={2} minRows={2}
maxRows={16}
sx={{ sx={{
"& textarea": { "& textarea": {
fontSize: "0.8rem", fontSize: "0.8rem",
@@ -1668,7 +1671,9 @@ export default function InvoiceDetail() {
{(s.content || "").replace(/<[^>]*>/g, "").trim() && ( {(s.content || "").replace(/<[^>]*>/g, "").trim() && (
<RichTextView <RichTextView
dangerouslySetInnerHTML={{ 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>" ? ( invoice.internal_notes !== "<p><br></p>" ? (
<RichTextView <RichTextView
dangerouslySetInnerHTML={{ dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(invoice.internal_notes), __html: DOMPurify.sanitize(
restoreQuillBlankLines(invoice.internal_notes),
),
}} }}
/> />
) : ( ) : (

View File

@@ -18,7 +18,11 @@ import Typography from "@mui/material/Typography";
import OrderConfirmationModal from "../components/OrderConfirmationModal"; import OrderConfirmationModal from "../components/OrderConfirmationModal";
import Forbidden from "../components/Forbidden"; import Forbidden from "../components/Forbidden";
import apiFetch from "../utils/api"; 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 { ORDER_STATUS, statusLabel, statusColor } from "../lib/documentStatus";
import { import {
Button, Button,
@@ -713,7 +717,9 @@ export default function OrderDetail() {
<RichTextView <RichTextView
sx={{ p: 2 }} sx={{ p: 2 }}
dangerouslySetInnerHTML={{ dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(section.content), __html: DOMPurify.sanitize(
restoreQuillBlankLines(section.content),
),
}} }}
/> />
)} )}

View File

@@ -1,12 +1,12 @@
import { useState, useEffect, useRef } from "react"; 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 { useApiMutation } from "../lib/queries/mutations";
import apiFetch from "../utils/api";
import { useAlert } from "../context/AlertContext"; import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext"; import { useAuth } from "../context/AuthContext";
import { useParams, useNavigate, Link as RouterLink } from "react-router-dom"; import { useParams, useNavigate, Link as RouterLink } from "react-router-dom";
import Box from "@mui/material/Box"; import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography"; import Typography from "@mui/material/Typography";
import IconButton from "@mui/material/IconButton";
import MenuItem from "@mui/material/MenuItem"; import MenuItem from "@mui/material/MenuItem";
import { import {
@@ -17,6 +17,7 @@ import {
import { userListOptions, type User as ApiUser } from "../lib/queries/users"; import { userListOptions, type User as ApiUser } from "../lib/queries/users";
import Forbidden from "../components/Forbidden"; import Forbidden from "../components/Forbidden";
import ProjectFileManager from "../components/ProjectFileManager"; import ProjectFileManager from "../components/ProjectFileManager";
import NoteCard from "../components/NoteCard";
import { import {
Button, Button,
Card, Card,
@@ -49,17 +50,6 @@ const STATUS_COLORS: Record<
zruseny: "default", 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 { interface User {
id: number; id: number;
name: string; name: string;
@@ -86,28 +76,12 @@ const BackIcon = (
</svg> </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() { export default function ProjectDetail() {
const { id } = useParams(); const { id } = useParams();
const alert = useAlert(); const alert = useAlert();
const { hasPermission, isAdmin } = useAuth(); const { hasPermission, isAdmin, user } = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
const qc = useQueryClient();
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [form, setForm] = useState<ProjectForm>({ 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) { if (isPending) {
return <LoadingState />; return <LoadingState />;
} }
@@ -493,61 +491,18 @@ export default function ProjectDetail() {
{(notes.length > 0 || project.notes) && ( {(notes.length > 0 || project.notes) && (
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}> <Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
{notes.map((note) => ( {notes.map((note) => (
<Box <NoteCard
key={note.id} key={note.id}
sx={{ authorName={note.user_name}
p: 1.5, createdAt={note.created_at}
bgcolor: "action.hover", editedAt={note.edited_at}
borderRadius: 2, content={note.content || ""}
position: "relative", canEdit={user?.id === note.user_id}
}} canDelete={isAdmin}
> deleting={deletingNoteId === note.id}
<Box onSave={(content) => saveNoteEdit(note.id, content)}
sx={{ onDelete={() => handleDeleteNote(note.id)}
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>
))} ))}
</Box> </Box>
)} )}

View File

@@ -554,7 +554,7 @@ export default function Projects() {
setCreateForm({ ...createForm, customer_id: value }) setCreateForm({ ...createForm, customer_id: value })
} }
options={[ options={[
{ value: "", label: " bez zákazníka " }, { value: "", label: "Vyberte zákaznika" },
...(customersQuery.data ?? []).map((c) => ({ ...(customersQuery.data ?? []).map((c) => ({
value: String(c.id), value: String(c.id),
label: c.name, label: c.name,
@@ -571,7 +571,7 @@ export default function Projects() {
setCreateForm({ ...createForm, responsible_user_id: value }) setCreateForm({ ...createForm, responsible_user_id: value })
} }
options={[ options={[
{ value: "", label: " nepřiřazeno " }, { value: "", label: "Vyberte zaměstnance" },
...(usersQuery.data ?? []) ...(usersQuery.data ?? [])
.filter((u) => u.is_active) .filter((u) => u.is_active)
.map((u) => ({ .map((u) => ({

View File

@@ -625,7 +625,7 @@ export default function OrdersReceived({
setCreateForm({ ...createForm, customer_id: value }) setCreateForm({ ...createForm, customer_id: value })
} }
options={[ options={[
{ value: "", label: "— bez zákazníka" }, { value: "", label: "Vyberte zákazníka" },
...(customersQuery.data ?? []).map((c) => ({ ...(customersQuery.data ?? []).map((c) => ({
value: String(c.id), value: String(c.id),
label: c.name, label: c.name,

View File

@@ -76,3 +76,15 @@ export function czechPlural(
if (n >= 2 && n <= 4) return few; if (n >= 2 && n <= 4) return few;
return many; 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>");
}

View File

@@ -253,7 +253,7 @@ export function buildInvoicePdfFooter(
const t = translations[lang] || translations.cs; const t = translations[lang] || translations.cs;
const pageWord = lang === "cs" ? "Strana" : "Page"; const pageWord = lang === "cs" ? "Strana" : "Page";
const ofWord = lang === "cs" ? "z" : "of"; 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: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; text-align:center;">${pageWord} <span class="pageNumber"></span> ${ofWord} <span class="totalPages"></span></div>
<div style="flex:1;"></div> <div style="flex:1;"></div>
@@ -898,7 +898,7 @@ export default async function invoicesPdfRoutes(
} }
.section-content, .section-content,
.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 { font-size: 14px; }
.section-content h1 { font-size: 20px; } .section-content h1 { font-size: 20px; }
@@ -910,7 +910,7 @@ export default async function invoicesPdfRoutes(
.section-content li { margin-bottom: 0.2em; } .section-content li { margin-bottom: 0.2em; }
/* Quill fonty v PDF vynuceno Tahoma */ /* 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-center { text-align: center; }
.ql-align-right { text-align: right; } .ql-align-right { text-align: right; }
.ql-align-justify { text-align: justify; } .ql-align-justify { text-align: justify; }

View File

@@ -267,7 +267,7 @@ export function buildIssuedOrderPdfFooter(
const t = translations[lang]; const t = translations[lang];
const pageWord = lang === "cs" ? "Strana" : "Page"; const pageWord = lang === "cs" ? "Strana" : "Page";
const ofWord = lang === "cs" ? "z" : "of"; 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: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; text-align:center;">${pageWord} <span class="pageNumber"></span> ${ofWord} <span class="totalPages"></span></div>
<div style="flex:1;"></div> <div style="flex:1;"></div>
@@ -655,7 +655,7 @@ export function renderIssuedOrderHtml(
} }
.section-content, .section-content,
.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 { font-size: 14px; }
.section-content h1 { font-size: 20px; } .section-content h1 { font-size: 20px; }
@@ -667,7 +667,7 @@ export function renderIssuedOrderHtml(
.section-content li { margin-bottom: 0.2em; } .section-content li { margin-bottom: 0.2em; }
/* Quill fonty v PDF vynuceno Tahoma */ /* 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-center { text-align: center; }
.ql-align-right { text-align: right; } .ql-align-right { text-align: right; }
.ql-align-justify { text-align: justify; } .ql-align-justify { text-align: justify; }

View File

@@ -293,7 +293,7 @@ export function renderOfferHtml(
img, table, pre, code { max-width: 100%; } img, table, pre, code { max-width: 100%; }
/* ---- Quill font classes v PDF vynuceno Tahoma ---- */ /* ---- 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 ---- */ /* ---- Quill alignment ---- */
.ql-align-center { text-align: center; } .ql-align-center { text-align: center; }
@@ -502,7 +502,7 @@ ${indentCSS}
} }
.section-content, .section-content,
.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 { font-size: 14px; }
.section-content h1 { font-size: 20px; } .section-content h1 { font-size: 20px; }

View File

@@ -570,7 +570,7 @@ export function renderOrderConfirmationHtml(
.invoice-notes-content li { margin-bottom: 0.2em; } .invoice-notes-content li { margin-bottom: 0.2em; }
.invoice-notes-content, .invoice-notes-content,
.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 { font-size: 14px; }
.invoice-notes-content h1 { font-size: 20px; } .invoice-notes-content h1 { font-size: 20px; }
@@ -579,7 +579,7 @@ export function renderOrderConfirmationHtml(
.invoice-notes-content h4 { font-size: 15px; } .invoice-notes-content h4 { font-size: 15px; }
/* Quill fonty v PDF vynuceno Tahoma */ /* 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-center { text-align: center; }
.ql-align-right { text-align: right; } .ql-align-right { text-align: right; }
.ql-align-justify { text-align: justify; } .ql-align-justify { text-align: justify; }

View File

@@ -9,6 +9,7 @@ import {
UpdateProjectSchema, UpdateProjectSchema,
CreateProjectSchema, CreateProjectSchema,
CreateProjectNoteSchema, CreateProjectNoteSchema,
UpdateProjectNoteSchema,
} from "../../schemas/projects.schema"; } from "../../schemas/projects.schema";
import { import {
listProjects, listProjects,
@@ -19,6 +20,7 @@ import {
deleteProject, deleteProject,
createProjectNote, createProjectNote,
deleteProjectNote, deleteProjectNote,
updateProjectNote,
} from "../../services/projects.service"; } from "../../services/projects.service";
// Body for DELETE /:id — optional delete_files flag. The body may be absent on // 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 } }>( fastify.delete<{ Params: { id: string; noteId: string } }>(
"/:id/notes/:noteId", "/:id/notes/:noteId",
{ preHandler: requirePermission("projects.edit") }, { preHandler: requirePermission("projects.edit") },
@@ -183,6 +225,9 @@ export default async function projectsRoutes(
if (noteId === null) return; if (noteId === null) return;
const projectId = parseId(request.params.id, reply); const projectId = parseId(request.params.id, reply);
if (projectId === null) return; 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); const note = await deleteProjectNote(projectId, noteId);
if (!note) return error(reply, "Poznámka nenalezena", 404); if (!note) return error(reply, "Poznámka nenalezena", 404);

View File

@@ -27,6 +27,14 @@ export const CreateProjectNoteSchema = z.object({
content: z.string().nullish(), 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 CreateProjectInput = z.infer<typeof CreateProjectSchema>;
export type UpdateProjectInput = z.infer<typeof UpdateProjectSchema>; export type UpdateProjectInput = z.infer<typeof UpdateProjectSchema>;
export type CreateProjectNoteInput = z.infer<typeof CreateProjectNoteSchema>; export type CreateProjectNoteInput = z.infer<typeof CreateProjectNoteSchema>;

View File

@@ -16,6 +16,7 @@ import {
getWorkfund, getWorkfund,
getProjectReport, getProjectReport,
} from "./attendance.service"; } from "./attendance.service";
import { toCzk } from "./exchange-rates";
import { resolveGrid, listPlanUsers } from "./plan.service"; import { resolveGrid, listPlanUsers } from "./plan.service";
/** /**
@@ -94,6 +95,14 @@ const hhmm = (d: Date | null): string | null =>
? `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}` ? `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`
: null; : null;
const round2 = (n: number): number => Math.round(n * 100) / 100; 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. */ /** Quill/HTML rich text → compact plain text for model context. */
const stripHtml = (html: string | null | undefined): string => const stripHtml = (html: string | null | undefined): string =>
@@ -472,7 +481,7 @@ const TOOLS: ToolSpec[] = [
definition: { definition: {
name: "list_customers", name: "list_customers",
description: 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: { input_schema: {
type: "object", type: "object",
properties: { properties: {
@@ -485,12 +494,13 @@ const TOOLS: ToolSpec[] = [
}, },
handler: async (input) => { handler: async (input) => {
// Mirrors the customers route's read (search on name/company_id). // 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 search = str(input.search);
const where = search const where = search
? { ? {
OR: [ OR: [
{ name: { contains: search } }, { name: { contains: search } },
{ company_id: { contains: search } }, { company_id: { contains: search.replace(/\s+/g, "") } },
], ],
} }
: {}; : {};
@@ -554,26 +564,32 @@ const TOOLS: ToolSpec[] = [
} }
// Stock math mirrors the service's total-stock rule: sum of // Stock math mirrors the service's total-stock rule: sum of
// unconsumed batch quantities. // unconsumed batch quantities.
const rows = await prisma.sklad_items.findMany({ const where = {
where: { is_active: true,
is_active: true, OR: [
OR: [ { name: { contains: search } },
{ name: { contains: search } }, { item_number: { contains: search } },
{ item_number: { contains: search } }, ],
], };
}, const [rows, total] = await Promise.all([
take: 20, prisma.sklad_items.findMany({
select: { where,
item_number: true, orderBy: [{ name: "asc" }, { id: "asc" }],
name: true, take: 20,
unit: true, select: {
batches: { item_number: true,
where: { is_consumed: false }, name: true,
select: { quantity: true }, unit: true,
batches: {
where: { is_consumed: false },
select: { quantity: true },
},
}, },
}, }),
}); prisma.sklad_items.count({ where }),
]);
return { return {
total_matching: total,
shown: rows.length, shown: rows.length,
items: rows.map((r) => ({ items: rows.map((r) => ({
item_number: r.item_number, item_number: r.item_number,
@@ -789,20 +805,24 @@ const TOOLS: ToolSpec[] = [
if (!fullDirectory && populations.length === 0) { if (!fullDirectory && populations.length === 0) {
return { error: DENIED("seznam zaměstnanců") }; return { error: DENIED("seznam zaměstnanců") };
} }
const rows = await prisma.users.findMany({ const where = fullDirectory
where: fullDirectory ? { OR: or }
? { OR: or } : { AND: [{ OR: or }, { is_active: true }, { OR: populations }] };
: { AND: [{ OR: or }, { is_active: true }, { OR: populations }] }, const [rows, total] = await Promise.all([
orderBy: [{ is_active: "desc" }, { last_name: "asc" }, { id: "asc" }], prisma.users.findMany({
take: 10, where,
select: { orderBy: [{ is_active: "desc" }, { last_name: "asc" }, { id: "asc" }],
id: true, take: 10,
first_name: true, select: {
last_name: true, id: true,
username: true, first_name: true,
is_active: true, last_name: true,
}, username: true,
}); is_active: true,
},
}),
prisma.users.count({ where }),
]);
// GET /trips/users deliberately omits usernames (login identifiers); // GET /trips/users deliberately omits usernames (login identifiers);
// only the attendance picker and the users admin expose them. // only the attendance picker and the users admin expose them.
const showUsername = const showUsername =
@@ -810,6 +830,7 @@ const TOOLS: ToolSpec[] = [
ctxCan(ctx, "attendance.record") || ctxCan(ctx, "attendance.record") ||
ctxCan(ctx, "attendance.manage"); ctxCan(ctx, "attendance.manage");
return { return {
total_matching: total,
shown: rows.length, shown: rows.length,
employees: rows.map((u) => ({ employees: rows.map((u) => ({
user_id: u.id, user_id: u.id,
@@ -883,14 +904,28 @@ const TOOLS: ToolSpec[] = [
} }
const vehicle = str(input.vehicle); const vehicle = str(input.vehicle);
if (vehicle) { if (vehicle) {
where.vehicles = { // Resolve in JS so SPZ spacing is normalized on both sides
is: { // ("4SY7039" must match the stored "4SY 7039") — SQL contains is
OR: [ // literal. The vehicles table is tiny.
{ name: { contains: vehicle } }, const allVehicles = await prisma.vehicles.findMany({
{ spz: { contains: vehicle } }, 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([ const [rows, all] = await Promise.all([
@@ -1285,6 +1320,7 @@ const TOOLS: ToolSpec[] = [
recent_notes: p.project_notes.slice(0, 5).map((n) => ({ recent_notes: p.project_notes.slice(0, 5).map((n) => ({
author: n.user_name, author: n.user_name,
date: n.created_at, date: n.created_at,
...(n.edited_at ? { edited_at: n.edited_at } : {}),
text: stripHtml(n.content).slice(0, 300), text: stripHtml(n.content).slice(0, 300),
})), })),
}; };
@@ -1531,29 +1567,36 @@ const TOOLS: ToolSpec[] = [
handler: async (input, ctx) => { handler: async (input, ctx) => {
const search = str(input.search); const search = str(input.search);
if (!search) return { error: "Zadej název, IČO nebo město dodavatele." }; 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) // Route parity: the orders.view picker (GET /issued-orders/suppliers)
// filters is_active: true — only the warehouse.manage supplier admin // filters is_active: true — only the warehouse.manage supplier admin
// sees deactivated (soft-deleted) suppliers. // sees deactivated (soft-deleted) suppliers.
const rows = await prisma.sklad_suppliers.findMany({ const where = {
where: { ...(ctxCan(ctx, "warehouse.manage") ? {} : { is_active: true }),
...(ctxCan(ctx, "warehouse.manage") ? {} : { is_active: true }), OR: [
OR: [ { name: { contains: search } },
{ name: { contains: search } }, { ico: { contains: icoDigits } },
{ ico: { contains: search } }, { city: { contains: search } },
{ city: { contains: search } }, ],
], };
}, const [rows, total] = await Promise.all([
orderBy: [{ is_active: "desc" }, { name: "asc" }, { id: "asc" }], prisma.sklad_suppliers.findMany({
take: 10, where,
select: { orderBy: [{ is_active: "desc" }, { name: "asc" }, { id: "asc" }],
name: true, take: 10,
city: true, select: {
ico: true, name: true,
dic: true, city: true,
is_active: true, ico: true,
}, dic: true,
}); is_active: true,
},
}),
prisma.sklad_suppliers.count({ where }),
]);
return { return {
total_matching: total,
shown: rows.length, shown: rows.length,
suppliers: rows.map((s) => ({ suppliers: rows.map((s) => ({
name: s.name, name: s.name,
@@ -1589,17 +1632,12 @@ const TOOLS: ToolSpec[] = [
}, },
handler: async (input) => { handler: async (input) => {
const search = str(input.search); const search = str(input.search);
const where = 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
OR: [{ name: { contains: search } }, { spz: { contains: search } }], // stored "4SY 7039" and vice versa); a SQL contains is literal.
} const [allVehicles, tripStats] = await Promise.all([
: {};
// Same odometer rule as GET /vehicles: max(initial_km, max trip end_km).
const [vehicles, total, tripStats] = await Promise.all([
prisma.vehicles.findMany({ prisma.vehicles.findMany({
where,
orderBy: [{ name: "asc" }, { id: "asc" }], orderBy: [{ name: "asc" }, { id: "asc" }],
take: 20,
select: { select: {
id: true, id: true,
name: true, name: true,
@@ -1610,13 +1648,21 @@ const TOOLS: ToolSpec[] = [
is_active: true, is_active: true,
}, },
}), }),
prisma.vehicles.count({ where }),
prisma.trips.groupBy({ prisma.trips.groupBy({
by: ["vehicle_id"], by: ["vehicle_id"],
_max: { end_km: true }, _max: { end_km: true },
_count: { id: 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( const statsMap = new Map(
tripStats.map((s) => [ tripStats.map((s) => [
s.vehicle_id, s.vehicle_id,
@@ -1644,35 +1690,38 @@ const TOOLS: ToolSpec[] = [
}, },
{ {
// Coarse gate any-of; the handler re-checks the exact permission per // Coarse gate any-of; the handler re-checks the exact permission per
// document type (offers vs orders families). // document type (offers vs orders vs invoices families).
permission: ["offers.view", "orders.view"], permission: ["offers.view", "orders.view", "invoices.view"],
definition: { definition: {
name: "get_document_totals", name: "get_document_totals",
description: description:
"Součty hodnot dokumentů za CELÝ filtr (ne jen prvních 20 z listu!) po měnách, BEZ DPH. Typy: offers (nabídky), orders (objednávky přijaté), issued_orders (objednávky vydané). VŽDY použij tento nástroj místo sčítání řádků z list_* nástrojů, když je výsledků víc než 20.", "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: { input_schema: {
type: "object", type: "object",
properties: { properties: {
type: { type: {
type: "string", type: "string",
enum: ["offers", "orders", "issued_orders"], enum: ["offers", "orders", "issued_orders", "received_invoices"],
description: "Typ dokumentů", description:
"Typ dokumentů (received_invoices = přijaté faktury / výdaje, VČETNĚ DPH + přepočet do CZK)",
}, },
status: { status: {
type: "string", type: "string",
description: "Filtr stavu (vynech pro všechny)", description:
"Filtr stavu (vynech pro všechny; received_invoices: unpaid/paid)",
}, },
search: { search: {
type: "string", type: "string",
description: "Hledání jako v list_* nástroji", description:
"Hledání jako v list_* nástroji (ne received_invoices)",
}, },
month: { month: {
type: "number", type: "number",
description: "Měsíc 1-12 (jen orders/issued_orders)", description: "Měsíc 1-12 (ne offers)",
}, },
year: { year: {
type: "number", type: "number",
description: "Rok (jen orders/issued_orders)", description: "Rok (ne offers)",
}, },
}, },
required: ["type"], required: ["type"],
@@ -1722,6 +1771,59 @@ const TOOLS: ToolSpec[] = [
const { totals } = await getOrderTotals(filters); const { totals } = await getOrderTotals(filters);
return { type, period, totals, note: "Součty bez DPH, po měnách." }; 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 (type === "issued_orders") {
if (!ctxCan(ctx, "orders.view")) { if (!ctxCan(ctx, "orders.view")) {
return { error: DENIED("objednávky vydané") }; return { error: DENIED("objednávky vydané") };
@@ -1787,6 +1889,121 @@ const TOOLS: ToolSpec[] = [
}; };
}, },
}, },
{
// 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. */ /** Tool definitions the caller may use — filtered by their permissions. */
@@ -1861,4 +2078,5 @@ export const TOOL_LABELS: Record<string, string> = {
list_vehicles: "Vozidla", list_vehicles: "Vozidla",
get_document_totals: "Součty dokumentů", get_document_totals: "Součty dokumentů",
get_received_invoice_detail: "Detail přijaté faktury", get_received_invoice_detail: "Detail přijaté faktury",
get_top_customers: "TOP zákazníci",
}; };

View File

@@ -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. " + "Odpovědi piš jako PROSTÝ TEXT — žádný Markdown (žádné tabulky, **tučné**, nadpisy); výčty piš jako řádky s pomlčkou. " +
(tools.length > 0 (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). " + ? "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 (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. " ? "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. "
: "") + : "") +

View File

@@ -283,3 +283,37 @@ export async function deleteProjectNote(projectId: number, noteId: number) {
await prisma.project_notes.delete({ where: { id: noteId } }); await prisma.project_notes.delete({ where: { id: noteId } });
return note; 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 };
}

View File

@@ -94,7 +94,7 @@ export function buildPdfHeaderTemplate(
logoDataUri: string, logoDataUri: string,
heading: string, heading: string,
): 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="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="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> <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 * and merge adjacent identical spans. Runs AFTER DOMPurify at every call site
* (regex pass = defense-in-depth, not the primary sanitizer). * (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 { export function cleanQuillHtml(html: string | null | undefined): string {
if (!html) return ""; if (!html) return "";
let s = html; let s = html;
@@ -134,7 +141,32 @@ export function cleanQuillHtml(html: string | null | undefined): string {
); );
// Replace &nbsp; with regular space (outside of tags) // Replace &nbsp; with regular space (outside of tags)
s = s.replace(/(&nbsp;)/g, " "); s = s.replace(/(&nbsp;)/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 // Merge adjacent spans with same attributes
let prev = ""; let prev = "";
while (prev !== s) { while (prev !== s) {