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:
190
client/src/pages/OwnerDetail.tsx
Normal file
190
client/src/pages/OwnerDetail.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
import { useState } from 'react';
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { ArrowLeft, Phone, Mail, Car, Edit2, Plus } from 'lucide-react';
|
||||
import { api } from '../api/client';
|
||||
import { Card, Button, Badge, Input, Modal } from '../components/ui';
|
||||
import type { Owner, Vehicle, Job } from '../types';
|
||||
|
||||
const statusLabel: Record<string, string> = {
|
||||
received: 'Received', in_progress: 'In Progress', completed: 'Completed', invoiced: 'Invoiced', closed: 'Closed',
|
||||
};
|
||||
|
||||
export default function OwnerDetail() {
|
||||
const { id } = useParams();
|
||||
const queryClient = useQueryClient();
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
|
||||
const { data: owner, isLoading } = useQuery<Owner & { vehicles: Vehicle[] }>({
|
||||
queryKey: ['owners', id],
|
||||
queryFn: () => api.getOwner(Number(id)),
|
||||
});
|
||||
|
||||
// Get all jobs for this owner
|
||||
const { data: jobs = [] } = useQuery({
|
||||
queryKey: ['jobs', { ownerId: id }],
|
||||
queryFn: async () => {
|
||||
const allJobs = await api.getJobs({});
|
||||
return allJobs.filter((j: Job) => j.owner_id === Number(id));
|
||||
},
|
||||
enabled: !!id,
|
||||
});
|
||||
|
||||
// Get invoices for this owner
|
||||
const { data: invoices = [] } = useQuery({
|
||||
queryKey: ['invoices', { ownerId: id }],
|
||||
queryFn: () => api.getInvoices({ ownerId: Number(id) }),
|
||||
enabled: !!id,
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (data: any) => api.updateOwner(Number(id), data),
|
||||
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['owners', id] }); setShowEditModal(false); },
|
||||
});
|
||||
|
||||
if (isLoading || !owner) return <p className="text-gray-500 py-4">Loading...</p>;
|
||||
|
||||
const vehicles = owner.vehicles || [];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Link to="/owners" className="inline-flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-700">
|
||||
<ArrowLeft size={16} /> Back to Owners
|
||||
</Link>
|
||||
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">{owner.first_name} {owner.last_name}</h1>
|
||||
<div className="flex gap-4 mt-1 text-sm text-gray-500">
|
||||
{owner.phone && <span className="flex items-center gap-1"><Phone size={14} /> {owner.phone}</span>}
|
||||
{owner.email && <span className="flex items-center gap-1"><Mail size={14} /> {owner.email}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="secondary" onClick={() => setShowEditModal(true)}>
|
||||
<Edit2 size={16} /> Edit
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{owner.notes && (
|
||||
<Card className="p-4">
|
||||
<h3 className="text-xs font-medium text-gray-500 uppercase mb-1">Notes</h3>
|
||||
<p className="text-sm text-gray-700">{owner.notes}</p>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Vehicles */}
|
||||
<Card>
|
||||
<div className="p-4 border-b border-gray-200 flex items-center justify-between">
|
||||
<h2 className="font-semibold text-gray-900">Vehicles ({vehicles.length})</h2>
|
||||
</div>
|
||||
{vehicles.length === 0 ? (
|
||||
<p className="p-4 text-sm text-gray-500">No vehicles registered.</p>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-100">
|
||||
{vehicles.map((v: Vehicle) => (
|
||||
<Link key={v.id} to={`/vehicles/${v.id}`} className="flex items-center gap-3 p-3 hover:bg-gray-50">
|
||||
<Car size={16} className="text-gray-400" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900">{[v.year, v.make, v.model].filter(Boolean).join(' ')}</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
{[v.color, v.vin && `VIN: ${v.vin}`, v.license_plate && `Plate: ${v.license_plate}`].filter(Boolean).join(' — ')}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Job History */}
|
||||
<Card>
|
||||
<div className="p-4 border-b border-gray-200">
|
||||
<h2 className="font-semibold text-gray-900">Job History ({jobs.length})</h2>
|
||||
</div>
|
||||
{jobs.length === 0 ? (
|
||||
<p className="p-4 text-sm text-gray-500">No jobs yet.</p>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-100">
|
||||
{jobs.map((job: Job) => (
|
||||
<div key={job.id} className="flex items-center justify-between p-3 hover:bg-gray-50">
|
||||
<div>
|
||||
<Link to={`/jobs/${job.id}`} className="text-sm font-medium text-blue-600 hover:underline">{job.job_number}</Link>
|
||||
<p className="text-xs text-gray-500">
|
||||
{[job.year, job.make, job.model].filter(Boolean).join(' ')}
|
||||
{job.description && ` — ${job.description.slice(0, 50)}${job.description.length > 50 ? '...' : ''}`}
|
||||
</p>
|
||||
</div>
|
||||
<Badge status={job.status}>{statusLabel[job.status]}</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Invoices */}
|
||||
{invoices.length > 0 && (
|
||||
<Card>
|
||||
<div className="p-4 border-b border-gray-200">
|
||||
<h2 className="font-semibold text-gray-900">Invoices ({invoices.length})</h2>
|
||||
</div>
|
||||
<div className="divide-y divide-gray-100">
|
||||
{invoices.map((inv: any) => (
|
||||
<div key={inv.id} className="flex items-center justify-between p-3 hover:bg-gray-50">
|
||||
<div>
|
||||
<Link to={`/invoices/${inv.id}`} className="text-sm font-medium text-blue-600 hover:underline">{inv.invoice_number}</Link>
|
||||
<p className="text-xs text-gray-500">{new Date(inv.invoice_date).toLocaleDateString()} — ${inv.total.toFixed(2)}</p>
|
||||
</div>
|
||||
<Badge status={inv.status}>{inv.status}</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{showEditModal && (
|
||||
<EditOwnerModal
|
||||
owner={owner}
|
||||
open={showEditModal}
|
||||
onClose={() => setShowEditModal(false)}
|
||||
onSave={(data) => updateMutation.mutate(data)}
|
||||
saving={updateMutation.isPending}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EditOwnerModal({ owner, open, onClose, onSave, saving }: {
|
||||
owner: Owner; open: boolean; onClose: () => void; onSave: (data: any) => void; saving: boolean;
|
||||
}) {
|
||||
const [form, setForm] = useState({
|
||||
firstName: owner.first_name,
|
||||
lastName: owner.last_name,
|
||||
phone: owner.phone || '',
|
||||
email: owner.email || '',
|
||||
notes: owner.notes || '',
|
||||
});
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={onClose} title="Edit Owner">
|
||||
<form onSubmit={(e) => { e.preventDefault(); onSave(form); }} className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Input label="First Name *" value={form.firstName} onChange={(e) => setForm({ ...form, firstName: e.target.value })} required />
|
||||
<Input label="Last Name *" value={form.lastName} onChange={(e) => setForm({ ...form, lastName: e.target.value })} required />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Input label="Phone" value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })} />
|
||||
<Input label="Email" value={form.email} onChange={(e) => setForm({ ...form, email: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Notes</label>
|
||||
<textarea className="block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm" rows={2} value={form.notes} onChange={(e) => setForm({ ...form, notes: e.target.value })} />
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button variant="secondary" type="button" onClick={onClose}>Cancel</Button>
|
||||
<Button type="submit" disabled={saving}>{saving ? 'Saving...' : 'Save'}</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user