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
This commit is contained in:
@@ -0,0 +1,269 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user