- Change attendance idx_attendance_user_date from unique to index (allow multiple shifts per day) - Reset migrations to single baseline init migration - Add seed script with admin user (admin/admin) - Update CLAUDE.md with migration workflow documentation - Various frontend fixes (queries, pages, hooks) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
696 lines
23 KiB
TypeScript
696 lines
23 KiB
TypeScript
import { useState, useEffect, useRef } from "react";
|
||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||
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 { Skeleton } from "boneyard-js/react";
|
||
import ProjectDetailFixture from "../fixtures/ProjectDetailFixture";
|
||
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 queryClient = useQueryClient();
|
||
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"));
|
||
const users: User[] = (usersData ?? []).map((u: ApiUser) => ({
|
||
id: u.id,
|
||
name: `${u.first_name || ""} ${u.last_name || ""}`.trim() || u.username,
|
||
}));
|
||
|
||
// 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 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 {
|
||
const response = await apiFetch(`${API_BASE}/projects/${id}`, {
|
||
method: "PUT",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
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,
|
||
}),
|
||
});
|
||
const result = await response.json();
|
||
if (result.success) {
|
||
alert.success(result.message || "Projekt byl aktualizován");
|
||
queryClient.invalidateQueries({ queryKey: ["projects"] });
|
||
queryClient.invalidateQueries({ queryKey: ["orders"] });
|
||
queryClient.invalidateQueries({ queryKey: ["offers"] });
|
||
queryClient.invalidateQueries({ queryKey: ["invoices"] });
|
||
queryClient.invalidateQueries({ queryKey: ["attendance"] });
|
||
} else {
|
||
alert.error(result.error || "Nepodařilo se uložit projekt");
|
||
}
|
||
} catch {
|
||
alert.error("Chyba připojení");
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
};
|
||
|
||
const handleDelete = async () => {
|
||
setDeleting(true);
|
||
try {
|
||
const response = await apiFetch(`${API_BASE}/projects/${id}`, {
|
||
method: "DELETE",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ delete_files: deleteFiles }),
|
||
});
|
||
const result = await response.json();
|
||
if (result.success) {
|
||
queryClient.invalidateQueries({ queryKey: ["projects"] });
|
||
queryClient.invalidateQueries({ queryKey: ["orders"] });
|
||
queryClient.invalidateQueries({ queryKey: ["offers"] });
|
||
queryClient.invalidateQueries({ queryKey: ["invoices"] });
|
||
queryClient.invalidateQueries({ queryKey: ["attendance"] });
|
||
navigate("/projects");
|
||
setTimeout(() => alert.success("Projekt byl smazán"), 300);
|
||
} else {
|
||
alert.error(result.error || "Nepodařilo se smazat projekt");
|
||
}
|
||
} catch {
|
||
alert.error("Chyba připojení");
|
||
} finally {
|
||
setDeleting(false);
|
||
}
|
||
};
|
||
|
||
const handleAddNote = async () => {
|
||
if (!newNote.trim()) return;
|
||
|
||
setAddingNote(true);
|
||
try {
|
||
const response = await apiFetch(`${API_BASE}/projects/${id}/notes`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ content: newNote.trim() }),
|
||
});
|
||
const result = await response.json();
|
||
if (result.success) {
|
||
setNewNote("");
|
||
alert.success("Poznámka byla přidána");
|
||
queryClient.invalidateQueries({ queryKey: ["projects"] });
|
||
queryClient.invalidateQueries({ queryKey: ["orders"] });
|
||
queryClient.invalidateQueries({ queryKey: ["offers"] });
|
||
queryClient.invalidateQueries({ queryKey: ["invoices"] });
|
||
queryClient.invalidateQueries({ queryKey: ["attendance"] });
|
||
} else {
|
||
alert.error(result.error || "Nepodařilo se přidat poznámku");
|
||
}
|
||
} catch {
|
||
alert.error("Chyba připojení");
|
||
} finally {
|
||
setAddingNote(false);
|
||
}
|
||
};
|
||
|
||
const handleDeleteNote = async (noteId: number) => {
|
||
setDeletingNoteId(noteId);
|
||
try {
|
||
const response = await apiFetch(
|
||
`${API_BASE}/projects/${id}/notes/${noteId}`,
|
||
{
|
||
method: "DELETE",
|
||
},
|
||
);
|
||
const result = await response.json();
|
||
if (result.success) {
|
||
alert.success("Poznámka byla smazána");
|
||
queryClient.invalidateQueries({ queryKey: ["projects"] });
|
||
queryClient.invalidateQueries({ queryKey: ["orders"] });
|
||
queryClient.invalidateQueries({ queryKey: ["offers"] });
|
||
queryClient.invalidateQueries({ queryKey: ["invoices"] });
|
||
queryClient.invalidateQueries({ queryKey: ["attendance"] });
|
||
} else {
|
||
alert.error(result.error || "Nepodařilo se smazat poznámku");
|
||
}
|
||
} catch {
|
||
alert.error("Chyba připojení");
|
||
} finally {
|
||
setDeletingNoteId(null);
|
||
}
|
||
};
|
||
|
||
if (!project) return null;
|
||
|
||
const canEdit = hasPermission("projects.edit");
|
||
|
||
return (
|
||
<Skeleton
|
||
name="project-detail"
|
||
loading={isPending}
|
||
fixture={<ProjectDetailFixture />}
|
||
>
|
||
<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 "{project.project_number} –{" "}
|
||
{project.name}"? 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>
|
||
</Skeleton>
|
||
);
|
||
}
|