7f7da15965
Completed: - Phase 0: Project Setup (T001-T003) - Docker Compose, FastAPI skeleton, React SPA - Phase 1: Auth System (T004-T008) - DB models, JWT auth, RBAC middleware, user management - Phase 2: Contacts & Tags (T009-T011) - CRUD API + UI - Phase 3: Equipment Catalog (T012-T014) - Models, API, UI with barcode/QR - Phase 4: Crew Management (T015-T017) - Models, availability, UI - Phase 5: Vehicle Fleet (T018-T020) - Models, assignments, UI - Phase 6: Projects (T021-T023) - Project hierarchy models, CRUD API, list/detail UI
270 lines
9.8 KiB
TypeScript
270 lines
9.8 KiB
TypeScript
import { useState, useEffect } from 'react';
|
|
import { useParams, useNavigate } from 'react-router-dom';
|
|
import { useAuthStore } from '../stores/authStore.ts';
|
|
import api from '../services/api.ts';
|
|
import { Pencil, Trash2, ArrowLeft, Calendar, Euro, FileText, Layers, FolderOpen, ListChecks, Plus } from 'lucide-react';
|
|
import FunctionGroupEditor from '../components/FunctionGroupEditor.tsx';
|
|
|
|
interface ProjectDetailData {
|
|
id: string;
|
|
account_id: string;
|
|
name: string;
|
|
description: string | null;
|
|
status: string;
|
|
start_date: string | null;
|
|
end_date: string | null;
|
|
budget: number | null;
|
|
total_costs: number;
|
|
notes: string | null;
|
|
created_at: string;
|
|
updated_at: string;
|
|
}
|
|
|
|
interface SubProjectItem {
|
|
id: string;
|
|
name: string;
|
|
project_id: string;
|
|
parent_id: string | null;
|
|
sort_order: number;
|
|
}
|
|
|
|
interface FunctionGroupItem {
|
|
id: string;
|
|
name: string;
|
|
project_id: string;
|
|
sort_order: number;
|
|
}
|
|
|
|
interface FunctionItem {
|
|
id: string;
|
|
name: string;
|
|
function_group_id: string;
|
|
quantity: number;
|
|
daily_costs: number;
|
|
total_costs: number;
|
|
sort_order: number;
|
|
}
|
|
|
|
export default function ProjectDetail() {
|
|
const { projectId } = useParams<{ projectId: string }>();
|
|
const { user } = useAuthStore();
|
|
const navigate = useNavigate();
|
|
|
|
const [project, setProject] = useState<ProjectDetailData | null>(null);
|
|
const [subProjects, setSubProjects] = useState<SubProjectItem[]>([]);
|
|
const [functionGroups, setFunctionGroups] = useState<FunctionGroupItem[]>([]);
|
|
const [functionsMap, setFunctionsMap] = useState<Record<string, FunctionItem[]>>({});
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
const canWrite = user?.permissions?.includes('projects:write');
|
|
const canDelete = user?.permissions?.includes('projects:delete');
|
|
|
|
useEffect(() => {
|
|
loadAll();
|
|
}, [projectId]);
|
|
|
|
const loadAll = async () => {
|
|
try {
|
|
setLoading(true);
|
|
|
|
// Load project details
|
|
const projectRes = await api.get(`/projects/${projectId}`);
|
|
setProject(projectRes.data);
|
|
|
|
// Load subprojects
|
|
const subRes = await api.get(`/projects/${projectId}/subprojects?size=100`);
|
|
setSubProjects(subRes.data.items || []);
|
|
|
|
// Load function groups
|
|
const fgRes = await api.get(`/projects/${projectId}/function-groups?size=100`);
|
|
const fgs: FunctionGroupItem[] = fgRes.data.items || [];
|
|
setFunctionGroups(fgs);
|
|
|
|
// Load functions for each function group
|
|
const funcMap: Record<string, FunctionItem[]> = {};
|
|
await Promise.all(
|
|
fgs.map(async (fg) => {
|
|
try {
|
|
const funcRes = await api.get(`/projects/${projectId}/function-groups/${fg.id}/functions?size=100`);
|
|
funcMap[fg.id] = funcRes.data.items || [];
|
|
} catch {
|
|
funcMap[fg.id] = [];
|
|
}
|
|
})
|
|
);
|
|
setFunctionsMap(funcMap);
|
|
} catch (err: any) {
|
|
setError(err.response?.data?.detail || 'Failed to load project');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleDelete = async () => {
|
|
if (!confirm(`Delete project "${project?.name}"?`)) return;
|
|
try {
|
|
await api.delete(`/projects/${projectId}`);
|
|
navigate('/projects');
|
|
} catch (err: any) {
|
|
alert(err.response?.data?.detail || 'Failed to delete project');
|
|
}
|
|
};
|
|
|
|
const getStatusBadge = (status: string) => {
|
|
const styles: Record<string, string> = {
|
|
draft: 'bg-gray-100 text-gray-800',
|
|
confirmed: 'bg-blue-100 text-blue-800',
|
|
in_progress: 'bg-yellow-100 text-yellow-800',
|
|
completed: 'bg-green-100 text-green-800',
|
|
cancelled: 'bg-red-100 text-red-800',
|
|
};
|
|
return styles[status] || 'bg-gray-100 text-gray-800';
|
|
};
|
|
|
|
const formatDate = (dateStr: string | null) => {
|
|
if (!dateStr) return '-';
|
|
const d = new Date(dateStr);
|
|
return d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' });
|
|
};
|
|
|
|
const iconClass = "w-5 h-5 text-text-secondary mt-0.5 flex-shrink-0";
|
|
const labelClass = "text-sm text-text-secondary min-w-[120px]";
|
|
const valueClass = "text-sm text-text-primary";
|
|
const fieldRowClass = "flex items-start gap-3 py-2";
|
|
const sectionClass = "mb-8";
|
|
const headingClass = "text-lg font-semibold text-text-primary mb-3";
|
|
|
|
if (loading) return <div className="p-6"><p className="text-text-secondary">Loading project...</p></div>;
|
|
if (error) return <div className="p-6"><p className="text-red-500">{error}</p></div>;
|
|
if (!project) return <div className="p-6"><p className="text-text-secondary">Project not found.</p></div>;
|
|
|
|
return (
|
|
<div className="p-6 max-w-5xl mx-auto">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between mb-6">
|
|
<button
|
|
onClick={() => navigate('/projects')}
|
|
className="flex items-center gap-2 text-text-secondary hover:text-text-primary transition-colors"
|
|
>
|
|
<ArrowLeft size={20} />
|
|
<span className="text-sm">Back to Projects</span>
|
|
</button>
|
|
<div className="flex items-center gap-2">
|
|
{canWrite && (
|
|
<button
|
|
onClick={() => navigate(`/projects/${project.id}/edit`)}
|
|
className="flex items-center gap-2 px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors text-sm"
|
|
>
|
|
<Pencil size={16} />
|
|
Edit
|
|
</button>
|
|
)}
|
|
{canDelete && (
|
|
<button
|
|
onClick={handleDelete}
|
|
className="flex items-center gap-2 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors text-sm"
|
|
>
|
|
<Trash2 size={16} />
|
|
Delete
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Title */}
|
|
<div className="mb-6">
|
|
<h1 className="text-2xl font-bold text-text-primary">{project.name}</h1>
|
|
<div className="flex items-center gap-3 mt-2">
|
|
<span className={`inline-block px-3 py-1 rounded-full text-xs font-medium ${getStatusBadge(project.status)}`}>
|
|
{project.status.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase())}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Project Details */}
|
|
<div className={sectionClass}>
|
|
<h2 className={headingClass}><FileText className="inline w-5 h-5 mr-2" />Project Details</h2>
|
|
<div className="bg-surface rounded-lg border border-border p-4 space-y-1">
|
|
{project.description && (
|
|
<div className={fieldRowClass}>
|
|
<FileText className={iconClass} />
|
|
<span className={labelClass}>Description</span>
|
|
<span className={valueClass}>{project.description}</span>
|
|
</div>
|
|
)}
|
|
<div className={fieldRowClass}>
|
|
<Calendar className={iconClass} />
|
|
<span className={labelClass}>Start Date</span>
|
|
<span className={valueClass}>{formatDate(project.start_date)}</span>
|
|
</div>
|
|
<div className={fieldRowClass}>
|
|
<Calendar className={iconClass} />
|
|
<span className={labelClass}>End Date</span>
|
|
<span className={valueClass}>{formatDate(project.end_date)}</span>
|
|
</div>
|
|
<div className={fieldRowClass}>
|
|
<Euro className={iconClass} />
|
|
<span className={labelClass}>Budget</span>
|
|
<span className={valueClass}>{project.budget != null ? `€${project.budget.toFixed(2)}` : '-'}</span>
|
|
</div>
|
|
<div className={fieldRowClass}>
|
|
<Euro className={iconClass} />
|
|
<span className={labelClass}>Total Costs</span>
|
|
<span className={valueClass}>{project.total_costs != null ? `€${project.total_costs.toFixed(2)}` : '-'}</span>
|
|
</div>
|
|
{project.notes && (
|
|
<div className={fieldRowClass}>
|
|
<FileText className={iconClass} />
|
|
<span className={labelClass}>Notes</span>
|
|
<span className={valueClass}>{project.notes}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* SubProjects Section */}
|
|
<div className={sectionClass}>
|
|
<div className="flex items-center justify-between mb-3">
|
|
<h2 className={headingClass}><Layers className="inline w-5 h-5 mr-2" />Sub-Projects ({subProjects.length})</h2>
|
|
</div>
|
|
{subProjects.length === 0 ? (
|
|
<p className="text-text-secondary text-sm">No sub-projects defined yet.</p>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{subProjects.map(sp => (
|
|
<div key={sp.id} className="bg-surface rounded-lg border border-border p-3 flex items-center justify-between">
|
|
<div className="flex items-center gap-3">
|
|
<Layers size={18} className="text-text-secondary flex-shrink-0" />
|
|
<div>
|
|
<p className="text-sm font-medium text-text-primary">{sp.name}</p>
|
|
<p className="text-xs text-text-secondary">Sort order: {sp.sort_order}</p>
|
|
</div>
|
|
</div>
|
|
{sp.parent_id && (
|
|
<span className="text-xs text-text-secondary bg-gray-100 px-2 py-1 rounded">Child</span>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Function Groups Section - Inline Editor */}
|
|
<div className={sectionClass}>
|
|
<div className="flex items-center justify-between mb-3">
|
|
<h2 className={headingClass}><FolderOpen className="inline w-5 h-5 mr-2" />Function Groups ({functionGroups.length})</h2>
|
|
</div>
|
|
<FunctionGroupEditor
|
|
projectId={project.id}
|
|
functionGroups={functionGroups}
|
|
functionsMap={functionsMap}
|
|
onReload={loadAll}
|
|
canWrite={canWrite}
|
|
canDelete={canDelete}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|