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>
This commit is contained in:
BOHA
2026-06-12 16:26:38 +02:00
parent 6f3f1c40e7
commit 64bb7f9c37
10 changed files with 611 additions and 86 deletions

View File

@@ -1,12 +1,12 @@
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 apiFetch from "../utils/api";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import { useParams, useNavigate, Link as RouterLink } from "react-router-dom";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import IconButton from "@mui/material/IconButton";
import MenuItem from "@mui/material/MenuItem";
import {
@@ -17,6 +17,7 @@ import {
import { userListOptions, type User as ApiUser } from "../lib/queries/users";
import Forbidden from "../components/Forbidden";
import ProjectFileManager from "../components/ProjectFileManager";
import NoteCard from "../components/NoteCard";
import {
Button,
Card,
@@ -49,17 +50,6 @@ const STATUS_COLORS: Record<
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 {
id: number;
name: string;
@@ -86,28 +76,12 @@ const BackIcon = (
</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() {
const { id } = useParams();
const alert = useAlert();
const { hasPermission, isAdmin } = useAuth();
const { hasPermission, isAdmin, user } = useAuth();
const navigate = useNavigate();
const qc = useQueryClient();
const [saving, setSaving] = useState(false);
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) {
return <LoadingState />;
}
@@ -493,61 +491,18 @@ export default function ProjectDetail() {
{(notes.length > 0 || project.notes) && (
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
{notes.map((note) => (
<Box
<NoteCard
key={note.id}
sx={{
p: 1.5,
bgcolor: "action.hover",
borderRadius: 2,
position: "relative",
}}
>
<Box
sx={{
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>
authorName={note.user_name}
createdAt={note.created_at}
editedAt={note.edited_at}
content={note.content || ""}
canEdit={user?.id === note.user_id}
canDelete={isAdmin}
deleting={deletingNoteId === note.id}
onSave={(content) => saveNoteEdit(note.id, content)}
onDelete={() => handleDeleteNote(note.id)}
/>
))}
</Box>
)}