Files
rentman-clone/frontend/src/pages/ProjectDetail.tsx
T
Agent Zero 7f7da15965 Initial commit: Rentman Clone - Phase 0-6 (T001-T023)
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
2026-05-31 20:36:42 +00:00

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>
);
}