Compare commits

...

3 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
9 changed files with 78 additions and 89 deletions

View File

@@ -1,6 +1,6 @@
{ {
"name": "app-ts", "name": "app-ts",
"version": "1.6.1", "version": "1.6.4",
"description": "", "description": "",
"main": "dist/server.js", "main": "dist/server.js",
"scripts": { "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 ReactQuill from "react-quill-new";
import "react-quill-new/dist/quill.snow.css"; import "react-quill-new/dist/quill.snow.css";
@@ -96,11 +96,11 @@ export default function RichEditor({
[onChange], [onChange],
); );
useEffect(() => { useLayoutEffect(() => {
if (!quillRef.current) return; if (!quillRef.current) return;
const editor = quillRef.current.getEditor(); const editor = quillRef.current.getEditor();
editor.format("font", "tahoma"); editor.root.style.fontFamily = "Tahoma, sans-serif";
editor.format("size", "14px"); editor.root.style.fontSize = "14px";
}, []); }, []);
return ( return (

View File

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

View File

@@ -105,7 +105,7 @@ export default function ProjectDetail() {
const isPending = projectQuery.isPending; const isPending = projectQuery.isPending;
const notes: Note[] = project?.project_notes || []; 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 ?? const rawUsers = ((usersData as Record<string, unknown>)?.items ??
usersData ?? usersData ??
[]) as any[]; []) as any[];

View File

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

View File

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

View File

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

View File

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

View File

@@ -33,6 +33,7 @@ export interface ListUsersParams {
sort: string; sort: string;
order: "asc" | "desc"; order: "asc" | "desc";
search: string; search: string;
permission?: string;
} }
export interface CreateUserData { export interface CreateUserData {
@@ -56,10 +57,10 @@ export interface UpdateUserData {
} }
export async function listUsers(params: ListUsersParams) { 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 sortField = ALLOWED_SORT_FIELDS.includes(sort) ? sort : "id";
const where = search let where: any = search
? { ? {
OR: [ OR: [
{ username: { contains: search } }, { 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([ const [users, total] = await Promise.all([
prisma.users.findMany({ prisma.users.findMany({
where, where,