Compare commits

...

5 Commits

Author SHA1 Message Date
BOHA
8f74efba97 chore(release): v2.4.30 - unified tab animations
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 18:04:39 +02:00
BOHA
b9103c59f0 feat(ui): unified tab-switch animation - enter-only fade in TabPanel
One 0.2s enter-only fade (opacity + 6px rise) in the kit TabPanel covers
every tab switch (M3 clean-fade pattern; CSS animation so the global
reduced-motion kill switch applies). Orders and Invoices view switchers
now render through TabPanel (also fixes their dangling aria-controls).
Removed the inconsistent bespoke framer entrances inside tab content
(CompanySettings cascade, ReceivedInvoices card). Filter-style status
tabs (Projects/Offers/Invoices) deliberately keep the refetch dim only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 18:04:32 +02:00
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
23 changed files with 1499 additions and 1015 deletions

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "app-ts", "name": "app-ts",
"version": "2.4.28", "version": "2.4.30",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "app-ts", "name": "app-ts",
"version": "2.4.28", "version": "2.4.30",
"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.28", "version": "2.4.30",
"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

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

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

@@ -8,7 +8,6 @@ import {
type CompanySettingsData, type CompanySettingsData,
} from "../lib/queries/settings"; } from "../lib/queries/settings";
import { bankAccountsOptions, type BankAccount } from "../lib/queries/common"; import { bankAccountsOptions, type BankAccount } from "../lib/queries/common";
import { motion } from "framer-motion";
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 IconButton from "@mui/material/IconButton";
@@ -633,11 +632,6 @@ export default function CompanySettings({
return ( return (
<Box> <Box>
{!embedded && ( {!embedded && (
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
>
<Box <Box
sx={{ sx={{
display: "flex", display: "flex",
@@ -650,11 +644,7 @@ export default function CompanySettings({
> >
<Box> <Box>
<Typography variant="h4">Nastavení firmy</Typography> <Typography variant="h4">Nastavení firmy</Typography>
<Typography <Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>
variant="body2"
color="text.secondary"
sx={{ mt: 0.5 }}
>
Firemní údaje a bankovní účty Firemní údaje a bankovní účty
</Typography> </Typography>
</Box> </Box>
@@ -662,7 +652,6 @@ export default function CompanySettings({
{saving ? "Ukládání..." : "Uložit nastavení"} {saving ? "Ukládání..." : "Uložit nastavení"}
</Button> </Button>
</Box> </Box>
</motion.div>
)} )}
<Box <Box
@@ -674,11 +663,6 @@ export default function CompanySettings({
> >
{/* Company Info */} {/* Company Info */}
{(canEditCompany || !embedded) && ( {(canEditCompany || !embedded) && (
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
>
<Card> <Card>
<Typography variant="h6" sx={{ mb: 2 }}> <Typography variant="h6" sx={{ mb: 2 }}>
Firemní údaje Firemní údaje
@@ -834,10 +818,7 @@ export default function CompanySettings({
<Box sx={{ mt: 0.5 }}> <Box sx={{ mt: 0.5 }}>
<CheckboxField <CheckboxField
label={ label={
<Typography <Typography variant="body2" sx={{ fontSize: "0.8rem" }}>
variant="body2"
sx={{ fontSize: "0.8rem" }}
>
Zobrazit název v PDF Zobrazit název v PDF
</Typography> </Typography>
} }
@@ -876,16 +857,10 @@ export default function CompanySettings({
</Button> </Button>
</Box> </Box>
</Card> </Card>
</motion.div>
)} )}
{/* Bank Accounts */} {/* Bank Accounts */}
{(canManageBanking || !embedded) && ( {(canManageBanking || !embedded) && (
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.08 }}
>
<Card> <Card>
<Typography variant="h6" sx={{ mb: 2 }}> <Typography variant="h6" sx={{ mb: 2 }}>
Bankovní účty Bankovní účty
@@ -921,9 +896,7 @@ export default function CompanySettings({
color="text.secondary" color="text.secondary"
sx={{ mb: 1.5 }} sx={{ mb: 1.5 }}
> >
{editingBank !== null {editingBank !== null ? "Upravit účet" : "Přidat nový účet"}
? "Upravit účet"
: "Přidat nový účet"}
</Typography> </Typography>
<Box <Box
sx={{ sx={{
@@ -1046,16 +1019,10 @@ export default function CompanySettings({
</> </>
)} )}
</Card> </Card>
</motion.div>
)} )}
{/* PDF Field Order */} {/* PDF Field Order */}
{(canEditCompany || !embedded) && ( {(canEditCompany || !embedded) && (
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.08 }}
>
<Card> <Card>
<Typography variant="h6" sx={{ mb: 2 }}> <Typography variant="h6" sx={{ mb: 2 }}>
Pořadí polí dodavatele v PDF Pořadí polí dodavatele v PDF
@@ -1119,16 +1086,10 @@ export default function CompanySettings({
))} ))}
</Box> </Box>
</Card> </Card>
</motion.div>
)} )}
{/* Document Numbering */} {/* Document Numbering */}
{(canEditCompany || !embedded) && ( {(canEditCompany || !embedded) && (
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.1 }}
>
<Card> <Card>
<Typography variant="h6" sx={{ mb: 2 }}> <Typography variant="h6" sx={{ mb: 2 }}>
Číslování dokladů Číslování dokladů
@@ -1143,11 +1104,11 @@ export default function CompanySettings({
mb: 2, mb: 2,
}} }}
> >
<strong>Dostupné zástupné znaky:</strong>{" "} <strong>Dostupné zástupné znaky:</strong> <code>{"{YYYY}"}</code>{" "}
<code>{"{YYYY}"}</code> rok, <code>{"{YY}"}</code> rok (2 rok, <code>{"{YY}"}</code> rok (2 číslice),{" "}
číslice), <code>{"{PREFIX}"}</code> prefix (nabídky / sklad),{" "} <code>{"{PREFIX}"}</code> prefix (nabídky / sklad),{" "}
<code>{"{CODE}"}</code> typový kód, <code>{"{NNN}"}</code>{" "} <code>{"{CODE}"}</code> typový kód, <code>{"{NNN}"}</code> pořadí
pořadí (3 číslice), <code>{"{NNNN}"}</code> pořadí (4 číslice),{" "} (3 číslice), <code>{"{NNNN}"}</code> pořadí (4 číslice),{" "}
<code>{"{NNNNN}"}</code> pořadí (5 číslic) <code>{"{NNNNN}"}</code> pořadí (5 číslic)
</Box> </Box>
{[ {[
@@ -1310,16 +1271,10 @@ export default function CompanySettings({
); );
})} })}
</Card> </Card>
</motion.div>
)} )}
{/* Currency & VAT */} {/* Currency & VAT */}
{(canEditCompany || !embedded) && ( {(canEditCompany || !embedded) && (
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.12 }}
>
<Card> <Card>
<Typography variant="h6" sx={{ mb: 2 }}> <Typography variant="h6" sx={{ mb: 2 }}>
Měna a DPH Měna a DPH
@@ -1383,16 +1338,10 @@ export default function CompanySettings({
</Field> </Field>
</Box> </Box>
</Card> </Card>
</motion.div>
)} )}
{/* Logo */} {/* Logo */}
{(canEditCompany || !embedded) && ( {(canEditCompany || !embedded) && (
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.14 }}
>
<Card> <Card>
<Typography variant="h6" sx={{ mb: 2 }}> <Typography variant="h6" sx={{ mb: 2 }}>
Logo Logo
@@ -1506,25 +1455,13 @@ export default function CompanySettings({
</Box> </Box>
</Box> </Box>
</Card> </Card>
</motion.div>
)} )}
</Box> </Box>
{embedded && canEditCompany && ( {embedded && canEditCompany && (
<motion.div <Button onClick={handleSave} fullWidth sx={{ mt: 2 }} disabled={saving}>
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.2 }}
>
<Button
onClick={handleSave}
fullWidth
sx={{ mt: 2 }}
disabled={saving}
>
{saving ? "Ukládání..." : "Uložit nastavení firmy"} {saving ? "Ukládání..." : "Uložit nastavení firmy"}
</Button> </Button>
</motion.div>
)} )}
<ConfirmDialog <ConfirmDialog

View File

@@ -40,6 +40,7 @@ import {
LoadingState, LoadingState,
FilterBar, FilterBar,
Tabs, Tabs,
TabPanel,
PageHeader, PageHeader,
PageEnter, PageEnter,
type DataColumn, type DataColumn,
@@ -706,7 +707,7 @@ export default function Invoices() {
</Box> </Box>
</Box> </Box>
{activeTab === "received" ? ( <TabPanel value="received" current={activeTab} sx={{ pt: 0 }}>
<Suspense fallback={<LoadingState />}> <Suspense fallback={<LoadingState />}>
<ReceivedInvoices <ReceivedInvoices
statsMonth={statsMonth} statsMonth={statsMonth}
@@ -715,7 +716,8 @@ export default function Invoices() {
setUploadOpen={setReceivedUploadOpen} setUploadOpen={setReceivedUploadOpen}
/> />
</Suspense> </Suspense>
) : ( </TabPanel>
<TabPanel value="issued" current={activeTab} sx={{ pt: 0 }}>
<> <>
{renderKpi()} {renderKpi()}
@@ -808,7 +810,7 @@ export default function Invoices() {
loading={deleting} loading={deleting}
/> />
</> </>
)} </TabPanel>
</PageEnter> </PageEnter>
); );
} }

View File

@@ -5,7 +5,14 @@ import Typography from "@mui/material/Typography";
import IconButton from "@mui/material/IconButton"; import IconButton from "@mui/material/IconButton";
import { useAuth } from "../context/AuthContext"; import { useAuth } from "../context/AuthContext";
import Forbidden from "../components/Forbidden"; import Forbidden from "../components/Forbidden";
import { Button, Tabs, PageHeader, PageEnter, LoadingState } from "../ui"; import {
Button,
Tabs,
TabPanel,
PageHeader,
PageEnter,
LoadingState,
} from "../ui";
import OrdersReceived from "./ReceivedOrders"; import OrdersReceived from "./ReceivedOrders";
const IssuedOrders = lazy(() => import("./IssuedOrders")); const IssuedOrders = lazy(() => import("./IssuedOrders"));
@@ -170,18 +177,19 @@ export default function Orders() {
</Box> </Box>
</Box> </Box>
{activeTab === "vydane" ? ( <TabPanel value="vydane" current={activeTab} sx={{ pt: 0 }}>
<Suspense fallback={<LoadingState />}> <Suspense fallback={<LoadingState />}>
<IssuedOrders month={statsMonth} year={statsYear} /> <IssuedOrders month={statsMonth} year={statsYear} />
</Suspense> </Suspense>
) : ( </TabPanel>
<TabPanel value="prijate" current={activeTab} sx={{ pt: 0 }}>
<OrdersReceived <OrdersReceived
month={statsMonth} month={statsMonth}
year={statsYear} year={statsYear}
createOpen={createOpen} createOpen={createOpen}
setCreateOpen={setCreateOpen} setCreateOpen={setCreateOpen}
/> />
)} </TabPanel>
</PageEnter> </PageEnter>
); );
} }

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

@@ -1,6 +1,5 @@
import { useState, useEffect, useRef } from "react"; import { useState, useEffect, useRef } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { motion } from "framer-motion";
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 IconButton from "@mui/material/IconButton";
@@ -810,11 +809,6 @@ export default function ReceivedInvoices({
<> <>
{renderKpi()} {renderKpi()}
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.08 }}
>
<Card> <Card>
<FilterBar> <FilterBar>
<Box sx={{ flex: "1 1 320px" }}> <Box sx={{ flex: "1 1 320px" }}>
@@ -876,7 +870,6 @@ export default function ReceivedInvoices({
</Box> </Box>
)} )}
</Card> </Card>
</motion.div>
{/* Upload Modal */} {/* Upload Modal */}
<Modal <Modal

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

@@ -2,6 +2,8 @@ import type { ReactNode } from "react";
import MuiTabs from "@mui/material/Tabs"; import MuiTabs from "@mui/material/Tabs";
import Tab from "@mui/material/Tab"; import Tab from "@mui/material/Tab";
import Box from "@mui/material/Box"; import Box from "@mui/material/Box";
import { keyframes } from "@mui/system";
import type { SxProps, Theme } from "@mui/material/styles";
export interface TabDef { export interface TabDef {
value: string; value: string;
@@ -47,15 +49,26 @@ export function Tabs({
); );
} }
// The ONE tab-switch animation, app-wide: enter-only fade of the incoming
// panel (M3 "clean fade" — old content drops instantly, no exit/slide; tabs
// are too high-frequency for anything longer). Kept as a CSS animation so the
// global prefers-reduced-motion kill switch in GlobalStyles neutralizes it.
const panelEnter = keyframes({
from: { opacity: 0, transform: "translateY(6px)" },
to: { opacity: 1, transform: "none" },
});
/** Renders children only when its value matches the current tab. */ /** Renders children only when its value matches the current tab. */
export function TabPanel({ export function TabPanel({
value, value,
current, current,
children, children,
sx,
}: { }: {
value: string; value: string;
current: string; current: string;
children: ReactNode; children: ReactNode;
sx?: SxProps<Theme>;
}) { }) {
if (value !== current) return null; if (value !== current) return null;
return ( return (
@@ -63,7 +76,13 @@ export function TabPanel({
role="tabpanel" role="tabpanel"
id={panelId(value)} id={panelId(value)}
aria-labelledby={tabId(value)} aria-labelledby={tabId(value)}
sx={{ pt: 2.5 }} sx={[
{
pt: 2.5,
animation: `${panelEnter} 0.2s cubic-bezier(0.16, 1, 0.3, 1)`,
},
...(Array.isArray(sx) ? sx : [sx]),
]}
> >
{children} {children}
</Box> </Box>

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

@@ -1320,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),
})), })),
}; };

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