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:
Agent Zero
2026-05-31 20:36:42 +00:00
commit 7f7da15965
135 changed files with 18980 additions and 0 deletions
+15
View File
@@ -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"]
+13
View File
@@ -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>
+4616
View File
File diff suppressed because it is too large Load Diff
+43
View File
@@ -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"
}
}
+143
View File
@@ -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>
);
}
+136
View File
@@ -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>
);
}
+367
View File
@@ -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>
);
}
+123
View File
@@ -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>
);
}
+203
View File
@@ -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>
);
}
+163
View File
@@ -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 />;
}
+138
View File
@@ -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>
);
}
+32
View File
@@ -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;
}
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
+29
View File
@@ -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>,
);
+270
View File
@@ -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>
);
}
+224
View File
@@ -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>
);
}
+71
View File
@@ -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>
);
}
+23
View File
@@ -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>
);
}
+162
View File
@@ -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>
);
}
+178
View File
@@ -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>
);
}
+51
View File
@@ -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>
);
}
+17
View File
@@ -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>
);
}
+23
View File
@@ -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>
);
}
+239
View File
@@ -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>
);
}
+244
View File
@@ -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>
);
}
+79
View File
@@ -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>
);
}
+44
View File
@@ -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>
);
}
+31
View File
@@ -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>
);
}
+112
View File
@@ -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>
);
}
+269
View File
@@ -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>
);
}
+57
View File
@@ -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>
);
}
+23
View File
@@ -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>
);
}
+236
View File
@@ -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>
);
}
+183
View File
@@ -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>
);
}
+140
View File
@@ -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>
);
}
+168
View File
@@ -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>
);
}
+158
View File
@@ -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>
);
}
+52
View File
@@ -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>
);
}
+17
View File
@@ -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>
);
}
+143
View File
@@ -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>
);
}
+70
View File
@@ -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;
+80
View File
@@ -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');
}
}
},
}));
+33
View File
@@ -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"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+20
View File
@@ -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"]
}
+17
View File
@@ -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,
},
});