Files
app/src/admin/pages/ProjectDetail.tsx
BOHA e0d2fccf50 fix(projects): edit-page responsible picker shows only active users (keeps current assignee)
Mirror the create-modal fix on ProjectDetail: filter the 'Zodpovědná osoba'
select to active users (already scoped to projects.view), but keep the
project's currently-assigned user selectable even if inactive, labelled
'(neaktivní)', so the saved value still shows.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 14:24:41 +02:00

681 lines
21 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 } from "react-router-dom";
import { motion } from "framer-motion";
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 ConfirmModal from "../components/ConfirmModal";
import FormField from "../components/FormField";
import AdminDatePicker from "../components/AdminDatePicker";
import ProjectFileManager from "../components/ProjectFileManager";
import apiFetch from "../utils/api";
const API_BASE = "/api/admin";
const STATUS_LABELS: Record<string, string> = {
aktivni: "Aktivní",
dokonceny: "Dokončený",
zruseny: "Zrušený",
};
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;
}
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 (!project) return null;
const canEdit = hasPermission("projects.edit");
if (isPending) {
return (
<div className="admin-loading">
<div className="admin-spinner" />
</div>
);
}
return (
<div>
{/* 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"
>
<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}
>
{saving ? (
<>
<div className="admin-spinner admin-spinner-sm" />
Ukládání...
</>
) : (
"Uložit"
)}
</button>
{!project.order_id && (
<button
onClick={() => setDeleteConfirm(true)}
className="admin-btn admin-btn-primary"
>
Smazat
</button>
)}
</div>
)}
</motion.div>
{/* 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>
</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>
{/* Add note */}
<div className="mb-4">
<textarea
value={newNote}
onChange={(e) => setNewNote(e.target.value)}
className="admin-form-input"
rows={2}
placeholder="Napište poznámku..."
style={{ resize: "vertical", width: "100%" }}
onKeyDown={(e) => {
if (e.key === "Enter" && e.ctrlKey && newNote.trim()) {
handleAddNote();
}
}}
/>
<div className="mt-2">
<button
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>
{/* 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)",
}}
>
<div
style={{
fontSize: "0.75rem",
color: "var(--text-tertiary)",
marginBottom: "0.25rem",
}}
>
Starší poznámka (před zavedením systému)
</div>
<div style={{ whiteSpace: "pre-wrap" }}>{project.notes}</div>
</div>
)}
{/* Notes list */}
{notes.length === 0 && !project.notes && (
<div
style={{
color: "var(--text-tertiary)",
fontSize: "0.875rem",
textAlign: "center",
padding: "1rem 0",
}}
>
Zatím žádné poznámky
</div>
)}
{(notes.length > 0 || project.notes) && (
<div
style={{
display: "flex",
flexDirection: "column",
gap: "0.5rem",
}}
>
{notes.map((note) => (
<div
key={note.id}
style={{
padding: "0.75rem",
background: "var(--bg-secondary)",
borderRadius: "0.5rem",
position: "relative",
}}
>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "flex-start",
gap: "0.5rem",
}}
>
<div className="flex-1">
<div
style={{
display: "flex",
alignItems: "center",
gap: "0.5rem",
marginBottom: "0.25rem",
}}
>
<span style={{ fontWeight: 600, fontSize: "0.85rem" }}>
{note.user_name}
</span>
<span
style={{
color: "var(--text-tertiary)",
fontSize: "0.75rem",
}}
>
{formatNoteDate(note.created_at)}
</span>
</div>
<div
style={{
whiteSpace: "pre-wrap",
fontSize: "0.875rem",
lineHeight: 1.5,
}}
>
{note.content}
</div>
</div>
{isAdmin && (
<button
onClick={() => handleDeleteNote(note.id)}
className="admin-btn-icon"
title="Smazat poznámku"
disabled={deletingNoteId === note.id}
style={{
flexShrink: 0,
opacity: deletingNoteId === note.id ? 0.5 : 1,
}}
>
{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>
)}
</div>
</div>
))}
</div>
)}
</div>
</motion.div>
{/* Project File Manager */}
<motion.div
style={{ marginBottom: "1rem" }}
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.12 }}
>
<ProjectFileManager
projectId={project.id}
projectNumber={project.project_number}
hasPermission={hasPermission}
hasNasFolder={project.has_nas_folder ?? false}
/>
</motion.div>
{/* 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>
{project.order_id ? (
<Link
to={`/orders/${project.order_id}`}
className="link-accent"
>
{project.order_number}
{project.order_status && (
<span
className="text-tertiary"
style={{ fontWeight: 400, marginLeft: "0.5rem" }}
>
(
{STATUS_LABELS[project.order_status] ||
project.order_status}
)
</span>
)}
</Link>
) : (
"—"
)}
</div>
</FormField>
<FormField label="Nabídka">
<div>
{project.quotation_id ? (
<Link
to={`/offers/${project.quotation_id}`}
className="link-accent"
>
{project.quotation_number}
</Link>
) : (
"—"
)}
</div>
</FormField>
</div>
</div>
</motion.div>
<ConfirmModal
isOpen={deleteConfirm}
onClose={() => {
setDeleteConfirm(false);
setDeleteFiles(false);
}}
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>
)}
</>
}
confirmText="Smazat"
cancelText="Zrušit"
type="danger"
loading={deleting}
/>
</div>
);
}