Files
app/src/admin/pages/ProjectDetail.tsx
BOHA 3481b97d47 fix: useEffect anti-patterns, attendance permissions, and received-invoices schema mismatch
- Remove ref-mirror useEffect in AuthContext (cachedUserRef already written at mutation sites)
- Replace useEffect slide direction in ReceivedInvoices with render-time computation
- Fix Login.tsx useEffect dependency array (mount-only alert should have [] deps)
- Move "project created" alert to navigation source in ProjectCreate, remove useEffect in ProjectDetail
- Move companySettings defaults into fetch callbacks in InvoiceDetail and OfferDetail
- Replace due_date useEffect with useMemo in InvoiceDetail
- Capture initial snapshots from API data instead of useEffect in InvoiceDetail, OfferDetail, OrderDetail
- Replace localStorage draft useEffect with lazy useState initializer in OfferDetail
- Fix attendance dropdown to filter by attendance.record permission only
- Fix clock-out 404 on update-address (remove departure_time filter for departure action)
- Fix received-invoices stats endpoint referencing non-existent is_deleted and amount_czk columns

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 10:28:15 +02:00

774 lines
24 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 { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import { useParams, useNavigate, Link } from "react-router-dom";
import { motion } from "framer-motion";
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 Note {
id: number;
content: string;
user_name: string;
created_at: string;
}
interface User {
id: number;
name: string;
}
interface ProjectData {
id: number;
project_number: string;
name: string;
status: string;
start_date: string;
end_date: string;
customer_name: string;
responsible_user_id: string;
notes?: string;
order_id?: number;
order_number?: string;
order_status?: string;
quotation_id?: number;
quotation_number?: string;
has_nas_folder?: boolean;
}
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 [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [project, setProject] = useState<ProjectData | null>(null);
const [form, setForm] = useState<ProjectForm>({
name: "",
status: "aktivni",
start_date: "",
end_date: "",
responsible_user_id: "",
});
const [users, setUsers] = useState<User[]>([]);
const [deleteConfirm, setDeleteConfirm] = useState(false);
const [deleting, setDeleting] = useState(false);
const [deleteFiles, setDeleteFiles] = useState(false);
// Dynamic notes
const [notes, setNotes] = useState<Note[]>([]);
const [notesLoading, setNotesLoading] = useState(true);
const [newNote, setNewNote] = useState("");
const [addingNote, setAddingNote] = useState(false);
const [deletingNoteId, setDeletingNoteId] = useState<number | null>(null);
const fetchNotes = async () => {
try {
const response = await apiFetch(`${API_BASE}/projects/${id}`);
if (response.status === 401) return;
const result = await response.json();
if (result.success) {
setNotes(result.data.project_notes || []);
}
} catch {
// silent - notes are supplementary
} finally {
setNotesLoading(false);
}
};
useEffect(() => {
const fetchDetail = async () => {
try {
const response = await apiFetch(`${API_BASE}/projects/${id}`);
if (response.status === 401) return;
const result = await response.json();
if (result.success) {
const p = result.data;
setProject(p);
setForm({
name: p.name || "",
status: p.status || "aktivni",
start_date: (p.start_date || "").substring(0, 10),
end_date: (p.end_date || "").substring(0, 10),
responsible_user_id: p.responsible_user_id || "",
});
} else {
alert.error(result.error || "Nepodařilo se načíst projekt");
navigate("/projects");
}
} catch {
alert.error("Chyba připojení");
navigate("/projects");
} finally {
setLoading(false);
}
};
const fetchUsers = async () => {
try {
const res = await apiFetch(`${API_BASE}/users`);
if (res.status === 401) return;
const data = await res.json();
if (data.success) {
const raw = Array.isArray(data.data)
? data.data
: data.data?.items || [];
setUsers(
raw.map((u: any) => ({
id: u.id,
name:
`${u.first_name || ""} ${u.last_name || ""}`.trim() ||
u.username,
})),
);
}
} catch {
// silent
}
};
fetchDetail();
fetchNotes();
fetchUsers();
}, [id, alert, navigate]); // 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");
} 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) {
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) {
setNotes((prev) => [result.data.note, ...prev]);
setNewNote("");
alert.success("Poznámka byla přidána");
} 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) {
setNotes((prev) => prev.filter((n) => n.id !== noteId));
alert.success("Poznámka byla smazána");
} else {
alert.error(result.error || "Nepodařilo se smazat poznámku");
}
} catch {
alert.error("Chyba připojení");
} finally {
setDeletingNoteId(null);
}
};
if (loading) {
return (
<div className="admin-skeleton" style={{ padding: 0, gap: "1.5rem" }}>
<div
className="admin-skeleton-row"
style={{ justifyContent: "space-between" }}
>
<div className="flex-row-gap">
<div
className="admin-skeleton-line"
style={{ width: "32px", height: "32px", borderRadius: "8px" }}
/>
<div
className="admin-skeleton-line h-8"
style={{ width: "200px" }}
/>
</div>
<div className="admin-skeleton-row" style={{ gap: "0.5rem" }}>
<div
className="admin-skeleton-line h-10"
style={{ width: "100px", borderRadius: "8px" }}
/>
<div
className="admin-skeleton-line h-10"
style={{ width: "100px", borderRadius: "8px" }}
/>
</div>
</div>
<div className="admin-card">
<div className="admin-skeleton" style={{ gap: "1.25rem" }}>
{[0, 1, 2, 3].map((i) => (
<div key={i} className="admin-skeleton-row">
<div className="admin-skeleton-line w-1/4" />
<div className="admin-skeleton-line w-1/2" />
</div>
))}
</div>
</div>
</div>
);
}
if (!project) return null;
const canEdit = hasPermission("projects.edit");
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 */}
{notesLoading && (
<div className="admin-skeleton" style={{ gap: "0.75rem" }}>
{[0, 1, 2].map((i) => (
<div
key={i}
className="admin-skeleton-line"
style={{ height: "52px", borderRadius: "8px" }}
/>
))}
</div>
)}
{!notesLoading && 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>
)}
{!notesLoading && (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>
);
}