Compare commits

...

5 Commits

Author SHA1 Message Date
BOHA
1db5060c42 v1.6.4: fix RichEditor auto-scroll when opening offers with multiple sections
Replace Quill editor.format() with direct DOM style manipulation to
avoid getSelection(true) calls that steal focus and scroll the page
to the sections area on mount.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 08:07:18 +02:00
BOHA
4cabba395d v1.6.3: fix project file upload, filter responsible user by permission
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 20:26:32 +02:00
BOHA
59b478f262 v1.6.2: fix RichEditor auto-scroll and PDF offers multi-page header
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 20:23:36 +02:00
BOHA
e4f14a24b7 fix: handle plain month number in attendance route, not just YYYY-MM
AttendanceAdmin sends ?year=YYYY&month=M (separate params), but the
route handler assumed month was always in "YYYY-MM" format. When
query.month was just "4", the split produced NaN for month and 4 for
year, causing the service to skip the month filter entirely.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 16:50:20 +02:00
BOHA
3bd0d055d9 v1.6.0: fix offer items mobile layout and localStorage draft save/restore
- Fix items table description column width on mobile (was ~82px, now ~260px)
- Activate unused offers-items-table CSS class in OfferDetail form
- Save all form fields to localStorage draft (language, VAT, exchange rate were missing)
- Use DRAFT_KEY constant in loadOfferDraft, add error logging

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 15:23:42 +02:00
12 changed files with 211 additions and 116 deletions

View File

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

View File

