Files
app/src/admin/pages/ProjectDetail.tsx
BOHA decadd895e fix(ui): stack detail-page header action buttons one-per-row on mobile
Detail-page headers put their action buttons in a flex row with
flexWrap:"wrap", so on a phone the 2-4 buttons (Potvrzení / Zahájit
realizaci / Smazat, etc.) wrapped into a ragged grid.

New shared `headerActionsSx` (ui/PageHeader): on xs it's a full-width
column (each button its own full-width row); from sm up it's the usual
right-aligned wrapping row — desktop unchanged. A StatusChip kept in the
same group stays its natural size (alignSelf) instead of stretching.

Applied to PageHeader's own actions slot and every detail-page header
action group: Order/Offer/Invoice (both views)/Project + the Warehouse
Receipt/Issue/Item/Inventory detail headers.

Verified in Chrome at 430px on OrderDetail: the group is flexDirection
column with all three buttons at the same x, full-width, stacked. tsc -b
--noEmit, npm run build, vitest 152/152 clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 21:44:22 +02:00

660 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect, useRef } from "react";
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 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 {
projectDetailOptions,
type ProjectData,
type ProjectNote,
} from "../lib/queries/projects";
import { userListOptions, type User as ApiUser } from "../lib/queries/users";
import Forbidden from "../components/Forbidden";
import ProjectFileManager from "../components/ProjectFileManager";
import {
Button,
Card,
Field,
TextField,
Select,
DateField,
StatusChip,
CheckboxField,
ConfirmDialog,
LoadingState,
PageEnter,
headerActionsSx,
} from "../ui";
const API_BASE = "/api/admin";
const STATUS_LABELS: Record<string, string> = {
aktivni: "Aktivní",
dokonceny: "Dokončený",
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);
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;
}
interface ProjectForm {
name: string;
status: string;
start_date: string;
end_date: 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() {
const { id } = useParams();
const alert = useAlert();
const { hasPermission, isAdmin } = useAuth();
const navigate = useNavigate();
const [saving, setSaving] = useState(false);
const [form, setForm] = useState<ProjectForm>({
name: "",
status: "aktivni",
start_date: "",
end_date: "",
responsible_user_id: "",
});
const [deleteConfirm, setDeleteConfirm] = useState(false);
const [deleting, setDeleting] = useState(false);
const [deleteFiles, setDeleteFiles] = useState(false);
// Dynamic notes
const [newNote, setNewNote] = useState("");
const [addingNote, setAddingNote] = useState(false);
const [deletingNoteId, setDeletingNoteId] = useState<number | null>(null);
const projectQuery = useQuery(projectDetailOptions(id));
const project = projectQuery.data;
const isPending = projectQuery.isPending;
const notes: ProjectNote[] = project?.project_notes || [];
const { data: usersData } = useQuery(userListOptions("projects.view"));
// Only active users with projects.view — but keep the project's CURRENT
// assignee in the list even if they're now inactive, so the select still
// reflects the saved value (marked "(neaktivní)" so it's clear).
const assignedUserId = project?.responsible_user_id || "";
const users: User[] = (usersData ?? [])
.filter((u: ApiUser) => u.is_active || String(u.id) === assignedUserId)
.map((u: ApiUser) => ({
id: u.id,
name:
(`${u.first_name || ""} ${u.last_name || ""}`.trim() || u.username) +
(u.is_active ? "" : " (neaktivní)"),
}));
// Reset form sync when navigating to a different project
const formInitialized = useRef(false);
useEffect(() => {
formInitialized.current = false;
}, [id]);
// Sync project data to local form state on first load
useEffect(() => {
if (project && !formInitialized.current) {
setForm({
name: project.name || "",
status: project.status || "aktivni",
start_date: (project.start_date || "").substring(0, 10),
end_date: (project.end_date || "").substring(0, 10),
responsible_user_id: project.responsible_user_id || "",
});
formInitialized.current = true;
}
}, [project]);
// Navigate away on fetch error
useEffect(() => {
if (projectQuery.error) {
alert.error("Nepodařilo se načíst projekt");
navigate("/projects");
}
}, [projectQuery.error]); // eslint-disable-line react-hooks/exhaustive-deps
if (!hasPermission("projects.view")) return <Forbidden />;
const projectSaveMutation = useApiMutation<
{
name: string;
status: string;
start_date: string | null;
end_date: string | null;
responsible_user_id: string | null;
},
unknown
>({
url: () => `${API_BASE}/projects/${id}`,
method: () => "PUT",
invalidate: ["projects", "warehouse"],
});
const projectDeleteMutation = useApiMutation<
{ delete_files: boolean },
unknown
>({
url: () => `${API_BASE}/projects/${id}`,
method: () => "DELETE",
invalidate: ["projects", "warehouse"],
});
const projectAddNoteMutation = useApiMutation<{ content: string }, unknown>({
url: () => `${API_BASE}/projects/${id}/notes`,
method: () => "POST",
invalidate: ["projects", "warehouse"],
});
const projectDeleteNoteMutation = useApiMutation<number, unknown>({
url: (noteId) => `${API_BASE}/projects/${id}/notes/${noteId}`,
method: () => "DELETE",
invalidate: ["projects", "warehouse"],
});
const updateForm = (field: keyof ProjectForm, value: string) =>
setForm((prev) => ({ ...prev, [field]: value }));
const handleSave = async () => {
if (!form.name.trim()) {
alert.error("Název projektu je povinný");
return;
}
setSaving(true);
try {
await projectSaveMutation.mutateAsync({
name: form.name,
status: form.status,
start_date: form.start_date || null,
end_date: form.end_date || null,
responsible_user_id: form.responsible_user_id || null,
});
alert.success("Projekt byl aktualizován");
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
} finally {
setSaving(false);
}
};
const handleDelete = async () => {
setDeleting(true);
try {
await projectDeleteMutation.mutateAsync({ delete_files: deleteFiles });
navigate("/projects");
setTimeout(() => alert.success("Projekt byl smazán"), 300);
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
} finally {
setDeleting(false);
}
};
const handleAddNote = async () => {
if (!newNote.trim()) return;
setAddingNote(true);
try {
await projectAddNoteMutation.mutateAsync({ content: newNote.trim() });
setNewNote("");
alert.success("Poznámka byla přidána");
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
} finally {
setAddingNote(false);
}
};
const handleDeleteNote = async (noteId: number) => {
setDeletingNoteId(noteId);
try {
await projectDeleteNoteMutation.mutateAsync(noteId);
alert.success("Poznámka byla smazána");
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
} finally {
setDeletingNoteId(null);
}
};
if (isPending) {
return <LoadingState />;
}
if (!project) return null;
const canEdit = hasPermission("projects.edit");
return (
<PageEnter>
{/* Header */}
<Box>
<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}
>
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={headerActionsSx}>
<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>
)}
</Box>
)}
</Box>
</Box>
{/* Basic info form */}
<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>
{/* Notes */}
<Card sx={{ mb: 3 }}>
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
Poznámky
</Typography>
{/* Add note */}
<Box sx={{ mb: 3 }}>
<TextField
value={newNote}
onChange={(e) => setNewNote(e.target.value)}
multiline
minRows={2}
placeholder="Napište poznámku..."
fullWidth
onKeyDown={(e) => {
if (e.key === "Enter" && e.ctrlKey && newNote.trim()) {
handleAddNote();
}
}}
/>
<Box sx={{ mt: 1 }}>
<Button
variant="outlined"
color="inherit"
onClick={handleAddNote}
disabled={addingNote || !newNote.trim()}
>
{addingNote ? "Přidávání..." : "Přidat poznámku"}
</Button>
</Box>
</Box>
{/* Legacy notes (read-only) */}
{project.notes && (
<Box
sx={{
p: 1.5,
bgcolor: "action.hover",
borderRadius: 2,
mb: 1,
}}
>
<Typography
variant="caption"
color="text.secondary"
sx={{ display: "block", mb: 0.5 }}
>
Starší poznámka (před zavedením systému)
</Typography>
<Typography
variant="body2"
color="text.secondary"
sx={{ whiteSpace: "pre-wrap" }}
>
{project.notes}
</Typography>
</Box>
)}
{/* Notes list */}
{notes.length === 0 && !project.notes && (
<Typography
variant="body2"
color="text.secondary"
sx={{ textAlign: "center", py: 2 }}
>
Zatím žádné poznámky
</Typography>
)}
{(notes.length > 0 || project.notes) && (
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
{notes.map((note) => (
<Box
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>
))}
</Box>
)}
</Card>
{/* Project File Manager */}
<Box sx={{ mb: 2 }}>
<ProjectFileManager
projectId={project.id}
projectNumber={project.project_number}
hasPermission={hasPermission}
hasNasFolder={project.has_nas_folder ?? false}
/>
</Box>
{/* Links */}
<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 ? (
<Box
component={RouterLink}
to={`/orders/${project.order_id}`}
sx={{
color: "primary.main",
textDecoration: "none",
"&:hover": { textDecoration: "underline" },
}}
>
{project.order_number}
{project.order_status && (
<Box
component="span"
sx={{ color: "text.secondary", ml: 1 }}
>
(
{STATUS_LABELS[project.order_status] ||
project.order_status}
)
</Box>
)}
</Box>
) : (
"—"
)}
</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">
Nabídka
</Typography>
<Typography variant="body2">
{project.quotation_id ? (
<Box
component={RouterLink}
to={`/offers/${project.quotation_id}`}
sx={{
color: "primary.main",
textDecoration: "none",
"&:hover": { textDecoration: "underline" },
}}
>
{project.quotation_number}
</Box>
) : (
"—"
)}
</Typography>
</Box>
</Box>
</Card>
<ConfirmDialog
isOpen={deleteConfirm}
onClose={() => {
setDeleteConfirm(false);
setDeleteFiles(false);
}}
onConfirm={handleDelete}
title="Smazat projekt"
message={`Opravdu chcete smazat projekt „${project.project_number} ${project.name}"? Tato akce je nevratná.`}
confirmText="Smazat"
confirmVariant="danger"
loading={deleting}
>
{project.has_nas_folder && (
<CheckboxField
label="Smazat i soubory na disku"
checked={deleteFiles}
onChange={setDeleteFiles}
/>
)}
</ConfirmDialog>
</PageEnter>
);
}