feat(mui): migrate ProjectDetail onto MUI kit

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-06-07 08:15:18 +02:00
parent 063ffd15ef
commit 262f4c6ca5

View File

@@ -3,8 +3,12 @@ import { useQuery } from "@tanstack/react-query";
import { useApiMutation } from "../lib/queries/mutations"; import { useApiMutation } from "../lib/queries/mutations";
import { useAlert } from "../context/AlertContext"; import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext"; import { useAuth } from "../context/AuthContext";
import { useParams, useNavigate, Link } from "react-router-dom"; import { useParams, useNavigate, Link as RouterLink } from "react-router-dom";
import { motion } from "framer-motion"; import { motion } from "framer-motion";
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 { import {
projectDetailOptions, projectDetailOptions,
@@ -13,11 +17,19 @@ import {
} from "../lib/queries/projects"; } from "../lib/queries/projects";
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 ConfirmModal from "../components/ConfirmModal";
import FormField from "../components/FormField";
import AdminDatePicker from "../components/AdminDatePicker";
import ProjectFileManager from "../components/ProjectFileManager"; import ProjectFileManager from "../components/ProjectFileManager";
import apiFetch from "../utils/api"; import {
Button,
Card,
Field,
TextField,
Select,
DateField,
StatusChip,
CheckboxField,
ConfirmDialog,
LoadingState,
} from "../ui";
const API_BASE = "/api/admin"; const API_BASE = "/api/admin";
@@ -27,6 +39,15 @@ const STATUS_LABELS: Record<string, string> = {
zruseny: "Zrušený", zruseny: "Zrušený",
}; };
const STATUS_COLORS: Record<
string,
"default" | "success" | "error" | "warning" | "info"
> = {
aktivni: "success",
dokonceny: "info",
zruseny: "default",
};
function formatNoteDate(dateStr: string) { function formatNoteDate(dateStr: string) {
if (!dateStr) return ""; if (!dateStr) return "";
const d = new Date(dateStr); const d = new Date(dateStr);
@@ -51,6 +72,36 @@ interface ProjectForm {
responsible_user_id: string; responsible_user_id: string;
} }
const BackIcon = (
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M19 12H5M12 19l-7-7 7-7" />
</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();
@@ -226,353 +277,302 @@ export default function ProjectDetail() {
} }
}; };
if (isPending) {
return <LoadingState />;
}
if (!project) return null; if (!project) return null;
const canEdit = hasPermission("projects.edit"); const canEdit = hasPermission("projects.edit");
if (isPending) {
return (
<div className="admin-loading">
<div className="admin-spinner" />
</div>
);
}
return ( return (
<div> <Box>
{/* Header */} {/* Header */}
<motion.div <motion.div
className="admin-page-header"
initial={{ opacity: 0, y: 12 }} initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }} transition={{ duration: 0.25 }}
> >
<div style={{ display: "flex", alignItems: "center", gap: "1rem" }}> <Box
<Link sx={{
to="/projects" display: "flex",
className="admin-btn-icon" alignItems: "flex-start",
title="Zpět" justifyContent: "space-between",
aria-label="Zpět" flexWrap: "wrap",
> gap: 2,
<svg mb: 3,
width="20" }}
height="20" >
viewBox="0 0 24 24" <Box sx={{ display: "flex", alignItems: "center", gap: 2 }}>
fill="none" <Button
stroke="currentColor" component={RouterLink}
strokeWidth="2" to="/projects"
variant="outlined"
color="inherit"
startIcon={BackIcon}
> >
<path d="M19 12H5M12 19l-7-7 7-7" /> Zpět
</svg> </Button>
</Link> <Box sx={{ display: "flex", alignItems: "center", gap: 1.5 }}>
<div> <Typography variant="h4">
<h1 className="admin-page-title"> Projekt {project.project_number}
Projekt {project.project_number} </Typography>
</h1> <StatusChip
</div> label={STATUS_LABELS[project.status] || project.status}
</div> color={STATUS_COLORS[project.status] || "default"}
{canEdit && ( />
<div className="admin-page-actions"> </Box>
<button </Box>
onClick={handleSave} {canEdit && (
className="admin-btn admin-btn-primary" <Box
disabled={saving} sx={{
display: "flex",
alignItems: "center",
gap: 1,
flexShrink: 0,
}}
> >
{saving ? ( <Button onClick={handleSave} disabled={saving}>
<> {saving ? "Ukládání..." : "Uložit"}
<div className="admin-spinner admin-spinner-sm" /> </Button>
Ukládání... {!project.order_id && (
</> <Button
) : ( variant="outlined"
"Uložit" color="error"
onClick={() => setDeleteConfirm(true)}
>
Smazat
</Button>
)} )}
</button> </Box>
{!project.order_id && ( )}
<button </Box>
onClick={() => setDeleteConfirm(true)}
className="admin-btn admin-btn-primary"
>
Smazat
</button>
)}
</div>
)}
</motion.div> </motion.div>
{/* Form */} {/* Basic info form */}
<motion.div <motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }} initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }} transition={{ duration: 0.25, delay: 0.06 }}
> >
<div className="admin-card-body"> <Card sx={{ mb: 3 }}>
<h3 className="admin-card-title">Základní údaje</h3> <Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
<div className="admin-form"> Základní údaje
<div className="admin-form-row"> </Typography>
<FormField label="Číslo projektu"> <Box
<input sx={{
type="text" display: "grid",
value={project.project_number} gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
className="admin-form-input" gap: 2,
readOnly }}
style={{ >
backgroundColor: "var(--bg-secondary)", <Field label="Číslo projektu">
cursor: "default", <TextField value={project.project_number} fullWidth disabled />
}} </Field>
/> <Field label="Název">
</FormField> <TextField
<FormField label="Název"> value={form.name}
<input onChange={(e) => updateForm("name", e.target.value)}
type="text" placeholder="Název projektu"
value={form.name} fullWidth
onChange={(e) => updateForm("name", e.target.value)} disabled={!canEdit}
className="admin-form-input" />
placeholder="Název projektu" </Field>
disabled={!canEdit} <Field label="Zákazník">
/> <TextField
</FormField> value={project.customer_name || "—"}
</div> fullWidth
disabled
<div className="admin-form-row"> />
<FormField label="Zákazník"> </Field>
<input <Field label="Zodpovědná osoba">
type="text" <Select
value={project.customer_name || "—"} value={form.responsible_user_id}
className="admin-form-input" onChange={(v) => updateForm("responsible_user_id", v)}
readOnly disabled={!canEdit}
style={{ >
backgroundColor: "var(--bg-secondary)", <MenuItem value=""> Nevybráno </MenuItem>
cursor: "default", {users.map((u) => (
}} <MenuItem key={u.id} value={String(u.id)}>
/> {u.name}
</FormField> </MenuItem>
<FormField label="Zodpovědná osoba"> ))}
<select </Select>
value={form.responsible_user_id} </Field>
onChange={(e) => </Box>
updateForm("responsible_user_id", e.target.value) <Box
} sx={{
className="admin-form-select" display: "grid",
disabled={!canEdit} gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr 1fr" },
> gap: 2,
<option value=""> Nevybráno </option> }}
{users.map((u) => ( >
<option key={u.id} value={u.id}> <Field label="Stav">
{u.name} <Select
</option> value={form.status}
))} onChange={(v) => updateForm("status", v)}
</select> disabled={!canEdit}
</FormField> >
</div> <MenuItem value="aktivni">Aktivní</MenuItem>
<MenuItem value="dokonceny">Dokončený</MenuItem>
<div className="admin-form-row admin-form-row-3"> <MenuItem value="zruseny">Zrušený</MenuItem>
<FormField label="Stav"> </Select>
<select </Field>
value={form.status} <Field label="Datum zahájení">
onChange={(e) => updateForm("status", e.target.value)} <DateField
className="admin-form-select" value={form.start_date}
disabled={!canEdit} onChange={(val) => updateForm("start_date", val)}
> disabled={!canEdit}
<option value="aktivni">Aktivní</option> />
<option value="dokonceny">Dokončený</option> </Field>
<option value="zruseny">Zrušený</option> <Field label="Datum ukončení">
</select> <DateField
</FormField> value={form.end_date}
<FormField label="Datum zahájení"> onChange={(val) => updateForm("end_date", val)}
<AdminDatePicker disabled={!canEdit}
mode="date" />
value={form.start_date} </Field>
onChange={(val: string) => updateForm("start_date", val)} </Box>
disabled={!canEdit} </Card>
/>
</FormField>
<FormField label="Datum ukončení">
<AdminDatePicker
mode="date"
value={form.end_date}
onChange={(val: string) => updateForm("end_date", val)}
disabled={!canEdit}
/>
</FormField>
</div>
</div>
</div>
</motion.div> </motion.div>
{/* Notes */} {/* Notes */}
<motion.div <motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }} initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.08 }} transition={{ duration: 0.25, delay: 0.08 }}
> >
<div className="admin-card-body"> <Card sx={{ mb: 3 }}>
<h3 className="admin-card-title">Poznámky</h3> <Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
Poznámky
</Typography>
{/* Add note */} {/* Add note */}
<div className="mb-4"> <Box sx={{ mb: 3 }}>
<textarea <TextField
value={newNote} value={newNote}
onChange={(e) => setNewNote(e.target.value)} onChange={(e) => setNewNote(e.target.value)}
className="admin-form-input" multiline
rows={2} minRows={2}
placeholder="Napište poznámku..." placeholder="Napište poznámku..."
style={{ resize: "vertical", width: "100%" }} fullWidth
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "Enter" && e.ctrlKey && newNote.trim()) { if (e.key === "Enter" && e.ctrlKey && newNote.trim()) {
handleAddNote(); handleAddNote();
} }
}} }}
/> />
<div className="mt-2"> <Box sx={{ mt: 1 }}>
<button <Button
variant="outlined"
color="inherit"
onClick={handleAddNote} onClick={handleAddNote}
className="admin-btn admin-btn-secondary admin-btn-sm"
disabled={addingNote || !newNote.trim()} disabled={addingNote || !newNote.trim()}
> >
{addingNote ? ( {addingNote ? "Přidávání..." : "Přidat poznámku"}
<div className="admin-spinner admin-spinner-sm" /> </Button>
) : ( </Box>
"Přidat poznámku" </Box>
)}
</button>
</div>
</div>
{/* Legacy notes (read-only) */} {/* Legacy notes (read-only) */}
{project.notes && ( {project.notes && (
<div <Box
style={{ sx={{
padding: "0.75rem", p: 1.5,
background: "var(--bg-secondary)", bgcolor: "action.hover",
borderRadius: "0.5rem", borderRadius: 2,
marginBottom: "0.5rem", mb: 1,
fontSize: "0.85rem",
color: "var(--text-secondary)",
}} }}
> >
<div <Typography
style={{ variant="caption"
fontSize: "0.75rem", color="text.secondary"
color: "var(--text-tertiary)", sx={{ display: "block", mb: 0.5 }}
marginBottom: "0.25rem",
}}
> >
Starší poznámka (před zavedením systému) Starší poznámka (před zavedením systému)
</div> </Typography>
<div style={{ whiteSpace: "pre-wrap" }}>{project.notes}</div> <Typography
</div> variant="body2"
color="text.secondary"
sx={{ whiteSpace: "pre-wrap" }}
>
{project.notes}
</Typography>
</Box>
)} )}
{/* Notes list */} {/* Notes list */}
{notes.length === 0 && !project.notes && ( {notes.length === 0 && !project.notes && (
<div <Typography
style={{ variant="body2"
color: "var(--text-tertiary)", color="text.secondary"
fontSize: "0.875rem", sx={{ textAlign: "center", py: 2 }}
textAlign: "center",
padding: "1rem 0",
}}
> >
Zatím žádné poznámky Zatím žádné poznámky
</div> </Typography>
)} )}
{(notes.length > 0 || project.notes) && ( {(notes.length > 0 || project.notes) && (
<div <Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
style={{
display: "flex",
flexDirection: "column",
gap: "0.5rem",
}}
>
{notes.map((note) => ( {notes.map((note) => (
<div <Box
key={note.id} key={note.id}
style={{ sx={{
padding: "0.75rem", p: 1.5,
background: "var(--bg-secondary)", bgcolor: "action.hover",
borderRadius: "0.5rem", borderRadius: 2,
position: "relative", position: "relative",
}} }}
> >
<div <Box
style={{ sx={{
display: "flex", display: "flex",
justifyContent: "space-between", justifyContent: "space-between",
alignItems: "flex-start", alignItems: "flex-start",
gap: "0.5rem", gap: 1,
}} }}
> >
<div className="flex-1"> <Box sx={{ flex: 1 }}>
<div <Box
style={{ sx={{
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
gap: "0.5rem", gap: 1,
marginBottom: "0.25rem", mb: 0.5,
}} }}
> >
<span style={{ fontWeight: 600, fontSize: "0.85rem" }}> <Typography variant="body2" sx={{ fontWeight: 600 }}>
{note.user_name} {note.user_name}
</span> </Typography>
<span <Typography variant="caption" color="text.secondary">
style={{
color: "var(--text-tertiary)",
fontSize: "0.75rem",
}}
>
{formatNoteDate(note.created_at)} {formatNoteDate(note.created_at)}
</span> </Typography>
</div> </Box>
<div <Typography
style={{ variant="body2"
whiteSpace: "pre-wrap", sx={{ whiteSpace: "pre-wrap", lineHeight: 1.5 }}
fontSize: "0.875rem",
lineHeight: 1.5,
}}
> >
{note.content} {note.content}
</div> </Typography>
</div> </Box>
{isAdmin && ( {isAdmin && (
<button <IconButton
size="small"
color="error"
onClick={() => handleDeleteNote(note.id)} onClick={() => handleDeleteNote(note.id)}
className="admin-btn-icon"
title="Smazat poznámku" title="Smazat poznámku"
aria-label="Smazat poznámku"
disabled={deletingNoteId === note.id} disabled={deletingNoteId === note.id}
style={{ sx={{ flexShrink: 0 }}
flexShrink: 0,
opacity: deletingNoteId === note.id ? 0.5 : 1,
}}
> >
{deletingNoteId === note.id ? ( {TrashIcon}
<div </IconButton>
className="admin-spinner"
style={{ width: 14, height: 14, borderWidth: 2 }}
/>
) : (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<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>
)}
</button>
)} )}
</div> </Box>
</div> </Box>
))} ))}
</div> </Box>
)} )}
</div> </Card>
</motion.div> </motion.div>
{/* Project File Manager */} {/* Project File Manager */}
@@ -592,58 +592,81 @@ export default function ProjectDetail() {
{/* Links */} {/* Links */}
<motion.div <motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }} initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.15 }} transition={{ duration: 0.25, delay: 0.15 }}
> >
<div className="admin-card-body"> <Card sx={{ mb: 3 }}>
<h3 className="admin-card-title">Propojení</h3> <Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
<div className="admin-form-row"> Propojení
<FormField label="Objednávka"> </Typography>
<div> <Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
gap: 2,
}}
>
<Box>
<Typography variant="caption" color="text.secondary">
Objednávka
</Typography>
<Typography variant="body2">
{project.order_id ? ( {project.order_id ? (
<Link <Box
component={RouterLink}
to={`/orders/${project.order_id}`} to={`/orders/${project.order_id}`}
className="link-accent" sx={{
color: "primary.main",
textDecoration: "none",
"&:hover": { textDecoration: "underline" },
}}
> >
{project.order_number} {project.order_number}
{project.order_status && ( {project.order_status && (
<span <Box
className="text-tertiary" component="span"
style={{ fontWeight: 400, marginLeft: "0.5rem" }} sx={{ color: "text.secondary", ml: 1 }}
> >
( (
{STATUS_LABELS[project.order_status] || {STATUS_LABELS[project.order_status] ||
project.order_status} project.order_status}
) )
</span> </Box>
)} )}
</Link> </Box>
) : ( ) : (
"—" "—"
)} )}
</div> </Typography>
</FormField> </Box>
<FormField label="Nabídka"> <Box>
<div> <Typography variant="caption" color="text.secondary">
Nabídka
</Typography>
<Typography variant="body2">
{project.quotation_id ? ( {project.quotation_id ? (
<Link <Box
component={RouterLink}
to={`/offers/${project.quotation_id}`} to={`/offers/${project.quotation_id}`}
className="link-accent" sx={{
color: "primary.main",
textDecoration: "none",
"&:hover": { textDecoration: "underline" },
}}
> >
{project.quotation_number} {project.quotation_number}
</Link> </Box>
) : ( ) : (
"—" "—"
)} )}
</div> </Typography>
</FormField> </Box>
</div> </Box>
</div> </Card>
</motion.div> </motion.div>
<ConfirmModal <ConfirmDialog
isOpen={deleteConfirm} isOpen={deleteConfirm}
onClose={() => { onClose={() => {
setDeleteConfirm(false); setDeleteConfirm(false);
@@ -651,30 +674,19 @@ export default function ProjectDetail() {
}} }}
onConfirm={handleDelete} onConfirm={handleDelete}
title="Smazat projekt" title="Smazat projekt"
message={ message={`Opravdu chcete smazat projekt „${project.project_number} ${project.name}"? Tato akce je nevratná.`}
<>
Opravdu chcete smazat projekt &quot;{project.project_number} {" "}
{project.name}&quot;? Tato akce je nevratná.
{project.has_nas_folder && (
<label
className="admin-form-checkbox"
style={{ marginTop: "1rem", display: "flex" }}
>
<input
type="checkbox"
checked={deleteFiles}
onChange={(e) => setDeleteFiles(e.target.checked)}
/>
<span>Smazat i soubory na disku</span>
</label>
)}
</>
}
confirmText="Smazat" confirmText="Smazat"
cancelText="Zrušit" confirmVariant="danger"
type="danger"
loading={deleting} loading={deleting}
/> >
</div> {project.has_nas_folder && (
<CheckboxField
label="Smazat i soubory na disku"
checked={deleteFiles}
onChange={setDeleteFiles}
/>
)}
</ConfirmDialog>
</Box>
); );
} }