@@ -1,4 +1,4 @@
import { useMemo, useRef, useCallback, useEffect } from "react";
import { useMemo, useRef, useCallback, useLayoutEffect } from "react";
import ReactQuill from "react-quill-new";
import "react-quill-new/dist/quill.snow.css";
@@ -96,11 +96,11 @@ export default function RichEditor({
[onChange],
);
useEffect(() => {
useLayoutEffect(() => {
if (!quillRef.current) return;
const editor = quillRef.current.getEditor();
editor.format("font", "tahoma");
editor.format("size", "14px");
editor.root.style.fontFamily = "Tahoma, sans-serif";
editor.root.style.fontSize = "14px";
}, []);
return (

View File

@@ -1,12 +1,14 @@
import { queryOptions } from "@tanstack/react-query";
import { jsonQuery } from "../apiAdapter";
export const userListOptions = () =>
export const userListOptions = (permission?: string) =>
queryOptions({
queryKey: ["users"],
queryKey: ["users", { permission }],
queryFn: () => {
// The users endpoint returns { success, data: { items, ... } } or { success, data: [...] }
return jsonQuery<Record<string, unknown>>("/api/admin/users");
const url = permission
? `/api/admin/users?permission=${encodeURIComponent(permission)}&limit=100`
: "/api/admin/users?limit=100";
return jsonQuery<Record<string, unknown>>(url);
},
});

View File

@@ -491,10 +491,69 @@
display: none;
}
@media (max-width: 768px) {
.offers-items-table .admin-table td .admin-form-input {
font-size: 16px;
min-height: 44px;
padding: 9px 10px;
}
/* Give the description column more room by shrinking numeric columns */
.offers-col-qty {
width: 4rem !important;
}
.offers-col-unit {
width: 4rem !important;
}
.offers-col-price {
width: 5.5rem !important;
}
.offers-col-included {
width: 3.5rem !important;
}
.offers-col-total {
width: 5.5rem !important;
}
.offers-col-del {
width: 2.5rem !important;
}
}
@media (max-width: 640px) {
.offers-items-table {
margin: 0 -1rem;
width: calc(100% + 2rem);
border-radius: 0;
border-left: none;
border-right: none;
}
.offers-items-table .admin-table {
min-width: 700px;
}
.offers-items-table .admin-table td {
padding: 6px;
font-size: 12px;
}
.offers-items-table .admin-table th {
font-size: 10px;
padding: 6px;
}
/* Further reduce numeric columns on very small screens */
.offers-col-qty {
width: 3.5rem !important;
}
.offers-col-unit {
width: 3.5rem !important;
}
.offers-col-price {
width: 5rem !important;
}
.offers-col-total {
width: 5rem !important;
}
}

View File

@@ -274,9 +274,10 @@ function loadOfferDraft(): {
sections?: unknown[];
} | null {
try {
const raw = localStorage.getItem("boha_offer_draft");
const raw = localStorage.getItem(DRAFT_KEY);
return raw ? JSON.parse(raw) : null;
} catch {
} catch (e) {
console.error("Failed to load offer draft:", e);
return null;
}
}
@@ -353,17 +354,28 @@ export default function OfferDetail() {
const draft = loadOfferDraft();
if (draft?.form) {
return {
...emptyForm,
quotation_number:
(draft.form.quotation_number as string) || emptyForm.quotation_number,
project_code:
(draft.form.project_code as string) || emptyForm.project_code,
customer_id:
(draft.form.customer_id as number | null) ?? emptyForm.customer_id,
customer_name:
(draft.form.customer_name as string) || emptyForm.customer_name,
created_at: (draft.form.created_at as string) || emptyForm.created_at,
valid_until:
(draft.form.valid_until as string) || emptyForm.valid_until,
currency: (draft.form.currency as string) || emptyForm.currency,
customer_id:
(draft.form.customer_id as number | null) ?? emptyForm.customer_id,
language: (draft.form.language as string) || emptyForm.language,
vat_rate: (draft.form.vat_rate as number) ?? emptyForm.vat_rate,
apply_vat: (draft.form.apply_vat as boolean) ?? emptyForm.apply_vat,
exchange_rate:
(draft.form.exchange_rate as string) || emptyForm.exchange_rate,
scope_title:
(draft.form.scope_title as string) || emptyForm.scope_title,
scope_description:
(draft.form.scope_description as string) ||
emptyForm.scope_description,
};
}
return emptyForm;
@@ -590,22 +602,27 @@ export default function OfferDetail() {
useEffect(() => {
if (isEdit) return;
try {
const data = JSON.parse(debouncedDraft);
const draft = {
form: {
project_code: form.project_code,
customer_id: form.customer_id,
customer_name: form.customer_name,
created_at: form.created_at,
valid_until: form.valid_until,
currency: form.currency,
project_code: data.form.project_code ?? "",
customer_id: data.form.customer_id ?? null,
customer_name: data.form.customer_name ?? "",
created_at: data.form.created_at ?? "",
valid_until: data.form.valid_until ?? "",
currency: data.form.currency ?? "CZK",
language: data.form.language ?? "EN",
vat_rate: data.form.vat_rate ?? 21,
apply_vat: data.form.apply_vat ?? false,
exchange_rate: data.form.exchange_rate ?? "",
},
items,
sections,
items: data.items,
sections: data.sections,
savedAt: new Date().toISOString(),
};
localStorage.setItem(DRAFT_KEY, JSON.stringify(draft));
} catch {
/* localStorage full or unavailable */
} catch (e) {
console.error("Failed to save offer draft:", e);
}
}, [debouncedDraft]); // eslint-disable-line react-hooks/exhaustive-deps
@@ -697,8 +714,8 @@ export default function OfferDetail() {
if (!isEdit) {
try {
localStorage.removeItem(DRAFT_KEY);
} catch {
/* ignore */
} catch (e) {
console.error("Failed to remove offer draft:", e);
}
}
if (!isEdit && result.data?.id) {
@@ -1283,7 +1300,7 @@ export default function OfferDetail() {
</p>
)}
<div className="admin-table-responsive">
<div className="offers-items-table">
<DndContext
sensors={dndSensors}
collisionDetection={closestCenter}
@@ -1316,18 +1333,42 @@ export default function OfferDetail() {
<th style={{ width: "2.5rem", textAlign: "center" }}>
#
</th>
<th>Popis</th>
<th style={{ width: "5rem" }}>Množství</th>
<th style={{ width: "5rem" }}>Jednotka</th>
<th style={{ width: "7rem" }}>Cena/ks</th>
<th style={{ width: "4rem", textAlign: "center" }}>
<th className="offers-col-desc">Popis</th>
<th
className="offers-col-qty"
style={{ width: "5rem" }}
>
Množství
</th>
<th
className="offers-col-unit"
style={{ width: "5rem" }}
>
Jednotka
</th>
<th
className="offers-col-price"
style={{ width: "7rem" }}
>
Cena/ks
</th>
<th
className="offers-col-included"
style={{ width: "4rem", textAlign: "center" }}
>
V ceně
</th>
<th style={{ width: "7rem", textAlign: "right" }}>
<th
className="offers-col-total"
style={{ width: "7rem", textAlign: "right" }}
>
Celkem
</th>
{!isInvalidated && !isLockedByOther && (
<th style={{ width: "3rem" }} />
<th
className="offers-col-del"
style={{ width: "3rem" }}
/>
)}
</tr>
</thead>

View File

@@ -105,7 +105,7 @@ export default function ProjectDetail() {
const isPending = projectQuery.isPending;
const notes: Note[] = project?.project_notes || [];
const { data: usersData } = useQuery(userListOptions());
const { data: usersData } = useQuery(userListOptions("projects.view"));
const rawUsers = ((usersData as Record<string, unknown>)?.items ??
usersData ??
[]) as any[];

View File

@@ -221,10 +221,16 @@ export default async function attendanceRoutes(
isAdmin,
authUserId: authData.userId,
month: query.month
? String(query.month).includes("-")
? Number(String(query.month).split("-")[1])
: Number(query.month)
: undefined,
year: query.month
? String(query.month).includes("-")
? Number(String(query.month).split("-")[0])
: query.year
? Number(query.year)
: undefined
: query.year
? Number(query.year)
: undefined,

View File

@@ -632,14 +632,6 @@ ${indentCSS}
border: none;
vertical-align: top;
}
.logo-header {
text-align: right;
padding-bottom: 4mm;
}
.first-content {
margin-top: -26mm;
}
/* ---- Page break helpers ---- */
table.page-layout thead { display: table-header-group; }
table.items tbody tr { break-inside: avoid; }
@@ -696,30 +688,16 @@ ${indentCSS}
display: block;
width: 100%;
}
.first-content {
margin-top: 0 !important;
}
.logo-header {
text-align: right;
padding-bottom: 0;
margin-bottom: -18mm;
}
}
</style>
</head>
<body>
<!-- ============ QUOTATION (logo repeats via thead, full header only on first page) ============ -->
<!-- ============ QUOTATION (full header in thead repeats on every page) ============ -->
<div class="quotation-page">
<table class="page-layout">
<thead>
<tr><td>
<div class="logo-header">${logoImg}</div>
</td></tr>
</thead>
<tbody>
<tr><td>
<div class="first-content">
<div class="page-header">
<div class="left">
<div class="page-title">${escapeHtml(t("title"))}</div>
@@ -727,9 +705,13 @@ ${indentCSS}
${quotation.project_code ? `<div class="project-code">${escapeHtml(quotation.project_code)}</div>` : ""}
<div class="valid-until">${escapeHtml(t("valid_until"))}: ${escapeHtml(formatDate(quotation.valid_until))}</div>
</div>
${logoImg ? `<div class="right">${logoImg}</div>` : ""}
</div>
<hr class="separator" />
</td></tr>
</thead>
<tbody>
<tr><td>
<div class="addresses">
<div class="address-block left">
<div class="address-label">${escapeHtml(t("customer"))}</div>
@@ -763,7 +745,6 @@ ${indentCSS}
${totalsHtml}
</div>
</div>
</div>
</td></tr>
</tbody>
</table>

View File

@@ -156,7 +156,6 @@ export default async function projectFilesRoutes(
"/upload",
{
preHandler: requirePermission("projects.files"),
bodyLimit: config.nas.maxUploadSize,
},
async (request, reply) => {
const parsedQuery = parseProjectFilesQuery(request.query);
@@ -183,13 +182,25 @@ export default async function projectFilesRoutes(
const rawFileName = file.filename;
const fileName = rawFileName.replace(/[\/\\:*?"<>|]/g, "_");
const buffer = await file.toBuffer();
const err = await fm.uploadFile(
project.project_number,
subPath,
file.file,
buffer,
fileName,
);
if (err !== null) return error(reply, err);
if (err !== null) {
request.log.error(
{
err,
project_number: project.project_number,
subPath,
fileName,
},
"uploadFile failed",
);
return error(reply, err);
}
await logAudit({
request,

View File

@@ -22,8 +22,12 @@ export default async function usersRoutes(
"/",
{ preHandler: requirePermission("users.view") },
async (request, reply) => {
const params = parsePagination(request.query as Record<string, unknown>);
const result = await listUsers(params);
const query = request.query as Record<string, unknown>;
const params = parsePagination(query);
const permission = query.permission
? String(query.permission)
: undefined;
const result = await listUsers({ ...params, permission });
return paginated(
reply,

View File

@@ -1,6 +1,5 @@
import fs from "fs";
import path from "path";
import { pipeline } from "stream/promises";
import { config } from "../config/env";
import { localDateStr, localTimeStr } from "../utils/date";
@@ -237,7 +236,7 @@ export class NasFileManager {
if (isLink) {
try {
const target = fs.readlinkSync(fullPath);
item.link_target = target.replace(/\//g, "\\");
item.link_target = target;
} catch {
// ignore
}
@@ -288,14 +287,14 @@ export class NasFileManager {
path: subPath,
items,
breadcrumb,
full_path: realDirPath.replace(/\//g, "\\"),
full_path: realDirPath,
};
}
public async uploadFile(
projectNumber: string,
subPath: string,
fileStream: NodeJS.ReadableStream,
fileBuffer: Buffer,
fileName: string,
): Promise<string | null> {
const dirPath = this.resolveProjectPath(projectNumber, subPath);
@@ -304,13 +303,20 @@ export class NasFileManager {
}
try {
const stat = await fs.promises.stat(dirPath);
if (!stat.isDirectory()) {
await fs.promises.mkdir(dirPath, { recursive: true, mode: 0o775 });
} catch {
return "Cílová složka neexistuje";
}
let stat: fs.Stats;
try {
stat = await fs.promises.stat(dirPath);
} catch {
return "Cílová složka neexistuje";
}
if (!stat.isDirectory()) {
return "Cílová složka neexistuje";
}
const originalName = path.basename(fileName);
let safeName = this.sanitizeFilename(originalName);
@@ -323,46 +329,27 @@ export class NasFileManager {
return "Tento typ souboru není povolen";
}
const tempPath = path.join(
require("os").tmpdir(),
`upload-${Date.now()}-${Math.random().toString(36).slice(2)}`,
);
try {
await pipeline(fileStream, fs.createWriteStream(tempPath));
} catch {
await fs.promises.unlink(tempPath).catch(() => {});
return "Nepodařilo se uložit soubor";
}
try {
const typeResult = await FileType.fromFile(tempPath);
// MIME validation
const typeResult = await FileType.fromBuffer(fileBuffer);
if (typeResult) {
if (this.isSuspiciousMime(typeResult.mime)) {
await fs.promises.unlink(tempPath).catch(() => {});
return "Obsah souboru neodpovídá jeho příponě";
}
const expectedMime = ext ? MIME_MAP[ext] : null;
if (expectedMime && typeResult.mime !== expectedMime) {
await fs.promises.unlink(tempPath).catch(() => {});
return "Obsah souboru neodpovídá jeho příponě";
}
}
} catch {
// If file-type fails, continue without MIME check
}
let destPath = dirPath + "/" + safeName;
// Attempt atomic rename; if destination exists, append counter
let renamed = false;
// If destination exists, append counter
let attempts = 0;
const maxAttempts = 1000;
do {
while (attempts < maxAttempts) {
try {
await fs.promises.rename(tempPath, destPath);
renamed = true;
break;
await fs.promises.writeFile(destPath, fileBuffer, { flag: "wx" });
return null;
} catch (err) {
const e = err as NodeJS.ErrnoException;
if (e.code === "EEXIST") {
@@ -371,19 +358,14 @@ export class NasFileManager {
safeName = base + "_" + attempts + (ext ? "." + ext : "");
destPath = dirPath + "/" + safeName;
} else {
break;
return `Nepodařilo se uložit soubor (${e?.message || String(e)})`;
}
}
}
} while (!renamed && attempts < maxAttempts);
if (!renamed) {
await fs.promises.unlink(tempPath).catch(() => {});
return "Nepodařilo se uložit soubor";
}
return null;
}
public downloadFile(
projectNumber: string,
filePath: string,

View File

@@ -33,6 +33,7 @@ export interface ListUsersParams {
sort: string;
order: "asc" | "desc";
search: string;
permission?: string;
}
export interface CreateUserData {
@@ -56,10 +57,10 @@ export interface UpdateUserData {
}
export async function listUsers(params: ListUsersParams) {
const { page, limit, skip, sort, order, search } = params;
const { page, limit, skip, sort, order, search, permission } = params;
const sortField = ALLOWED_SORT_FIELDS.includes(sort) ? sort : "id";
const where = search
let where: any = search
? {
OR: [
{ username: { contains: search } },
@@ -70,6 +71,14 @@ export async function listUsers(params: ListUsersParams) {
}
: {};
if (permission) {
where.roles = {
role_permissions: {
some: { permissions: { name: permission } },
},
};
}
const [users, total] = await Promise.all([
prisma.users.findMany({
where,