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>
660 lines
19 KiB
TypeScript
660 lines
19 KiB
TypeScript
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>
|
||
);
|
||
}
|