- PROJECT_STATUS added to documentStatus.ts (aktivni=info, dokonceny=success, zruseny=error — app convention); Projects/ProjectDetail local maps deleted; status Select derives its MenuItems from the shared map. - ProjectDetail linked-order status now rendered via the received-order map (was the project map with zero key overlap → raw DB token). - Cancelled label unified across received/issued orders in documentStatus.ts. - 'Chyba pripojeni' → 'Chyba připojení' (Dashboard, AuthContext). - Dashboard lazy-loaded like every other route — its 7 subcomponents leave the Login critical path. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
636 lines
18 KiB
TypeScript
636 lines
18 KiB
TypeScript
import { useState, useEffect, useRef } from "react";
|
||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||
import { useApiMutation } from "../lib/queries/mutations";
|
||
import apiFetch from "../utils/api";
|
||
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 MenuItem from "@mui/material/MenuItem";
|
||
|
||
import {
|
||
projectDetailOptions,
|
||
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 NoteCard from "../components/NoteCard";
|
||
import {
|
||
Button,
|
||
Card,
|
||
Field,
|
||
TextField,
|
||
Select,
|
||
DateField,
|
||
StatusChip,
|
||
CheckboxField,
|
||
ConfirmDialog,
|
||
LoadingState,
|
||
PageEnter,
|
||
headerActionsSx,
|
||
} from "../ui";
|
||
import {
|
||
PROJECT_STATUS,
|
||
ORDER_STATUS,
|
||
statusLabel,
|
||
statusColor,
|
||
} from "../lib/documentStatus";
|
||
|
||
const API_BASE = "/api/admin";
|
||
|
||
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>
|
||
);
|
||
|
||
export default function ProjectDetail() {
|
||
const { id } = useParams();
|
||
const alert = useAlert();
|
||
const { hasPermission, isAdmin, user } = useAuth();
|
||
const navigate = useNavigate();
|
||
const qc = useQueryClient();
|
||
|
||
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
|
||
|
||
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"],
|
||
});
|
||
|
||
if (!hasPermission("projects.view")) return <Forbidden />;
|
||
|
||
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);
|
||
}
|
||
};
|
||
|
||
// Throws on failure so NoteCard keeps its editor open with the draft.
|
||
const saveNoteEdit = async (noteId: number, content: string) => {
|
||
// Raw apiFetch: the body must carry ONLY content (strict schema), the
|
||
// note id lives in the URL — useApiMutation sends its whole input.
|
||
let res: Response;
|
||
try {
|
||
res = await apiFetch(`${API_BASE}/projects/${id}/notes/${noteId}`, {
|
||
method: "PUT",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ content }),
|
||
});
|
||
} catch {
|
||
alert.error("Chyba připojení");
|
||
throw new Error("network");
|
||
}
|
||
const body = await res.json().catch(() => null);
|
||
if (!res.ok) {
|
||
alert.error(body?.error || "Uložení selhalo");
|
||
throw new Error(body?.error || "Uložení selhalo");
|
||
}
|
||
qc.invalidateQueries({ queryKey: ["projects"] });
|
||
alert.success("Poznámka upravena");
|
||
};
|
||
|
||
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,
|
||
}}
|
||
>
|
||
{/* flexWrap: the title must drop below the Zpět button on phones
|
||
instead of being squeezed beside it (mirrors Offer/Invoice
|
||
detail headers). */}
|
||
<Box
|
||
sx={{
|
||
display: "flex",
|
||
alignItems: "center",
|
||
gap: 2,
|
||
flexWrap: "wrap",
|
||
}}
|
||
>
|
||
<Button
|
||
component={RouterLink}
|
||
to="/projects"
|
||
variant="outlined"
|
||
color="inherit"
|
||
startIcon={BackIcon}
|
||
>
|
||
Zpět
|
||
</Button>
|
||
<Box
|
||
sx={{
|
||
display: "flex",
|
||
alignItems: "center",
|
||
gap: 1.5,
|
||
flexWrap: "wrap",
|
||
}}
|
||
>
|
||
<Typography variant="h4">
|
||
Projekt
|
||
<Box
|
||
component="span"
|
||
sx={{ color: "text.secondary", ml: 1, fontWeight: 400 }}
|
||
>
|
||
{project.project_number}
|
||
</Box>
|
||
</Typography>
|
||
<StatusChip
|
||
label={statusLabel(PROJECT_STATUS, project.status)}
|
||
color={statusColor(PROJECT_STATUS, project.status)}
|
||
/>
|
||
</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}
|
||
>
|
||
{Object.entries(PROJECT_STATUS).map(([value, s]) => (
|
||
<MenuItem key={value} value={value}>
|
||
{s.label}
|
||
</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) => (
|
||
<NoteCard
|
||
key={note.id}
|
||
authorName={note.user_name}
|
||
createdAt={note.created_at}
|
||
editedAt={note.edited_at}
|
||
content={note.content || ""}
|
||
canEdit={user?.id === note.user_id}
|
||
canDelete={isAdmin}
|
||
deleting={deletingNoteId === note.id}
|
||
onSave={(content) => saveNoteEdit(note.id, content)}
|
||
onDelete={() => handleDeleteNote(note.id)}
|
||
/>
|
||
))}
|
||
</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"
|
||
component="div"
|
||
sx={{
|
||
display: "flex",
|
||
alignItems: "center",
|
||
gap: 1,
|
||
flexWrap: "wrap",
|
||
}}
|
||
>
|
||
{project.order_id ? (
|
||
<>
|
||
<Box
|
||
component={RouterLink}
|
||
to={`/orders/${project.order_id}`}
|
||
sx={{
|
||
color: "primary.main",
|
||
textDecoration: "none",
|
||
"&:hover": { textDecoration: "underline" },
|
||
}}
|
||
>
|
||
{project.order_number}
|
||
</Box>
|
||
{project.order_status && (
|
||
<StatusChip
|
||
label={statusLabel(ORDER_STATUS, project.order_status)}
|
||
color={statusColor(ORDER_STATUS, project.order_status)}
|
||
/>
|
||
)}
|
||
</>
|
||
) : (
|
||
"—"
|
||
)}
|
||
</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>
|
||
);
|
||
}
|