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,15 @@
|
||||
FROM node:22-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files and install dependencies
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm install
|
||||
|
||||
# Copy application code
|
||||
COPY . .
|
||||
|
||||
# Expose Vite dev server port
|
||||
EXPOSE 5173
|
||||
|
||||
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Rentman Clone</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+4616
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"name": "rentman-clone-frontend",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint ."
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-router-dom": "^7.5.0",
|
||||
"zustand": "^5.0.3",
|
||||
"@tanstack/react-query": "^5.75.5",
|
||||
"@radix-ui/react-slot": "^1.2.0",
|
||||
"@radix-ui/react-dialog": "^1.1.0",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.0",
|
||||
"@radix-ui/react-label": "^2.1.0",
|
||||
"@radix-ui/react-separator": "^1.1.0",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.1",
|
||||
"tailwind-merge": "^3.2.0",
|
||||
"lucide-react": "^0.515.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.1.0",
|
||||
"@types/react-dom": "^19.1.0",
|
||||
"@vitejs/plugin-react": "^4.4.1",
|
||||
"typescript": "~5.8.0",
|
||||
"vite": "^6.3.0",
|
||||
"tailwindcss": "^4.1.0",
|
||||
"@tailwindcss/vite": "^4.1.0",
|
||||
"@eslint/js": "^9.25.0",
|
||||
"eslint": "^9.25.0",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.0",
|
||||
"globals": "^16.0.0",
|
||||
"typescript-eslint": "^8.30.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { Routes, Route, Navigate } from 'react-router-dom';
|
||||
import AppLayout from './components/AppLayout.tsx';
|
||||
import ProtectedRoute from './components/ProtectedRoute.tsx';
|
||||
import Home from './pages/Home.tsx';
|
||||
import Login from './pages/Login.tsx';
|
||||
import Register from './pages/Register.tsx';
|
||||
import Dashboard from './pages/Dashboard.tsx';
|
||||
import Users from './pages/Users.tsx';
|
||||
import Roles from './pages/Roles.tsx';
|
||||
import Contacts from './pages/Contacts.tsx';
|
||||
import ContactsNew from './pages/ContactsNew.tsx';
|
||||
import ContactsEdit from './pages/ContactsEdit.tsx';
|
||||
import ContactDetail from './pages/ContactDetail.tsx';
|
||||
import Equipment from './pages/Equipment.tsx';
|
||||
import EquipmentNew from './pages/EquipmentNew.tsx';
|
||||
import EquipmentEdit from './pages/EquipmentEdit.tsx';
|
||||
import EquipmentDetail from './pages/EquipmentDetail.tsx';
|
||||
import CrewPage from './pages/Crew.tsx';
|
||||
import CrewNew from './pages/CrewNew.tsx';
|
||||
import CrewEdit from './pages/CrewEdit.tsx';
|
||||
import CrewDetail from './pages/CrewDetail.tsx';
|
||||
import Vehicles from './pages/Vehicles.tsx';
|
||||
import VehicleNew from './pages/VehicleNew.tsx';
|
||||
import VehicleEdit from './pages/VehicleEdit.tsx';
|
||||
import VehicleDetail from './pages/VehicleDetail.tsx';
|
||||
import Projects from './pages/Projects.tsx';
|
||||
import ProjectNew from './pages/ProjectNew.tsx';
|
||||
import ProjectEdit from './pages/ProjectEdit.tsx';
|
||||
import ProjectDetail from './pages/ProjectDetail.tsx';
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<Routes>
|
||||
{/* Public routes */}
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/register" element={<Register />} />
|
||||
|
||||
{/* Protected routes (require authentication) */}
|
||||
<Route element={<ProtectedRoute />}>
|
||||
<Route element={<AppLayout />}>
|
||||
<Route path="/dashboard" element={<Dashboard />} />
|
||||
</Route>
|
||||
</Route>
|
||||
|
||||
{/* Contacts routes (require contacts:read permission) */}
|
||||
<Route element={<ProtectedRoute requiredPermission="contacts:read" />}>
|
||||
<Route element={<AppLayout />}>
|
||||
<Route path="/contacts" element={<Contacts />} />
|
||||
<Route path="/contacts/:contactId" element={<ContactDetail />} />
|
||||
</Route>
|
||||
</Route>
|
||||
|
||||
{/* Contacts write routes (require contacts:write permission) */}
|
||||
<Route element={<ProtectedRoute requiredPermission="contacts:write" />}>
|
||||
<Route element={<AppLayout />}>
|
||||
<Route path="/contacts/new" element={<ContactsNew />} />
|
||||
<Route path="/contacts/:contactId/edit" element={<ContactsEdit />} />
|
||||
</Route>
|
||||
</Route>
|
||||
|
||||
{/* Equipment routes (require equipment:read permission) */}
|
||||
<Route element={<ProtectedRoute requiredPermission="equipment:read" />}>
|
||||
<Route element={<AppLayout />}>
|
||||
<Route path="/equipment" element={<Equipment />} />
|
||||
<Route path="/equipment/:itemId" element={<EquipmentDetail />} />
|
||||
</Route>
|
||||
</Route>
|
||||
|
||||
{/* Equipment write routes (require equipment:write permission) */}
|
||||
<Route element={<ProtectedRoute requiredPermission="equipment:write" />}>
|
||||
<Route element={<AppLayout />}>
|
||||
<Route path="/equipment/new" element={<EquipmentNew />} />
|
||||
<Route path="/equipment/:itemId/edit" element={<EquipmentEdit />} />
|
||||
</Route>
|
||||
</Route>
|
||||
|
||||
{/* Crew routes (require crew:read permission) */}
|
||||
<Route element={<ProtectedRoute requiredPermission="crew:read" />}>
|
||||
<Route element={<AppLayout />}>
|
||||
<Route path="/crew" element={<CrewPage />} />
|
||||
<Route path="/crew/:memberId" element={<CrewDetail />} />
|
||||
</Route>
|
||||
</Route>
|
||||
|
||||
{/* Crew write routes (require crew:write permission) */}
|
||||
<Route element={<ProtectedRoute requiredPermission="crew:write" />}>
|
||||
<Route element={<AppLayout />}>
|
||||
<Route path="/crew/new" element={<CrewNew />} />
|
||||
<Route path="/crew/:memberId/edit" element={<CrewEdit />} />
|
||||
</Route>
|
||||
</Route>
|
||||
|
||||
{/* Vehicles routes (require vehicles:read permission) */}
|
||||
<Route element={<ProtectedRoute requiredPermission="vehicles:read" />}>
|
||||
<Route element={<AppLayout />}>
|
||||
<Route path="/vehicles" element={<Vehicles />} />
|
||||
<Route path="/vehicles/:vehicleId" element={<VehicleDetail />} />
|
||||
</Route>
|
||||
</Route>
|
||||
|
||||
{/* Vehicles write routes (require vehicles:write permission) */}
|
||||
<Route element={<ProtectedRoute requiredPermission="vehicles:write" />}>
|
||||
<Route element={<AppLayout />}>
|
||||
<Route path="/vehicles/new" element={<VehicleNew />} />
|
||||
<Route path="/vehicles/:vehicleId/edit" element={<VehicleEdit />} />
|
||||
</Route>
|
||||
</Route>
|
||||
|
||||
{/* Projects routes (require projects:read permission) */}
|
||||
<Route element={<ProtectedRoute requiredPermission="projects:read" />}>
|
||||
<Route element={<AppLayout />}>
|
||||
<Route path="/projects" element={<Projects />} />
|
||||
<Route path="/projects/:projectId" element={<ProjectDetail />} />
|
||||
</Route>
|
||||
</Route>
|
||||
|
||||
{/* Projects write routes (require projects:write permission) */}
|
||||
<Route element={<ProtectedRoute requiredPermission="projects:write" />}>
|
||||
<Route element={<AppLayout />}>
|
||||
<Route path="/projects/new" element={<ProjectNew />} />
|
||||
<Route path="/projects/:projectId/edit" element={<ProjectEdit />} />
|
||||
</Route>
|
||||
</Route>
|
||||
|
||||
{/* Admin routes (require users:read permission) */}
|
||||
<Route element={<ProtectedRoute requiredPermission="users:read" />}>
|
||||
<Route element={<AppLayout />}>
|
||||
<Route path="/users" element={<Users />} />
|
||||
</Route>
|
||||
</Route>
|
||||
|
||||
<Route element={<ProtectedRoute requiredPermission="roles:read" />}>
|
||||
<Route element={<AppLayout />}>
|
||||
<Route path="/roles" element={<Roles />} />
|
||||
</Route>
|
||||
</Route>
|
||||
|
||||
{/* Catch-all: redirect to dashboard if authenticated, else home */}
|
||||
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { Outlet, Link, useNavigate } from 'react-router-dom';
|
||||
import { useAuthStore } from '../stores/authStore.ts';
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Package,
|
||||
Users,
|
||||
Truck,
|
||||
FolderKanban,
|
||||
LogOut,
|
||||
Menu,
|
||||
Shield,
|
||||
AddressBook,
|
||||
} from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
|
||||
interface SidebarLink {
|
||||
to: string;
|
||||
label: string;
|
||||
icon: typeof LayoutDashboard;
|
||||
permission?: string;
|
||||
}
|
||||
|
||||
export default function AppLayout() {
|
||||
const { user, logout } = useAuthStore();
|
||||
const navigate = useNavigate();
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
|
||||
const sidebarLinks: SidebarLink[] = [
|
||||
{ to: '/dashboard', label: 'Dashboard', icon: LayoutDashboard },
|
||||
{ to: '/contacts', label: 'Contacts', icon: AddressBook },
|
||||
{ to: '/projects', label: 'Projects', icon: FolderKanban },
|
||||
{ to: '/equipment', label: 'Equipment', icon: Package },
|
||||
{ to: '/crew', label: 'Crew', icon: Users },
|
||||
{ to: '/vehicles', label: 'Vehicles', icon: Truck },
|
||||
];
|
||||
|
||||
// Admin links – only show if user has the required permission
|
||||
if (user?.permissions?.includes('users:read')) {
|
||||
sidebarLinks.push({ to: '/users', label: 'Manage Users', icon: Shield, permission: 'users:read' });
|
||||
}
|
||||
if (user?.permissions?.includes('roles:read')) {
|
||||
sidebarLinks.push({ to: '/roles', label: 'Manage Roles', icon: Shield, permission: 'roles:read' });
|
||||
}
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex bg-background">
|
||||
{/* Sidebar */}
|
||||
<aside
|
||||
className={`
|
||||
fixed inset-y-0 left-0 z-30 w-64 bg-surface border-r border-border
|
||||
transform transition-transform lg:translate-x-0 lg:static lg:z-auto
|
||||
${sidebarOpen ? 'translate-x-0' : '-translate-x-full'}
|
||||
`}
|
||||
>
|
||||
<div className="flex items-center justify-between h-16 px-6 border-b border-border">
|
||||
<Link to="/dashboard" className="text-xl font-bold text-primary">
|
||||
Rentman
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 px-4 py-4 space-y-1 overflow-y-auto">
|
||||
{sidebarLinks.map((link) => {
|
||||
const Icon = link.icon;
|
||||
return (
|
||||
<Link
|
||||
key={link.to}
|
||||
to={link.to}
|
||||
className="flex items-center gap-3 px-3 py-2.5 rounded-lg text-text-secondary hover:bg-primary/5 hover:text-primary transition-colors"
|
||||
>
|
||||
<Icon size={20} />
|
||||
<span className="text-sm font-medium">{link.label}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="p-4 border-t border-border">
|
||||
{user && (
|
||||
<div className="mb-3 px-3">
|
||||
<p className="text-sm font-medium text-text-primary truncate">
|
||||
{user.full_name}
|
||||
</p>
|
||||
<p className="text-xs text-text-secondary truncate">
|
||||
{user.email}
|
||||
</p>
|
||||
{user.role_name && (
|
||||
<p className="text-xs text-primary mt-0.5">{user.role_name}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex items-center gap-3 w-full px-3 py-2.5 rounded-lg text-text-secondary hover:bg-red-50 hover:text-red-600 transition-colors"
|
||||
>
|
||||
<LogOut size={20} />
|
||||
<span className="text-sm font-medium">Sign Out</span>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Overlay for mobile */}
|
||||
{sidebarOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-20 bg-black/50 lg:hidden"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Main content */}
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
{/* Header */}
|
||||
<header className="h-16 bg-surface border-b border-border flex items-center px-6">
|
||||
<button
|
||||
className="lg:hidden mr-4 text-text-secondary hover:text-text-primary"
|
||||
onClick={() => setSidebarOpen(!sidebarOpen)}
|
||||
>
|
||||
<Menu size={24} />
|
||||
</button>
|
||||
<h2 className="text-lg font-semibold text-text-primary">
|
||||
Rentman Clone
|
||||
</h2>
|
||||
</header>
|
||||
|
||||
{/* Page content */}
|
||||
<main className="flex-1 overflow-auto">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import api from '../services/api.ts';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
interface TagItem {
|
||||
id: string;
|
||||
name: string;
|
||||
color: string | null;
|
||||
}
|
||||
|
||||
export interface ContactFormData {
|
||||
type: 'company' | 'person';
|
||||
company_name: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
mobile: string;
|
||||
website: string;
|
||||
billing_street: string;
|
||||
billing_number: string;
|
||||
billing_postalcode: string;
|
||||
billing_city: string;
|
||||
billing_country: string;
|
||||
shipping_street: string;
|
||||
shipping_number: string;
|
||||
shipping_postalcode: string;
|
||||
shipping_city: string;
|
||||
shipping_country: string;
|
||||
tax_number: string;
|
||||
note: string;
|
||||
tag_ids: string[];
|
||||
}
|
||||
|
||||
export const emptyFormData: ContactFormData = {
|
||||
type: 'company',
|
||||
company_name: '',
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
mobile: '',
|
||||
website: '',
|
||||
billing_street: '',
|
||||
billing_number: '',
|
||||
billing_postalcode: '',
|
||||
billing_city: '',
|
||||
billing_country: '',
|
||||
shipping_street: '',
|
||||
shipping_number: '',
|
||||
shipping_postalcode: '',
|
||||
shipping_city: '',
|
||||
shipping_country: '',
|
||||
tax_number: '',
|
||||
note: '',
|
||||
tag_ids: [],
|
||||
};
|
||||
|
||||
interface ContactFormProps {
|
||||
initialData?: ContactFormData;
|
||||
onSubmit: (data: ContactFormData) => Promise<void>;
|
||||
submitLabel?: string;
|
||||
}
|
||||
|
||||
export default function ContactForm({ initialData, onSubmit, submitLabel = 'Save' }: ContactFormProps) {
|
||||
const [form, setForm] = useState<ContactFormData>(initialData || emptyFormData);
|
||||
const [tags, setTags] = useState<TagItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [newTagName, setNewTagName] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
loadTags();
|
||||
}, []);
|
||||
|
||||
const loadTags = async () => {
|
||||
try {
|
||||
const response = await api.get('/tags');
|
||||
setTags(response.data);
|
||||
} catch (err: any) {
|
||||
console.error('Failed to load tags', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateTag = async () => {
|
||||
if (!newTagName.trim()) return;
|
||||
try {
|
||||
const response = await api.post('/tags', { name: newTagName.trim() });
|
||||
setTags([...tags, response.data]);
|
||||
setForm({ ...form, tag_ids: [...form.tag_ids, response.data.id] });
|
||||
setNewTagName('');
|
||||
} catch (err: any) {
|
||||
alert(err.response?.data?.detail || 'Failed to create tag');
|
||||
}
|
||||
};
|
||||
|
||||
const toggleTag = (tagId: string) => {
|
||||
setForm(prev => ({
|
||||
...prev,
|
||||
tag_ids: prev.tag_ids.includes(tagId)
|
||||
? prev.tag_ids.filter(id => id !== tagId)
|
||||
: [...prev.tag_ids, tagId],
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
await onSubmit(form);
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.detail || 'Failed to save contact');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const updateField = (field: keyof ContactFormData, value: any) => {
|
||||
setForm(prev => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
const setType = (type: 'company' | 'person') => {
|
||||
setForm(prev => ({ ...prev, type }));
|
||||
};
|
||||
|
||||
const inputClass = "w-full px-3 py-2 border border-border rounded-lg focus:ring-2 focus:ring-primary outline-none text-sm";
|
||||
const labelClass = "block text-sm font-medium text-text-primary mb-1";
|
||||
const sectionClass = "mb-6";
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{error && (
|
||||
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm">{error}</div>
|
||||
)}
|
||||
|
||||
{/* Type Tabs */}
|
||||
<div className={sectionClass}>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setType('company')}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
form.type === 'company'
|
||||
? 'bg-primary text-white'
|
||||
: 'bg-surface border border-border text-text-secondary hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
Company
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setType('person')}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
form.type === 'person'
|
||||
? 'bg-primary text-white'
|
||||
: 'bg-surface border border-border text-text-secondary hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
Person
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Basic Info */}
|
||||
<div className={sectionClass}>
|
||||
<h3 className="text-lg font-semibold text-text-primary mb-3">
|
||||
{form.type === 'company' ? 'Company Information' : 'Personal Information'}
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{form.type === 'company' && (
|
||||
<div className="md:col-span-2">
|
||||
<label className={labelClass}>Company Name *</label>
|
||||
<input type="text" value={form.company_name} onChange={e => updateField('company_name', e.target.value)}
|
||||
required className={inputClass} />
|
||||
</div>
|
||||
)}
|
||||
{form.type === 'person' && (
|
||||
<>
|
||||
<div>
|
||||
<label className={labelClass}>First Name *</label>
|
||||
<input type="text" value={form.first_name} onChange={e => updateField('first_name', e.target.value)}
|
||||
required className={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Last Name *</label>
|
||||
<input type="text" value={form.last_name} onChange={e => updateField('last_name', e.target.value)}
|
||||
required className={inputClass} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div>
|
||||
<label className={labelClass}>Email</label>
|
||||
<input type="email" value={form.email} onChange={e => updateField('email', e.target.value)}
|
||||
className={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Phone</label>
|
||||
<input type="text" value={form.phone} onChange={e => updateField('phone', e.target.value)}
|
||||
className={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Mobile</label>
|
||||
<input type="text" value={form.mobile} onChange={e => updateField('mobile', e.target.value)}
|
||||
className={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Website</label>
|
||||
<input type="url" value={form.website} onChange={e => updateField('website', e.target.value)}
|
||||
className={inputClass} placeholder="https://" />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Tax Number</label>
|
||||
<input type="text" value={form.tax_number} onChange={e => updateField('tax_number', e.target.value)}
|
||||
className={inputClass} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
<div className={sectionClass}>
|
||||
<h3 className="text-lg font-semibold text-text-primary mb-3">Tags</h3>
|
||||
<div className="flex flex-wrap gap-2 mb-3">
|
||||
{tags.map(tag => (
|
||||
<button
|
||||
key={tag.id}
|
||||
type="button"
|
||||
onClick={() => toggleTag(tag.id)}
|
||||
className={`inline-flex items-center gap-1 px-3 py-1 rounded-full text-xs font-medium transition-colors ${
|
||||
form.tag_ids.includes(tag.id)
|
||||
? 'bg-primary text-white'
|
||||
: 'bg-gray-100 text-text-secondary hover:bg-gray-200'
|
||||
}`}
|
||||
style={form.tag_ids.includes(tag.id) && tag.color ? { backgroundColor: tag.color, color: '#fff' } : undefined}
|
||||
>
|
||||
{tag.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="New tag name..."
|
||||
value={newTagName}
|
||||
onChange={e => setNewTagName(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); handleCreateTag(); } }}
|
||||
className={`${inputClass} flex-1`}
|
||||
/>
|
||||
<button type="button" onClick={handleCreateTag} className="px-3 py-2 bg-surface border border-border rounded-lg text-sm hover:bg-gray-50">
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Billing Address */}
|
||||
<div className={sectionClass}>
|
||||
<h3 className="text-lg font-semibold text-text-primary mb-3">Billing Address</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="md:col-span-2">
|
||||
<label className={labelClass}>Street</label>
|
||||
<input type="text" value={form.billing_street} onChange={e => updateField('billing_street', e.target.value)}
|
||||
className={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Number</label>
|
||||
<input type="text" value={form.billing_number} onChange={e => updateField('billing_number', e.target.value)}
|
||||
className={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Postal Code</label>
|
||||
<input type="text" value={form.billing_postalcode} onChange={e => updateField('billing_postalcode', e.target.value)}
|
||||
className={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>City</label>
|
||||
<input type="text" value={form.billing_city} onChange={e => updateField('billing_city', e.target.value)}
|
||||
className={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Country</label>
|
||||
<input type="text" value={form.billing_country} onChange={e => updateField('billing_country', e.target.value)}
|
||||
className={inputClass} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Shipping Address */}
|
||||
<div className={sectionClass}>
|
||||
<h3 className="text-lg font-semibold text-text-primary mb-3 flex items-center gap-2">
|
||||
Shipping Address
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setForm(prev => ({
|
||||
...prev,
|
||||
shipping_street: prev.billing_street,
|
||||
shipping_number: prev.billing_number,
|
||||
shipping_postalcode: prev.billing_postalcode,
|
||||
shipping_city: prev.billing_city,
|
||||
shipping_country: prev.billing_country,
|
||||
}));
|
||||
}}
|
||||
className="text-xs text-primary underline font-normal"
|
||||
>
|
||||
Copy from billing
|
||||
</button>
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="md:col-span-2">
|
||||
<label className={labelClass}>Street</label>
|
||||
<input type="text" value={form.shipping_street} onChange={e => updateField('shipping_street', e.target.value)}
|
||||
className={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Number</label>
|
||||
<input type="text" value={form.shipping_number} onChange={e => updateField('shipping_number', e.target.value)}
|
||||
className={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Postal Code</label>
|
||||
<input type="text" value={form.shipping_postalcode} onChange={e => updateField('shipping_postalcode', e.target.value)}
|
||||
className={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>City</label>
|
||||
<input type="text" value={form.shipping_city} onChange={e => updateField('shipping_city', e.target.value)}
|
||||
className={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Country</label>
|
||||
<input type="text" value={form.shipping_country} onChange={e => updateField('shipping_country', e.target.value)}
|
||||
className={inputClass} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
<div className={sectionClass}>
|
||||
<h3 className="text-lg font-semibold text-text-primary mb-3">Notes</h3>
|
||||
<textarea
|
||||
value={form.note}
|
||||
onChange={e => updateField('note', e.target.value)}
|
||||
rows={4}
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Submit */}
|
||||
<div className="flex items-center gap-4 pt-4 border-t border-border">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="px-6 py-2.5 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors disabled:opacity-50 font-medium"
|
||||
>
|
||||
{loading ? 'Saving...' : submitLabel}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => window.history.back()}
|
||||
className="px-4 py-2 bg-surface border border-border rounded-lg hover:bg-gray-50 text-sm"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
export interface CrewFormData {
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
role_title: string;
|
||||
hourly_rate: number | null;
|
||||
is_active: boolean;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
export const emptyCrewFormData: CrewFormData = {
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
role_title: '',
|
||||
hourly_rate: null,
|
||||
is_active: true,
|
||||
notes: '',
|
||||
};
|
||||
|
||||
interface CrewFormProps {
|
||||
initialData?: CrewFormData;
|
||||
onSubmit: (data: CrewFormData) => Promise<void>;
|
||||
submitLabel?: string;
|
||||
}
|
||||
|
||||
export default function CrewForm({ initialData, onSubmit, submitLabel = 'Save' }: CrewFormProps) {
|
||||
const [form, setForm] = useState<CrewFormData>(initialData || emptyCrewFormData);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
await onSubmit(form);
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.detail || 'Failed to save crew member');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const updateField = (field: keyof CrewFormData, value: any) => {
|
||||
setForm(prev => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
const inputClass = "w-full px-3 py-2 border border-border rounded-lg focus:ring-2 focus:ring-primary outline-none text-sm";
|
||||
const labelClass = "block text-sm font-medium text-text-primary mb-1";
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{error && (
|
||||
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm">{error}</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className={labelClass}>First Name *</label>
|
||||
<input type="text" value={form.first_name} onChange={e => updateField('first_name', e.target.value)}
|
||||
required className={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Last Name *</label>
|
||||
<input type="text" value={form.last_name} onChange={e => updateField('last_name', e.target.value)}
|
||||
required className={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Email</label>
|
||||
<input type="email" value={form.email} onChange={e => updateField('email', e.target.value)}
|
||||
className={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Phone</label>
|
||||
<input type="text" value={form.phone} onChange={e => updateField('phone', e.target.value)}
|
||||
className={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Role / Title</label>
|
||||
<input type="text" value={form.role_title} onChange={e => updateField('role_title', e.target.value)}
|
||||
className={inputClass} placeholder="e.g. Tontechniker" />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Hourly Rate (€)</label>
|
||||
<input type="number" step="0.50" value={form.hourly_rate ?? ''}
|
||||
onChange={e => updateField('hourly_rate', e.target.value ? parseFloat(e.target.value) : null)}
|
||||
className={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Status</label>
|
||||
<select value={form.is_active ? 'active' : 'inactive'}
|
||||
onChange={e => updateField('is_active', e.target.value === 'active')}
|
||||
className={inputClass}>
|
||||
<option value="active">Active</option>
|
||||
<option value="inactive">Inactive</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={labelClass}>Notes</label>
|
||||
<textarea value={form.notes} onChange={e => updateField('notes', e.target.value)}
|
||||
rows={3} className={inputClass} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 pt-4 border-t border-border">
|
||||
<button type="submit" disabled={loading}
|
||||
className="px-6 py-2.5 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors disabled:opacity-50 font-medium">
|
||||
{loading ? 'Saving...' : submitLabel}
|
||||
</button>
|
||||
<button type="button" onClick={() => window.history.back()}
|
||||
className="px-4 py-2 bg-surface border border-border rounded-lg hover:bg-gray-50 text-sm">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
interface LocationItem {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface EquipmentFormData {
|
||||
name: string;
|
||||
category: string;
|
||||
brand: string;
|
||||
serial_number: string;
|
||||
barcode: string;
|
||||
status: string;
|
||||
purchase_price: number | null;
|
||||
current_value: number | null;
|
||||
weight_kg: number | null;
|
||||
dimensions: string;
|
||||
power_watt: number | null;
|
||||
notes: string;
|
||||
location_id: string;
|
||||
}
|
||||
|
||||
export const emptyEquipmentFormData: EquipmentFormData = {
|
||||
name: '',
|
||||
category: '',
|
||||
brand: '',
|
||||
serial_number: '',
|
||||
barcode: '',
|
||||
status: 'available',
|
||||
purchase_price: null,
|
||||
current_value: null,
|
||||
weight_kg: null,
|
||||
dimensions: '',
|
||||
power_watt: null,
|
||||
notes: '',
|
||||
location_id: '',
|
||||
};
|
||||
|
||||
interface EquipmentFormProps {
|
||||
initialData?: EquipmentFormData;
|
||||
locations: LocationItem[];
|
||||
onSubmit: (data: EquipmentFormData) => Promise<void>;
|
||||
submitLabel?: string;
|
||||
}
|
||||
|
||||
export default function EquipmentForm({ initialData, locations, onSubmit, submitLabel = 'Save' }: EquipmentFormProps) {
|
||||
const [form, setForm] = useState<EquipmentFormData>(initialData || emptyEquipmentFormData);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
await onSubmit(form);
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.detail || 'Failed to save equipment');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const updateField = (field: keyof EquipmentFormData, value: any) => {
|
||||
setForm(prev => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
const inputClass = "w-full px-3 py-2 border border-border rounded-lg focus:ring-2 focus:ring-primary outline-none text-sm";
|
||||
const labelClass = "block text-sm font-medium text-text-primary mb-1";
|
||||
const sectionClass = "mb-6";
|
||||
|
||||
const statusOptions = [
|
||||
{ value: 'available', label: 'Available' },
|
||||
{ value: 'rented', label: 'Rented' },
|
||||
{ value: 'maintenance', label: 'In Maintenance' },
|
||||
{ value: 'retired', label: 'Retired' },
|
||||
];
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{error && (
|
||||
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm">{error}</div>
|
||||
)}
|
||||
|
||||
{/* Basic Info */}
|
||||
<div className={sectionClass}>
|
||||
<h3 className="text-lg font-semibold text-text-primary mb-3">Equipment Information</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="md:col-span-2">
|
||||
<label className={labelClass}>Name *</label>
|
||||
<input type="text" value={form.name} onChange={e => updateField('name', e.target.value)}
|
||||
required className={inputClass} placeholder="e.g. LED Par 64" />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Category</label>
|
||||
<input type="text" value={form.category} onChange={e => updateField('category', e.target.value)}
|
||||
className={inputClass} placeholder="e.g. Lighting, Audio" />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Brand</label>
|
||||
<input type="text" value={form.brand} onChange={e => updateField('brand', e.target.value)}
|
||||
className={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Serial Number</label>
|
||||
<input type="text" value={form.serial_number} onChange={e => updateField('serial_number', e.target.value)}
|
||||
className={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Barcode</label>
|
||||
<input type="text" value={form.barcode} onChange={e => updateField('barcode', e.target.value)}
|
||||
className={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Status</label>
|
||||
<select value={form.status} onChange={e => updateField('status', e.target.value)}
|
||||
className={inputClass}>
|
||||
{statusOptions.map(opt => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Location</label>
|
||||
<select value={form.location_id} onChange={e => updateField('location_id', e.target.value)}
|
||||
className={inputClass}>
|
||||
<option value="">No location</option>
|
||||
{locations.map(loc => (
|
||||
<option key={loc.id} value={loc.id}>{loc.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Financial & Physical */}
|
||||
<div className={sectionClass}>
|
||||
<h3 className="text-lg font-semibold text-text-primary mb-3">Financial & Physical Details</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className={labelClass}>Purchase Price (€)</label>
|
||||
<input type="number" step="0.01" value={form.purchase_price ?? ''}
|
||||
onChange={e => updateField('purchase_price', e.target.value ? parseFloat(e.target.value) : null)}
|
||||
className={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Current Value (€)</label>
|
||||
<input type="number" step="0.01" value={form.current_value ?? ''}
|
||||
onChange={e => updateField('current_value', e.target.value ? parseFloat(e.target.value) : null)}
|
||||
className={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Weight (kg)</label>
|
||||
<input type="number" step="0.1" value={form.weight_kg ?? ''}
|
||||
onChange={e => updateField('weight_kg', e.target.value ? parseFloat(e.target.value) : null)}
|
||||
className={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Dimensions</label>
|
||||
<input type="text" value={form.dimensions} onChange={e => updateField('dimensions', e.target.value)}
|
||||
className={inputClass} placeholder="e.g. 50x30x20 cm" />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Power (Watt)</label>
|
||||
<input type="number" step="1" value={form.power_watt ?? ''}
|
||||
onChange={e => updateField('power_watt', e.target.value ? parseFloat(e.target.value) : null)}
|
||||
className={inputClass} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
<div className={sectionClass}>
|
||||
<h3 className="text-lg font-semibold text-text-primary mb-3">Notes</h3>
|
||||
<textarea
|
||||
value={form.notes}
|
||||
onChange={e => updateField('notes', e.target.value)}
|
||||
rows={4}
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Submit */}
|
||||
<div className="flex items-center gap-4 pt-4 border-t border-border">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="px-6 py-2.5 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors disabled:opacity-50 font-medium"
|
||||
>
|
||||
{loading ? 'Saving...' : submitLabel}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => window.history.back()}
|
||||
className="px-4 py-2 bg-surface border border-border rounded-lg hover:bg-gray-50 text-sm"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,654 @@
|
||||
import { useState } from 'react';
|
||||
import api from '../services/api.ts';
|
||||
import {
|
||||
FolderOpen,
|
||||
ListChecks,
|
||||
Pencil,
|
||||
Trash2,
|
||||
Plus,
|
||||
Save,
|
||||
X,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
} from 'lucide-react';
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
interface FunctionGroupEditorProps {
|
||||
projectId: string;
|
||||
functionGroups: FunctionGroupItem[];
|
||||
functionsMap: Record<string, FunctionItem[]>;
|
||||
onReload: () => void;
|
||||
canWrite: boolean;
|
||||
canDelete: boolean;
|
||||
}
|
||||
|
||||
export default function FunctionGroupEditor({
|
||||
projectId,
|
||||
functionGroups,
|
||||
functionsMap,
|
||||
onReload,
|
||||
canWrite,
|
||||
canDelete,
|
||||
}: FunctionGroupEditorProps) {
|
||||
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
|
||||
const [editingGroupId, setEditingGroupId] = useState<string | null>(null);
|
||||
const [editingGroupName, setEditingGroupName] = useState('');
|
||||
const [editingFuncId, setEditingFuncId] = useState<string | null>(null);
|
||||
const [editingFuncData, setEditingFuncData] = useState<{
|
||||
name: string;
|
||||
quantity: number;
|
||||
daily_costs: number;
|
||||
total_costs: number;
|
||||
} | null>(null);
|
||||
const [newGroupName, setNewGroupName] = useState('');
|
||||
const [showNewGroup, setShowNewGroup] = useState(false);
|
||||
const [newFuncMap, setNewFuncMap] = useState<Record<string, boolean>>({});
|
||||
const [newFuncData, setNewFuncData] = useState<Record<string, {
|
||||
name: string;
|
||||
quantity: number;
|
||||
daily_costs: number;
|
||||
total_costs: number;
|
||||
}>>({});
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const toggleExpand = (fgId: string) => {
|
||||
setExpandedGroups(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(fgId)) {
|
||||
next.delete(fgId);
|
||||
} else {
|
||||
next.add(fgId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const startEditGroup = (fg: FunctionGroupItem) => {
|
||||
setEditingGroupId(fg.id);
|
||||
setEditingGroupName(fg.name);
|
||||
};
|
||||
|
||||
const cancelEditGroup = () => {
|
||||
setEditingGroupId(null);
|
||||
setEditingGroupName('');
|
||||
};
|
||||
|
||||
const saveGroup = async (fgId: string) => {
|
||||
if (!editingGroupName.trim()) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await api.put(`/projects/${projectId}/function-groups/${fgId}`, {
|
||||
name: editingGroupName.trim(),
|
||||
});
|
||||
setEditingGroupId(null);
|
||||
onReload();
|
||||
} catch (err: any) {
|
||||
alert(err.response?.data?.detail || 'Failed to update function group');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteGroup = async (fgId: string) => {
|
||||
if (!confirm('Delete this function group and all its functions?')) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await api.delete(`/projects/${projectId}/function-groups/${fgId}`);
|
||||
// Also remove from expanded set
|
||||
setExpandedGroups(prev => {
|
||||
const next = new Set(prev);
|
||||
next.delete(fgId);
|
||||
return next;
|
||||
});
|
||||
onReload();
|
||||
} catch (err: any) {
|
||||
alert(err.response?.data?.detail || 'Failed to delete function group');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const startEditFunc = (fn: FunctionItem) => {
|
||||
setEditingFuncId(fn.id);
|
||||
setEditingFuncData({
|
||||
name: fn.name,
|
||||
quantity: fn.quantity,
|
||||
daily_costs: fn.daily_costs,
|
||||
total_costs: fn.total_costs,
|
||||
});
|
||||
};
|
||||
|
||||
const cancelEditFunc = () => {
|
||||
setEditingFuncId(null);
|
||||
setEditingFuncData(null);
|
||||
};
|
||||
|
||||
const saveFunc = async (fgId: string, funcId: string) => {
|
||||
if (!editingFuncData || !editingFuncData.name.trim()) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await api.put(`/projects/${projectId}/function-groups/${fgId}/functions/${funcId}`, {
|
||||
name: editingFuncData.name.trim(),
|
||||
quantity: editingFuncData.quantity,
|
||||
daily_costs: editingFuncData.daily_costs,
|
||||
total_costs: editingFuncData.total_costs,
|
||||
});
|
||||
setEditingFuncId(null);
|
||||
setEditingFuncData(null);
|
||||
onReload();
|
||||
} catch (err: any) {
|
||||
alert(err.response?.data?.detail || 'Failed to update function');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteFunc = async (fgId: string, funcId: string) => {
|
||||
if (!confirm('Delete this function?')) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await api.delete(`/projects/${projectId}/function-groups/${fgId}/functions/${funcId}`);
|
||||
onReload();
|
||||
} catch (err: any) {
|
||||
alert(err.response?.data?.detail || 'Failed to delete function');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const addNewGroup = async () => {
|
||||
if (!newGroupName.trim()) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await api.post(`/projects/${projectId}/function-groups`, {
|
||||
name: newGroupName.trim(),
|
||||
sort_order: functionGroups.length,
|
||||
});
|
||||
setNewGroupName('');
|
||||
setShowNewGroup(false);
|
||||
onReload();
|
||||
} catch (err: any) {
|
||||
alert(err.response?.data?.detail || 'Failed to create function group');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const showNewFunc = (fgId: string) => {
|
||||
setNewFuncMap(prev => ({ ...prev, [fgId]: true }));
|
||||
setNewFuncData(prev => ({
|
||||
...prev,
|
||||
[fgId]: { name: '', quantity: 1, daily_costs: 0, total_costs: 0 },
|
||||
}));
|
||||
};
|
||||
|
||||
const cancelNewFunc = (fgId: string) => {
|
||||
setNewFuncMap(prev => {
|
||||
const next = { ...prev };
|
||||
delete next[fgId];
|
||||
return next;
|
||||
});
|
||||
setNewFuncData(prev => {
|
||||
const next = { ...prev };
|
||||
delete next[fgId];
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const saveNewFunc = async (fgId: string) => {
|
||||
const data = newFuncData[fgId];
|
||||
if (!data || !data.name.trim()) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await api.post(`/projects/${projectId}/function-groups/${fgId}/functions`, {
|
||||
name: data.name.trim(),
|
||||
quantity: data.quantity,
|
||||
daily_costs: data.daily_costs,
|
||||
total_costs: data.total_costs,
|
||||
sort_order: (functionsMap[fgId] || []).length,
|
||||
});
|
||||
cancelNewFunc(fgId);
|
||||
onReload();
|
||||
} catch (err: any) {
|
||||
alert(err.response?.data?.detail || 'Failed to create function');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const inputClass =
|
||||
'w-full px-2 py-1 border border-border rounded focus:ring-2 focus:ring-primary outline-none text-sm';
|
||||
const labelClass = 'text-xs text-text-secondary';
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{canWrite && (
|
||||
<div className="flex justify-end">
|
||||
{showNewGroup ? (
|
||||
<div className="flex items-center gap-2 w-full">
|
||||
<input
|
||||
type="text"
|
||||
value={newGroupName}
|
||||
onChange={e => setNewGroupName(e.target.value)}
|
||||
placeholder="New function group name..."
|
||||
className={inputClass}
|
||||
autoFocus
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') addNewGroup();
|
||||
if (e.key === 'Escape') {
|
||||
setShowNewGroup(false);
|
||||
setNewGroupName('');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={addNewGroup}
|
||||
disabled={saving || !newGroupName.trim()}
|
||||
className="flex items-center gap-1 px-3 py-1.5 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors text-xs disabled:opacity-50"
|
||||
>
|
||||
<Save size={14} />
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowNewGroup(false);
|
||||
setNewGroupName('');
|
||||
}}
|
||||
className="flex items-center gap-1 px-3 py-1.5 bg-surface border border-border rounded-lg hover:bg-gray-50 text-xs"
|
||||
>
|
||||
<X size={14} />
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setShowNewGroup(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors text-sm"
|
||||
>
|
||||
<Plus size={16} />
|
||||
Add Function Group
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{functionGroups.length === 0 && !showNewGroup && (
|
||||
<p className="text-text-secondary text-sm">No function groups defined yet.</p>
|
||||
)}
|
||||
|
||||
{functionGroups.map(fg => {
|
||||
const isExpanded = expandedGroups.has(fg.id);
|
||||
const funcs = functionsMap[fg.id] || [];
|
||||
const isEditingGroup = editingGroupId === fg.id;
|
||||
const isAddingFunc = newFuncMap[fg.id];
|
||||
|
||||
return (
|
||||
<div key={fg.id} className="bg-surface rounded-lg border border-border overflow-hidden">
|
||||
{/* Group Header */}
|
||||
<div className="px-4 py-3 bg-gray-50 border-b border-border flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => toggleExpand(fg.id)}
|
||||
className="flex items-center gap-1 text-text-secondary hover:text-text-primary transition-colors"
|
||||
>
|
||||
{isExpanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
|
||||
</button>
|
||||
<FolderOpen size={18} className="text-primary flex-shrink-0" />
|
||||
|
||||
{isEditingGroup ? (
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
<input
|
||||
type="text"
|
||||
value={editingGroupName}
|
||||
onChange={e => setEditingGroupName(e.target.value)}
|
||||
className={inputClass}
|
||||
autoFocus
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') saveGroup(fg.id);
|
||||
if (e.key === 'Escape') cancelEditGroup();
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={() => saveGroup(fg.id)}
|
||||
disabled={saving || !editingGroupName.trim()}
|
||||
className="flex items-center gap-1 px-2 py-1 bg-primary text-white rounded hover:bg-primary-dark transition-colors text-xs disabled:opacity-50"
|
||||
>
|
||||
<Save size={12} />
|
||||
</button>
|
||||
<button
|
||||
onClick={cancelEditGroup}
|
||||
className="flex items-center gap-1 px-2 py-1 border border-border rounded hover:bg-gray-50 text-xs"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<span className="font-medium text-text-primary text-sm flex-1">{fg.name}</span>
|
||||
)}
|
||||
|
||||
<span className="text-xs text-text-secondary">Sort: {fg.sort_order}</span>
|
||||
|
||||
{(canWrite || canDelete) && !isEditingGroup && (
|
||||
<div className="flex items-center gap-1 ml-2">
|
||||
{canWrite && (
|
||||
<button
|
||||
onClick={() => startEditGroup(fg)}
|
||||
className="p-1 text-text-secondary hover:text-primary transition-colors"
|
||||
title="Edit group name"
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</button>
|
||||
)}
|
||||
{canDelete && (
|
||||
<button
|
||||
onClick={() => deleteGroup(fg.id)}
|
||||
className="p-1 text-text-secondary hover:text-red-600 transition-colors"
|
||||
title="Delete group"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Functions List (accordion body) */}
|
||||
{isExpanded && (
|
||||
<div>
|
||||
{funcs.length === 0 && !isAddingFunc && (
|
||||
<p className="px-4 py-3 text-sm text-text-secondary">No functions in this group.</p>
|
||||
)}
|
||||
|
||||
{funcs.length > 0 && (
|
||||
<div className="divide-y divide-border">
|
||||
{funcs.map(fn => {
|
||||
const isEditingFunc = editingFuncId === fn.id;
|
||||
return (
|
||||
<div
|
||||
key={fn.id}
|
||||
className="px-4 py-3 flex items-center justify-between hover:bg-gray-50"
|
||||
>
|
||||
{isEditingFunc && editingFuncData ? (
|
||||
<div className="flex-1 grid grid-cols-4 gap-2 items-end">
|
||||
<div>
|
||||
<label className={labelClass}>Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editingFuncData.name}
|
||||
onChange={e =>
|
||||
setEditingFuncData(prev =>
|
||||
prev ? { ...prev, name: e.target.value } : prev
|
||||
)
|
||||
}
|
||||
className={inputClass}
|
||||
autoFocus
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') saveFunc(fg.id, fn.id);
|
||||
if (e.key === 'Escape') cancelEditFunc();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Qty</label>
|
||||
<input
|
||||
type="number"
|
||||
step="any"
|
||||
value={editingFuncData.quantity}
|
||||
onChange={e =>
|
||||
setEditingFuncData(prev =>
|
||||
prev
|
||||
? { ...prev, quantity: parseFloat(e.target.value) || 0 }
|
||||
: prev
|
||||
)
|
||||
}
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Daily €</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={editingFuncData.daily_costs}
|
||||
onChange={e =>
|
||||
setEditingFuncData(prev =>
|
||||
prev
|
||||
? {
|
||||
...prev,
|
||||
daily_costs: parseFloat(e.target.value) || 0,
|
||||
}
|
||||
: prev
|
||||
)
|
||||
}
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Total €</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={editingFuncData.total_costs}
|
||||
onChange={e =>
|
||||
setEditingFuncData(prev =>
|
||||
prev
|
||||
? {
|
||||
...prev,
|
||||
total_costs: parseFloat(e.target.value) || 0,
|
||||
}
|
||||
: prev
|
||||
)
|
||||
}
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-4 flex items-center gap-2 mt-1">
|
||||
<button
|
||||
onClick={() => saveFunc(fg.id, fn.id)}
|
||||
disabled={saving || !editingFuncData.name.trim()}
|
||||
className="flex items-center gap-1 px-3 py-1.5 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors text-xs disabled:opacity-50"
|
||||
>
|
||||
<Save size={14} />
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
onClick={cancelEditFunc}
|
||||
className="flex items-center gap-1 px-3 py-1.5 bg-surface border border-border rounded-lg hover:bg-gray-50 text-xs"
|
||||
>
|
||||
<X size={14} />
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center gap-3 flex-1">
|
||||
<ListChecks size={16} className="text-text-secondary flex-shrink-0" />
|
||||
<div className="grid grid-cols-4 gap-4 flex-1">
|
||||
<div>
|
||||
<p className="text-sm text-text-primary">{fn.name}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-text-secondary">
|
||||
Qty: {fn.quantity}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-text-secondary">
|
||||
Daily: €{fn.daily_costs.toFixed(2)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-text-secondary">
|
||||
Total: €{fn.total_costs.toFixed(2)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 ml-2">
|
||||
{(canWrite || canDelete) && (
|
||||
<>
|
||||
{canWrite && (
|
||||
<button
|
||||
onClick={() => startEditFunc(fn)}
|
||||
className="p-1 text-text-secondary hover:text-primary transition-colors"
|
||||
title="Edit function"
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</button>
|
||||
)}
|
||||
{canDelete && (
|
||||
<button
|
||||
onClick={() => deleteFunc(fg.id, fn.id)}
|
||||
className="p-1 text-text-secondary hover:text-red-600 transition-colors"
|
||||
title="Delete function"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<span className="text-xs text-text-secondary ml-1">
|
||||
#{fn.sort_order}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add new function form */}
|
||||
{isAddingFunc && (
|
||||
<div className="px-4 py-3 border-t border-border bg-gray-50">
|
||||
<div className="grid grid-cols-4 gap-2 items-end mb-2">
|
||||
<div>
|
||||
<label className={labelClass}>Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newFuncData[fg.id]?.name || ''}
|
||||
onChange={e =>
|
||||
setNewFuncData(prev => ({
|
||||
...prev,
|
||||
[fg.id]: {
|
||||
...prev[fg.id],
|
||||
name: e.target.value,
|
||||
},
|
||||
}))
|
||||
}
|
||||
className={inputClass}
|
||||
placeholder="Function name"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Qty</label>
|
||||
<input
|
||||
type="number"
|
||||
step="any"
|
||||
value={newFuncData[fg.id]?.quantity || 1}
|
||||
onChange={e =>
|
||||
setNewFuncData(prev => ({
|
||||
...prev,
|
||||
[fg.id]: {
|
||||
...prev[fg.id],
|
||||
quantity: parseFloat(e.target.value) || 1,
|
||||
},
|
||||
}))
|
||||
}
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Daily €</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={newFuncData[fg.id]?.daily_costs || 0}
|
||||
onChange={e =>
|
||||
setNewFuncData(prev => ({
|
||||
...prev,
|
||||
[fg.id]: {
|
||||
...prev[fg.id],
|
||||
daily_costs: parseFloat(e.target.value) || 0,
|
||||
},
|
||||
}))
|
||||
}
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Total €</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={newFuncData[fg.id]?.total_costs || 0}
|
||||
onChange={e =>
|
||||
setNewFuncData(prev => ({
|
||||
...prev,
|
||||
[fg.id]: {
|
||||
...prev[fg.id],
|
||||
total_costs: parseFloat(e.target.value) || 0,
|
||||
},
|
||||
}))
|
||||
}
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => saveNewFunc(fg.id)}
|
||||
disabled={saving || !newFuncData[fg.id]?.name?.trim()}
|
||||
className="flex items-center gap-1 px-3 py-1.5 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors text-xs disabled:opacity-50"
|
||||
>
|
||||
<Save size={14} />
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
onClick={() => cancelNewFunc(fg.id)}
|
||||
className="flex items-center gap-1 px-3 py-1.5 bg-surface border border-border rounded-lg hover:bg-gray-50 text-xs"
|
||||
>
|
||||
<X size={14} />
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{canWrite && !isAddingFunc && (
|
||||
<div className="px-4 py-2 border-t border-border">
|
||||
<button
|
||||
onClick={() => showNewFunc(fg.id)}
|
||||
className="flex items-center gap-1 text-xs text-primary hover:text-primary-dark transition-colors"
|
||||
>
|
||||
<Plus size={14} />
|
||||
Add Function
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
export interface ProjectFormData {
|
||||
name: string;
|
||||
description: string;
|
||||
status: string;
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
budget: number | null;
|
||||
total_costs: number | null;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
export const emptyProjectFormData: ProjectFormData = {
|
||||
name: '',
|
||||
description: '',
|
||||
status: 'draft',
|
||||
start_date: '',
|
||||
end_date: '',
|
||||
budget: null,
|
||||
total_costs: null,
|
||||
notes: '',
|
||||
};
|
||||
|
||||
interface ProjectFormProps {
|
||||
initialData?: ProjectFormData;
|
||||
onSubmit: (data: ProjectFormData) => Promise<void>;
|
||||
submitLabel?: string;
|
||||
}
|
||||
|
||||
export default function ProjectForm({ initialData, onSubmit, submitLabel = 'Save' }: ProjectFormProps) {
|
||||
const [form, setForm] = useState<ProjectFormData>(initialData || emptyProjectFormData);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
await onSubmit(form);
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.detail || 'Failed to save project');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const updateField = (field: keyof ProjectFormData, value: any) => {
|
||||
setForm(prev => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
const inputClass = "w-full px-3 py-2 border border-border rounded-lg focus:ring-2 focus:ring-primary outline-none text-sm";
|
||||
const labelClass = "block text-sm font-medium text-text-primary mb-1";
|
||||
const sectionClass = "mb-6";
|
||||
|
||||
const statusOptions = [
|
||||
{ value: 'draft', label: 'Draft' },
|
||||
{ value: 'confirmed', label: 'Confirmed' },
|
||||
{ value: 'in_progress', label: 'In Progress' },
|
||||
{ value: 'completed', label: 'Completed' },
|
||||
{ value: 'cancelled', label: 'Cancelled' },
|
||||
];
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{error && (
|
||||
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm">{error}</div>
|
||||
)}
|
||||
|
||||
{/* Basic Info */}
|
||||
<div className={sectionClass}>
|
||||
<h3 className="text-lg font-semibold text-text-primary mb-3">Project Information</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="md:col-span-2">
|
||||
<label className={labelClass}>Name *</label>
|
||||
<input type="text" value={form.name} onChange={e => updateField('name', e.target.value)}
|
||||
required className={inputClass} placeholder="e.g. Summer Festival 2026" />
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<label className={labelClass}>Description</label>
|
||||
<textarea
|
||||
value={form.description}
|
||||
onChange={e => updateField('description', e.target.value)}
|
||||
rows={3}
|
||||
className={inputClass}
|
||||
placeholder="Project description..."
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Status</label>
|
||||
<select value={form.status} onChange={e => updateField('status', e.target.value)}
|
||||
className={inputClass}>
|
||||
{statusOptions.map(opt => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Budget (€)</label>
|
||||
<input type="number" step="0.01" value={form.budget ?? ''}
|
||||
onChange={e => updateField('budget', e.target.value ? parseFloat(e.target.value) : null)}
|
||||
className={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Start Date</label>
|
||||
<input type="datetime-local" value={form.start_date}
|
||||
onChange={e => updateField('start_date', e.target.value)}
|
||||
className={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>End Date</label>
|
||||
<input type="datetime-local" value={form.end_date}
|
||||
onChange={e => updateField('end_date', e.target.value)}
|
||||
className={inputClass} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Financial */}
|
||||
<div className={sectionClass}>
|
||||
<h3 className="text-lg font-semibold text-text-primary mb-3">Financial Details</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className={labelClass}>Total Costs (€)</label>
|
||||
<input type="number" step="0.01" value={form.total_costs ?? ''}
|
||||
onChange={e => updateField('total_costs', e.target.value ? parseFloat(e.target.value) : null)}
|
||||
className={inputClass} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
<div className={sectionClass}>
|
||||
<h3 className="text-lg font-semibold text-text-primary mb-3">Notes</h3>
|
||||
<textarea
|
||||
value={form.notes}
|
||||
onChange={e => updateField('notes', e.target.value)}
|
||||
rows={4}
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Submit */}
|
||||
<div className="flex items-center gap-4 pt-4 border-t border-border">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="px-6 py-2.5 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors disabled:opacity-50 font-medium"
|
||||
>
|
||||
{loading ? 'Saving...' : submitLabel}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => window.history.back()}
|
||||
className="px-4 py-2 bg-surface border border-border rounded-lg hover:bg-gray-50 text-sm"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Navigate, Outlet } from 'react-router-dom';
|
||||
import { useAuthStore } from '../stores/authStore.ts';
|
||||
|
||||
interface ProtectedRouteProps {
|
||||
requiredPermission?: string;
|
||||
}
|
||||
|
||||
export default function ProtectedRoute({ requiredPermission }: ProtectedRouteProps) {
|
||||
const { isAuthenticated, user } = useAuthStore();
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
// Check permission if required
|
||||
if (requiredPermission && user) {
|
||||
const hasPermission = user.permissions?.includes(requiredPermission);
|
||||
if (!hasPermission) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-background">
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl font-bold text-text-primary mb-2">
|
||||
Access Denied
|
||||
</h1>
|
||||
<p className="text-text-secondary">
|
||||
You don't have permission to access this page.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return <Outlet />;
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
export interface VehicleFormData {
|
||||
name: string;
|
||||
license_plate: string;
|
||||
brand: string;
|
||||
model: string;
|
||||
year: number | null;
|
||||
color: string;
|
||||
vehicle_type: string;
|
||||
payload_capacity_kg: number | null;
|
||||
load_volume_m3: number | null;
|
||||
fuel_type: string;
|
||||
is_active: boolean;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
export const emptyVehicleFormData: VehicleFormData = {
|
||||
name: '',
|
||||
license_plate: '',
|
||||
brand: '',
|
||||
model: '',
|
||||
year: null,
|
||||
color: '',
|
||||
vehicle_type: '',
|
||||
payload_capacity_kg: null,
|
||||
load_volume_m3: null,
|
||||
fuel_type: '',
|
||||
is_active: true,
|
||||
notes: '',
|
||||
};
|
||||
|
||||
interface VehicleFormProps {
|
||||
initialData?: VehicleFormData;
|
||||
onSubmit: (data: VehicleFormData) => Promise<void>;
|
||||
submitLabel?: string;
|
||||
}
|
||||
|
||||
export default function VehicleForm({ initialData, onSubmit, submitLabel = 'Save' }: VehicleFormProps) {
|
||||
const [form, setForm] = useState<VehicleFormData>(initialData || emptyVehicleFormData);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
try { await onSubmit(form); }
|
||||
catch (err: any) { setError(err.response?.data?.detail || 'Failed to save vehicle'); }
|
||||
finally { setLoading(false); }
|
||||
};
|
||||
|
||||
const u = (field: keyof VehicleFormData, value: any) => setForm(p => ({ ...p, [field]: value }));
|
||||
|
||||
const inputClass = "w-full px-3 py-2 border border-border rounded-lg focus:ring-2 focus:ring-primary outline-none text-sm";
|
||||
const labelClass = "block text-sm font-medium text-text-primary mb-1";
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{error && <div className="p-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm">{error}</div>}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="md:col-span-2">
|
||||
<label className={labelClass}>Vehicle Name *</label>
|
||||
<input type="text" value={form.name} onChange={e => u('name', e.target.value)} required className={inputClass} placeholder="e.g. Sprinter 315 CDI" />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>License Plate</label>
|
||||
<input type="text" value={form.license_plate} onChange={e => u('license_plate', e.target.value)} className={inputClass} placeholder="e.g. GÜ-AB 1234" />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Brand</label>
|
||||
<input type="text" value={form.brand} onChange={e => u('brand', e.target.value)} className={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Model</label>
|
||||
<input type="text" value={form.model} onChange={e => u('model', e.target.value)} className={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Year</label>
|
||||
<input type="number" value={form.year ?? ''} onChange={e => u('year', e.target.value ? parseInt(e.target.value) : null)} className={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Color</label>
|
||||
<input type="text" value={form.color} onChange={e => u('color', e.target.value)} className={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Vehicle Type</label>
|
||||
<select value={form.vehicle_type} onChange={e => u('vehicle_type', e.target.value)} className={inputClass}>
|
||||
<option value="">Select type...</option>
|
||||
<option value="van">Van</option>
|
||||
<option value="truck">Truck</option>
|
||||
<option value="trailer">Trailer</option>
|
||||
<option value="car">Car</option>
|
||||
<option value="other">Other</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Fuel Type</label>
|
||||
<select value={form.fuel_type} onChange={e => u('fuel_type', e.target.value)} className={inputClass}>
|
||||
<option value="">Select...</option>
|
||||
<option value="diesel">Diesel</option>
|
||||
<option value="petrol">Petrol</option>
|
||||
<option value="electric">Electric</option>
|
||||
<option value="hybrid">Hybrid</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Payload Capacity (kg)</label>
|
||||
<input type="number" step="10" value={form.payload_capacity_kg ?? ''} onChange={e => u('payload_capacity_kg', e.target.value ? parseFloat(e.target.value) : null)} className={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Load Volume (m³)</label>
|
||||
<input type="number" step="0.5" value={form.load_volume_m3 ?? ''} onChange={e => u('load_volume_m3', e.target.value ? parseFloat(e.target.value) : null)} className={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Status</label>
|
||||
<select value={form.is_active ? 'active' : 'inactive'} onChange={e => u('is_active', e.target.value === 'active')} className={inputClass}>
|
||||
<option value="active">Active</option>
|
||||
<option value="inactive">Inactive</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={labelClass}>Notes</label>
|
||||
<textarea value={form.notes} onChange={e => u('notes', e.target.value)} rows={3} className={inputClass} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 pt-4 border-t border-border">
|
||||
<button type="submit" disabled={loading} className="px-6 py-2.5 bg-primary text-white rounded-lg hover:bg-primary-dark disabled:opacity-50 font-medium">
|
||||
{loading ? 'Saving...' : submitLabel}
|
||||
</button>
|
||||
<button type="button" onClick={() => window.history.back()} className="px-4 py-2 bg-surface border border-border rounded-lg hover:bg-gray-50 text-sm">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
--color-primary: #6366f1;
|
||||
--color-primary-dark: #4f46e5;
|
||||
--color-secondary: #10b981;
|
||||
--color-accent: #f59e0b;
|
||||
--color-background: #f8fafc;
|
||||
--color-surface: #ffffff;
|
||||
--color-text-primary: #1e293b;
|
||||
--color-text-secondary: #64748b;
|
||||
--color-border: #e2e8f0;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family:
|
||||
"Inter",
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
"Segoe UI",
|
||||
Roboto,
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
background-color: var(--color-background);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
#root {
|
||||
min-height: 100vh;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import App from './App.tsx';
|
||||
import { useAuthStore } from './stores/authStore.ts';
|
||||
import './index.css';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 1000 * 60 * 5,
|
||||
retry: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Initialize auth store from localStorage before rendering
|
||||
useAuthStore.getState().initialize();
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,270 @@
|
||||
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, Building2, User, Mail, Phone, Smartphone, Globe, MapPin, FileText, Hash } from 'lucide-react';
|
||||
|
||||
interface TagItem {
|
||||
id: string;
|
||||
name: string;
|
||||
color: string | null;
|
||||
}
|
||||
|
||||
interface ContactDetailData {
|
||||
id: string;
|
||||
account_id: string;
|
||||
type: string;
|
||||
company_name: string | null;
|
||||
first_name: string | null;
|
||||
last_name: string | null;
|
||||
email: string | null;
|
||||
phone: string | null;
|
||||
mobile: string | null;
|
||||
website: string | null;
|
||||
billing_street: string | null;
|
||||
billing_number: string | null;
|
||||
billing_postalcode: string | null;
|
||||
billing_city: string | null;
|
||||
billing_country: string | null;
|
||||
shipping_street: string | null;
|
||||
shipping_number: string | null;
|
||||
shipping_postalcode: string | null;
|
||||
shipping_city: string | null;
|
||||
shipping_country: string | null;
|
||||
tax_number: string | null;
|
||||
note: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
tags: TagItem[];
|
||||
}
|
||||
|
||||
export default function ContactDetail() {
|
||||
const { contactId } = useParams<{ contactId: string }>();
|
||||
const { user } = useAuthStore();
|
||||
const navigate = useNavigate();
|
||||
const [contact, setContact] = useState<ContactDetailData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const canWrite = user?.permissions?.includes('contacts:write');
|
||||
const canDelete = user?.permissions?.includes('contacts:delete');
|
||||
|
||||
useEffect(() => {
|
||||
loadContact();
|
||||
}, [contactId]);
|
||||
|
||||
const loadContact = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await api.get(`/contacts/${contactId}`);
|
||||
setContact(response.data);
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.detail || 'Failed to load contact');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
const displayName = contact?.type === 'company'
|
||||
? contact?.company_name
|
||||
: [contact?.first_name, contact?.last_name].filter(Boolean).join(' ');
|
||||
if (!confirm(`Delete contact "${displayName}"?`)) return;
|
||||
try {
|
||||
await api.delete(`/contacts/${contactId}`);
|
||||
navigate('/contacts');
|
||||
} catch (err: any) {
|
||||
alert(err.response?.data?.detail || 'Failed to delete contact');
|
||||
}
|
||||
};
|
||||
|
||||
const getDisplayName = (): string => {
|
||||
if (!contact) return '';
|
||||
if (contact.type === 'company') return contact.company_name || 'Unnamed Company';
|
||||
return [contact.first_name, contact.last_name].filter(Boolean).join(' ') || 'Unnamed Person';
|
||||
};
|
||||
|
||||
const formatAddress = (prefix: 'billing' | 'shipping'): string => {
|
||||
if (!contact) return '';
|
||||
const parts = [
|
||||
contact[`${prefix}_street`],
|
||||
contact[`${prefix}_number`],
|
||||
].filter(Boolean).join(' ');
|
||||
const cityParts = [
|
||||
contact[`${prefix}_postalcode`],
|
||||
contact[`${prefix}_city`],
|
||||
].filter(Boolean).join(' ');
|
||||
const country = contact[`${prefix}_country`];
|
||||
return [parts, cityParts, country].filter(Boolean).join(', ') || '-';
|
||||
};
|
||||
|
||||
if (loading) return <div className="p-6"><p className="text-text-secondary">Loading contact...</p></div>;
|
||||
if (error) return <div className="p-6"><p className="text-red-500">{error}</p></div>;
|
||||
if (!contact) return <div className="p-6"><p className="text-text-secondary">Contact not found.</p></div>;
|
||||
|
||||
const sectionClass = "mb-8";
|
||||
const headingClass = "text-lg font-semibold text-text-primary mb-3";
|
||||
const fieldRowClass = "flex items-start gap-3 py-2";
|
||||
const iconClass = "w-5 h-5 text-text-secondary mt-0.5 flex-shrink-0";
|
||||
const labelClass = "text-sm text-text-secondary min-w-[80px]";
|
||||
const valueClass = "text-sm text-text-primary";
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-4xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<button
|
||||
onClick={() => navigate('/contacts')}
|
||||
className="flex items-center gap-2 text-text-secondary hover:text-text-primary transition-colors"
|
||||
>
|
||||
<ArrowLeft size={20} />
|
||||
<span className="text-sm">Back to Contacts</span>
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
{canWrite && (
|
||||
<button
|
||||
onClick={() => navigate(`/contacts/${contact.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">{getDisplayName()}</h1>
|
||||
<span className={`inline-block mt-2 px-3 py-1 rounded-full text-xs font-medium ${
|
||||
contact.type === 'company' ? 'bg-blue-100 text-blue-800' : 'bg-purple-100 text-purple-800'
|
||||
}`}>
|
||||
{contact.type === 'company' ? 'Company' : 'Person'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
{contact.tags.length > 0 && (
|
||||
<div className={sectionClass}>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{contact.tags.map(tag => (
|
||||
<span
|
||||
key={tag.id}
|
||||
className="inline-flex items-center px-3 py-1 rounded-full text-xs font-medium text-white"
|
||||
style={{ backgroundColor: tag.color || '#6b7280' }}
|
||||
>
|
||||
{tag.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Contact Info */}
|
||||
<div className={sectionClass}>
|
||||
<h2 className={headingClass}>Contact Information</h2>
|
||||
<div className="bg-surface rounded-lg border border-border p-4 space-y-1">
|
||||
{contact.email && (
|
||||
<div className={fieldRowClass}>
|
||||
<Mail className={iconClass} />
|
||||
<span className={labelClass}>Email</span>
|
||||
<a href={`mailto:${contact.email}`} className={`${valueClass} text-primary hover:underline`}>{contact.email}</a>
|
||||
</div>
|
||||
)}
|
||||
{contact.phone && (
|
||||
<div className={fieldRowClass}>
|
||||
<Phone className={iconClass} />
|
||||
<span className={labelClass}>Phone</span>
|
||||
<span className={valueClass}>{contact.phone}</span>
|
||||
</div>
|
||||
)}
|
||||
{contact.mobile && (
|
||||
<div className={fieldRowClass}>
|
||||
<Smartphone className={iconClass} />
|
||||
<span className={labelClass}>Mobile</span>
|
||||
<span className={valueClass}>{contact.mobile}</span>
|
||||
</div>
|
||||
)}
|
||||
{contact.website && (
|
||||
<div className={fieldRowClass}>
|
||||
<Globe className={iconClass} />
|
||||
<span className={labelClass}>Website</span>
|
||||
<a href={contact.website.startsWith('http') ? contact.website : `https://${contact.website}`}
|
||||
target="_blank" rel="noopener noreferrer" className={`${valueClass} text-primary hover:underline`}>
|
||||
{contact.website}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{contact.tax_number && (
|
||||
<div className={fieldRowClass}>
|
||||
<Hash className={iconClass} />
|
||||
<span className={labelClass}>Tax Number</span>
|
||||
<span className={valueClass}>{contact.tax_number}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Addresses */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
|
||||
<div>
|
||||
<h2 className={headingClass}>Billing Address</h2>
|
||||
<div className="bg-surface rounded-lg border border-border p-4">
|
||||
<div className={fieldRowClass}>
|
||||
<MapPin className={iconClass} />
|
||||
<span className={valueClass}>{formatAddress('billing')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className={headingClass}>Shipping Address</h2>
|
||||
<div className="bg-surface rounded-lg border border-border p-4">
|
||||
<div className={fieldRowClass}>
|
||||
<MapPin className={iconClass} />
|
||||
<span className={valueClass}>{formatAddress('shipping')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
{contact.note && (
|
||||
<div className={sectionClass}>
|
||||
<h2 className={headingClass}>Notes</h2>
|
||||
<div className="bg-surface rounded-lg border border-border p-4">
|
||||
<div className={fieldRowClass}>
|
||||
<FileText className={iconClass} />
|
||||
<span className={valueClass} style={{ whiteSpace: 'pre-wrap' }}>{contact.note}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Linked Projects (placeholder) */}
|
||||
<div className={sectionClass}>
|
||||
<h2 className={headingClass}>Linked Projects</h2>
|
||||
<div className="bg-surface rounded-lg border border-border p-4">
|
||||
<p className="text-sm text-text-secondary italic">
|
||||
Project linking will be available in a future update.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Metadata */}
|
||||
<div className="mt-8 pt-4 border-t border-border text-xs text-text-secondary space-y-1">
|
||||
<p>Created: {new Date(contact.created_at).toLocaleString()}</p>
|
||||
<p>Updated: {new Date(contact.updated_at).toLocaleString()}</p>
|
||||
<p>ID: {contact.id}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuthStore } from '../stores/authStore.ts';
|
||||
import api from '../services/api.ts';
|
||||
import { Plus, Search, Pencil, Trash2, Eye } from 'lucide-react';
|
||||
|
||||
interface ContactItem {
|
||||
id: string;
|
||||
account_id: string;
|
||||
type: string;
|
||||
company_name: string | null;
|
||||
first_name: string | null;
|
||||
last_name: string | null;
|
||||
email: string | null;
|
||||
phone: string | null;
|
||||
mobile: string | null;
|
||||
tags: { id: string; name: string; color: string | null }[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export default function Contacts() {
|
||||
const { user } = useAuthStore();
|
||||
const navigate = useNavigate();
|
||||
const [contacts, setContacts] = useState<ContactItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [search, setSearch] = useState('');
|
||||
const [typeFilter, setTypeFilter] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
const [total, setTotal] = useState(0);
|
||||
const size = 20;
|
||||
|
||||
const canWrite = user?.permissions?.includes('contacts:write');
|
||||
const canDelete = user?.permissions?.includes('contacts:delete');
|
||||
|
||||
const fetchContacts = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const params = new URLSearchParams({ page: String(page), size: String(size) });
|
||||
if (search) params.set('search', search);
|
||||
if (typeFilter) params.set('type', typeFilter);
|
||||
const response = await api.get(`/contacts?${params.toString()}`);
|
||||
setContacts(response.data.items);
|
||||
setTotal(response.data.total);
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.detail || 'Failed to load contacts');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchContacts();
|
||||
}, [page, typeFilter]);
|
||||
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setPage(1);
|
||||
fetchContacts();
|
||||
};
|
||||
|
||||
const handleDelete = async (contactId: string, contactName: string) => {
|
||||
if (!confirm(`Delete contact "${contactName}"?`)) return;
|
||||
try {
|
||||
await api.delete(`/contacts/${contactId}`);
|
||||
fetchContacts();
|
||||
} catch (err: any) {
|
||||
alert(err.response?.data?.detail || 'Failed to delete contact');
|
||||
}
|
||||
};
|
||||
|
||||
const getDisplayName = (c: ContactItem): string => {
|
||||
if (c.type === 'company') return c.company_name || 'Unnamed Company';
|
||||
return [c.first_name, c.last_name].filter(Boolean).join(' ') || 'Unnamed Person';
|
||||
};
|
||||
|
||||
const totalPages = Math.ceil(total / size);
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold text-text-primary">Contacts</h1>
|
||||
{canWrite && (
|
||||
<button
|
||||
onClick={() => navigate('/contacts/new')}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors"
|
||||
>
|
||||
<Plus size={18} />
|
||||
Add Contact
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Search and Filter Bar */}
|
||||
<div className="mb-4 flex flex-col sm:flex-row gap-3">
|
||||
<form onSubmit={handleSearch} className="flex-1 flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search size={18} className="absolute left-3 top-1/2 -translate-y-1/2 text-text-secondary" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by name, email, phone..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full pl-10 pr-3 py-2 border border-border rounded-lg focus:ring-2 focus:ring-primary outline-none"
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" className="px-4 py-2 bg-surface border border-border rounded-lg hover:bg-gray-50 text-sm">
|
||||
Search
|
||||
</button>
|
||||
</form>
|
||||
<select
|
||||
value={typeFilter}
|
||||
onChange={(e) => { setTypeFilter(e.target.value); setPage(1); }}
|
||||
className="px-3 py-2 border border-border rounded-lg focus:ring-2 focus:ring-primary outline-none text-sm"
|
||||
>
|
||||
<option value="">All Types</option>
|
||||
<option value="company">Companies</option>
|
||||
<option value="person">Persons</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
{loading ? (
|
||||
<p className="text-text-secondary">Loading contacts...</p>
|
||||
) : error ? (
|
||||
<p className="text-red-500">{error}</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="bg-surface rounded-lg border border-border overflow-hidden">
|
||||
<table className="w-full text-left">
|
||||
<thead className="bg-gray-50 border-b border-border">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-sm font-medium text-text-secondary">Name</th>
|
||||
<th className="px-4 py-3 text-sm font-medium text-text-secondary">Type</th>
|
||||
<th className="px-4 py-3 text-sm font-medium text-text-secondary">Email</th>
|
||||
<th className="px-4 py-3 text-sm font-medium text-text-secondary">Phone</th>
|
||||
<th className="px-4 py-3 text-sm font-medium text-text-secondary">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{contacts.map((c) => (
|
||||
<tr key={c.id} className="border-b border-border last:border-0 hover:bg-gray-50">
|
||||
<td className="px-4 py-3 text-text-primary font-medium">
|
||||
{getDisplayName(c)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||
c.type === 'company' ? 'bg-blue-100 text-blue-800' : 'bg-purple-100 text-purple-800'
|
||||
}`}>
|
||||
{c.type === 'company' ? 'Company' : 'Person'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-text-secondary">
|
||||
{c.email || '-'}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-text-secondary">
|
||||
{c.phone || c.mobile || '-'}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => navigate(`/contacts/${c.id}`)}
|
||||
className="p-1.5 text-text-secondary hover:text-primary rounded hover:bg-primary/5"
|
||||
title="View"
|
||||
>
|
||||
<Eye size={16} />
|
||||
</button>
|
||||
{canWrite && (
|
||||
<button
|
||||
onClick={() => navigate(`/contacts/${c.id}/edit`)}
|
||||
className="p-1.5 text-text-secondary hover:text-primary rounded hover:bg-primary/5"
|
||||
title="Edit"
|
||||
>
|
||||
<Pencil size={16} />
|
||||
</button>
|
||||
)}
|
||||
{canDelete && (
|
||||
<button
|
||||
onClick={() => handleDelete(c.id, getDisplayName(c))}
|
||||
className="p-1.5 text-text-secondary hover:text-red-600 rounded hover:bg-red-50"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{contacts.length === 0 && (
|
||||
<p className="px-4 py-6 text-text-secondary text-center">No contacts found.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-center gap-2 mt-4">
|
||||
<button
|
||||
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||
disabled={page === 1}
|
||||
className="px-3 py-1 border border-border rounded disabled:opacity-50"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<span className="text-sm text-text-secondary">
|
||||
Page {page} of {totalPages}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
|
||||
disabled={page === totalPages}
|
||||
className="px-3 py-1 border border-border rounded disabled:opacity-50"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import ContactForm, { ContactFormData, emptyFormData } from '../components/ContactForm.tsx';
|
||||
import api from '../services/api.ts';
|
||||
|
||||
export default function ContactsEdit() {
|
||||
const { contactId } = useParams<{ contactId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [initialData, setInitialData] = useState<ContactFormData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadContact();
|
||||
}, [contactId]);
|
||||
|
||||
const loadContact = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await api.get(`/contacts/${contactId}`);
|
||||
const c = response.data;
|
||||
setInitialData({
|
||||
type: c.type,
|
||||
company_name: c.company_name || '',
|
||||
first_name: c.first_name || '',
|
||||
last_name: c.last_name || '',
|
||||
email: c.email || '',
|
||||
phone: c.phone || '',
|
||||
mobile: c.mobile || '',
|
||||
website: c.website || '',
|
||||
billing_street: c.billing_street || '',
|
||||
billing_number: c.billing_number || '',
|
||||
billing_postalcode: c.billing_postalcode || '',
|
||||
billing_city: c.billing_city || '',
|
||||
billing_country: c.billing_country || '',
|
||||
shipping_street: c.shipping_street || '',
|
||||
shipping_number: c.shipping_number || '',
|
||||
shipping_postalcode: c.shipping_postalcode || '',
|
||||
shipping_city: c.shipping_city || '',
|
||||
shipping_country: c.shipping_country || '',
|
||||
tax_number: c.tax_number || '',
|
||||
note: c.note || '',
|
||||
tag_ids: c.tags?.map((t: any) => t.id) || [],
|
||||
});
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.detail || 'Failed to load contact');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdate = async (formData: ContactFormData) => {
|
||||
await api.put(`/contacts/${contactId}`, formData);
|
||||
navigate(`/contacts/${contactId}`);
|
||||
};
|
||||
|
||||
if (loading) return <div className="p-6"><p className="text-text-secondary">Loading contact...</p></div>;
|
||||
if (error) return <div className="p-6"><p className="text-red-500">{error}</p></div>;
|
||||
if (!initialData) return <div className="p-6"><p className="text-text-secondary">Contact not found.</p></div>;
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-3xl mx-auto">
|
||||
<h1 className="text-2xl font-bold text-text-primary mb-6">Edit Contact</h1>
|
||||
<ContactForm
|
||||
initialData={initialData}
|
||||
onSubmit={handleUpdate}
|
||||
submitLabel="Save Changes"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import ContactForm, { emptyFormData, ContactFormData } from '../components/ContactForm.tsx';
|
||||
import api from '../services/api.ts';
|
||||
|
||||
export default function ContactsNew() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleCreate = async (formData: ContactFormData) => {
|
||||
await api.post('/contacts', formData);
|
||||
navigate('/contacts');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-3xl mx-auto">
|
||||
<h1 className="text-2xl font-bold text-text-primary mb-6">Create Contact</h1>
|
||||
<ContactForm
|
||||
initialData={emptyFormData}
|
||||
onSubmit={handleCreate}
|
||||
submitLabel="Create Contact"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuthStore } from '../stores/authStore.ts';
|
||||
import api from '../services/api.ts';
|
||||
import { Plus, Search, Pencil, Trash2, Eye } from 'lucide-react';
|
||||
|
||||
interface CrewItem {
|
||||
id: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
email: string | null;
|
||||
phone: string | null;
|
||||
role_title: string | null;
|
||||
hourly_rate: number | null;
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
export default function CrewPage() {
|
||||
const { user } = useAuthStore();
|
||||
const navigate = useNavigate();
|
||||
const [items, setItems] = useState<CrewItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [search, setSearch] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
const [total, setTotal] = useState(0);
|
||||
const size = 20;
|
||||
|
||||
const canWrite = user?.permissions?.includes('crew:write');
|
||||
const canDelete = user?.permissions?.includes('crew:delete');
|
||||
|
||||
const fetchItems = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const params = new URLSearchParams({ page: String(page), size: String(size) });
|
||||
if (search) params.set('search', search);
|
||||
const response = await api.get(`/crew?${params.toString()}`);
|
||||
setItems(response.data.items);
|
||||
setTotal(response.data.total);
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.detail || 'Failed to load crew');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => { fetchItems(); }, [page]);
|
||||
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setPage(1);
|
||||
fetchItems();
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string, name: string) => {
|
||||
if (!confirm(`Delete crew member "${name}"?`)) return;
|
||||
try {
|
||||
await api.delete(`/crew/${id}`);
|
||||
fetchItems();
|
||||
} catch (err: any) {
|
||||
alert(err.response?.data?.detail || 'Failed to delete');
|
||||
}
|
||||
};
|
||||
|
||||
const totalPages = Math.ceil(total / size);
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold text-text-primary">Crew</h1>
|
||||
{canWrite && (
|
||||
<button onClick={() => navigate('/crew/new')}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors">
|
||||
<Plus size={18} /> Add Member
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mb-4 flex gap-3">
|
||||
<form onSubmit={handleSearch} className="flex-1 flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search size={18} className="absolute left-3 top-1/2 -translate-y-1/2 text-text-secondary" />
|
||||
<input type="text" placeholder="Search by name, email, role..." value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full pl-10 pr-3 py-2 border border-border rounded-lg focus:ring-2 focus:ring-primary outline-none" />
|
||||
</div>
|
||||
<button type="submit" className="px-4 py-2 bg-surface border border-border rounded-lg hover:bg-gray-50 text-sm">Search</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<p className="text-text-secondary">Loading crew...</p>
|
||||
) : error ? (
|
||||
<p className="text-red-500">{error}</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="bg-surface rounded-lg border border-border overflow-hidden">
|
||||
<table className="w-full text-left">
|
||||
<thead className="bg-gray-50 border-b border-border">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-sm font-medium text-text-secondary">Name</th>
|
||||
<th className="px-4 py-3 text-sm font-medium text-text-secondary">Role</th>
|
||||
<th className="px-4 py-3 text-sm font-medium text-text-secondary">Email</th>
|
||||
<th className="px-4 py-3 text-sm font-medium text-text-secondary">Phone</th>
|
||||
<th className="px-4 py-3 text-sm font-medium text-text-secondary">Rate</th>
|
||||
<th className="px-4 py-3 text-sm font-medium text-text-secondary">Status</th>
|
||||
<th className="px-4 py-3 text-sm font-medium text-text-secondary">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((m) => (
|
||||
<tr key={m.id} className="border-b border-border last:border-0 hover:bg-gray-50">
|
||||
<td className="px-4 py-3 text-text-primary font-medium">{m.first_name} {m.last_name}</td>
|
||||
<td className="px-4 py-3 text-text-secondary">{m.role_title || '-'}</td>
|
||||
<td className="px-4 py-3 text-text-secondary">{m.email || '-'}</td>
|
||||
<td className="px-4 py-3 text-text-secondary">{m.phone || '-'}</td>
|
||||
<td className="px-4 py-3 text-text-secondary">{m.hourly_rate != null ? `€${m.hourly_rate.toFixed(2)}` : '-'}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${m.is_active ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-500'}`}>
|
||||
{m.is_active ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={() => navigate(`/crew/${m.id}`)}
|
||||
className="p-1.5 text-text-secondary hover:text-primary rounded hover:bg-primary/5" title="View">
|
||||
<Eye size={16} />
|
||||
</button>
|
||||
{canWrite && (
|
||||
<button onClick={() => navigate(`/crew/${m.id}/edit`)}
|
||||
className="p-1.5 text-text-secondary hover:text-primary rounded hover:bg-primary/5" title="Edit">
|
||||
<Pencil size={16} />
|
||||
</button>
|
||||
)}
|
||||
{canDelete && (
|
||||
<button onClick={() => handleDelete(m.id, `${m.first_name} ${m.last_name}`)}
|
||||
className="p-1.5 text-text-secondary hover:text-red-600 rounded hover:bg-red-50" title="Delete">
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{items.length === 0 && <p className="px-4 py-6 text-text-secondary text-center">No crew members found.</p>}
|
||||
</div>
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-center gap-2 mt-4">
|
||||
<button onClick={() => setPage(p => Math.max(1, p-1))} disabled={page===1}
|
||||
className="px-3 py-1 border border-border rounded disabled:opacity-50">Previous</button>
|
||||
<span className="text-sm text-text-secondary">Page {page} of {totalPages}</span>
|
||||
<button onClick={() => setPage(p => Math.min(totalPages, p+1))} disabled={page===totalPages}
|
||||
className="px-3 py-1 border border-border rounded disabled:opacity-50">Next</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
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, User, Mail, Phone, Briefcase, Euro, Calendar } from 'lucide-react';
|
||||
|
||||
interface AvailabilityItem {
|
||||
id: string;
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
status: string;
|
||||
notes: string | null;
|
||||
}
|
||||
|
||||
interface CrewDetailData {
|
||||
id: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
email: string | null;
|
||||
phone: string | null;
|
||||
role_title: string | null;
|
||||
hourly_rate: number | null;
|
||||
is_active: boolean;
|
||||
notes: string | null;
|
||||
availabilities: AvailabilityItem[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export default function CrewDetail() {
|
||||
const { memberId } = useParams<{ memberId: string }>();
|
||||
const { user } = useAuthStore();
|
||||
const navigate = useNavigate();
|
||||
const [member, setMember] = useState<CrewDetailData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const canWrite = user?.permissions?.includes('crew:write');
|
||||
const canDelete = user?.permissions?.includes('crew:delete');
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const response = await api.get(`/crew/${memberId}`);
|
||||
setMember(response.data);
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.detail || 'Failed to load');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
})();
|
||||
}, [memberId]);
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!confirm(`Delete ${member?.first_name} ${member?.last_name}?`)) return;
|
||||
try {
|
||||
await api.delete(`/crew/${memberId}`);
|
||||
navigate('/crew');
|
||||
} catch (err: any) {
|
||||
alert(err.response?.data?.detail || 'Failed to delete');
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <div className="p-6"><p className="text-text-secondary">Loading...</p></div>;
|
||||
if (error) return <div className="p-6"><p className="text-red-500">{error}</p></div>;
|
||||
if (!member) return <div className="p-6"><p className="text-text-secondary">Not found.</p></div>;
|
||||
|
||||
const iconClass = "w-5 h-5 text-text-secondary mt-0.5 flex-shrink-0";
|
||||
const labelClass = "text-sm text-text-secondary min-w-[100px]";
|
||||
const valueClass = "text-sm text-text-primary";
|
||||
const fieldRowClass = "flex items-start gap-3 py-2";
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-4xl mx-auto">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<button onClick={() => navigate('/crew')}
|
||||
className="flex items-center gap-2 text-text-secondary hover:text-text-primary transition-colors">
|
||||
<ArrowLeft size={20} /> <span className="text-sm">Back to Crew</span>
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
{canWrite && (
|
||||
<button onClick={() => navigate(`/crew/${member.id}/edit`)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-dark 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 text-sm">
|
||||
<Trash2 size={16} /> Delete
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-text-primary">{member.first_name} {member.last_name}</h1>
|
||||
<span className={`inline-block mt-2 px-3 py-1 rounded-full text-xs font-medium ${member.is_active ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-500'}`}>
|
||||
{member.is_active ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
|
||||
<div className="bg-surface rounded-lg border border-border p-4 space-y-1">
|
||||
<h2 className="text-lg font-semibold text-text-primary mb-3">Contact</h2>
|
||||
{member.email && (
|
||||
<div className={fieldRowClass}>
|
||||
<Mail className={iconClass} />
|
||||
<span className={labelClass}>Email</span>
|
||||
<span className={valueClass}>{member.email}</span>
|
||||
</div>
|
||||
)}
|
||||
{member.phone && (
|
||||
<div className={fieldRowClass}>
|
||||
<Phone className={iconClass} />
|
||||
<span className={labelClass}>Phone</span>
|
||||
<span className={valueClass}>{member.phone}</span>
|
||||
</div>
|
||||
)}
|
||||
{member.role_title && (
|
||||
<div className={fieldRowClass}>
|
||||
<Briefcase className={iconClass} />
|
||||
<span className={labelClass}>Role</span>
|
||||
<span className={valueClass}>{member.role_title}</span>
|
||||
</div>
|
||||
)}
|
||||
{member.hourly_rate != null && (
|
||||
<div className={fieldRowClass}>
|
||||
<Euro className={iconClass} />
|
||||
<span className={labelClass}>Rate</span>
|
||||
<span className={valueClass}>€{member.hourly_rate.toFixed(2)}/h</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-surface rounded-lg border border-border p-4">
|
||||
<h2 className="text-lg font-semibold text-text-primary mb-3">Availability</h2>
|
||||
{member.availabilities.length === 0 ? (
|
||||
<p className="text-sm text-text-secondary italic">No availability entries.</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{member.availabilities.slice(0, 5).map(a => (
|
||||
<div key={a.id} className="flex items-center gap-2 text-sm">
|
||||
<Calendar size={14} className="text-text-secondary" />
|
||||
<span className="text-text-secondary">{new Date(a.start_date).toLocaleDateString()}</span>
|
||||
<span className="text-text-secondary">→</span>
|
||||
<span className="text-text-secondary">{new Date(a.end_date).toLocaleDateString()}</span>
|
||||
<span className={`px-2 py-0.5 rounded text-xs font-medium ${
|
||||
a.status === 'available' ? 'bg-green-100 text-green-800' :
|
||||
a.status === 'booked' ? 'bg-blue-100 text-blue-800' : 'bg-yellow-100 text-yellow-800'
|
||||
}`}>{a.status}</span>
|
||||
</div>
|
||||
))}
|
||||
{member.availabilities.length > 5 && (
|
||||
<p className="text-xs text-text-secondary">+{member.availabilities.length - 5} more</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{member.notes && (
|
||||
<div className="mb-8">
|
||||
<h2 className="text-lg font-semibold text-text-primary mb-3">Notes</h2>
|
||||
<div className="bg-surface rounded-lg border border-border p-4">
|
||||
<p className="text-sm text-text-primary whitespace-pre-wrap">{member.notes}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-8 pt-4 border-t border-border text-xs text-text-secondary space-y-1">
|
||||
<p>Created: {new Date(member.created_at).toLocaleString()}</p>
|
||||
<p>Updated: {new Date(member.updated_at).toLocaleString()}</p>
|
||||
<p>ID: {member.id}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import CrewForm, { CrewFormData, emptyCrewFormData } from '../components/CrewForm.tsx';
|
||||
import api from '../services/api.ts';
|
||||
|
||||
export default function CrewEdit() {
|
||||
const { memberId } = useParams<{ memberId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [initialData, setInitialData] = useState<CrewFormData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const response = await api.get(`/crew/${memberId}`);
|
||||
const m = response.data;
|
||||
setInitialData({
|
||||
first_name: m.first_name || '',
|
||||
last_name: m.last_name || '',
|
||||
email: m.email || '',
|
||||
phone: m.phone || '',
|
||||
role_title: m.role_title || '',
|
||||
hourly_rate: m.hourly_rate ?? null,
|
||||
is_active: m.is_active !== false,
|
||||
notes: m.notes || '',
|
||||
});
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.detail || 'Failed to load');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
})();
|
||||
}, [memberId]);
|
||||
|
||||
const handleUpdate = async (formData: CrewFormData) => {
|
||||
await api.put(`/crew/${memberId}`, formData);
|
||||
navigate(`/crew/${memberId}`);
|
||||
};
|
||||
|
||||
if (loading) return <div className="p-6"><p className="text-text-secondary">Loading...</p></div>;
|
||||
if (error) return <div className="p-6"><p className="text-red-500">{error}</p></div>;
|
||||
if (!initialData) return <div className="p-6"><p className="text-text-secondary">Not found.</p></div>;
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-3xl mx-auto">
|
||||
<h1 className="text-2xl font-bold text-text-primary mb-6">Edit Crew Member</h1>
|
||||
<CrewForm initialData={initialData} onSubmit={handleUpdate} submitLabel="Save Changes" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import CrewForm, { emptyCrewFormData } from '../components/CrewForm.tsx';
|
||||
import api from '../services/api.ts';
|
||||
|
||||
export default function CrewNew() {
|
||||
const navigate = useNavigate();
|
||||
const handleCreate = async (formData: any) => {
|
||||
await api.post('/crew', formData);
|
||||
navigate('/crew');
|
||||
};
|
||||
return (
|
||||
<div className="p-6 max-w-3xl mx-auto">
|
||||
<h1 className="text-2xl font-bold text-text-primary mb-6">Add Crew Member</h1>
|
||||
<CrewForm initialData={emptyCrewFormData} onSubmit={handleCreate} submitLabel="Create Member" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
export default function Dashboard() {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<h1 className="text-2xl font-bold text-text-primary mb-2">Dashboard</h1>
|
||||
<p className="text-text-secondary">
|
||||
Welcome to your Rentman Clone workspace. Content coming soon.
|
||||
</p>
|
||||
|
||||
{/* Placeholder cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mt-8">
|
||||
{['Projects', 'Equipment', 'Crew', 'Revenue'].map((title) => (
|
||||
<div
|
||||
key={title}
|
||||
className="bg-surface rounded-lg border border-border p-5"
|
||||
>
|
||||
<h3 className="text-sm font-medium text-text-secondary">{title}</h3>
|
||||
<p className="text-2xl font-bold text-text-primary mt-1">—</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuthStore } from '../stores/authStore.ts';
|
||||
import api from '../services/api.ts';
|
||||
import { Plus, Search, Pencil, Trash2, Eye } from 'lucide-react';
|
||||
|
||||
interface EquipmentItem {
|
||||
id: string;
|
||||
name: string;
|
||||
category: string | null;
|
||||
brand: string | null;
|
||||
serial_number: string | null;
|
||||
barcode: string | null;
|
||||
status: string;
|
||||
location: { id: string; name: string } | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export default function Equipment() {
|
||||
const { user } = useAuthStore();
|
||||
const navigate = useNavigate();
|
||||
const [items, setItems] = useState<EquipmentItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [search, setSearch] = useState('');
|
||||
const [categoryFilter, setCategoryFilter] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
const [total, setTotal] = useState(0);
|
||||
const size = 20;
|
||||
|
||||
const canWrite = user?.permissions?.includes('equipment:write');
|
||||
const canDelete = user?.permissions?.includes('equipment:delete');
|
||||
|
||||
const fetchItems = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const params = new URLSearchParams({ page: String(page), size: String(size) });
|
||||
if (search) params.set('search', search);
|
||||
if (categoryFilter) params.set('category', categoryFilter);
|
||||
if (statusFilter) params.set('status', statusFilter);
|
||||
const response = await api.get(`/equipment?${params.toString()}`);
|
||||
setItems(response.data.items);
|
||||
setTotal(response.data.total);
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.detail || 'Failed to load equipment');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchItems();
|
||||
}, [page, categoryFilter, statusFilter]);
|
||||
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setPage(1);
|
||||
fetchItems();
|
||||
};
|
||||
|
||||
const handleDelete = async (itemId: string, itemName: string) => {
|
||||
if (!confirm(`Delete equipment "${itemName}"?`)) return;
|
||||
try {
|
||||
await api.delete(`/equipment/${itemId}`);
|
||||
fetchItems();
|
||||
} catch (err: any) {
|
||||
alert(err.response?.data?.detail || 'Failed to delete equipment');
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
const styles: Record<string, string> = {
|
||||
available: 'bg-green-100 text-green-800',
|
||||
rented: 'bg-blue-100 text-blue-800',
|
||||
maintenance: 'bg-yellow-100 text-yellow-800',
|
||||
retired: 'bg-red-100 text-red-800',
|
||||
};
|
||||
return styles[status] || 'bg-gray-100 text-gray-800';
|
||||
};
|
||||
|
||||
const totalPages = Math.ceil(total / size);
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold text-text-primary">Equipment</h1>
|
||||
{canWrite && (
|
||||
<button
|
||||
onClick={() => navigate('/equipment/new')}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors"
|
||||
>
|
||||
<Plus size={18} />
|
||||
Add Equipment
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Search and Filter Bar */}
|
||||
<div className="mb-4 flex flex-col sm:flex-row gap-3">
|
||||
<form onSubmit={handleSearch} className="flex-1 flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search size={18} className="absolute left-3 top-1/2 -translate-y-1/2 text-text-secondary" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by name, brand, serial number..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full pl-10 pr-3 py-2 border border-border rounded-lg focus:ring-2 focus:ring-primary outline-none"
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" className="px-4 py-2 bg-surface border border-border rounded-lg hover:bg-gray-50 text-sm">
|
||||
Search
|
||||
</button>
|
||||
</form>
|
||||
<select
|
||||
value={categoryFilter}
|
||||
onChange={(e) => { setCategoryFilter(e.target.value); setPage(1); }}
|
||||
className="px-3 py-2 border border-border rounded-lg focus:ring-2 focus:ring-primary outline-none text-sm"
|
||||
>
|
||||
<option value="">All Categories</option>
|
||||
<option value="Lighting">Lighting</option>
|
||||
<option value="Audio">Audio</option>
|
||||
<option value="Video">Video</option>
|
||||
<option value="Stage">Stage</option>
|
||||
<option value="Rigging">Rigging</option>
|
||||
<option value="Cables">Cables</option>
|
||||
<option value="Other">Other</option>
|
||||
</select>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => { setStatusFilter(e.target.value); setPage(1); }}
|
||||
className="px-3 py-2 border border-border rounded-lg focus:ring-2 focus:ring-primary outline-none text-sm"
|
||||
>
|
||||
<option value="">All Statuses</option>
|
||||
<option value="available">Available</option>
|
||||
<option value="rented">Rented</option>
|
||||
<option value="maintenance">In Maintenance</option>
|
||||
<option value="retired">Retired</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
{loading ? (
|
||||
<p className="text-text-secondary">Loading equipment...</p>
|
||||
) : error ? (
|
||||
<p className="text-red-500">{error}</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="bg-surface rounded-lg border border-border overflow-hidden">
|
||||
<table className="w-full text-left">
|
||||
<thead className="bg-gray-50 border-b border-border">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-sm font-medium text-text-secondary">Name</th>
|
||||
<th className="px-4 py-3 text-sm font-medium text-text-secondary">Category</th>
|
||||
<th className="px-4 py-3 text-sm font-medium text-text-secondary">Brand</th>
|
||||
<th className="px-4 py-3 text-sm font-medium text-text-secondary">Status</th>
|
||||
<th className="px-4 py-3 text-sm font-medium text-text-secondary">Location</th>
|
||||
<th className="px-4 py-3 text-sm font-medium text-text-secondary">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((item) => (
|
||||
<tr key={item.id} className="border-b border-border last:border-0 hover:bg-gray-50">
|
||||
<td className="px-4 py-3 text-text-primary font-medium">{item.name}</td>
|
||||
<td className="px-4 py-3 text-text-secondary">{item.category || '-'}</td>
|
||||
<td className="px-4 py-3 text-text-secondary">{item.brand || '-'}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${getStatusBadge(item.status)}`}>
|
||||
{item.status.charAt(0).toUpperCase() + item.status.slice(1)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-text-secondary">{item.location?.name || '-'}</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => navigate(`/equipment/${item.id}`)}
|
||||
className="p-1.5 text-text-secondary hover:text-primary rounded hover:bg-primary/5"
|
||||
title="View"
|
||||
>
|
||||
<Eye size={16} />
|
||||
</button>
|
||||
{canWrite && (
|
||||
<button
|
||||
onClick={() => navigate(`/equipment/${item.id}/edit`)}
|
||||
className="p-1.5 text-text-secondary hover:text-primary rounded hover:bg-primary/5"
|
||||
title="Edit"
|
||||
>
|
||||
<Pencil size={16} />
|
||||
</button>
|
||||
)}
|
||||
{canDelete && (
|
||||
<button
|
||||
onClick={() => handleDelete(item.id, item.name)}
|
||||
className="p-1.5 text-text-secondary hover:text-red-600 rounded hover:bg-red-50"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{items.length === 0 && (
|
||||
<p className="px-4 py-6 text-text-secondary text-center">No equipment found.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-center gap-2 mt-4">
|
||||
<button
|
||||
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||
disabled={page === 1}
|
||||
className="px-3 py-1 border border-border rounded disabled:opacity-50"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<span className="text-sm text-text-secondary">
|
||||
Page {page} of {totalPages}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
|
||||
disabled={page === totalPages}
|
||||
className="px-3 py-1 border border-border rounded disabled:opacity-50"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
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, Package, Barcode, Hash, Ruler, Weight, Zap, Euro, MapPin } from 'lucide-react';
|
||||
|
||||
interface EquipmentDetailData {
|
||||
id: string;
|
||||
account_id: string;
|
||||
name: string;
|
||||
category: string | null;
|
||||
brand: string | null;
|
||||
serial_number: string | null;
|
||||
barcode: string | null;
|
||||
qr_code: string | null;
|
||||
status: string;
|
||||
purchase_price: number | null;
|
||||
current_value: number | null;
|
||||
weight_kg: number | null;
|
||||
dimensions: string | null;
|
||||
power_watt: number | null;
|
||||
notes: string | null;
|
||||
location: { id: string; name: string } | null;
|
||||
supplier: { id: string; name: string | null; company_name: string | null } | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export default function EquipmentDetail() {
|
||||
const { itemId } = useParams<{ itemId: string }>();
|
||||
const { user } = useAuthStore();
|
||||
const navigate = useNavigate();
|
||||
const [item, setItem] = useState<EquipmentDetailData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const canWrite = user?.permissions?.includes('equipment:write');
|
||||
const canDelete = user?.permissions?.includes('equipment:delete');
|
||||
|
||||
useEffect(() => {
|
||||
loadItem();
|
||||
}, [itemId]);
|
||||
|
||||
const loadItem = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await api.get(`/equipment/${itemId}`);
|
||||
setItem(response.data);
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.detail || 'Failed to load equipment');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!confirm(`Delete equipment "${item?.name}"?`)) return;
|
||||
try {
|
||||
await api.delete(`/equipment/${itemId}`);
|
||||
navigate('/equipment');
|
||||
} catch (err: any) {
|
||||
alert(err.response?.data?.detail || 'Failed to delete equipment');
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
const styles: Record<string, string> = {
|
||||
available: 'bg-green-100 text-green-800',
|
||||
rented: 'bg-blue-100 text-blue-800',
|
||||
maintenance: 'bg-yellow-100 text-yellow-800',
|
||||
retired: 'bg-red-100 text-red-800',
|
||||
};
|
||||
return styles[status] || 'bg-gray-100 text-gray-800';
|
||||
};
|
||||
|
||||
if (loading) return <div className="p-6"><p className="text-text-secondary">Loading equipment...</p></div>;
|
||||
if (error) return <div className="p-6"><p className="text-red-500">{error}</p></div>;
|
||||
if (!item) return <div className="p-6"><p className="text-text-secondary">Equipment not found.</p></div>;
|
||||
|
||||
const iconClass = "w-5 h-5 text-text-secondary mt-0.5 flex-shrink-0";
|
||||
const labelClass = "text-sm text-text-secondary min-w-[100px]";
|
||||
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";
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-4xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<button
|
||||
onClick={() => navigate('/equipment')}
|
||||
className="flex items-center gap-2 text-text-secondary hover:text-text-primary transition-colors"
|
||||
>
|
||||
<ArrowLeft size={20} />
|
||||
<span className="text-sm">Back to Equipment</span>
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
{canWrite && (
|
||||
<button
|
||||
onClick={() => navigate(`/equipment/${item.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">{item.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(item.status)}`}>
|
||||
{item.status.charAt(0).toUpperCase() + item.status.slice(1)}
|
||||
</span>
|
||||
{item.category && (
|
||||
<span className="inline-block px-3 py-1 bg-primary/10 text-primary rounded-full text-xs font-medium">
|
||||
{item.category}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Basic Info */}
|
||||
<div className={sectionClass}>
|
||||
<h2 className={headingClass}><Package className="inline w-5 h-5 mr-2" />Equipment Details</h2>
|
||||
<div className="bg-surface rounded-lg border border-border p-4 space-y-1">
|
||||
{item.brand && (
|
||||
<div className={fieldRowClass}>
|
||||
<span className={labelClass}>Brand</span>
|
||||
<span className={valueClass}>{item.brand}</span>
|
||||
</div>
|
||||
)}
|
||||
{item.serial_number && (
|
||||
<div className={fieldRowClass}>
|
||||
<Barcode className={iconClass} />
|
||||
<span className={labelClass}>Serial #</span>
|
||||
<span className={valueClass}>{item.serial_number}</span>
|
||||
</div>
|
||||
)}
|
||||
{item.barcode && (
|
||||
<div className={fieldRowClass}>
|
||||
<Hash className={iconClass} />
|
||||
<span className={labelClass}>Barcode</span>
|
||||
<span className={valueClass}>{item.barcode}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Financial & Physical */}
|
||||
<div className={`${sectionClass} grid grid-cols-1 md:grid-cols-2 gap-6`}>
|
||||
<div>
|
||||
<h2 className={headingClass}>Financial</h2>
|
||||
<div className="bg-surface rounded-lg border border-border p-4 space-y-1">
|
||||
<div className={fieldRowClass}>
|
||||
<Euro className={iconClass} />
|
||||
<span className={labelClass}>Purchase Price</span>
|
||||
<span className={valueClass}>{item.purchase_price != null ? `€${item.purchase_price.toFixed(2)}` : '-'}</span>
|
||||
</div>
|
||||
<div className={fieldRowClass}>
|
||||
<Euro className={iconClass} />
|
||||
<span className={labelClass}>Current Value</span>
|
||||
<span className={valueClass}>{item.current_value != null ? `€${item.current_value.toFixed(2)}` : '-'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className={headingClass}>Physical</h2>
|
||||
<div className="bg-surface rounded-lg border border-border p-4 space-y-1">
|
||||
<div className={fieldRowClass}>
|
||||
<Weight className={iconClass} />
|
||||
<span className={labelClass}>Weight</span>
|
||||
<span className={valueClass}>{item.weight_kg != null ? `${item.weight_kg} kg` : '-'}</span>
|
||||
</div>
|
||||
<div className={fieldRowClass}>
|
||||
<Ruler className={iconClass} />
|
||||
<span className={labelClass}>Dimensions</span>
|
||||
<span className={valueClass}>{item.dimensions || '-'}</span>
|
||||
</div>
|
||||
<div className={fieldRowClass}>
|
||||
<Zap className={iconClass} />
|
||||
<span className={labelClass}>Power</span>
|
||||
<span className={valueClass}>{item.power_watt != null ? `${item.power_watt} W` : '-'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Location */}
|
||||
{item.location && (
|
||||
<div className={sectionClass}>
|
||||
<h2 className={headingClass}>Location</h2>
|
||||
<div className="bg-surface rounded-lg border border-border p-4">
|
||||
<div className={fieldRowClass}>
|
||||
<MapPin className={iconClass} />
|
||||
<span className={valueClass}>{item.location.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Supplier */}
|
||||
{item.supplier && (
|
||||
<div className={sectionClass}>
|
||||
<h2 className={headingClass}>Supplier</h2>
|
||||
<div className="bg-surface rounded-lg border border-border p-4">
|
||||
<p className="text-sm text-text-primary">
|
||||
{item.supplier.company_name || item.supplier.name || 'Unknown'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Notes */}
|
||||
{item.notes && (
|
||||
<div className={sectionClass}>
|
||||
<h2 className={headingClass}>Notes</h2>
|
||||
<div className="bg-surface rounded-lg border border-border p-4">
|
||||
<p className="text-sm text-text-primary" style={{ whiteSpace: 'pre-wrap' }}>{item.notes}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Metadata */}
|
||||
<div className="mt-8 pt-4 border-t border-border text-xs text-text-secondary space-y-1">
|
||||
<p>Created: {new Date(item.created_at).toLocaleString()}</p>
|
||||
<p>Updated: {new Date(item.updated_at).toLocaleString()}</p>
|
||||
<p>ID: {item.id}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import EquipmentForm, { EquipmentFormData, emptyEquipmentFormData } from '../components/EquipmentForm.tsx';
|
||||
import api from '../services/api.ts';
|
||||
|
||||
interface LocationItem {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export default function EquipmentEdit() {
|
||||
const { itemId } = useParams<{ itemId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [initialData, setInitialData] = useState<EquipmentFormData | null>(null);
|
||||
const [locations, setLocations] = useState<LocationItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
loadEquipment(),
|
||||
loadLocations(),
|
||||
]).finally(() => setLoading(false));
|
||||
}, [itemId]);
|
||||
|
||||
const loadEquipment = async () => {
|
||||
try {
|
||||
const response = await api.get(`/equipment/${itemId}`);
|
||||
const e = response.data;
|
||||
setInitialData({
|
||||
name: e.name || '',
|
||||
category: e.category || '',
|
||||
brand: e.brand || '',
|
||||
serial_number: e.serial_number || '',
|
||||
barcode: e.barcode || '',
|
||||
status: e.status || 'available',
|
||||
purchase_price: e.purchase_price ?? null,
|
||||
current_value: e.current_value ?? null,
|
||||
weight_kg: e.weight_kg ?? null,
|
||||
dimensions: e.dimensions || '',
|
||||
power_watt: e.power_watt ?? null,
|
||||
notes: e.notes || '',
|
||||
location_id: e.location?.id || '',
|
||||
});
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.detail || 'Failed to load equipment');
|
||||
}
|
||||
};
|
||||
|
||||
const loadLocations = async () => {
|
||||
try {
|
||||
const response = await api.get('/stock-locations?size=100');
|
||||
setLocations(response.data.items || []);
|
||||
} catch (err: any) {
|
||||
console.error('Failed to load locations', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdate = async (formData: EquipmentFormData) => {
|
||||
await api.put(`/equipment/${itemId}`, formData);
|
||||
navigate(`/equipment/${itemId}`);
|
||||
};
|
||||
|
||||
if (loading) return <div className="p-6"><p className="text-text-secondary">Loading equipment...</p></div>;
|
||||
if (error) return <div className="p-6"><p className="text-red-500">{error}</p></div>;
|
||||
if (!initialData) return <div className="p-6"><p className="text-text-secondary">Equipment not found.</p></div>;
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-3xl mx-auto">
|
||||
<h1 className="text-2xl font-bold text-text-primary mb-6">Edit Equipment</h1>
|
||||
<EquipmentForm
|
||||
initialData={initialData}
|
||||
locations={locations}
|
||||
onSubmit={handleUpdate}
|
||||
submitLabel="Save Changes"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import EquipmentForm, { emptyEquipmentFormData } from '../components/EquipmentForm.tsx';
|
||||
import api from '../services/api.ts';
|
||||
|
||||
interface LocationItem {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export default function EquipmentNew() {
|
||||
const navigate = useNavigate();
|
||||
const [locations, setLocations] = useState<LocationItem[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
loadLocations();
|
||||
}, []);
|
||||
|
||||
const loadLocations = async () => {
|
||||
try {
|
||||
const response = await api.get('/stock-locations?size=100');
|
||||
setLocations(response.data.items || []);
|
||||
} catch (err: any) {
|
||||
console.error('Failed to load locations', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreate = async (formData: any) => {
|
||||
await api.post('/equipment', formData);
|
||||
navigate('/equipment');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-3xl mx-auto">
|
||||
<h1 className="text-2xl font-bold text-text-primary mb-6">Add Equipment</h1>
|
||||
<EquipmentForm
|
||||
initialData={emptyEquipmentFormData}
|
||||
locations={locations}
|
||||
onSubmit={handleCreate}
|
||||
submitLabel="Create Equipment"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col items-center justify-center bg-background px-4">
|
||||
<div className="text-center max-w-2xl">
|
||||
<h1 className="text-5xl font-bold text-primary mb-6">
|
||||
Rentman Clone
|
||||
</h1>
|
||||
<p className="text-xl text-text-secondary mb-8">
|
||||
A project-centric platform for rental and event industries.
|
||||
Manage projects, equipment, crew, vehicles, and finances.
|
||||
</p>
|
||||
<div className="flex gap-4 justify-center">
|
||||
<Link
|
||||
to="/login"
|
||||
className="px-6 py-3 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors"
|
||||
>
|
||||
Sign In
|
||||
</Link>
|
||||
<Link
|
||||
to="/register"
|
||||
className="px-6 py-3 border border-primary text-primary rounded-lg hover:bg-primary/10 transition-colors"
|
||||
>
|
||||
Create Account
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useAuthStore } from '../stores/authStore.ts';
|
||||
import api from '../services/api.ts';
|
||||
|
||||
export default function Login() {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const login = useAuthStore((s) => s.login);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const response = await api.post('/auth/login', { email, password });
|
||||
const { access_token, refresh_token, user } = response.data;
|
||||
login(access_token, refresh_token, user);
|
||||
navigate('/dashboard');
|
||||
} catch (err: any) {
|
||||
const message = err.response?.data?.detail || 'Login failed. Please try again.';
|
||||
setError(message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-background px-4">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="bg-surface rounded-xl shadow-sm border border-border p-8">
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-2xl font-bold text-text-primary">
|
||||
Sign In
|
||||
</h1>
|
||||
<p className="text-text-secondary mt-2">
|
||||
Access your Rentman Clone account
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="email"
|
||||
className="block text-sm font-medium text-text-primary mb-1"
|
||||
>
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
className="w-full px-4 py-2.5 border border-border rounded-lg focus:ring-2 focus:ring-primary focus:border-primary outline-none transition-colors"
|
||||
placeholder="you@example.com"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="password"
|
||||
className="block text-sm font-medium text-text-primary mb-1"
|
||||
>
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
className="w-full px-4 py-2.5 border border-border rounded-lg focus:ring-2 focus:ring-primary focus:border-primary outline-none transition-colors"
|
||||
placeholder="Enter your password"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full py-2.5 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors font-medium disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? 'Signing in...' : 'Sign In'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="text-center text-sm text-text-secondary mt-6">
|
||||
Don't have an account?{' '}
|
||||
<Link
|
||||
to="/register"
|
||||
className="text-primary hover:underline font-medium"
|
||||
>
|
||||
Create one
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import ProjectForm, { ProjectFormData, emptyProjectFormData } from '../components/ProjectForm.tsx';
|
||||
import api from '../services/api.ts';
|
||||
|
||||
export default function ProjectEdit() {
|
||||
const { projectId } = useParams<{ projectId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [initialData, setInitialData] = useState<ProjectFormData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadProject();
|
||||
}, [projectId]);
|
||||
|
||||
const loadProject = async () => {
|
||||
try {
|
||||
const response = await api.get(`/projects/${projectId}`);
|
||||
const p = response.data;
|
||||
setInitialData({
|
||||
name: p.name || '',
|
||||
description: p.description || '',
|
||||
status: p.status || 'draft',
|
||||
start_date: p.start_date || '',
|
||||
end_date: p.end_date || '',
|
||||
budget: p.budget ?? null,
|
||||
total_costs: p.total_costs ?? null,
|
||||
notes: p.notes || '',
|
||||
});
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.detail || 'Failed to load project');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdate = async (formData: ProjectFormData) => {
|
||||
await api.put(`/projects/${projectId}`, formData);
|
||||
navigate(`/projects/${projectId}`);
|
||||
};
|
||||
|
||||
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 (!initialData) return <div className="p-6"><p className="text-text-secondary">Project not found.</p></div>;
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-3xl mx-auto">
|
||||
<h1 className="text-2xl font-bold text-text-primary mb-6">Edit Project</h1>
|
||||
<ProjectForm
|
||||
initialData={initialData}
|
||||
onSubmit={handleUpdate}
|
||||
submitLabel="Save Changes"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import ProjectForm, { emptyProjectFormData } from '../components/ProjectForm.tsx';
|
||||
import api from '../services/api.ts';
|
||||
|
||||
export default function ProjectNew() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleCreate = async (formData: any) => {
|
||||
await api.post('/projects', formData);
|
||||
navigate('/projects');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-3xl mx-auto">
|
||||
<h1 className="text-2xl font-bold text-text-primary mb-6">New Project</h1>
|
||||
<ProjectForm
|
||||
initialData={emptyProjectFormData}
|
||||
onSubmit={handleCreate}
|
||||
submitLabel="Create Project"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuthStore } from '../stores/authStore.ts';
|
||||
import api from '../services/api.ts';
|
||||
import { Plus, Search, Pencil, Trash2, Eye } from 'lucide-react';
|
||||
|
||||
interface ProjectItem {
|
||||
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;
|
||||
}
|
||||
|
||||
export default function Projects() {
|
||||
const { user } = useAuthStore();
|
||||
const navigate = useNavigate();
|
||||
const [items, setItems] = useState<ProjectItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [search, setSearch] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
const [total, setTotal] = useState(0);
|
||||
const size = 20;
|
||||
|
||||
const canWrite = user?.permissions?.includes('projects:write');
|
||||
const canDelete = user?.permissions?.includes('projects:delete');
|
||||
|
||||
const fetchItems = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const params = new URLSearchParams({ page: String(page), size: String(size) });
|
||||
if (search) params.set('search', search);
|
||||
if (statusFilter) params.set('status', statusFilter);
|
||||
const response = await api.get(`/projects?${params.toString()}`);
|
||||
setItems(response.data.items);
|
||||
setTotal(response.data.total);
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.detail || 'Failed to load projects');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchItems();
|
||||
}, [page, statusFilter]);
|
||||
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setPage(1);
|
||||
fetchItems();
|
||||
};
|
||||
|
||||
const handleDelete = async (itemId: string, itemName: string) => {
|
||||
if (!confirm(`Delete project "${itemName}"?`)) return;
|
||||
try {
|
||||
await api.delete(`/projects/${itemId}`);
|
||||
fetchItems();
|
||||
} 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();
|
||||
};
|
||||
|
||||
const totalPages = Math.ceil(total / size);
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold text-text-primary">Projects</h1>
|
||||
{canWrite && (
|
||||
<button
|
||||
onClick={() => navigate('/projects/new')}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors"
|
||||
>
|
||||
<Plus size={18} />
|
||||
Add Project
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Search and Filter Bar */}
|
||||
<div className="mb-4 flex flex-col sm:flex-row gap-3">
|
||||
<form onSubmit={handleSearch} className="flex-1 flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search size={18} className="absolute left-3 top-1/2 -translate-y-1/2 text-text-secondary" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by project name..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full pl-10 pr-3 py-2 border border-border rounded-lg focus:ring-2 focus:ring-primary outline-none"
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" className="px-4 py-2 bg-surface border border-border rounded-lg hover:bg-gray-50 text-sm">
|
||||
Search
|
||||
</button>
|
||||
</form>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => { setStatusFilter(e.target.value); setPage(1); }}
|
||||
className="px-3 py-2 border border-border rounded-lg focus:ring-2 focus:ring-primary outline-none text-sm"
|
||||
>
|
||||
<option value="">All Statuses</option>
|
||||
<option value="draft">Draft</option>
|
||||
<option value="confirmed">Confirmed</option>
|
||||
<option value="in_progress">In Progress</option>
|
||||
<option value="completed">Completed</option>
|
||||
<option value="cancelled">Cancelled</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
{loading ? (
|
||||
<p className="text-text-secondary">Loading projects...</p>
|
||||
) : error ? (
|
||||
<p className="text-red-500">{error}</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="bg-surface rounded-lg border border-border overflow-hidden">
|
||||
<table className="w-full text-left">
|
||||
<thead className="bg-gray-50 border-b border-border">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-sm font-medium text-text-secondary">Name</th>
|
||||
<th className="px-4 py-3 text-sm font-medium text-text-secondary">Status</th>
|
||||
<th className="px-4 py-3 text-sm font-medium text-text-secondary">Start Date</th>
|
||||
<th className="px-4 py-3 text-sm font-medium text-text-secondary">End Date</th>
|
||||
<th className="px-4 py-3 text-sm font-medium text-text-secondary">Budget</th>
|
||||
<th className="px-4 py-3 text-sm font-medium text-text-secondary">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((item) => (
|
||||
<tr key={item.id} className="border-b border-border last:border-0 hover:bg-gray-50">
|
||||
<td className="px-4 py-3 text-text-primary font-medium">{item.name}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${getStatusBadge(item.status)}`}>
|
||||
{item.status.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase())}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-text-secondary">{formatDate(item.start_date)}</td>
|
||||
<td className="px-4 py-3 text-text-secondary">{formatDate(item.end_date)}</td>
|
||||
<td className="px-4 py-3 text-text-secondary">{item.budget != null ? `€${item.budget.toFixed(2)}` : '-'}</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => navigate(`/projects/${item.id}`)}
|
||||
className="p-1.5 text-text-secondary hover:text-primary rounded hover:bg-primary/5"
|
||||
title="View"
|
||||
>
|
||||
<Eye size={16} />
|
||||
</button>
|
||||
{canWrite && (
|
||||
<button
|
||||
onClick={() => navigate(`/projects/${item.id}/edit`)}
|
||||
className="p-1.5 text-text-secondary hover:text-primary rounded hover:bg-primary/5"
|
||||
title="Edit"
|
||||
>
|
||||
<Pencil size={16} />
|
||||
</button>
|
||||
)}
|
||||
{canDelete && (
|
||||
<button
|
||||
onClick={() => handleDelete(item.id, item.name)}
|
||||
className="p-1.5 text-text-secondary hover:text-red-600 rounded hover:bg-red-50"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{items.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-4 py-8 text-center text-text-secondary">
|
||||
No projects found.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between mt-4 text-sm text-text-secondary">
|
||||
<span>Page {page} of {totalPages} ({total} total)</span>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
disabled={page <= 1}
|
||||
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||
className="px-3 py-1 border border-border rounded hover:bg-gray-50 disabled:opacity-50"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<button
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => setPage(p => p + 1)}
|
||||
className="px-3 py-1 border border-border rounded hover:bg-gray-50 disabled:opacity-50"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import { useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useAuthStore } from '../stores/authStore.ts';
|
||||
import api from '../services/api.ts';
|
||||
|
||||
export default function Register() {
|
||||
const [fullName, setFullName] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [companyName, setCompanyName] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const login = useAuthStore((s) => s.login);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
setError('Passwords do not match');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await api.post('/auth/register', {
|
||||
account_name: companyName,
|
||||
full_name: fullName,
|
||||
email,
|
||||
password,
|
||||
});
|
||||
const { access_token, refresh_token, user } = response.data;
|
||||
login(access_token, refresh_token, user);
|
||||
navigate('/dashboard');
|
||||
} catch (err: any) {
|
||||
const message = err.response?.data?.detail || 'Registration failed. Please try again.';
|
||||
setError(message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-background px-4 py-8">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="bg-surface rounded-xl shadow-sm border border-border p-8">
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-2xl font-bold text-text-primary">
|
||||
Create Account
|
||||
</h1>
|
||||
<p className="text-text-secondary mt-2">
|
||||
Set up your Rentman Clone workspace
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="fullName"
|
||||
className="block text-sm font-medium text-text-primary mb-1"
|
||||
>
|
||||
Full Name
|
||||
</label>
|
||||
<input
|
||||
id="fullName"
|
||||
type="text"
|
||||
value={fullName}
|
||||
onChange={(e) => setFullName(e.target.value)}
|
||||
required
|
||||
className="w-full px-4 py-2.5 border border-border rounded-lg focus:ring-2 focus:ring-primary focus:border-primary outline-none"
|
||||
placeholder="Your name"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="email"
|
||||
className="block text-sm font-medium text-text-primary mb-1"
|
||||
>
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
className="w-full px-4 py-2.5 border border-border rounded-lg focus:ring-2 focus:ring-primary focus:border-primary outline-none"
|
||||
placeholder="you@company.com"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="companyName"
|
||||
className="block text-sm font-medium text-text-primary mb-1"
|
||||
>
|
||||
Company Name
|
||||
</label>
|
||||
<input
|
||||
id="companyName"
|
||||
type="text"
|
||||
value={companyName}
|
||||
onChange={(e) => setCompanyName(e.target.value)}
|
||||
required
|
||||
className="w-full px-4 py-2.5 border border-border rounded-lg focus:ring-2 focus:ring-primary focus:border-primary outline-none"
|
||||
placeholder="Your company"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="password"
|
||||
className="block text-sm font-medium text-text-primary mb-1"
|
||||
>
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
minLength={8}
|
||||
className="w-full px-4 py-2.5 border border-border rounded-lg focus:ring-2 focus:ring-primary focus:border-primary outline-none"
|
||||
placeholder="At least 8 characters"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="confirmPassword"
|
||||
className="block text-sm font-medium text-text-primary mb-1"
|
||||
>
|
||||
Confirm Password
|
||||
</label>
|
||||
<input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
required
|
||||
className="w-full px-4 py-2.5 border border-border rounded-lg focus:ring-2 focus:ring-primary focus:border-primary outline-none"
|
||||
placeholder="Repeat your password"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full py-2.5 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors font-medium disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? 'Creating Account...' : 'Create Account'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="text-center text-sm text-text-secondary mt-6">
|
||||
Already have an account?{' '}
|
||||
<Link
|
||||
to="/login"
|
||||
className="text-primary hover:underline font-medium"
|
||||
>
|
||||
Sign in
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import api from '../services/api.ts';
|
||||
|
||||
interface RoleItem {
|
||||
id: string;
|
||||
account_id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
permissions: string[];
|
||||
}
|
||||
|
||||
export default function Roles() {
|
||||
const [roles, setRoles] = useState<RoleItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [form, setForm] = useState({ name: '', description: '', permissions: '' });
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
|
||||
const fetchRoles = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await api.get('/roles');
|
||||
setRoles(response.data);
|
||||
} catch (err: any) {
|
||||
setError('Failed to load roles');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchRoles();
|
||||
}, []);
|
||||
|
||||
const handleCreate = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setFormError(null);
|
||||
try {
|
||||
const permissionsArray = form.permissions
|
||||
.split(',')
|
||||
.map(p => p.trim())
|
||||
.filter(p => p);
|
||||
await api.post('/roles', {
|
||||
name: form.name,
|
||||
description: form.description || null,
|
||||
permissions: permissionsArray,
|
||||
});
|
||||
setForm({ name: '', description: '', permissions: '' });
|
||||
setShowCreate(false);
|
||||
fetchRoles();
|
||||
} catch (err: any) {
|
||||
setFormError(err.response?.data?.detail || 'Failed to create role');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold text-text-primary">Roles</h1>
|
||||
<button
|
||||
onClick={() => setShowCreate(!showCreate)}
|
||||
className="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors"
|
||||
>
|
||||
{showCreate ? 'Cancel' : 'Create Role'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showCreate && (
|
||||
<form onSubmit={handleCreate} className="mb-6 p-4 bg-surface rounded-lg border border-border space-y-4">
|
||||
{formError && <div className="p-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm">{formError}</div>}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Role Name"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
required
|
||||
className="px-3 py-2 border border-border rounded-lg focus:ring-2 focus:ring-primary outline-none"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Description (optional)"
|
||||
value={form.description}
|
||||
onChange={(e) => setForm({ ...form, description: e.target.value })}
|
||||
className="px-3 py-2 border border-border rounded-lg focus:ring-2 focus:ring-primary outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">Permissions (comma-separated)</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="projects:read, projects:write"
|
||||
value={form.permissions}
|
||||
onChange={(e) => setForm({ ...form, permissions: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-border rounded-lg focus:ring-2 focus:ring-primary outline-none"
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors">Create</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<p className="text-text-secondary">Loading roles...</p>
|
||||
) : error ? (
|
||||
<p className="text-red-500">{error}</p>
|
||||
) : (
|
||||
<div className="bg-surface rounded-lg border border-border overflow-hidden">
|
||||
<table className="w-full text-left">
|
||||
<thead className="bg-gray-50 border-b border-border">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-sm font-medium text-text-secondary">Name</th>
|
||||
<th className="px-4 py-3 text-sm font-medium text-text-secondary">Description</th>
|
||||
<th className="px-4 py-3 text-sm font-medium text-text-secondary">Permissions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{roles.map((r) => (
|
||||
<tr key={r.id} className="border-b border-border last:border-0 hover:bg-gray-50">
|
||||
<td className="px-4 py-3 text-text-primary font-medium">{r.name}</td>
|
||||
<td className="px-4 py-3 text-text-secondary">{r.description || '-'}</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{r.permissions.map(p => (
|
||||
<span key={p} className="inline-block px-2 py-0.5 bg-primary/10 text-primary rounded-full text-xs font-medium">
|
||||
{p}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{roles.length === 0 && <p className="px-4 py-6 text-text-secondary text-center">No roles found.</p>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useAuthStore } from '../stores/authStore.ts';
|
||||
import api from '../services/api.ts';
|
||||
|
||||
interface UserItem {
|
||||
id: string;
|
||||
account_id: string;
|
||||
email: string;
|
||||
full_name: string;
|
||||
is_active: boolean;
|
||||
role_id: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export default function Users() {
|
||||
const { user } = useAuthStore();
|
||||
const [users, setUsers] = useState<UserItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [form, setForm] = useState({ email: '', full_name: '', password: '', role_id: '' });
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
|
||||
const fetchUsers = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await api.get('/users?page=1&size=50');
|
||||
setUsers(response.data.items);
|
||||
} catch (err: any) {
|
||||
setError('Failed to load users');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsers();
|
||||
}, []);
|
||||
|
||||
const handleCreate = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setFormError(null);
|
||||
try {
|
||||
await api.post('/users', form);
|
||||
setForm({ email: '', full_name: '', password: '', role_id: '' });
|
||||
setShowCreate(false);
|
||||
fetchUsers();
|
||||
} catch (err: any) {
|
||||
setFormError(err.response?.data?.detail || 'Failed to create user');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeactivate = async (userId: string) => {
|
||||
if (!confirm('Deactivate this user?')) return;
|
||||
try {
|
||||
await api.delete(`/users/${userId}`);
|
||||
fetchUsers();
|
||||
} catch (err: any) {
|
||||
alert(err.response?.data?.detail || 'Failed to deactivate user');
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleActive = async (targetUser: UserItem) => {
|
||||
try {
|
||||
await api.put(`/users/${targetUser.id}`, { is_active: !targetUser.is_active });
|
||||
fetchUsers();
|
||||
} catch (err: any) {
|
||||
alert(err.response?.data?.detail || 'Failed to update user');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold text-text-primary">Users</h1>
|
||||
<button
|
||||
onClick={() => setShowCreate(!showCreate)}
|
||||
className="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors"
|
||||
>
|
||||
{showCreate ? 'Cancel' : 'Create User'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showCreate && (
|
||||
<form onSubmit={handleCreate} className="mb-6 p-4 bg-surface rounded-lg border border-border space-y-4">
|
||||
{formError && <div className="p-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm">{formError}</div>}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Full Name"
|
||||
value={form.full_name}
|
||||
onChange={(e) => setForm({ ...form, full_name: e.target.value })}
|
||||
required
|
||||
className="px-3 py-2 border border-border rounded-lg focus:ring-2 focus:ring-primary outline-none"
|
||||
/>
|
||||
<input
|
||||
type="email"
|
||||
placeholder="Email"
|
||||
value={form.email}
|
||||
onChange={(e) => setForm({ ...form, email: e.target.value })}
|
||||
required
|
||||
className="px-3 py-2 border border-border rounded-lg focus:ring-2 focus:ring-primary outline-none"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="Password"
|
||||
value={form.password}
|
||||
onChange={(e) => setForm({ ...form, password: e.target.value })}
|
||||
required
|
||||
minLength={8}
|
||||
className="px-3 py-2 border border-border rounded-lg focus:ring-2 focus:ring-primary outline-none"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Role ID (optional)"
|
||||
value={form.role_id}
|
||||
onChange={(e) => setForm({ ...form, role_id: e.target.value })}
|
||||
className="px-3 py-2 border border-border rounded-lg focus:ring-2 focus:ring-primary outline-none"
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors">Create</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<p className="text-text-secondary">Loading users...</p>
|
||||
) : error ? (
|
||||
<p className="text-red-500">{error}</p>
|
||||
) : (
|
||||
<div className="bg-surface rounded-lg border border-border overflow-hidden">
|
||||
<table className="w-full text-left">
|
||||
<thead className="bg-gray-50 border-b border-border">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-sm font-medium text-text-secondary">Name</th>
|
||||
<th className="px-4 py-3 text-sm font-medium text-text-secondary">Email</th>
|
||||
<th className="px-4 py-3 text-sm font-medium text-text-secondary">Status</th>
|
||||
<th className="px-4 py-3 text-sm font-medium text-text-secondary">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map((u) => (
|
||||
<tr key={u.id} className="border-b border-border last:border-0 hover:bg-gray-50">
|
||||
<td className="px-4 py-3 text-text-primary">{u.full_name}</td>
|
||||
<td className="px-4 py-3 text-text-secondary">{u.email}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${u.is_active ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'}`}>
|
||||
{u.is_active ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 space-x-2">
|
||||
<button
|
||||
onClick={() => handleToggleActive(u)}
|
||||
className="text-sm text-primary hover:underline"
|
||||
>
|
||||
{u.is_active ? 'Deactivate' : 'Activate'}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{users.length === 0 && <p className="px-4 py-6 text-text-secondary text-center">No users found.</p>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
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, Truck, Car, Hash, Ruler, Weight, Zap, Fuel } from 'lucide-react';
|
||||
|
||||
interface AssignmentItem {
|
||||
id: string;
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
interface VehicleDetailData {
|
||||
id: string;
|
||||
name: string;
|
||||
license_plate: string | null;
|
||||
brand: string | null;
|
||||
model: string | null;
|
||||
year: number | null;
|
||||
color: string | null;
|
||||
vehicle_type: string | null;
|
||||
payload_capacity_kg: number | null;
|
||||
load_volume_m3: number | null;
|
||||
fuel_type: string | null;
|
||||
is_active: boolean;
|
||||
notes: string | null;
|
||||
assignments: AssignmentItem[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export default function VehicleDetail() {
|
||||
const { vehicleId } = useParams<{ vehicleId: string }>();
|
||||
const { user } = useAuthStore();
|
||||
const navigate = useNavigate();
|
||||
const [vehicle, setVehicle] = useState<VehicleDetailData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const canWrite = user?.permissions?.includes('vehicles:write');
|
||||
const canDelete = user?.permissions?.includes('vehicles:delete');
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try { setLoading(true); const r = await api.get(`/vehicles/${vehicleId}`); setVehicle(r.data); }
|
||||
catch (err: any) { setError(err.response?.data?.detail || 'Failed to load'); }
|
||||
finally { setLoading(false); }
|
||||
})();
|
||||
}, [vehicleId]);
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!confirm(`Delete "${vehicle?.name}"?`)) return;
|
||||
try { await api.delete(`/vehicles/${vehicleId}`); navigate('/vehicles'); }
|
||||
catch (err: any) { alert(err.response?.data?.detail || 'Failed'); }
|
||||
};
|
||||
|
||||
if (loading) return <div className="p-6"><p className="text-text-secondary">Loading...</p></div>;
|
||||
if (error) return <div className="p-6"><p className="text-red-500">{error}</p></div>;
|
||||
if (!vehicle) return <div className="p-6"><p className="text-text-secondary">Not found.</p></div>;
|
||||
|
||||
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";
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-4xl mx-auto">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<button onClick={() => navigate('/vehicles')}
|
||||
className="flex items-center gap-2 text-text-secondary hover:text-text-primary">
|
||||
<ArrowLeft size={20} /> <span className="text-sm">Back to Vehicles</span>
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
{canWrite && <button onClick={() => navigate(`/vehicles/${vehicle.id}/edit`)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-dark 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 text-sm">
|
||||
<Trash2 size={16} /> Delete</button>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-text-primary flex items-center gap-3">
|
||||
{vehicle.vehicle_type === 'truck' ? <Truck /> : <Car />}
|
||||
{vehicle.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 ${vehicle.is_active ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-500'}`}>
|
||||
{vehicle.is_active ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
{vehicle.vehicle_type && (
|
||||
<span className="inline-block px-3 py-1 bg-primary/10 text-primary rounded-full text-xs font-medium">
|
||||
{vehicle.vehicle_type}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
|
||||
<div className="bg-surface rounded-lg border border-border p-4 space-y-1">
|
||||
<h2 className="text-lg font-semibold text-text-primary mb-3">Vehicle Details</h2>
|
||||
{vehicle.license_plate && <div className={fieldRowClass}><Hash className={iconClass} /><span className={labelClass}>License Plate</span><span className={valueClass}>{vehicle.license_plate}</span></div>}
|
||||
{vehicle.brand && <div className={fieldRowClass}><span className={labelClass}>Brand</span><span className={valueClass}>{vehicle.brand}</span></div>}
|
||||
{vehicle.model && <div className={fieldRowClass}><span className={labelClass}>Model</span><span className={valueClass}>{vehicle.model}</span></div>}
|
||||
{vehicle.year && <div className={fieldRowClass}><span className={labelClass}>Year</span><span className={valueClass}>{vehicle.year}</span></div>}
|
||||
{vehicle.color && <div className={fieldRowClass}><span className={labelClass}>Color</span><span className={valueClass}>{vehicle.color}</span></div>}
|
||||
{vehicle.fuel_type && <div className={fieldRowClass}><Fuel className={iconClass} /><span className={labelClass}>Fuel</span><span className={valueClass}>{vehicle.fuel_type}</span></div>}
|
||||
</div>
|
||||
<div className="bg-surface rounded-lg border border-border p-4 space-y-1">
|
||||
<h2 className="text-lg font-semibold text-text-primary mb-3">Capacity</h2>
|
||||
{vehicle.payload_capacity_kg != null && <div className={fieldRowClass}><Weight className={iconClass} /><span className={labelClass}>Payload</span><span className={valueClass}>{vehicle.payload_capacity_kg} kg</span></div>}
|
||||
{vehicle.load_volume_m3 != null && <div className={fieldRowClass}><Ruler className={iconClass} /><span className={labelClass}>Volume</span><span className={valueClass}>{vehicle.load_volume_m3} m³</span></div>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-8">
|
||||
<h2 className="text-lg font-semibold text-text-primary mb-3">Assignments</h2>
|
||||
<div className="bg-surface rounded-lg border border-border p-4">
|
||||
{vehicle.assignments.length === 0 ? (
|
||||
<p className="text-sm text-text-secondary italic">No assignments yet.</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{vehicle.assignments.slice(0, 5).map(a => (
|
||||
<div key={a.id} className="flex items-center gap-3 text-sm">
|
||||
<span className="text-text-secondary">{new Date(a.start_date).toLocaleDateString()}</span>
|
||||
<span className="text-text-secondary">→</span>
|
||||
<span className="text-text-secondary">{new Date(a.end_date).toLocaleDateString()}</span>
|
||||
<span className={`px-2 py-0.5 rounded text-xs font-medium ${
|
||||
a.status === 'assigned' ? 'bg-blue-100 text-blue-800' :
|
||||
a.status === 'in_use' ? 'bg-yellow-100 text-yellow-800' : 'bg-green-100 text-green-800'
|
||||
}`}>{a.status}</span>
|
||||
</div>
|
||||
))}
|
||||
{vehicle.assignments.length > 5 && <p className="text-xs text-text-secondary">+{vehicle.assignments.length - 5} more</p>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{vehicle.notes && (
|
||||
<div className="mb-8">
|
||||
<h2 className="text-lg font-semibold text-text-primary mb-3">Notes</h2>
|
||||
<div className="bg-surface rounded-lg border border-border p-4">
|
||||
<p className="text-sm text-text-primary whitespace-pre-wrap">{vehicle.notes}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-8 pt-4 border-t border-border text-xs text-text-secondary space-y-1">
|
||||
<p>Created: {new Date(vehicle.created_at).toLocaleString()}</p>
|
||||
<p>Updated: {new Date(vehicle.updated_at).toLocaleString()}</p>
|
||||
<p>ID: {vehicle.id}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import VehicleForm, { VehicleFormData } from '../components/VehicleForm.tsx';
|
||||
import api from '../services/api.ts';
|
||||
|
||||
export default function VehicleEdit() {
|
||||
const { vehicleId } = useParams<{ vehicleId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [initialData, setInitialData] = useState<VehicleFormData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const r = await api.get(`/vehicles/${vehicleId}`);
|
||||
const v = r.data;
|
||||
setInitialData({
|
||||
name: v.name || '',
|
||||
license_plate: v.license_plate || '',
|
||||
brand: v.brand || '',
|
||||
model: v.model || '',
|
||||
year: v.year ?? null,
|
||||
color: v.color || '',
|
||||
vehicle_type: v.vehicle_type || '',
|
||||
payload_capacity_kg: v.payload_capacity_kg ?? null,
|
||||
load_volume_m3: v.load_volume_m3 ?? null,
|
||||
fuel_type: v.fuel_type || '',
|
||||
is_active: v.is_active !== false,
|
||||
notes: v.notes || '',
|
||||
});
|
||||
} catch (err: any) { setError(err.response?.data?.detail || 'Failed to load'); }
|
||||
finally { setLoading(false); }
|
||||
})();
|
||||
}, [vehicleId]);
|
||||
|
||||
const handleUpdate = async (formData: VehicleFormData) => {
|
||||
await api.put(`/vehicles/${vehicleId}`, formData);
|
||||
navigate(`/vehicles/${vehicleId}`);
|
||||
};
|
||||
|
||||
if (loading) return <div className="p-6"><p className="text-text-secondary">Loading...</p></div>;
|
||||
if (error) return <div className="p-6"><p className="text-red-500">{error}</p></div>;
|
||||
if (!initialData) return <div className="p-6"><p className="text-text-secondary">Not found.</p></div>;
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-3xl mx-auto">
|
||||
<h1 className="text-2xl font-bold text-text-primary mb-6">Edit Vehicle</h1>
|
||||
<VehicleForm initialData={initialData} onSubmit={handleUpdate} submitLabel="Save Changes" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import VehicleForm, { emptyVehicleFormData } from '../components/VehicleForm.tsx';
|
||||
import api from '../services/api.ts';
|
||||
|
||||
export default function VehicleNew() {
|
||||
const navigate = useNavigate();
|
||||
const handleCreate = async (formData: any) => {
|
||||
await api.post('/vehicles', formData);
|
||||
navigate('/vehicles');
|
||||
};
|
||||
return (
|
||||
<div className="p-6 max-w-3xl mx-auto">
|
||||
<h1 className="text-2xl font-bold text-text-primary mb-6">Add Vehicle</h1>
|
||||
<VehicleForm initialData={emptyVehicleFormData} onSubmit={handleCreate} submitLabel="Create Vehicle" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuthStore } from '../stores/authStore.ts';
|
||||
import api from '../services/api.ts';
|
||||
import { Plus, Search, Pencil, Trash2, Eye } from 'lucide-react';
|
||||
|
||||
interface VehicleItem {
|
||||
id: string;
|
||||
name: string;
|
||||
license_plate: string | null;
|
||||
brand: string | null;
|
||||
model: string | null;
|
||||
vehicle_type: string | null;
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
export default function Vehicles() {
|
||||
const { user } = useAuthStore();
|
||||
const navigate = useNavigate();
|
||||
const [items, setItems] = useState<VehicleItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [search, setSearch] = useState('');
|
||||
const [typeFilter, setTypeFilter] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
const [total, setTotal] = useState(0);
|
||||
const size = 20;
|
||||
|
||||
const canWrite = user?.permissions?.includes('vehicles:write');
|
||||
const canDelete = user?.permissions?.includes('vehicles:delete');
|
||||
|
||||
const fetchItems = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const params = new URLSearchParams({ page: String(page), size: String(size) });
|
||||
if (search) params.set('search', search);
|
||||
if (typeFilter) params.set('vehicle_type', typeFilter);
|
||||
const response = await api.get(`/vehicles?${params.toString()}`);
|
||||
setItems(response.data.items);
|
||||
setTotal(response.data.total);
|
||||
} catch (err: any) { setError(err.response?.data?.detail || 'Failed to load'); }
|
||||
finally { setLoading(false); }
|
||||
};
|
||||
|
||||
useEffect(() => { fetchItems(); }, [page, typeFilter]);
|
||||
|
||||
const handleSearch = (e: React.FormEvent) => { e.preventDefault(); setPage(1); fetchItems(); };
|
||||
|
||||
const handleDelete = async (id: string, name: string) => {
|
||||
if (!confirm(`Delete vehicle "${name}"?`)) return;
|
||||
try { await api.delete(`/vehicles/${id}`); fetchItems(); }
|
||||
catch (err: any) { alert(err.response?.data?.detail || 'Failed to delete'); }
|
||||
};
|
||||
|
||||
const totalPages = Math.ceil(total / size);
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold text-text-primary">Vehicles</h1>
|
||||
{canWrite && (
|
||||
<button onClick={() => navigate('/vehicles/new')}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-dark">
|
||||
<Plus size={18} /> Add Vehicle
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="mb-4 flex flex-col sm:flex-row gap-3">
|
||||
<form onSubmit={handleSearch} className="flex-1 flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search size={18} className="absolute left-3 top-1/2 -translate-y-1/2 text-text-secondary" />
|
||||
<input type="text" placeholder="Search by name, plate, brand..." value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
className="w-full pl-10 pr-3 py-2 border border-border rounded-lg focus:ring-2 focus:ring-primary outline-none" />
|
||||
</div>
|
||||
<button type="submit" className="px-4 py-2 bg-surface border border-border rounded-lg hover:bg-gray-50 text-sm">Search</button>
|
||||
</form>
|
||||
<select value={typeFilter} onChange={e => { setTypeFilter(e.target.value); setPage(1); }}
|
||||
className="px-3 py-2 border border-border rounded-lg text-sm">
|
||||
<option value="">All Types</option>
|
||||
<option value="van">Van</option>
|
||||
<option value="truck">Truck</option>
|
||||
<option value="trailer">Trailer</option>
|
||||
<option value="car">Car</option>
|
||||
<option value="other">Other</option>
|
||||
</select>
|
||||
</div>
|
||||
{loading ? <p className="text-text-secondary">Loading...</p>
|
||||
: error ? <p className="text-red-500">{error}</p>
|
||||
: (<>
|
||||
<div className="bg-surface rounded-lg border border-border overflow-hidden">
|
||||
<table className="w-full text-left">
|
||||
<thead className="bg-gray-50 border-b border-border">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-sm font-medium text-text-secondary">Name</th>
|
||||
<th className="px-4 py-3 text-sm font-medium text-text-secondary">License Plate</th>
|
||||
<th className="px-4 py-3 text-sm font-medium text-text-secondary">Brand / Model</th>
|
||||
<th className="px-4 py-3 text-sm font-medium text-text-secondary">Type</th>
|
||||
<th className="px-4 py-3 text-sm font-medium text-text-secondary">Status</th>
|
||||
<th className="px-4 py-3 text-sm font-medium text-text-secondary">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map(v => (
|
||||
<tr key={v.id} className="border-b border-border last:border-0 hover:bg-gray-50">
|
||||
<td className="px-4 py-3 font-medium text-text-primary">{v.name}</td>
|
||||
<td className="px-4 py-3 text-text-secondary">{v.license_plate || '-'}</td>
|
||||
<td className="px-4 py-3 text-text-secondary">{[v.brand, v.model].filter(Boolean).join(' ') || '-'}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="inline-block px-2 py-0.5 rounded-full text-xs bg-gray-100 text-gray-700">
|
||||
{v.vehicle_type || 'N/A'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${v.is_active ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-500'}`}>
|
||||
{v.is_active ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={() => navigate(`/vehicles/${v.id}`)} className="p-1.5 text-text-secondary hover:text-primary" title="View"><Eye size={16} /></button>
|
||||
{canWrite && <button onClick={() => navigate(`/vehicles/${v.id}/edit`)} className="p-1.5 text-text-secondary hover:text-primary" title="Edit"><Pencil size={16} /></button>}
|
||||
{canDelete && <button onClick={() => handleDelete(v.id, v.name)} className="p-1.5 text-text-secondary hover:text-red-600" title="Delete"><Trash2 size={16} /></button>}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{items.length === 0 && <p className="px-4 py-6 text-text-secondary text-center">No vehicles found.</p>}
|
||||
</div>
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-center gap-2 mt-4">
|
||||
<button onClick={() => setPage(p => Math.max(1, p-1))} disabled={page===1} className="px-3 py-1 border border-border rounded disabled:opacity-50">Previous</button>
|
||||
<span className="text-sm text-text-secondary">Page {page} of {totalPages}</span>
|
||||
<button onClick={() => setPage(p => Math.min(totalPages, p+1))} disabled={page===totalPages} className="px-3 py-1 border border-border rounded disabled:opacity-50">Next</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
"""Axios API client with base URL and auth interceptor."""
|
||||
|
||||
import axios from 'axios';
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000/api/v1';
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
// Request interceptor: attach access token
|
||||
api.interceptors.request.use(
|
||||
(config) => {
|
||||
const token = localStorage.getItem('access_token');
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
},
|
||||
(error) => Promise.reject(error),
|
||||
);
|
||||
|
||||
// Response interceptor: handle 401 & auto-refresh
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error) => {
|
||||
const originalRequest = error.config;
|
||||
|
||||
// If 401 and not already retried and a refresh token exists
|
||||
if (
|
||||
error.response?.status === 401 &&
|
||||
!originalRequest._retry &&
|
||||
localStorage.getItem('refresh_token')
|
||||
) {
|
||||
originalRequest._retry = true;
|
||||
|
||||
try {
|
||||
const refreshToken = localStorage.getItem('refresh_token');
|
||||
const response = await axios.post(`${API_BASE_URL}/auth/refresh`, {
|
||||
refresh_token: refreshToken,
|
||||
});
|
||||
|
||||
const { access_token, refresh_token: newRefreshToken, user } = response.data;
|
||||
localStorage.setItem('access_token', access_token);
|
||||
localStorage.setItem('refresh_token', newRefreshToken);
|
||||
// Optionally update store
|
||||
const { useAuthStore } = await import('../stores/authStore');
|
||||
useAuthStore.getState().setUser(user);
|
||||
|
||||
// Retry original request with new token
|
||||
originalRequest.headers.Authorization = `Bearer ${access_token}`;
|
||||
return api(originalRequest);
|
||||
} catch (refreshError) {
|
||||
// Refresh failed – logout
|
||||
localStorage.removeItem('access_token');
|
||||
localStorage.removeItem('refresh_token');
|
||||
localStorage.removeItem('user');
|
||||
window.location.href = '/login';
|
||||
return Promise.reject(refreshError);
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
|
||||
export default api;
|
||||
@@ -0,0 +1,80 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
full_name: string;
|
||||
account_id: string;
|
||||
role_id: string | null;
|
||||
role_name: string | null;
|
||||
permissions: string[];
|
||||
}
|
||||
|
||||
interface AuthState {
|
||||
token: string | null;
|
||||
refreshToken: string | null;
|
||||
user: User | null;
|
||||
isAuthenticated: boolean;
|
||||
login: (accessToken: string, refreshToken: string, user: User) => void;
|
||||
setUser: (user: User) => void;
|
||||
logout: () => void;
|
||||
initialize: () => void;
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>((set) => ({
|
||||
token: null,
|
||||
refreshToken: null,
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
|
||||
login: (accessToken, refreshToken, user) => {
|
||||
localStorage.setItem('access_token', accessToken);
|
||||
localStorage.setItem('refresh_token', refreshToken);
|
||||
localStorage.setItem('user', JSON.stringify(user));
|
||||
set({
|
||||
token: accessToken,
|
||||
refreshToken,
|
||||
user,
|
||||
isAuthenticated: true,
|
||||
});
|
||||
},
|
||||
|
||||
setUser: (user) => {
|
||||
localStorage.setItem('user', JSON.stringify(user));
|
||||
set({ user });
|
||||
},
|
||||
|
||||
logout: () => {
|
||||
localStorage.removeItem('access_token');
|
||||
localStorage.removeItem('refresh_token');
|
||||
localStorage.removeItem('user');
|
||||
set({
|
||||
token: null,
|
||||
refreshToken: null,
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
});
|
||||
},
|
||||
|
||||
initialize: () => {
|
||||
const accessToken = localStorage.getItem('access_token');
|
||||
const refreshToken = localStorage.getItem('refresh_token');
|
||||
const userString = localStorage.getItem('user');
|
||||
if (accessToken && refreshToken && userString) {
|
||||
try {
|
||||
const user = JSON.parse(userString);
|
||||
set({
|
||||
token: accessToken,
|
||||
refreshToken,
|
||||
user,
|
||||
isAuthenticated: true,
|
||||
});
|
||||
} catch {
|
||||
// Invalid stored data, clear
|
||||
localStorage.removeItem('access_token');
|
||||
localStorage.removeItem('refresh_token');
|
||||
localStorage.removeItem('user');
|
||||
}
|
||||
}
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
|
||||
/* Path mapping */
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import path from 'path';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
host: true,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user