Files
shop-manager/client/src/pages/OwnerDetail.tsx
tonym 8839412b52 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>
2026-04-07 00:08:35 -05:00

191 lines
7.8 KiB
TypeScript

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>
);
}