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"; const TRANSITION_LABELS: Record = { dokonceny: "Dokončit projekt", zruseny: "Zrušit projekt", aktivni: "Obnovit projekt", }; interface User { id: number; name: string; } interface ProjectForm { name: string; start_date: string; end_date: string; responsible_user_id: string; } const BackIcon = ( ); 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({ name: "", start_date: "", end_date: "", responsible_user_id: "", }); const [statusChanging, setStatusChanging] = useState(null); const [statusConfirm, setStatusConfirm] = useState<{ show: boolean; status: string | null; }>({ show: false, status: null }); 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(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 || "", 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; start_date: string | null; end_date: string | null; responsible_user_id: string | null; }, unknown >({ url: () => `${API_BASE}/projects/${id}`, method: () => "PUT", invalidate: ["projects", "warehouse"], }); // Status transitions (header buttons). A project transition may cascade to // the linked order (and its documents), so invalidate broadly. const statusMutation = useApiMutation<{ status: string }, unknown>({ url: () => `${API_BASE}/projects/${id}`, method: () => "PUT", invalidate: ["projects", "orders", "offers", "invoices", "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({ url: (noteId) => `${API_BASE}/projects/${id}/notes/${noteId}`, method: () => "DELETE", invalidate: ["projects", "warehouse"], }); if (!hasPermission("projects.view")) return ; 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, 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 handleStatusChange = async () => { if (!statusConfirm.status) return; const newStatus = statusConfirm.status; setStatusChanging(newStatus); setStatusConfirm({ show: false, status: null }); try { await statusMutation.mutateAsync({ status: newStatus }); alert.success("Stav byl změněn"); } catch (e) { alert.error(e instanceof Error ? e.message : "Chyba připojení"); } finally { setStatusChanging(null); } }; // Confirm message with the cascade note — the linked-order cascade only // exists when the project actually has a linked order; reopening never // cascades. const statusConfirmMessage = (status: string | null): string => { if (!status || !project) return ""; if (status === "aktivni") { return ( 'Projekt se vrátí do stavu "Aktivní".' + (project.order_id ? " Propojená objednávka zůstane beze změny." : "") ); } const base = `Označit projekt "${project.project_number} – ${project.name}" jako ${ status === "zruseny" ? "zrušený" : "dokončený" }?`; // The cascade only fires while the order is still open (prijata / // v_realizaci) — a terminal order (e.g. after a project reopen) stays // untouched, so don't promise a change that won't happen. const orderCascades = project.order_id && (project.order_status === "prijata" || project.order_status === "v_realizaci"); if (!orderCascades) return base; return ( base + (status === "zruseny" ? " Propojená objednávka bude automaticky stornována." : " Propojená objednávka bude automaticky dokončena.") ); }; 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 ; } if (!project) return null; const canEdit = hasPermission("projects.edit"); return ( {/* Header */} {/* flexWrap: the title must drop below the Zpět button on phones instead of being squeezed beside it (mirrors Offer/Invoice detail headers). */} Projekt {project.project_number} {canEdit && ( {(project.valid_transitions ?? []).map((status) => ( ))} {!project.order_id && ( )} )} {/* Basic info form */} Základní údaje updateForm("name", e.target.value)} placeholder="Název projektu" fullWidth disabled={!canEdit} /> updateForm("start_date", val)} disabled={!canEdit} /> updateForm("end_date", val)} disabled={!canEdit} /> {/* Notes */} Poznámky {/* Add note */} setNewNote(e.target.value)} multiline minRows={2} placeholder="Napište poznámku..." fullWidth onKeyDown={(e) => { if (e.key === "Enter" && e.ctrlKey && newNote.trim()) { handleAddNote(); } }} /> {/* Legacy notes (read-only) */} {project.notes && ( Starší poznámka (před zavedením systému) {project.notes} )} {/* Notes list */} {notes.length === 0 && !project.notes && ( Zatím žádné poznámky )} {(notes.length > 0 || project.notes) && ( {notes.map((note) => ( saveNoteEdit(note.id, content)} onDelete={() => handleDeleteNote(note.id)} /> ))} )} {/* Project File Manager */} {/* Links */} Propojení Objednávka {project.order_id ? ( <> {project.order_number} {project.order_status && ( )} ) : ( "—" )} Nabídka {project.quotation_id ? ( {project.quotation_number} ) : ( "—" )} {/* Status change confirmation */} setStatusConfirm({ show: false, status: null })} onConfirm={handleStatusChange} title="Změnit stav projektu" message={statusConfirmMessage(statusConfirm.status)} confirmText={ TRANSITION_LABELS[statusConfirm.status || ""] || "Potvrdit" } confirmVariant={ statusConfirm.status === "zruseny" ? "danger" : "primary" } cancelText="Zrušit" /> { 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 && ( )} ); }