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

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