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",
"version": "1.6.1",
"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

@@ -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

@@ -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,