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:
90
client/src/components/FileUpload.tsx
Normal file
90
client/src/components/FileUpload.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
227
client/src/components/InspectionPanel.tsx
Normal file
227
client/src/components/InspectionPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
95
client/src/components/KanbanBoard.tsx
Normal file
95
client/src/components/KanbanBoard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
125
client/src/components/Layout.tsx
Normal file
125
client/src/components/Layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
104
client/src/components/TagPicker.tsx
Normal file
104
client/src/components/TagPicker.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
448
client/src/components/VehicleInspectionPanel.tsx
Normal file
448
client/src/components/VehicleInspectionPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
36
client/src/components/ui/Badge.tsx
Normal file
36
client/src/components/ui/Badge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
28
client/src/components/ui/Button.tsx
Normal file
28
client/src/components/ui/Button.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
9
client/src/components/ui/Card.tsx
Normal file
9
client/src/components/ui/Card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
19
client/src/components/ui/EmptyState.tsx
Normal file
19
client/src/components/ui/EmptyState.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
47
client/src/components/ui/Input.tsx
Normal file
47
client/src/components/ui/Input.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
33
client/src/components/ui/Modal.tsx
Normal file
33
client/src/components/ui/Modal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
6
client/src/components/ui/index.ts
Normal file
6
client/src/components/ui/index.ts
Normal 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';
|
||||
Reference in New Issue
Block a user