Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8f74efba97 | ||
|
|
b9103c59f0 | ||
|
|
bf33d0a543 | ||
|
|
64bb7f9c37 | ||
|
|
6f3f1c40e7 |
4
package-lock.json
generated
4
package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE `project_notes` ADD COLUMN `edited_at` DATETIME(0) NULL;
|
||||||
|
|
||||||
@@ -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")
|
||||||
|
|||||||
227
src/__tests__/project-notes.test.ts
Normal file
227
src/__tests__/project-notes.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 })),
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|||||||
249
src/admin/components/NoteCard.tsx
Normal file
249
src/admin/components/NoteCard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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}`,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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}`,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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) => ({
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
@@ -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),
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user