Initial commit — Shop Manager for Tony Moon

Full auto mechanic shop management tool: jobs, invoices, estimates,
inspections, inventory, recommendations, technicians, appointments,
reports, file uploads, kanban board, and more.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-07 00:08:35 -05:00
commit 8839412b52
100 changed files with 18510 additions and 0 deletions

View File

@@ -0,0 +1,90 @@
import { useRef } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Upload, Trash2, FileIcon, Image, File } from 'lucide-react';
import { api } from '../api/client';
import type { FileRecord } from '../types';
interface FileUploadProps {
entityType: string;
entityId: number;
}
function formatSize(bytes: number | null): string {
if (!bytes) return '';
if (bytes < 1024) return `${bytes}B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
}
function isImage(mimeType: string | null): boolean {
return !!mimeType && mimeType.startsWith('image/');
}
export default function FileUpload({ entityType, entityId }: FileUploadProps) {
const inputRef = useRef<HTMLInputElement>(null);
const queryClient = useQueryClient();
const queryKey = ['files', entityType, entityId];
const { data: files = [] } = useQuery<FileRecord[]>({
queryKey,
queryFn: () => api.getFiles(entityType, entityId),
});
const uploadMutation = useMutation({
mutationFn: (fileList: FileList) => api.uploadFiles(entityType, entityId, fileList),
onSuccess: () => queryClient.invalidateQueries({ queryKey }),
});
const deleteMutation = useMutation({
mutationFn: (id: number) => api.deleteFile(id),
onSuccess: () => queryClient.invalidateQueries({ queryKey }),
});
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-gray-700">Files</h3>
<button onClick={() => inputRef.current?.click()}
className="inline-flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium text-gray-600 bg-white border border-gray-300 rounded-lg hover:bg-gray-50">
<Upload size={14} /> Upload
</button>
<input ref={inputRef} type="file" multiple className="hidden"
onChange={(e) => { if (e.target.files?.length) { uploadMutation.mutate(e.target.files); e.target.value = ''; } }} />
</div>
{uploadMutation.isPending && <p className="text-xs text-blue-600">Uploading...</p>}
{files.length > 0 && (
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-2">
{files.map((file) => (
<div key={file.id} className="group relative border border-gray-200 rounded-lg overflow-hidden">
{isImage(file.mime_type) ? (
<a href={api.getFileUrl(file.id)} target="_blank" rel="noopener noreferrer">
<img src={api.getFileUrl(file.id)} alt={file.original_name}
className="w-full h-24 object-cover" />
</a>
) : (
<a href={api.getFileUrl(file.id)} target="_blank" rel="noopener noreferrer"
className="flex flex-col items-center justify-center h-24 bg-gray-50 hover:bg-gray-100">
<File size={24} className="text-gray-400" />
</a>
)}
<div className="px-2 py-1.5">
<p className="text-xs text-gray-700 truncate" title={file.original_name}>{file.original_name}</p>
<p className="text-xs text-gray-400">{formatSize(file.size)}</p>
</div>
<button onClick={() => deleteMutation.mutate(file.id)}
className="absolute top-1 right-1 p-1 bg-white/80 rounded text-gray-400 hover:text-red-500 opacity-0 group-hover:opacity-100 transition-opacity">
<Trash2 size={12} />
</button>
</div>
))}
</div>
)}
{files.length === 0 && !uploadMutation.isPending && (
<p className="text-xs text-gray-400">No files attached.</p>
)}
</div>
);
}

View File

@@ -0,0 +1,227 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { ClipboardCheck, Plus, CheckCircle, Camera, ChevronDown, ChevronRight } from 'lucide-react';
import { api } from '../api/client';
import { Card, Button, Badge, Modal } from './ui';
import FileUpload from './FileUpload';
import type { Inspection, InspectionItem, InspectionTemplate, InspectionCondition } from '../types';
const conditionColors: Record<string, string> = {
good: 'bg-green-500',
fair: 'bg-yellow-500',
poor: 'bg-orange-500',
critical: 'bg-red-500',
not_inspected: 'bg-gray-300',
};
const conditionLabels: Record<string, string> = {
good: 'Good', fair: 'Fair', poor: 'Poor', critical: 'Critical', not_inspected: 'N/A',
};
interface InspectionPanelProps {
jobId: number;
}
export default function InspectionPanel({ jobId }: InspectionPanelProps) {
const queryClient = useQueryClient();
const [showStartModal, setShowStartModal] = useState(false);
const [activeInspection, setActiveInspection] = useState<number | null>(null);
const { data: inspections = [] } = useQuery<Inspection[]>({
queryKey: ['inspections', jobId],
queryFn: () => api.getJobInspections(jobId),
});
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['inspections', jobId] });
return (
<Card>
<div className="p-4 border-b border-gray-200 flex items-center justify-between">
<h2 className="font-semibold text-gray-900 flex items-center gap-2">
<ClipboardCheck size={18} /> Inspections
</h2>
<Button variant="ghost" size="sm" onClick={() => setShowStartModal(true)}>
<Plus size={14} /> New Inspection
</Button>
</div>
{inspections.length === 0 ? (
<p className="p-4 text-sm text-gray-500">No inspections yet.</p>
) : (
<div className="divide-y divide-gray-100">
{inspections.map((insp) => (
<div key={insp.id}>
<button
onClick={() => setActiveInspection(activeInspection === insp.id ? null : insp.id)}
className="w-full flex items-center justify-between p-3 hover:bg-gray-50 text-left"
>
<div>
<p className="text-sm font-medium text-gray-900">
{insp.template_name || 'Custom Inspection'}
</p>
<p className="text-xs text-gray-500">
{new Date(insp.created_at).toLocaleDateString()}
{insp.technician_name && `${insp.technician_name}`}
</p>
</div>
<div className="flex items-center gap-2">
<Badge color={insp.status === 'completed' ? 'green' : 'yellow'}>
{insp.status === 'completed' ? 'Complete' : 'In Progress'}
</Badge>
{activeInspection === insp.id ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
</div>
</button>
{activeInspection === insp.id && (
<InspectionDetail inspectionId={insp.id} onUpdate={invalidate} />
)}
</div>
))}
</div>
)}
<StartInspectionModal open={showStartModal} onClose={() => setShowStartModal(false)} jobId={jobId} onDone={invalidate} />
</Card>
);
}
function InspectionDetail({ inspectionId, onUpdate }: { inspectionId: number; onUpdate: () => void }) {
const queryClient = useQueryClient();
const [expandedItem, setExpandedItem] = useState<number | null>(null);
const { data: inspection } = useQuery<Inspection>({
queryKey: ['inspection', inspectionId],
queryFn: () => api.getInspection(inspectionId),
});
const updateItemMutation = useMutation({
mutationFn: ({ itemId, data }: { itemId: number; data: any }) =>
api.updateInspectionItem(inspectionId, itemId, data),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['inspection', inspectionId] }),
});
const completeMutation = useMutation({
mutationFn: () => api.completeInspection(inspectionId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['inspection', inspectionId] });
onUpdate();
},
});
if (!inspection) return <p className="p-3 text-xs text-gray-400">Loading...</p>;
const items = inspection.items || [];
const sections = [...new Set(items.map(i => i.section))];
return (
<div className="px-3 pb-3 space-y-3">
{/* Summary bar */}
{inspection.summary && (
<div className="flex gap-3 text-xs">
{Object.entries(inspection.summary).map(([cond, count]) => (
<span key={cond} className="flex items-center gap-1">
<span className={`w-2.5 h-2.5 rounded-full ${conditionColors[cond]}`} />
{conditionLabels[cond]}: {count}
</span>
))}
</div>
)}
{sections.map(section => (
<div key={section}>
<h4 className="text-xs font-medium text-gray-500 uppercase mb-1">{section}</h4>
<div className="space-y-1">
{items.filter(i => i.section === section).map(item => (
<div key={item.id} className="bg-gray-50 rounded-lg">
<div className="flex items-center justify-between px-3 py-2">
<button
onClick={() => setExpandedItem(expandedItem === item.id ? null : item.id)}
className="flex-1 text-left text-sm text-gray-700"
>
{item.label}
</button>
<div className="flex gap-1">
{(['good', 'fair', 'poor', 'critical'] as InspectionCondition[]).map(cond => (
<button
key={cond}
onClick={() => updateItemMutation.mutate({ itemId: item.id, data: { condition: cond } })}
className={`w-6 h-6 rounded-full border-2 transition-all ${
item.condition === cond
? `${conditionColors[cond]} border-gray-700 ring-2 ring-offset-1 ring-gray-400`
: `${conditionColors[cond]} border-transparent opacity-40 hover:opacity-70`
}`}
title={conditionLabels[cond]}
/>
))}
</div>
</div>
{expandedItem === item.id && (
<div className="px-3 pb-2 space-y-2">
<textarea
className="w-full text-xs border border-gray-200 rounded px-2 py-1 resize-none"
placeholder="Notes..."
rows={2}
defaultValue={item.notes || ''}
onBlur={(e) => {
if (e.target.value !== (item.notes || '')) {
updateItemMutation.mutate({ itemId: item.id, data: { notes: e.target.value } });
}
}}
/>
<FileUpload entityType="inspection_item" entityId={item.id} />
</div>
)}
</div>
))}
</div>
</div>
))}
{inspection.status !== 'completed' && (
<Button size="sm" onClick={() => completeMutation.mutate()} disabled={completeMutation.isPending}>
<CheckCircle size={14} /> {completeMutation.isPending ? 'Completing...' : 'Complete Inspection'}
</Button>
)}
</div>
);
}
function StartInspectionModal({ open, onClose, jobId, onDone }: {
open: boolean; onClose: () => void; jobId: number; onDone: () => void;
}) {
const [templateId, setTemplateId] = useState('');
const { data: templates = [] } = useQuery<InspectionTemplate[]>({
queryKey: ['inspection-templates'],
queryFn: () => api.getInspectionTemplates(),
enabled: open,
});
const createMutation = useMutation({
mutationFn: () => api.createInspection({ jobId, templateId: templateId ? Number(templateId) : undefined }),
onSuccess: () => { onDone(); onClose(); },
});
return (
<Modal open={open} onClose={onClose} title="Start Inspection">
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Template</label>
<select className="block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
value={templateId} onChange={(e) => setTemplateId(e.target.value)}>
<option value="">No template (blank)</option>
{templates.map(t => (
<option key={t.id} value={t.id}>{t.name} ({t.items?.length || 0} items)</option>
))}
</select>
</div>
<div className="flex justify-end gap-2">
<Button variant="secondary" onClick={onClose}>Cancel</Button>
<Button onClick={() => createMutation.mutate()} disabled={createMutation.isPending}>
{createMutation.isPending ? 'Starting...' : 'Start Inspection'}
</Button>
</div>
</div>
</Modal>
);
}

View File

@@ -0,0 +1,95 @@
import { Link } from 'react-router-dom';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from '../api/client';
import { Badge } from './ui';
import type { Job, Tag } from '../types';
const columns: { status: string; label: string; color: string }[] = [
{ status: 'received', label: 'Received', color: 'border-t-blue-400' },
{ status: 'in_progress', label: 'In Progress', color: 'border-t-yellow-400' },
{ status: 'completed', label: 'Completed', color: 'border-t-green-400' },
{ status: 'invoiced', label: 'Invoiced', color: 'border-t-purple-400' },
];
const tagColorClasses: Record<string, string> = {
gray: 'bg-gray-100 text-gray-600',
red: 'bg-red-100 text-red-600',
yellow: 'bg-yellow-100 text-yellow-600',
green: 'bg-green-100 text-green-600',
blue: 'bg-blue-100 text-blue-600',
purple: 'bg-purple-100 text-purple-600',
};
interface KanbanBoardProps {
jobs: Job[];
}
export default function KanbanBoard({ jobs }: KanbanBoardProps) {
const queryClient = useQueryClient();
const statusMutation = useMutation({
mutationFn: ({ id, status }: { id: number; status: string }) => api.updateJob(id, { status }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['jobs'] }),
});
const handleDrop = (e: React.DragEvent, status: string) => {
e.preventDefault();
const jobId = parseInt(e.dataTransfer.getData('jobId'));
if (jobId) statusMutation.mutate({ id: jobId, status });
};
return (
<div className="flex gap-3 overflow-x-auto pb-4" style={{ minHeight: '60vh' }}>
{columns.map((col) => {
const colJobs = jobs.filter((j) => j.status === col.status);
return (
<div key={col.status}
className={`flex-shrink-0 w-72 bg-gray-50 rounded-lg border-t-4 ${col.color}`}
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => handleDrop(e, col.status)}
>
<div className="px-3 py-2.5 flex items-center justify-between">
<h3 className="text-sm font-semibold text-gray-700">{col.label}</h3>
<span className="text-xs bg-gray-200 text-gray-600 px-1.5 py-0.5 rounded-full">{colJobs.length}</span>
</div>
<div className="px-2 pb-2 space-y-2">
{colJobs.map((job) => (
<div key={job.id} draggable
onDragStart={(e) => e.dataTransfer.setData('jobId', String(job.id))}
className="bg-white rounded-lg border border-gray-200 p-3 shadow-sm hover:shadow-md transition-shadow cursor-grab active:cursor-grabbing"
>
<Link to={`/jobs/${job.id}`} className="block">
<div className="flex items-center justify-between mb-1">
<span className="font-mono text-xs font-semibold text-gray-900">{job.job_number}</span>
</div>
<p className="text-sm text-gray-700 leading-tight">
{[job.year, job.make, job.model].filter(Boolean).join(' ')}
</p>
<p className="text-xs text-gray-500 mt-0.5">
{job.first_name && job.last_name ? `${job.first_name} ${job.last_name}` : ''}
{job.shop_name ? (job.first_name ? `${job.shop_name}` : job.shop_name) : ''}
</p>
{job.description && <p className="text-xs text-gray-400 mt-1 line-clamp-2">{job.description}</p>}
</Link>
{job.tags && job.tags.length > 0 && (
<div className="flex flex-wrap gap-1 mt-2">
{job.tags.map((tag: Tag) => (
<span key={tag.id} className={`px-1.5 py-0.5 rounded text-[10px] font-medium ${tagColorClasses[tag.color] || tagColorClasses.gray}`}>
{tag.name}
</span>
))}
</div>
)}
</div>
))}
{colJobs.length === 0 && (
<div className="text-center py-8 text-xs text-gray-400">
Drop jobs here
</div>
)}
</div>
</div>
);
})}
</div>
);
}

View File

@@ -0,0 +1,125 @@
import { Link, useLocation, Outlet } from 'react-router-dom';
import { LayoutDashboard, Wrench, Building2, FileText, Settings, Menu, X, Users, Car, LogOut, ClipboardList, HardHat, Calendar, Package, BarChart3 } from 'lucide-react';
import { useStore } from '../store';
const navItems = [
{ to: '/', icon: LayoutDashboard, label: 'Dashboard' },
{ to: '/jobs', icon: Wrench, label: 'Jobs' },
{ to: '/estimates', icon: ClipboardList, label: 'Estimates' },
{ to: '/shops', icon: Building2, label: 'Shops' },
{ to: '/owners', icon: Users, label: 'Owners' },
{ to: '/vehicles', icon: Car, label: 'Vehicles' },
{ to: '/invoices', icon: FileText, label: 'Invoices' },
{ to: '/technicians', icon: HardHat, label: 'Technicians' },
{ to: '/appointments', icon: Calendar, label: 'Appointments' },
{ to: '/inventory', icon: Package, label: 'Inventory' },
{ to: '/reports', icon: BarChart3, label: 'Reports' },
{ to: '/settings', icon: Settings, label: 'Settings' },
];
export default function Layout() {
const location = useLocation();
const { sidebarOpen, toggleSidebar, logout, user } = useStore();
return (
<div className="min-h-screen bg-gray-50 flex">
{/* Desktop Sidebar */}
<aside className="hidden md:flex flex-col w-60 bg-white border-r border-gray-200 fixed h-full">
<div className="p-4 border-b border-gray-200">
<h1 className="text-xl font-bold text-gray-900">Shop Manager</h1>
{user && <p className="text-sm text-gray-500 mt-0.5">{user.displayName || user.username}</p>}
</div>
<nav className="flex-1 p-3 space-y-1">
{navItems.map(({ to, icon: Icon, label }) => {
const active = to === '/' ? location.pathname === '/' : location.pathname.startsWith(to);
return (
<Link
key={to}
to={to}
className={`flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
active
? 'bg-blue-50 text-blue-700'
: 'text-gray-600 hover:bg-gray-100 hover:text-gray-900'
}`}
>
<Icon size={18} />
{label}
</Link>
);
})}
</nav>
<button
onClick={() => { logout(); window.location.href = '/login'; }}
className="flex items-center gap-3 px-6 py-3 text-sm text-gray-500 hover:text-gray-700 border-t border-gray-200"
>
<LogOut size={18} />
Sign out
</button>
</aside>
{/* Mobile Header */}
<div className="md:hidden fixed top-0 left-0 right-0 bg-white border-b border-gray-200 z-30 flex items-center justify-between px-4 h-14">
<h1 className="text-lg font-bold text-gray-900">Shop Manager</h1>
<button onClick={toggleSidebar} className="p-2 text-gray-600">
{sidebarOpen ? <X size={22} /> : <Menu size={22} />}
</button>
</div>
{/* Mobile Sidebar Overlay */}
{sidebarOpen && (
<div className="md:hidden fixed inset-0 z-40">
<div className="absolute inset-0 bg-black/30" onClick={toggleSidebar} />
<aside className="absolute left-0 top-0 bottom-0 w-64 bg-white shadow-xl">
<div className="p-4 border-b border-gray-200">
<h1 className="text-xl font-bold text-gray-900">Shop Manager</h1>
</div>
<nav className="p-3 space-y-1">
{navItems.map(({ to, icon: Icon, label }) => {
const active = to === '/' ? location.pathname === '/' : location.pathname.startsWith(to);
return (
<Link
key={to}
to={to}
onClick={toggleSidebar}
className={`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium ${
active ? 'bg-blue-50 text-blue-700' : 'text-gray-600 hover:bg-gray-100'
}`}
>
<Icon size={18} />
{label}
</Link>
);
})}
</nav>
</aside>
</div>
)}
{/* Main Content */}
<main className="flex-1 md:ml-60 pt-14 md:pt-0">
<div className="p-4 md:p-6 max-w-6xl">
<Outlet />
</div>
</main>
{/* Mobile Bottom Nav */}
<nav className="md:hidden fixed bottom-0 left-0 right-0 bg-white border-t border-gray-200 z-30 flex">
{navItems.slice(0, 5).map(({ to, icon: Icon, label }) => {
const active = to === '/' ? location.pathname === '/' : location.pathname.startsWith(to);
return (
<Link
key={to}
to={to}
className={`flex-1 flex flex-col items-center py-2 text-xs ${
active ? 'text-blue-600' : 'text-gray-500'
}`}
>
<Icon size={20} />
<span className="mt-0.5">{label}</span>
</Link>
);
})}
</nav>
</div>
);
}

View File

@@ -0,0 +1,104 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { X, Plus, Tag as TagIcon } from 'lucide-react';
import { api } from '../api/client';
import type { Tag } from '../types';
const TAG_COLORS = ['gray', 'red', 'yellow', 'green', 'blue', 'purple'];
interface TagPickerProps {
jobId: number;
tags: Tag[];
}
export default function TagPicker({ jobId, tags }: TagPickerProps) {
const [showPicker, setShowPicker] = useState(false);
const [newTagName, setNewTagName] = useState('');
const [newTagColor, setNewTagColor] = useState('gray');
const queryClient = useQueryClient();
const { data: allTags = [] } = useQuery<Tag[]>({ queryKey: ['tags'], queryFn: api.getTags });
const invalidate = () => {
queryClient.invalidateQueries({ queryKey: ['jobs', String(jobId)] });
queryClient.invalidateQueries({ queryKey: ['jobs'] });
};
const addMutation = useMutation({ mutationFn: (tagId: number) => api.addTagToJob(jobId, tagId), onSuccess: invalidate });
const removeMutation = useMutation({ mutationFn: (tagId: number) => api.removeTagFromJob(jobId, tagId), onSuccess: invalidate });
const createMutation = useMutation({
mutationFn: api.createTag,
onSuccess: (tag: Tag) => {
queryClient.invalidateQueries({ queryKey: ['tags'] });
addMutation.mutate(tag.id);
setNewTagName('');
setNewTagColor('gray');
},
});
const availableTags = allTags.filter((t) => !tags.some((jt) => jt.id === t.id));
const colorClasses: Record<string, string> = {
gray: 'bg-gray-100 text-gray-700 border-gray-200',
red: 'bg-red-100 text-red-700 border-red-200',
yellow: 'bg-yellow-100 text-yellow-700 border-yellow-200',
green: 'bg-green-100 text-green-700 border-green-200',
blue: 'bg-blue-100 text-blue-700 border-blue-200',
purple: 'bg-purple-100 text-purple-700 border-purple-200',
};
const dotClasses: Record<string, string> = {
gray: 'bg-gray-400', red: 'bg-red-500', yellow: 'bg-yellow-500', green: 'bg-green-500', blue: 'bg-blue-500', purple: 'bg-purple-500',
};
return (
<div>
<div className="flex flex-wrap gap-1.5 items-center">
{tags.map((tag) => (
<span key={tag.id} className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium border ${colorClasses[tag.color] || colorClasses.gray}`}>
{tag.name}
<button onClick={() => removeMutation.mutate(tag.id)} className="hover:opacity-70">
<X size={12} />
</button>
</span>
))}
<button onClick={() => setShowPicker(!showPicker)} className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium border border-dashed border-gray-300 text-gray-500 hover:border-blue-400 hover:text-blue-600">
<Plus size={12} /> Tag
</button>
</div>
{showPicker && (
<div className="mt-2 border border-gray-200 rounded-lg p-3 bg-white shadow-sm space-y-2">
{availableTags.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{availableTags.map((tag) => (
<button key={tag.id} onClick={() => { addMutation.mutate(tag.id); setShowPicker(false); }}
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium border hover:opacity-80 ${colorClasses[tag.color] || colorClasses.gray}`}>
<Plus size={10} /> {tag.name}
</button>
))}
</div>
)}
<div className="flex gap-2 items-end">
<div className="flex-1">
<input className="block w-full border border-gray-300 rounded px-2 py-1 text-xs focus:border-blue-500 outline-none"
placeholder="New tag name..." value={newTagName} onChange={(e) => setNewTagName(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter' && newTagName.trim()) { e.preventDefault(); createMutation.mutate({ name: newTagName.trim(), color: newTagColor }); } }}
/>
</div>
<div className="flex gap-1">
{TAG_COLORS.map((c) => (
<button key={c} onClick={() => setNewTagColor(c)}
className={`w-5 h-5 rounded-full ${dotClasses[c]} ${newTagColor === c ? 'ring-2 ring-offset-1 ring-blue-400' : ''}`} />
))}
</div>
<button onClick={() => { if (newTagName.trim()) createMutation.mutate({ name: newTagName.trim(), color: newTagColor }); }}
disabled={!newTagName.trim()} className="px-2 py-1 text-xs font-medium bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50">
Add
</button>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,448 @@
import { useState, useRef } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { ClipboardCheck, Plus, CheckCircle, ChevronLeft, ChevronRight, Camera, MessageSquare, X, AlertTriangle, Check, Trash2 } from 'lucide-react';
import { api } from '../api/client';
import { Card, Button, Badge, Modal } from './ui';
import type { InspectionTemplate } from '../types';
const conditionConfig = [
{ value: 'good', label: 'Good', bg: 'bg-green-500 text-white', outline: 'border-green-400 text-green-700 hover:bg-green-50', icon: '✓' },
{ value: 'fair', label: 'Attention', bg: 'bg-yellow-500 text-white', outline: 'border-yellow-400 text-yellow-700 hover:bg-yellow-50', icon: '~' },
{ value: 'poor', label: 'Needs Work', bg: 'bg-red-500 text-white', outline: 'border-red-400 text-red-700 hover:bg-red-50', icon: '!' },
];
const conditionBorder: Record<string, string> = {
good: 'border-green-300 bg-green-50/50', fair: 'border-yellow-300 bg-yellow-50/50',
poor: 'border-red-300 bg-red-50/50', critical: 'border-red-500 bg-red-100',
not_inspected: 'border-gray-200 bg-white',
};
const conditionDot: Record<string, string> = {
good: 'bg-green-500', fair: 'bg-yellow-500', poor: 'bg-red-500', critical: 'bg-red-700', not_inspected: 'bg-gray-300',
};
interface Props {
vehicleId: number;
}
export default function VehicleInspectionPanel({ vehicleId }: Props) {
const queryClient = useQueryClient();
const [showStartModal, setShowStartModal] = useState(false);
const [activeInspectionId, setActiveInspectionId] = useState<number | null>(null);
const { data: inspections = [] } = useQuery<any[]>({
queryKey: ['vehicle-inspections', vehicleId],
queryFn: () => api.getVehicleInspections(vehicleId),
});
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['vehicle-inspections', vehicleId] });
return (
<>
<Card>
<div className="p-4 border-b border-gray-200 flex items-center justify-between">
<h2 className="font-semibold text-gray-900 flex items-center gap-2">
<ClipboardCheck size={18} /> Inspections
</h2>
<Button variant="ghost" size="sm" onClick={() => setShowStartModal(true)}>
<Plus size={14} /> New Inspection
</Button>
</div>
{inspections.length === 0 ? (
<div className="p-6 text-center">
<ClipboardCheck size={32} className="mx-auto text-gray-300 mb-2" />
<p className="text-sm text-gray-500">No inspections yet.</p>
<Button size="sm" className="mt-3" onClick={() => setShowStartModal(true)}>Start Inspection</Button>
</div>
) : (
<div className="divide-y divide-gray-100">
{inspections.map((insp: any) => (
<button key={insp.id} onClick={() => setActiveInspectionId(insp.id)}
className="w-full flex items-center justify-between p-3 hover:bg-gray-50 text-left">
<div>
<p className="text-sm font-medium text-gray-900">{insp.template_name || 'Inspection'}</p>
<p className="text-xs text-gray-500">
{new Date(insp.created_at).toLocaleDateString()}
{insp.technician_name && `${insp.technician_name}`}
{insp.job_number && `${insp.job_number}`}
</p>
</div>
<Badge color={insp.status === 'completed' ? 'green' : 'yellow'}>
{insp.status === 'completed' ? 'Complete' : 'In Progress'}
</Badge>
</button>
))}
</div>
)}
</Card>
<StartModal open={showStartModal} onClose={() => setShowStartModal(false)} vehicleId={vehicleId}
onDone={(id) => { invalidate(); setActiveInspectionId(id); }} />
{activeInspectionId && (
<MobileInspection inspectionId={activeInspectionId}
onClose={() => { setActiveInspectionId(null); invalidate(); }} />
)}
</>
);
}
function StartModal({ open, onClose, vehicleId, onDone }: {
open: boolean; onClose: () => void; vehicleId: number; onDone: (id: number) => void;
}) {
const [templateId, setTemplateId] = useState('');
const { data: templates = [] } = useQuery<InspectionTemplate[]>({
queryKey: ['inspection-templates'],
queryFn: () => api.getInspectionTemplates(),
enabled: open,
});
const createMutation = useMutation({
mutationFn: () => api.createInspection({ vehicleId, templateId: templateId ? Number(templateId) : undefined }),
onSuccess: (data: any) => { onDone(data.id); onClose(); },
});
return (
<Modal open={open} onClose={onClose} title="Start Inspection">
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Choose Template</label>
<div className="space-y-2">
{templates.map(t => (
<button key={t.id} onClick={() => setTemplateId(String(t.id))}
className={`w-full text-left p-3 rounded-lg border-2 transition-colors ${
templateId === String(t.id) ? 'border-blue-500 bg-blue-50' : 'border-gray-200 hover:border-gray-300'
}`}>
<p className="text-sm font-medium text-gray-900">{t.name}</p>
{(t as any).description && <p className="text-xs text-gray-500 mt-0.5">{(t as any).description}</p>}
</button>
))}
<button onClick={() => setTemplateId('')}
className={`w-full text-left p-3 rounded-lg border-2 transition-colors ${
templateId === '' ? 'border-blue-500 bg-blue-50' : 'border-gray-200 hover:border-gray-300'
}`}>
<p className="text-sm font-medium text-gray-900">Blank Inspection</p>
<p className="text-xs text-gray-500 mt-0.5">Start from scratch</p>
</button>
</div>
</div>
<div className="flex justify-end gap-2">
<Button variant="secondary" onClick={onClose}>Cancel</Button>
<Button onClick={() => createMutation.mutate()} disabled={createMutation.isPending}>
{createMutation.isPending ? 'Starting...' : 'Start Inspection'}
</Button>
</div>
</div>
</Modal>
);
}
function MobileInspection({ inspectionId, onClose }: { inspectionId: number; onClose: () => void }) {
const queryClient = useQueryClient();
const [currentSection, setCurrentSection] = useState(0);
const [expandedItem, setExpandedItem] = useState<number | null>(null);
const [showSummary, setShowSummary] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const [photoItemId, setPhotoItemId] = useState<number | null>(null);
const { data: inspection } = useQuery<any>({
queryKey: ['inspection', inspectionId],
queryFn: () => api.getInspection(inspectionId),
});
const { data: cannedNotes = [] } = useQuery<any[]>({
queryKey: ['canned-inspection-notes'],
queryFn: () => api.getCannedInspectionNotes(),
});
const updateItemMutation = useMutation({
mutationFn: ({ itemId, data }: { itemId: number; data: any }) =>
api.updateInspectionItem(inspectionId, itemId, data),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['inspection', inspectionId] }),
});
const completeMutation = useMutation({
mutationFn: () => api.completeInspection(inspectionId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['inspection', inspectionId] });
onClose();
},
});
const uploadMutation = useMutation({
mutationFn: ({ itemId, files }: { itemId: number; files: FileList }) =>
api.uploadFiles('inspection_item', itemId, files),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['inspection', inspectionId] }),
});
if (!inspection) return null;
const items: any[] = inspection.items || [];
const sections = [...new Set(items.map((i: any) => i.section))];
const sectionItems = showSummary ? items : items.filter((i: any) => i.section === sections[currentSection]);
const totalItems = items.length;
const inspectedItems = items.filter((i: any) => i.condition !== 'not_inspected').length;
const progress = totalItems > 0 ? Math.round((inspectedItems / totalItems) * 100) : 0;
const redItems = items.filter((i: any) => i.condition === 'poor' || i.condition === 'critical');
const yellowItems = items.filter((i: any) => i.condition === 'fair');
const greenItems = items.filter((i: any) => i.condition === 'good');
const unratedItems = items.filter((i: any) => i.condition === 'not_inspected');
return (
<div className="fixed inset-0 z-50 bg-white flex flex-col">
{/* Header */}
<div className="bg-gray-900 text-white px-4 py-3 flex items-center justify-between safe-area-top">
<button onClick={onClose} className="p-1"><X size={24} /></button>
<div className="text-center">
<p className="text-sm font-semibold">{inspection.template_name || 'Inspection'}</p>
<p className="text-xs text-gray-400">{inspectedItems}/{totalItems} {progress}%</p>
</div>
<button onClick={() => setShowSummary(!showSummary)}
className={`text-xs px-2 py-1 rounded ${showSummary ? 'bg-white text-gray-900' : 'bg-gray-700 text-white'}`}>
{showSummary ? 'Items' : 'Summary'}
</button>
</div>
{/* Progress bar */}
<div className="h-1.5 bg-gray-200">
<div className="h-full bg-green-500 transition-all duration-300" style={{ width: `${progress}%` }} />
</div>
{showSummary ? (
/* Summary View */
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{redItems.length > 0 && (
<div>
<h3 className="text-sm font-semibold text-red-700 flex items-center gap-1.5 mb-2">
<span className="w-3 h-3 rounded-full bg-red-500" /> Needs Work ({redItems.length})
</h3>
{redItems.map((item: any) => (
<div key={item.id} className="bg-red-50 rounded-lg p-3 mb-2 border border-red-200">
<p className="text-sm font-medium text-gray-900">{item.label}</p>
{item.notes && <p className="text-xs text-gray-600 mt-0.5">{item.notes}</p>}
{item.measurement && <p className="text-xs text-gray-500 mt-0.5">Measured: {item.measurement}{item.measurement_unit || ''}</p>}
</div>
))}
</div>
)}
{yellowItems.length > 0 && (
<div>
<h3 className="text-sm font-semibold text-yellow-700 flex items-center gap-1.5 mb-2">
<span className="w-3 h-3 rounded-full bg-yellow-500" /> Attention ({yellowItems.length})
</h3>
{yellowItems.map((item: any) => (
<div key={item.id} className="bg-yellow-50 rounded-lg p-3 mb-2 border border-yellow-200">
<p className="text-sm font-medium text-gray-900">{item.label}</p>
{item.notes && <p className="text-xs text-gray-600 mt-0.5">{item.notes}</p>}
</div>
))}
</div>
)}
{greenItems.length > 0 && (
<div>
<h3 className="text-sm font-semibold text-green-700 flex items-center gap-1.5 mb-2">
<span className="w-3 h-3 rounded-full bg-green-500" /> Good ({greenItems.length})
</h3>
<div className="bg-green-50 rounded-lg p-3 border border-green-200">
<p className="text-xs text-gray-600">{greenItems.map((i: any) => i.label).join(', ')}</p>
</div>
</div>
)}
{unratedItems.length > 0 && (
<div>
<h3 className="text-sm font-semibold text-gray-500 flex items-center gap-1.5 mb-2">
<span className="w-3 h-3 rounded-full bg-gray-300" /> Not Inspected ({unratedItems.length})
</h3>
<div className="bg-gray-50 rounded-lg p-3 border border-gray-200">
<p className="text-xs text-gray-500">{unratedItems.map((i: any) => i.label).join(', ')}</p>
</div>
</div>
)}
</div>
) : (
<>
{/* Section tabs */}
<div className="flex border-b border-gray-200 overflow-x-auto">
{sections.map((section, idx) => {
const sItems = items.filter((i: any) => i.section === section);
const done = sItems.filter((i: any) => i.condition !== 'not_inspected').length;
const allDone = done === sItems.length;
return (
<button key={section} onClick={() => setCurrentSection(idx)}
className={`flex-shrink-0 px-4 py-2.5 text-sm font-medium border-b-2 transition-colors ${
currentSection === idx ? 'border-blue-500 text-blue-600' : 'border-transparent text-gray-500'
}`}>
{allDone && <Check size={12} className="inline mr-1 text-green-500" />}
{section} <span className="text-xs text-gray-400 ml-1">{done}/{sItems.length}</span>
</button>
);
})}
</div>
{/* Items */}
<div className="flex-1 overflow-y-auto">
<div className="p-3 space-y-3">
{sectionItems.map((item: any) => (
<div key={item.id} className={`rounded-xl border-2 p-4 transition-all ${conditionBorder[item.condition] || conditionBorder.not_inspected}`}>
<p className="text-sm font-semibold text-gray-900 mb-3">{item.label}</p>
{/* 3 big condition buttons */}
<div className="grid grid-cols-3 gap-2">
{conditionConfig.map(c => (
<button key={c.value}
onClick={() => updateItemMutation.mutate({ itemId: item.id, data: { condition: c.value } })}
className={`py-3.5 rounded-xl text-sm font-bold border-2 transition-all ${
item.condition === c.value ? c.bg : `bg-white ${c.outline}`
}`}>
{c.label}
</button>
))}
</div>
{/* Critical safety flag — shows when red */}
{(item.condition === 'poor' || item.condition === 'critical') && (
<button onClick={() => updateItemMutation.mutate({ itemId: item.id, data: { condition: item.condition === 'critical' ? 'poor' : 'critical' } })}
className={`mt-2 w-full py-2 rounded-lg text-xs font-bold border-2 transition-all flex items-center justify-center gap-1.5 ${
item.condition === 'critical'
? 'bg-red-700 border-red-700 text-white'
: 'border-red-300 text-red-600 hover:bg-red-50'
}`}>
<AlertTriangle size={14} />
{item.condition === 'critical' ? 'CRITICAL SAFETY ISSUE' : 'Flag as Critical Safety Issue'}
</button>
)}
{/* Measurement — for brake/tire items */}
{item.measurement_type && (
<div className="mt-3 flex items-center gap-2">
<span className="text-xs text-gray-500">
{item.measurement_type === 'brake_mm' ? 'Pad thickness:' : 'Tread depth:'}
</span>
<input
className="w-16 border border-gray-300 rounded-lg px-2 py-1.5 text-sm text-center focus:border-blue-500 outline-none"
type="number" step="0.5" min="0"
defaultValue={item.measurement || ''}
onBlur={(e) => {
if (e.target.value !== (item.measurement || '')) {
updateItemMutation.mutate({
itemId: item.id,
data: {
measurement: e.target.value || null,
measurementUnit: item.measurement_type === 'brake_mm' ? 'mm' : '/32"',
},
});
}
}}
/>
<span className="text-xs text-gray-400">{item.measurement_type === 'brake_mm' ? 'mm' : '/32"'}</span>
</div>
)}
{/* Action row */}
<div className="flex gap-2 mt-3">
<button onClick={() => setExpandedItem(expandedItem === item.id ? null : item.id)}
className={`flex items-center gap-1.5 px-3 py-2 rounded-lg text-xs font-medium transition-colors ${
item.notes ? 'bg-blue-100 text-blue-700' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`}>
<MessageSquare size={14} /> {item.notes ? 'Notes' : 'Add Note'}
</button>
<button onClick={() => { setPhotoItemId(item.id); fileInputRef.current?.click(); }}
className="flex items-center gap-1.5 px-3 py-2 rounded-lg text-xs font-medium bg-gray-100 text-gray-600 hover:bg-gray-200">
<Camera size={14} /> Photo
</button>
</div>
{/* Expanded: notes + canned notes */}
{expandedItem === item.id && (
<div className="mt-3 space-y-2">
{/* Canned notes chips */}
<div className="flex flex-wrap gap-1.5">
{cannedNotes.map((cn: any) => {
const isApplied = item.notes?.includes(cn.note_text);
return (
<button key={cn.id}
onClick={() => {
const newNotes = isApplied
? (item.notes || '').replace(cn.note_text, '').replace(/\n+/g, '\n').trim()
: [item.notes, cn.note_text].filter(Boolean).join('\n');
updateItemMutation.mutate({ itemId: item.id, data: { notes: newNotes } });
}}
className={`px-2.5 py-1.5 rounded-lg text-xs font-medium border transition-colors ${
isApplied ? 'bg-blue-100 border-blue-300 text-blue-700' : 'bg-white border-gray-200 text-gray-600 hover:bg-gray-50'
}`}>
{cn.label}
</button>
);
})}
</div>
{/* Custom note */}
<textarea
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm resize-none focus:border-blue-500 outline-none"
placeholder="Custom notes..."
rows={2}
defaultValue={item.notes || ''}
onBlur={(e) => {
if (e.target.value !== (item.notes || '')) {
updateItemMutation.mutate({ itemId: item.id, data: { notes: e.target.value } });
}
}}
/>
</div>
)}
</div>
))}
</div>
</div>
</>
)}
{/* Hidden file input */}
<input ref={fileInputRef} type="file" accept="image/*" capture="environment" className="hidden"
onChange={(e) => {
if (e.target.files?.length && photoItemId) {
uploadMutation.mutate({ itemId: photoItemId, files: e.target.files });
e.target.value = '';
}
}}
/>
{/* Bottom nav */}
<div className="border-t border-gray-200 bg-white px-4 py-3 flex items-center justify-between safe-area-bottom">
{showSummary ? (
<>
<button onClick={() => setShowSummary(false)} className="flex items-center gap-1 px-4 py-2.5 rounded-lg text-sm font-medium text-gray-600 hover:bg-gray-100">
<ChevronLeft size={18} /> Back to Items
</button>
<button onClick={() => completeMutation.mutate()} disabled={completeMutation.isPending}
className="flex items-center gap-1.5 px-5 py-2.5 rounded-xl text-sm font-bold bg-green-600 text-white hover:bg-green-700 disabled:opacity-50">
<CheckCircle size={18} /> {completeMutation.isPending ? 'Saving...' : 'Complete Inspection'}
</button>
</>
) : (
<>
<button onClick={() => setCurrentSection(Math.max(0, currentSection - 1))}
disabled={currentSection === 0}
className="flex items-center gap-1 px-4 py-2.5 rounded-lg text-sm font-medium disabled:text-gray-300 text-gray-600 hover:bg-gray-100">
<ChevronLeft size={18} /> Prev
</button>
<span className="text-sm text-gray-500">{sections[currentSection]}</span>
{currentSection === sections.length - 1 ? (
<button onClick={() => setShowSummary(true)}
className="flex items-center gap-1 px-4 py-2.5 rounded-xl text-sm font-bold bg-blue-600 text-white hover:bg-blue-700">
Review <ChevronRight size={18} />
</button>
) : (
<button onClick={() => setCurrentSection(currentSection + 1)}
className="flex items-center gap-1 px-4 py-2.5 rounded-lg text-sm font-medium text-blue-600 hover:bg-blue-50">
Next <ChevronRight size={18} />
</button>
)}
</>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,36 @@
const colors: Record<string, string> = {
blue: 'bg-blue-100 text-blue-700',
green: 'bg-green-100 text-green-700',
yellow: 'bg-yellow-100 text-yellow-700',
red: 'bg-red-100 text-red-700',
gray: 'bg-gray-100 text-gray-700',
purple: 'bg-purple-100 text-purple-700',
};
const statusColors: Record<string, string> = {
received: 'blue',
in_progress: 'yellow',
completed: 'green',
invoiced: 'purple',
closed: 'gray',
draft: 'gray',
sent: 'blue',
paid: 'green',
partial: 'yellow',
overdue: 'red',
};
interface BadgeProps {
children: React.ReactNode;
color?: string;
status?: string;
}
export function Badge({ children, color, status }: BadgeProps) {
const c = color || (status ? statusColors[status] : 'gray') || 'gray';
return (
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${colors[c]}`}>
{children}
</span>
);
}

View File

@@ -0,0 +1,28 @@
import type { ButtonHTMLAttributes } from 'react';
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'danger' | 'ghost';
size?: 'sm' | 'md' | 'lg';
}
const variants = {
primary: 'bg-blue-600 text-white hover:bg-blue-700 shadow-sm',
secondary: 'bg-white text-gray-700 border border-gray-300 hover:bg-gray-50 shadow-sm',
danger: 'bg-red-600 text-white hover:bg-red-700 shadow-sm',
ghost: 'text-gray-600 hover:bg-gray-100 hover:text-gray-900',
};
const sizes = {
sm: 'px-2.5 py-1.5 text-xs',
md: 'px-3.5 py-2 text-sm',
lg: 'px-5 py-2.5 text-base',
};
export function Button({ variant = 'primary', size = 'md', className = '', ...props }: ButtonProps) {
return (
<button
className={`inline-flex items-center justify-center gap-2 font-medium rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed ${variants[variant]} ${sizes[size]} ${className}`}
{...props}
/>
);
}

View File

@@ -0,0 +1,9 @@
import type { HTMLAttributes } from 'react';
export function Card({ className = '', children, ...props }: HTMLAttributes<HTMLDivElement>) {
return (
<div className={`bg-white rounded-lg border border-gray-200 shadow-sm ${className}`} {...props}>
{children}
</div>
);
}

View File

@@ -0,0 +1,19 @@
import type { LucideIcon } from 'lucide-react';
interface EmptyStateProps {
icon: LucideIcon;
title: string;
description: string;
action?: React.ReactNode;
}
export function EmptyState({ icon: Icon, title, description, action }: EmptyStateProps) {
return (
<div className="text-center py-12">
<Icon size={40} className="mx-auto text-gray-300 mb-3" />
<h3 className="text-sm font-medium text-gray-900">{title}</h3>
<p className="text-sm text-gray-500 mt-1">{description}</p>
{action && <div className="mt-4">{action}</div>}
</div>
);
}

View File

@@ -0,0 +1,47 @@
import type { InputHTMLAttributes, TextareaHTMLAttributes, SelectHTMLAttributes } from 'react';
const inputBase = 'block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 outline-none disabled:bg-gray-50';
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
label?: string;
}
export function Input({ label, className = '', ...props }: InputProps) {
return (
<div>
{label && <label className="block text-sm font-medium text-gray-700 mb-1">{label}</label>}
<input className={`${inputBase} ${className}`} {...props} />
</div>
);
}
interface TextAreaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
label?: string;
}
export function TextArea({ label, className = '', ...props }: TextAreaProps) {
return (
<div>
{label && <label className="block text-sm font-medium text-gray-700 mb-1">{label}</label>}
<textarea className={`${inputBase} ${className}`} rows={3} {...props} />
</div>
);
}
interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
label?: string;
options: { value: string | number; label: string }[];
}
export function Select({ label, options, className = '', ...props }: SelectProps) {
return (
<div>
{label && <label className="block text-sm font-medium text-gray-700 mb-1">{label}</label>}
<select className={`${inputBase} ${className}`} {...props}>
{options.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
</div>
);
}

View File

@@ -0,0 +1,33 @@
import { useEffect } from 'react';
import { X } from 'lucide-react';
interface ModalProps {
open: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
}
export function Modal({ open, onClose, title, children }: ModalProps) {
useEffect(() => {
if (open) document.body.style.overflow = 'hidden';
return () => { document.body.style.overflow = ''; };
}, [open]);
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="absolute inset-0 bg-black/40" onClick={onClose} />
<div className="relative bg-white rounded-xl shadow-xl w-full max-w-lg max-h-[90vh] overflow-y-auto">
<div className="flex items-center justify-between p-4 border-b border-gray-200">
<h2 className="text-lg font-semibold text-gray-900">{title}</h2>
<button onClick={onClose} className="p-1 text-gray-400 hover:text-gray-600 rounded">
<X size={20} />
</button>
</div>
<div className="p-4">{children}</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,6 @@
export { Badge } from './Badge';
export { Button } from './Button';
export { Card } from './Card';
export { EmptyState } from './EmptyState';
export { Input, TextArea, Select } from './Input';
export { Modal } from './Modal';