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