Просмотр исходного кода

Fix mileage save bug, refactor service page, and sync vehicle mileage (#39)

* fix form input

* refactor service information files

* set milage to vehilce milage
Bernt Christian Egeland 6 месяцев назад
Родитель
Сommit
b52a88c9cf

+ 2 - 4
src/components/shared-link-card.tsx

@@ -27,10 +27,8 @@ export function SharedLinkCard({
   const [copied, setCopied] = useState(false);
   const [revoking, setRevoking] = useState(false);
 
-  const publicUrl =
-    typeof window !== "undefined"
-      ? `${window.location.origin}/share/${type === "quote" ? "quote" : "invoice"}/${organizationId}/${publicToken}`
-      : "";
+  const baseUrl = process.env.NEXT_PUBLIC_APP_URL || (typeof window !== "undefined" ? window.location.origin : "");
+  const publicUrl = `${baseUrl}/share/${type === "quote" ? "quote" : "invoice"}/${organizationId}/${publicToken}`;
 
   const handleCopy = async () => {
     try {

+ 16 - 1
src/features/vehicles/Actions/serviceActions.ts

@@ -349,7 +349,7 @@ export async function updateServiceRecord(input: unknown) {
       where: { id: data.id, vehicle: { organizationId } },
       include: {
         attachments: { select: { fileUrl: true, category: true } },
-        vehicle: { select: { id: true, make: true, model: true, year: true, licensePlate: true } },
+        vehicle: { select: { id: true, mileage: true, make: true, model: true, year: true, licensePlate: true } },
       },
     });
     if (!existing) throw new Error("Service record not found");
@@ -431,6 +431,21 @@ export async function updateServiceRecord(input: unknown) {
       return updated;
     });
 
+    // Update vehicle mileage if this is the latest service record and mileage is higher
+    if (recordData.mileage && recordData.mileage > existing.vehicle.mileage) {
+      const latestRecord = await db.serviceRecord.findFirst({
+        where: { vehicleId: existing.vehicle.id },
+        orderBy: { serviceDate: 'desc' },
+        select: { id: true },
+      });
+      if (latestRecord?.id === id) {
+        await db.vehicle.update({
+          where: { id: existing.vehicle.id },
+          data: { mileage: recordData.mileage },
+        });
+      }
+    }
+
     // Delete removed attachment files from disk (after successful DB transaction)
     for (const fileUrl of removedFileUrls) {
       try {

+ 1 - 1
src/features/vehicles/Components/service-detail/PaymentsSection.tsx

@@ -75,7 +75,7 @@ export function PaymentsSection({
 
   return (
     <div className="rounded-lg border p-3">
-      <div className="mb-2 flex items-center justify-between">
+      <div className="mb-2 flex flex-wrap items-center justify-between gap-2">
         <div className="flex items-center gap-2">
           <h3 className="flex items-center gap-2 text-sm font-semibold">
             <CreditCard className="h-3.5 w-3.5" />

+ 83 - 0
src/features/vehicles/Components/service-page/DetailsLeftColumn.tsx

@@ -0,0 +1,83 @@
+import { PartsEditor } from '../service-edit/PartsEditor'
+import { LaborEditor } from '../service-edit/LaborEditor'
+import { NotesSection } from '../service-edit/NotesSection'
+import { PaymentsSection } from '../service-detail/PaymentsSection'
+import { InvoiceSummary } from '../service-detail/InvoiceSummary'
+import type { useServiceFormState } from './useServiceFormState'
+import type { useServiceActions } from './useServiceActions'
+import type { ServiceDetail } from '../service-detail/types'
+import type { InventoryPartOption } from '../service-edit/form-types'
+
+interface DetailsLeftColumnProps {
+  formState: ReturnType<typeof useServiceFormState>
+  actions: ReturnType<typeof useServiceActions>
+  record: ServiceDetail
+  currencyCode: string
+  defaultLaborRate: number
+  inventoryParts: InventoryPartOption[]
+}
+
+export function DetailsLeftColumn({
+  formState,
+  actions,
+  record,
+  currencyCode,
+  defaultLaborRate,
+  inventoryParts,
+}: DetailsLeftColumnProps) {
+  return (
+    <div className="space-y-3">
+      <PartsEditor
+        partItems={formState.partItems}
+        setPartItems={formState.dirtySetPartItems}
+        updatePart={formState.updatePart}
+        partsSubtotal={formState.partsSubtotal}
+        currencyCode={currencyCode}
+        hasInventory={inventoryParts.length > 0}
+        onOpenInventory={() => formState.setShowInventoryPicker(true)}
+      />
+      <LaborEditor
+        laborItems={formState.laborItems}
+        setLaborItems={formState.dirtySetLaborItems}
+        updateLabor={formState.updateLabor}
+        laborSubtotal={formState.laborSubtotal}
+        currencyCode={currencyCode}
+        defaultLaborRate={defaultLaborRate}
+      />
+      <NotesSection initialData={formState.initialData} onNotesChange={formState.handleNotesChange} />
+      <div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
+        <PaymentsSection
+          payments={record.payments || []}
+          paymentStatus={formState.paymentStatus}
+          manuallyPaid={record.manuallyPaid}
+          totalPaid={formState.totalPaid}
+          displayTotal={formState.displayTotal}
+          balanceDue={formState.balanceDue}
+          currencyCode={currencyCode}
+          onCreatePayment={actions.handleCreatePayment}
+          onDeletePayment={actions.handleDeletePayment}
+          onTogglePaid={actions.handleTogglePaid}
+          paymentLoading={actions.paymentLoading}
+          deletingPayment={actions.deletingPayment}
+        />
+        <InvoiceSummary
+          hasPartItems={formState.partItems.length > 0}
+          hasLaborItems={formState.laborItems.length > 0}
+          partsSubtotal={formState.partsSubtotal}
+          laborSubtotal={formState.laborSubtotal}
+          subtotal={formState.subtotal}
+          discountAmount={formState.discountAmount}
+          discountType={formState.discountType === 'none' ? null : formState.discountType}
+          discountValue={formState.discountValue}
+          taxRate={formState.taxRate}
+          taxAmount={formState.taxAmount}
+          displayTotal={formState.displayTotal}
+          totalPaid={formState.totalPaid}
+          balanceDue={formState.balanceDue}
+          hasPayments={(record.payments?.length ?? 0) > 0}
+          currencyCode={currencyCode}
+        />
+      </div>
+    </div>
+  )
+}

+ 104 - 0
src/features/vehicles/Components/service-page/DetailsRightColumn.tsx

@@ -0,0 +1,104 @@
+import { useRouter } from 'next/navigation'
+import { SharedLinkCard } from '@/components/shared-link-card'
+import { BasicInfoSection } from '../service-edit/BasicInfoSection'
+import { ScheduleTimesSection } from '../service-edit/ScheduleTimesSection'
+import { TotalsSection } from '../service-edit/TotalsSection'
+import { ServiceAttachments } from '../service-detail/ServiceAttachments'
+import { revokePublicLink } from '@/features/vehicles/Actions/serviceActions'
+import type { useServiceFormState } from './useServiceFormState'
+import type { useServiceActions } from './useServiceActions'
+import type { ServiceDetail } from '../service-detail/types'
+import type { VehicleOption } from '../service-edit/form-types'
+import type { BoardTechnicianOption } from './service-page-types'
+
+interface DetailsRightColumnProps {
+  formState: ReturnType<typeof useServiceFormState>
+  actions: ReturnType<typeof useServiceActions>
+  record: ServiceDetail
+  vehicleId: string
+  organizationId: string
+  currencyCode: string
+  taxEnabled: boolean
+  vehicles: VehicleOption[]
+  boardTechnicians: BoardTechnicianOption[]
+}
+
+export function DetailsRightColumn({
+  formState,
+  actions,
+  record,
+  vehicleId,
+  organizationId,
+  currencyCode,
+  taxEnabled,
+  vehicles,
+  boardTechnicians,
+}: DetailsRightColumnProps) {
+  const router = useRouter()
+
+  return (
+    <div className="space-y-3">
+      {record.publicToken && (
+        <SharedLinkCard
+          publicToken={record.publicToken}
+          organizationId={organizationId}
+          type="invoice"
+          sharedAt={record.sharedAt}
+          viewCount={record.viewCount}
+          lastViewedAt={record.lastViewedAt}
+          onRevoke={async () => {
+            await revokePublicLink(record.id)
+            router.refresh()
+          }}
+        />
+      )}
+      <BasicInfoSection
+        initialData={formState.initialData}
+        vehicleId={vehicleId}
+        vehicleName={formState.vehicleName}
+        selectedVehicleId={formState.selectedVehicleId}
+        setSelectedVehicleId={formState.dirtySetSelectedVehicleId}
+        vehicles={vehicles}
+        vehicleOpen={formState.vehicleOpen}
+        setVehicleOpen={formState.setVehicleOpen}
+        type={formState.type}
+        setType={formState.dirtySetType}
+        status={formState.status}
+        setStatus={formState.dirtySetStatus}
+        techName={formState.techName}
+        customer={record.vehicle.customer}
+      />
+      <ScheduleTimesSection
+        serviceRecordId={record.id}
+        technicians={boardTechnicians}
+        initialStartDateTime={formState.initialData.startDateTime}
+        initialEndDateTime={formState.initialData.endDateTime}
+        initialTechnicianId={record.technicianId}
+        onSaved={formState.flashSaved}
+      />
+      <TotalsSection
+        partsSubtotal={formState.partsSubtotal}
+        laborSubtotal={formState.laborSubtotal}
+        subtotal={formState.subtotal}
+        discountType={formState.discountType}
+        setDiscountType={formState.dirtySetDiscountType}
+        discountValue={formState.discountValue}
+        setDiscountValue={formState.dirtySetDiscountValue}
+        discountAmount={formState.discountAmount}
+        taxEnabled={taxEnabled}
+        taxRate={formState.taxRate}
+        setTaxRate={formState.dirtySetTaxRate}
+        taxAmount={formState.taxAmount}
+        totalAmount={formState.totalAmount}
+        currencyCode={currencyCode}
+      />
+      <ServiceAttachments
+        attachments={record.attachments || []}
+        imageAttachments={formState.imageAttachments}
+        onImageClick={actions.onImageClick}
+        onDeleteAttachment={actions.handleDeleteAttachment}
+        deletingAttachment={actions.deletingAttachment}
+      />
+    </div>
+  )
+}

+ 71 - 573
src/features/vehicles/Components/service-page/ServicePageClient.tsx

@@ -1,86 +1,27 @@
 'use client'
 
-import { useState, useCallback, useRef, useEffect } from 'react'
-import { useRouter } from 'next/navigation'
-import { toast } from 'sonner'
-import { useGlassModal } from '@/components/glass-modal'
-import { useConfirm } from '@/components/confirm-dialog'
-import { updateServiceRecord, deleteServiceRecord, deleteServiceAttachment, toggleManuallyPaid, revokePublicLink } from '@/features/vehicles/Actions/serviceActions'
-import { createPayment, deletePayment } from '@/features/payments/Actions/paymentActions'
+import { useState } from 'react'
 import { sendInvoiceEmail } from '@/features/email/Actions/emailActions'
 import { SendEmailDialog } from '@/features/email/Components/SendEmailDialog'
-import type { ServicePartInput, ServiceLaborInput } from '@/features/vehicles/Schema/serviceSchema'
-import type { ServiceDetail } from '../service-detail/types'
-import type { InitialData, InventoryPartOption, VehicleOption } from '../service-edit/form-types'
-export interface BoardTechnicianOption {
-  id: string
-  name: string
-}
+import { useTranslations } from 'next-intl'
 
 import { ServiceDetailContent } from '../service-detail/ServiceDetailContent'
-import { InvoiceSummary } from '../service-detail/InvoiceSummary'
-import { PaymentsSection } from '../service-detail/PaymentsSection'
-import { ServiceAttachments } from '../service-detail/ServiceAttachments'
 import { ImageCarousel } from '../service-detail/ImageCarousel'
 import { ShareDialog } from '../service-detail/ShareDialog'
 import { NotifyCustomerDialog } from '@/components/notify-customer-dialog'
-import { formatCurrency } from '@/lib/format'
-import { getSmsTemplates } from '@/features/sms/Actions/smsActions'
-import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
-import { SMS_TEMPLATE_DEFAULTS, interpolateSmsTemplate } from '@/lib/sms-templates'
-
-import { BasicInfoSection } from '../service-edit/BasicInfoSection'
-import { ScheduleTimesSection } from '../service-edit/ScheduleTimesSection'
-import { PartsEditor } from '../service-edit/PartsEditor'
-import { LaborEditor } from '../service-edit/LaborEditor'
-import { TotalsSection } from '../service-edit/TotalsSection'
-import { NotesSection } from '../service-edit/NotesSection'
 import { InventoryPickerDialog } from '../service-edit/InventoryPickerDialog'
-
 import { ServiceImagesManager } from '../service-images-manager'
 import { ServiceVideoManager } from '../service-video-manager'
 import { ServiceDocumentsManager } from '../service-documents-manager'
-
 import { UnifiedServiceHeader, type ServiceTab } from './UnifiedServiceHeader'
-import { SharedLinkCard } from '@/components/shared-link-card'
-import { useTranslations } from 'next-intl'
 
-interface Attachment {
-  id: string
-  fileName: string
-  fileUrl: string
-  fileType: string
-  fileSize: number
-  category: string
-  description: string | null
-  includeInInvoice: boolean
-  createdAt: Date
-}
+import { useServiceFormState } from './useServiceFormState'
+import { useServiceActions } from './useServiceActions'
+import { DetailsLeftColumn } from './DetailsLeftColumn'
+import { DetailsRightColumn } from './DetailsRightColumn'
 
-export interface ServicePageClientProps {
-  record: ServiceDetail
-  vehicleId: string
-  organizationId: string
-  currencyCode: string
-  unitSystem: 'metric' | 'imperial'
-  defaultTaxRate: number
-  taxEnabled: boolean
-  defaultLaborRate: number
-  initialData: InitialData
-  inventoryParts: InventoryPartOption[]
-  vehicles: VehicleOption[]
-  boardTechnicians?: BoardTechnicianOption[]
-  currentUserName: string
-  // Media data
-  imageAttachmentsForManager: Attachment[]
-  videoAttachments: Attachment[]
-  documentAttachments: Attachment[]
-  maxImagesPerService: number
-  maxDiagnosticsPerService: number
-  maxDocumentsPerService: number
-  smsEnabled?: boolean
-  emailEnabled?: boolean
-}
+export type { ServicePageClientProps, BoardTechnicianOption } from './service-page-types'
+import type { ServicePageClientProps } from './service-page-types'
 
 export function ServicePageClient({
   record,
@@ -105,499 +46,32 @@ export function ServicePageClient({
   smsEnabled = false,
   emailEnabled = false,
 }: ServicePageClientProps) {
-  const router = useRouter()
-  const modal = useGlassModal()
-  const confirm = useConfirm()
   const t = useTranslations('service')
-  const tc = useTranslations('common')
-  const distUnit = unitSystem === 'metric' ? 'km' : 'mi'
-
-  // Tab state
   const [activeTab, setActiveTab] = useState<ServiceTab>('details')
 
-  // Form state (from ServiceRecordForm)
-  const [loading, setLoading] = useState(false)
-  const [selectedVehicleId, setSelectedVehicleId] = useState(vehicleId)
-  const [vehicleOpen, setVehicleOpen] = useState(false)
-  const [techName] = useState(initialData.techName || currentUserName)
-  const [type, setType] = useState(initialData.type || 'maintenance')
-  const [status, setStatus] = useState(initialData.status || 'completed')
-  const [partItems, setPartItems] = useState<ServicePartInput[]>(initialData.partItems || [])
-  const [laborItems, setLaborItems] = useState<ServiceLaborInput[]>(initialData.laborItems || [])
-  const [taxRate, setTaxRate] = useState(initialData.taxRate ?? defaultTaxRate)
-  const [discountType, setDiscountType] = useState<string>(initialData.discountType || 'none')
-  const [discountValue, setDiscountValue] = useState(initialData.discountValue ?? 0)
-  const [showInventoryPicker, setShowInventoryPicker] = useState(false)
-  // Detail-page state
-  const [downloading, setDownloading] = useState(false)
-  const [deletingAttachment, setDeletingAttachment] = useState<string | null>(null)
-  const [carouselIndex, setCarouselIndex] = useState<number | null>(null)
-  const [paymentLoading, setPaymentLoading] = useState(false)
-  const [deletingPayment, setDeletingPayment] = useState<string | null>(null)
-  const [showShareDialog, setShowShareDialog] = useState(false)
-  const [showEmailDialog, setShowEmailDialog] = useState(false)
-  const [showPaymentNotifyDialog, setShowPaymentNotifyDialog] = useState(false)
-  const [paymentNotifyMessage, setPaymentNotifyMessage] = useState('')
-
-  // Autosave state
-  const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false)
-  const [showSaved, setShowSaved] = useState(false)
-  const formRef = useRef<HTMLFormElement>(null)
-  const autosaveTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
-  const isSavingRef = useRef(false)
-  const savedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
-
-  const flashSaved = useCallback(() => {
-    if (savedTimerRef.current) clearTimeout(savedTimerRef.current)
-    setShowSaved(true)
-    savedTimerRef.current = setTimeout(() => setShowSaved(false), 2000)
-  }, [])
-
-  const markDirty = useCallback(() => {
-    setHasUnsavedChanges(true)
-    if (autosaveTimer.current) clearTimeout(autosaveTimer.current)
-    autosaveTimer.current = setTimeout(() => {
-      if (!isSavingRef.current && formRef.current) {
-        formRef.current.requestSubmit()
-      }
-    }, 5000)
-  }, [])
-
-  useEffect(() => {
-    if (!hasUnsavedChanges) return
-    const handler = (e: BeforeUnloadEvent) => {
-      e.preventDefault()
-    }
-    window.addEventListener('beforeunload', handler)
-    return () => window.removeEventListener('beforeunload', handler)
-  }, [hasUnsavedChanges])
-
-  const saveNow = async () => {
-    if (!formRef.current || isSavingRef.current) return
-    if (autosaveTimer.current) clearTimeout(autosaveTimer.current)
-    formRef.current.requestSubmit()
-    await new Promise<void>((resolve) => {
-      const check = () => {
-        if (!isSavingRef.current) return resolve()
-        setTimeout(check, 50)
-      }
-      setTimeout(check, 50)
-    })
-  }
-
-  const notesRef = useRef({
-    invoiceNotes: initialData.invoiceNotes || '',
-    diagnosticNotes: initialData.diagnosticNotes || '',
-    description: initialData.description || '',
+  const formState = useServiceFormState({
+    vehicleId,
+    initialData,
+    defaultTaxRate,
+    currentUserName,
+    record,
   })
 
-  const handleNotesChange = useCallback(
-    (field: 'invoiceNotes' | 'diagnosticNotes' | 'description', value: string) => {
-      notesRef.current[field] = value
-      markDirty()
-    },
-    [markDirty]
-  )
-
-  // Computed totals
-  const partsSubtotal = partItems.reduce((sum, p) => sum + p.total, 0)
-  const laborSubtotal = laborItems.reduce((sum, l) => sum + l.total, 0)
-  const subtotal = partsSubtotal + laborSubtotal
-  const discountAmount =
-    discountType === 'percentage'
-      ? subtotal * (discountValue / 100)
-      : discountType === 'fixed'
-        ? Math.min(discountValue, subtotal)
-        : 0
-  const taxAmount = (subtotal - discountAmount) * (taxRate / 100)
-  const totalAmount = subtotal - discountAmount + taxAmount
-
-  const displayTotal = totalAmount > 0 ? totalAmount : record.cost
-  const paidFromPayments = record.payments?.reduce((sum, p) => sum + p.amount, 0) || 0
-  const totalPaid = record.manuallyPaid ? displayTotal : paidFromPayments
-  const balanceDue = displayTotal - totalPaid
-  const paymentStatus = record.manuallyPaid
-    ? 'paid'
-    : totalPaid === 0
-      ? 'unpaid'
-      : balanceDue <= 0
-        ? 'paid'
-        : 'partial'
-  const imageAttachments = record.attachments?.filter((a) => a.category === 'image') || []
-  const vehicleName = `${record.vehicle.year} ${record.vehicle.make} ${record.vehicle.model}`
-
-  // Part/labor update helpers
-  const updatePart = useCallback(
-    (index: number, field: keyof ServicePartInput, value: string | number) => {
-      setPartItems((prev) => {
-        const updated = [...prev]
-        const part = { ...updated[index], [field]: value }
-        if (field === 'quantity' || field === 'unitPrice') {
-          part.total = Number(part.quantity) * Number(part.unitPrice)
-        }
-        updated[index] = part
-        return updated
-      })
-      markDirty()
-    },
-    [markDirty]
-  )
-
-  const updateLabor = useCallback(
-    (index: number, field: keyof ServiceLaborInput, value: string | number) => {
-      setLaborItems((prev) => {
-        const updated = [...prev]
-        const labor = { ...updated[index], [field]: value }
-        if (field === 'hours' || field === 'rate') {
-          labor.total = Number(labor.hours) * Number(labor.rate)
-        }
-        updated[index] = labor
-        return updated
-      })
-      markDirty()
-    },
-    [markDirty]
-  )
-
-  // Wrapped setters that trigger autosave
-  const dirtySetPartItems: typeof setPartItems = useCallback((action) => {
-    setPartItems(action)
-    markDirty()
-  }, [markDirty])
-
-  const dirtySetLaborItems: typeof setLaborItems = useCallback((action) => {
-    setLaborItems(action)
-    markDirty()
-  }, [markDirty])
-
-  const dirtySetDiscountType = useCallback((v: string) => { setDiscountType(v); markDirty() }, [markDirty])
-  const dirtySetDiscountValue = useCallback((v: number) => { setDiscountValue(v); markDirty() }, [markDirty])
-  const dirtySetTaxRate = useCallback((v: number) => { setTaxRate(v); markDirty() }, [markDirty])
-  const dirtySetType = useCallback((v: string) => { setType(v); markDirty() }, [markDirty])
-  const dirtySetStatus = useCallback((v: string) => { setStatus(v); markDirty() }, [markDirty])
-  const dirtySetSelectedVehicleId = useCallback((v: string) => { setSelectedVehicleId(v); markDirty() }, [markDirty])
-
-  // Form submit
-  const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
-    e.preventDefault()
-    if (isSavingRef.current) return
-    if (autosaveTimer.current) clearTimeout(autosaveTimer.current)
-    isSavingRef.current = true
-    setLoading(true)
-
-    const formData = new FormData(e.currentTarget)
-    const payload = {
-      id: initialData.id,
-      vehicleId: selectedVehicleId,
-      title: formData.get('title') as string,
-      description: notesRef.current.description || undefined,
-      type,
-      status,
-      cost: totalAmount,
-      mileage: Number(formData.get('mileage')) || undefined,
-      serviceDate: (formData.get('serviceDate') as string) || new Date().toISOString(),
-      techName: techName || undefined,
-      diagnosticNotes: notesRef.current.diagnosticNotes || undefined,
-      invoiceNotes: notesRef.current.invoiceNotes || undefined,
-      invoiceNumber: (formData.get('invoiceNumber') as string) || undefined,
-      partItems: partItems.filter((p) => p.name),
-      laborItems: laborItems.filter((l) => l.description),
-      subtotal,
-      taxRate,
-      taxAmount,
-      totalAmount,
-      discountType: discountType === 'none' ? undefined : discountType,
-      discountValue,
-      discountAmount,
-    }
-
-    const result = await updateServiceRecord(payload)
-
-    if (result.success) {
-      setHasUnsavedChanges(false)
-      flashSaved()
-      if (selectedVehicleId !== vehicleId) {
-        router.push(`/vehicles/${selectedVehicleId}/service/${initialData.id}`)
-      } else {
-        router.refresh()
-      }
-    } else {
-      modal.open('error', tc('errors.error'), result.error || t('page.failedUpdate'))
-    }
-
-    isSavingRef.current = false
-    setLoading(false)
-  }
-
-  // Detail actions
-  const handleDelete = async () => {
-    const ok = await confirm({
-      title: t('page.deleteTitle'),
-      description: t('page.deleteDescription'),
-      confirmLabel: tc('buttons.delete'),
-      destructive: true,
-    })
-    if (!ok) return
-    const result = await deleteServiceRecord(record.id)
-    if (result.success) {
-      router.push(`/vehicles/${vehicleId}`)
-      router.refresh()
-    } else {
-      modal.open('error', tc('errors.error'), result.error || t('page.failedDelete'))
-    }
-  }
-
-  const handleDownloadPDF = async () => {
-    setDownloading(true)
-    try {
-      const res = await fetch(`/api/protected/services/${record.id}/pdf`)
-      if (!res.ok) throw new Error('Failed')
-      const blob = await res.blob()
-      const url = URL.createObjectURL(blob)
-      const a = document.createElement('a')
-      a.href = url
-      a.download =
-        res.headers.get('Content-Disposition')?.match(/filename="(.+)"/)?.[1] ||
-        `invoice-${record.invoiceNumber || record.id}.pdf`
-      a.click()
-      URL.revokeObjectURL(url)
-    } catch {
-      modal.open('error', tc('errors.error'), t('payments.failedPdf'))
-    }
-    setDownloading(false)
-  }
-
-  const handleDeleteAttachment = async (id: string) => {
-    const ok = await confirm({
-      title: t('attachments.deleteTitle'),
-      description: t('attachments.deleteDescription'),
-      confirmLabel: tc('buttons.delete'),
-      destructive: true,
-    })
-    if (!ok) return
-    setDeletingAttachment(id)
-    const result = await deleteServiceAttachment(id)
-    if (result.success) {
-      toast.success(t('attachments.deleted'))
-      router.refresh()
-    } else {
-      modal.open('error', tc('errors.error'), result.error || t('attachments.failedDelete'))
-    }
-    setDeletingAttachment(null)
-  }
-
-  const handleCreatePayment = async (data: {
-    amount: number
-    date: string
-    method: string
-    note?: string
-  }) => {
-    setPaymentLoading(true)
-    const result = await createPayment({ serviceRecordId: record.id, ...data })
-    setPaymentLoading(false)
-    if (result.success) {
-      toast.success(t('payments.recorded'))
-      router.refresh()
-      if (record.vehicle.customer) {
-        const invoiceNum = record.invoiceNumber || `#${record.id.slice(-8).toUpperCase()}`
-        const tplResult = await getSmsTemplates()
-        const tplData = tplResult.success && tplResult.data ? tplResult.data : null
-        const tpl = tplData?.templates[SETTING_KEYS.SMS_TEMPLATE_PAYMENT_RECEIVED] || SMS_TEMPLATE_DEFAULTS[SETTING_KEYS.SMS_TEMPLATE_PAYMENT_RECEIVED] || ''
-        setPaymentNotifyMessage(
-          interpolateSmsTemplate(tpl, {
-            amount: formatCurrency(data.amount, currencyCode),
-            invoice_number: invoiceNum,
-            customer_name: record.vehicle.customer.name,
-            company_name: tplData?.companyName || '',
-            current_user: tplData?.currentUser || '',
-          })
-        )
-        setShowPaymentNotifyDialog(true)
-      }
-      return true
-    }
-    modal.open('error', tc('errors.error'), result.error || t('payments.failedRecord'))
-    return false
-  }
-
-  const handleTogglePaid = async () => {
-    setPaymentLoading(true)
-    const result = await toggleManuallyPaid(record.id)
-    setPaymentLoading(false)
-    if (result.success) {
-      toast.success(result.data?.manuallyPaid ? t('payments.markedPaid') : t('payments.markedUnpaid'))
-      router.refresh()
-      if (result.data?.manuallyPaid && record.vehicle.customer) {
-        const invoiceNum = record.invoiceNumber || `#${record.id.slice(-8).toUpperCase()}`
-        const tplResult = await getSmsTemplates()
-        const tplData = tplResult.success && tplResult.data ? tplResult.data : null
-        const tpl = tplData?.templates[SETTING_KEYS.SMS_TEMPLATE_PAYMENT_RECEIVED] || SMS_TEMPLATE_DEFAULTS[SETTING_KEYS.SMS_TEMPLATE_PAYMENT_RECEIVED] || ''
-        setPaymentNotifyMessage(
-          interpolateSmsTemplate(tpl, {
-            amount: '',
-            invoice_number: invoiceNum,
-            customer_name: record.vehicle.customer.name,
-            company_name: tplData?.companyName || '',
-            current_user: tplData?.currentUser || '',
-          })
-        )
-        setShowPaymentNotifyDialog(true)
-      }
-    } else {
-      modal.open('error', tc('errors.error'), result.error || t('payments.failedStatus'))
-    }
-  }
-
-  const handleDeletePayment = async (id: string) => {
-    const ok = await confirm({
-      title: t('payments.deleteTitle'),
-      description: t('payments.deleteDescription'),
-      confirmLabel: tc('buttons.delete'),
-      destructive: true,
-    })
-    if (!ok) return
-    setDeletingPayment(id)
-    const result = await deletePayment(id)
-    if (result.success) {
-      toast.success(t('payments.deleted'))
-      router.refresh()
-    } else {
-      modal.open('error', tc('errors.error'), result.error || t('payments.failedDelete'))
-    }
-    setDeletingPayment(null)
-  }
-
-  // Details tab content
-  const detailsLeftColumn = (
-    <div className="space-y-3">
-      <PartsEditor
-        partItems={partItems}
-        setPartItems={dirtySetPartItems}
-        updatePart={updatePart}
-        partsSubtotal={partsSubtotal}
-        currencyCode={currencyCode}
-        hasInventory={inventoryParts.length > 0}
-        onOpenInventory={() => setShowInventoryPicker(true)}
-      />
-      <LaborEditor
-        laborItems={laborItems}
-        setLaborItems={dirtySetLaborItems}
-        updateLabor={updateLabor}
-        laborSubtotal={laborSubtotal}
-        currencyCode={currencyCode}
-        defaultLaborRate={defaultLaborRate}
-      />
-      <NotesSection initialData={initialData} onNotesChange={handleNotesChange} />
-      <div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
-        <PaymentsSection
-          payments={record.payments || []}
-          paymentStatus={paymentStatus}
-          manuallyPaid={record.manuallyPaid}
-          totalPaid={totalPaid}
-          displayTotal={displayTotal}
-          balanceDue={balanceDue}
-          currencyCode={currencyCode}
-          onCreatePayment={handleCreatePayment}
-          onDeletePayment={handleDeletePayment}
-          onTogglePaid={handleTogglePaid}
-          paymentLoading={paymentLoading}
-          deletingPayment={deletingPayment}
-        />
-        <InvoiceSummary
-          hasPartItems={partItems.length > 0}
-          hasLaborItems={laborItems.length > 0}
-          partsSubtotal={partsSubtotal}
-          laborSubtotal={laborSubtotal}
-          subtotal={subtotal}
-          discountAmount={discountAmount}
-          discountType={discountType === 'none' ? null : discountType}
-          discountValue={discountValue}
-          taxRate={taxRate}
-          taxAmount={taxAmount}
-          displayTotal={displayTotal}
-          totalPaid={totalPaid}
-          balanceDue={balanceDue}
-          hasPayments={(record.payments?.length ?? 0) > 0}
-          currencyCode={currencyCode}
-        />
-      </div>
-    </div>
-  )
-
-  const detailsRightColumn = (
-    <div className="space-y-3">
-      {record.publicToken && (
-        <SharedLinkCard
-          publicToken={record.publicToken}
-          organizationId={organizationId}
-          type="invoice"
-          sharedAt={record.sharedAt}
-          viewCount={record.viewCount}
-          lastViewedAt={record.lastViewedAt}
-          onRevoke={async () => {
-            await revokePublicLink(record.id)
-            router.refresh()
-          }}
-        />
-      )}
-      <BasicInfoSection
-        initialData={initialData}
-        vehicleId={vehicleId}
-        vehicleName={vehicleName}
-        selectedVehicleId={selectedVehicleId}
-        setSelectedVehicleId={dirtySetSelectedVehicleId}
-        vehicles={vehicles}
-        vehicleOpen={vehicleOpen}
-        setVehicleOpen={setVehicleOpen}
-        type={type}
-        setType={dirtySetType}
-        status={status}
-        setStatus={dirtySetStatus}
-        techName={techName}
-        customer={record.vehicle.customer}
-      />
-      <ScheduleTimesSection
-        serviceRecordId={record.id}
-        technicians={boardTechnicians}
-        initialStartDateTime={initialData.startDateTime}
-        initialEndDateTime={initialData.endDateTime}
-        initialTechnicianId={record.technicianId}
-        onSaved={flashSaved}
-      />
-      <TotalsSection
-        partsSubtotal={partsSubtotal}
-        laborSubtotal={laborSubtotal}
-        subtotal={subtotal}
-        discountType={discountType}
-        setDiscountType={dirtySetDiscountType}
-        discountValue={discountValue}
-        setDiscountValue={dirtySetDiscountValue}
-        discountAmount={discountAmount}
-        taxEnabled={taxEnabled}
-        taxRate={taxRate}
-        setTaxRate={dirtySetTaxRate}
-        taxAmount={taxAmount}
-        totalAmount={totalAmount}
-        currencyCode={currencyCode}
-      />
-      <ServiceAttachments
-        attachments={record.attachments || []}
-        imageAttachments={imageAttachments}
-        onImageClick={setCarouselIndex}
-        onDeleteAttachment={handleDeleteAttachment}
-        deletingAttachment={deletingAttachment}
-      />
-    </div>
-  )
+  const actions = useServiceActions({
+    record,
+    vehicleId,
+    currencyCode,
+    formState,
+  })
 
   return (
     <div className="flex min-h-0 flex-1 flex-col overflow-hidden">
       <UnifiedServiceHeader
         vehicleId={vehicleId}
-        vehicleName={vehicleName}
+        vehicleName={formState.vehicleName}
         title={record.title}
-        status={status}
-        paymentStatus={paymentStatus}
+        status={formState.status}
+        paymentStatus={formState.paymentStatus}
         activeTab={activeTab}
         onTabChange={setActiveTab}
         tabCounts={{
@@ -605,19 +79,43 @@ export function ServicePageClient({
           video: videoAttachments.length,
           documents: documentAttachments.length,
         }}
-        downloading={downloading}
-        saving={loading}
-        hasUnsavedChanges={hasUnsavedChanges}
-        showSaved={showSaved}
-        onDownloadPDF={async () => { if (hasUnsavedChanges) await saveNow(); handleDownloadPDF() }}
-        onDelete={handleDelete}
-        onShowEmail={async () => { if (hasUnsavedChanges) await saveNow(); setShowEmailDialog(true) }}
-        onShowShare={async () => { if (hasUnsavedChanges) await saveNow(); setShowShareDialog(true) }}
+        downloading={actions.downloading}
+        saving={formState.loading}
+        hasUnsavedChanges={formState.hasUnsavedChanges}
+        showSaved={formState.showSaved}
+        onDownloadPDF={async () => { if (formState.hasUnsavedChanges) await actions.saveNow(); actions.handleDownloadPDF() }}
+        onDelete={actions.handleDelete}
+        onShowEmail={async () => { if (formState.hasUnsavedChanges) await actions.saveNow(); actions.setShowEmailDialog(true) }}
+        onShowShare={async () => { if (formState.hasUnsavedChanges) await actions.saveNow(); actions.setShowShareDialog(true) }}
       />
 
       {activeTab === 'details' && (
-        <form id="service-record-form" ref={formRef} onSubmit={handleSubmit} onInput={markDirty} className="flex min-h-0 flex-1 flex-col">
-          <ServiceDetailContent leftColumn={detailsLeftColumn} rightColumn={detailsRightColumn} />
+        <form id="service-record-form" ref={formState.formRef} onSubmit={actions.handleSubmit} onInput={formState.markDirty} className="flex min-h-0 flex-1 flex-col">
+          <ServiceDetailContent
+            leftColumn={
+              <DetailsLeftColumn
+                formState={formState}
+                actions={actions}
+                record={record}
+                currencyCode={currencyCode}
+                defaultLaborRate={defaultLaborRate}
+                inventoryParts={inventoryParts}
+              />
+            }
+            rightColumn={
+              <DetailsRightColumn
+                formState={formState}
+                actions={actions}
+                record={record}
+                vehicleId={vehicleId}
+                organizationId={organizationId}
+                currencyCode={currencyCode}
+                taxEnabled={taxEnabled}
+                vehicles={vehicles}
+                boardTechnicians={boardTechnicians}
+              />
+            }
+          />
         </form>
       )}
 
@@ -652,23 +150,23 @@ export function ServicePageClient({
       )}
 
       <InventoryPickerDialog
-        open={showInventoryPicker}
-        onOpenChange={setShowInventoryPicker}
+        open={formState.showInventoryPicker}
+        onOpenChange={formState.setShowInventoryPicker}
         inventoryParts={inventoryParts}
         currencyCode={currencyCode}
-        onSelectPart={(part) => dirtySetPartItems((prev) => [...prev, part])}
+        onSelectPart={(part) => formState.dirtySetPartItems((prev) => [...prev, part])}
       />
 
       <ImageCarousel
-        images={imageAttachments}
-        currentIndex={carouselIndex}
-        onClose={() => setCarouselIndex(null)}
-        onChangeIndex={setCarouselIndex}
+        images={formState.imageAttachments}
+        currentIndex={actions.carouselIndex}
+        onClose={() => actions.setCarouselIndex(null)}
+        onChangeIndex={actions.setCarouselIndex}
       />
 
       <SendEmailDialog
-        open={showEmailDialog}
-        onOpenChange={setShowEmailDialog}
+        open={actions.showEmailDialog}
+        onOpenChange={actions.setShowEmailDialog}
         defaultEmail={record.vehicle.customer?.email || ''}
         entityLabel={t('invoice.entityLabel')}
         onSend={async (email, message) =>
@@ -677,8 +175,8 @@ export function ServicePageClient({
       />
 
       <ShareDialog
-        open={showShareDialog}
-        onOpenChange={setShowShareDialog}
+        open={actions.showShareDialog}
+        onOpenChange={actions.setShowShareDialog}
         recordId={record.id}
         organizationId={organizationId}
         initialToken={record.publicToken}
@@ -689,10 +187,10 @@ export function ServicePageClient({
 
       {record.vehicle.customer && (
         <NotifyCustomerDialog
-          open={showPaymentNotifyDialog}
-          onOpenChange={setShowPaymentNotifyDialog}
+          open={actions.showPaymentNotifyDialog}
+          onOpenChange={actions.setShowPaymentNotifyDialog}
           customer={record.vehicle.customer}
-          defaultMessage={paymentNotifyMessage}
+          defaultMessage={actions.paymentNotifyMessage}
           emailSubject={t('invoice.emailSubject')}
           smsEnabled={smsEnabled}
           emailEnabled={emailEnabled}

+ 46 - 0
src/features/vehicles/Components/service-page/service-page-types.ts

@@ -0,0 +1,46 @@
+import type { ServicePartInput, ServiceLaborInput } from '@/features/vehicles/Schema/serviceSchema'
+import type { ServiceDetail } from '../service-detail/types'
+import type { InitialData, InventoryPartOption, VehicleOption } from '../service-edit/form-types'
+
+export interface BoardTechnicianOption {
+  id: string
+  name: string
+}
+
+export interface Attachment {
+  id: string
+  fileName: string
+  fileUrl: string
+  fileType: string
+  fileSize: number
+  category: string
+  description: string | null
+  includeInInvoice: boolean
+  createdAt: Date
+}
+
+export interface ServicePageClientProps {
+  record: ServiceDetail
+  vehicleId: string
+  organizationId: string
+  currencyCode: string
+  unitSystem: 'metric' | 'imperial'
+  defaultTaxRate: number
+  taxEnabled: boolean
+  defaultLaborRate: number
+  initialData: InitialData
+  inventoryParts: InventoryPartOption[]
+  vehicles: VehicleOption[]
+  boardTechnicians?: BoardTechnicianOption[]
+  currentUserName: string
+  imageAttachmentsForManager: Attachment[]
+  videoAttachments: Attachment[]
+  documentAttachments: Attachment[]
+  maxImagesPerService: number
+  maxDiagnosticsPerService: number
+  maxDocumentsPerService: number
+  smsEnabled?: boolean
+  emailEnabled?: boolean
+}
+
+export type { ServicePartInput, ServiceLaborInput, ServiceDetail, InitialData, InventoryPartOption, VehicleOption }

+ 262 - 0
src/features/vehicles/Components/service-page/useServiceActions.ts

@@ -0,0 +1,262 @@
+import { useState } from 'react'
+import { useRouter } from 'next/navigation'
+import { toast } from 'sonner'
+import { useGlassModal } from '@/components/glass-modal'
+import { useConfirm } from '@/components/confirm-dialog'
+import { useTranslations } from 'next-intl'
+import { updateServiceRecord, deleteServiceRecord, deleteServiceAttachment, toggleManuallyPaid } from '@/features/vehicles/Actions/serviceActions'
+import { createPayment, deletePayment } from '@/features/payments/Actions/paymentActions'
+import { getSmsTemplates } from '@/features/sms/Actions/smsActions'
+import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
+import { SMS_TEMPLATE_DEFAULTS, interpolateSmsTemplate } from '@/lib/sms-templates'
+import { formatCurrency } from '@/lib/format'
+import type { ServiceDetail } from '../service-detail/types'
+import type { useServiceFormState } from './useServiceFormState'
+
+type FormState = ReturnType<typeof useServiceFormState>
+
+export function useServiceActions({
+  record,
+  vehicleId,
+  currencyCode,
+  formState,
+}: {
+  record: ServiceDetail
+  vehicleId: string
+  currencyCode: string
+  formState: FormState
+}) {
+  const router = useRouter()
+  const modal = useGlassModal()
+  const confirm = useConfirm()
+  const t = useTranslations('service')
+  const tc = useTranslations('common')
+
+  const [carouselIndex, setCarouselIndex] = useState<number | null>(null)
+  const [downloading, setDownloading] = useState(false)
+  const [deletingAttachment, setDeletingAttachment] = useState<string | null>(null)
+  const [paymentLoading, setPaymentLoading] = useState(false)
+  const [deletingPayment, setDeletingPayment] = useState<string | null>(null)
+  const [showShareDialog, setShowShareDialog] = useState(false)
+  const [showEmailDialog, setShowEmailDialog] = useState(false)
+  const [showPaymentNotifyDialog, setShowPaymentNotifyDialog] = useState(false)
+  const [paymentNotifyMessage, setPaymentNotifyMessage] = useState('')
+
+  const {
+    selectedVehicleId, techName, type, status,
+    partItems, laborItems, subtotal, taxRate, taxAmount, totalAmount,
+    discountType, discountValue, discountAmount,
+    isSavingRef, autosaveTimer, setLoading,
+    setHasUnsavedChanges, flashSaved, notesRef,
+    initialData,
+  } = formState
+
+  const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
+    e.preventDefault()
+    if (isSavingRef.current) return
+    if (autosaveTimer.current) clearTimeout(autosaveTimer.current)
+    isSavingRef.current = true
+    setLoading(true)
+
+    // Mobile + desktop layouts both render inputs with the same name.
+    // formData.get() returns the first (hidden/stale) one, so read the visible input via offsetParent.
+    const form = e.currentTarget
+    const getVisible = (name: string) => {
+      const inputs = form.querySelectorAll<HTMLInputElement>(`input[name="${name}"]`)
+      const visible = Array.from(inputs).find(el => el.offsetParent !== null)
+      return visible?.value ?? new FormData(form).get(name) as string
+    }
+    const rawMileage = getVisible('mileage')
+    const parsedMileage = rawMileage ? Number(rawMileage) || undefined : undefined
+
+    const payload = {
+      id: initialData.id,
+      vehicleId: selectedVehicleId,
+      title: getVisible('title'),
+      description: notesRef.current.description || undefined,
+      type,
+      status,
+      cost: totalAmount,
+      mileage: parsedMileage,
+      serviceDate: getVisible('serviceDate') || new Date().toISOString(),
+      techName: techName || undefined,
+      diagnosticNotes: notesRef.current.diagnosticNotes || undefined,
+      invoiceNotes: notesRef.current.invoiceNotes || undefined,
+      invoiceNumber: getVisible('invoiceNumber') || undefined,
+      partItems: partItems.filter((p) => p.name),
+      laborItems: laborItems.filter((l) => l.description),
+      subtotal,
+      taxRate,
+      taxAmount,
+      totalAmount,
+      discountType: discountType === 'none' ? undefined : discountType,
+      discountValue,
+      discountAmount,
+    }
+
+    const result = await updateServiceRecord(payload)
+
+    if (result.success) {
+      setHasUnsavedChanges(false)
+      flashSaved()
+      if (selectedVehicleId !== vehicleId) {
+        router.push(`/vehicles/${selectedVehicleId}/service/${initialData.id}`)
+      } else {
+        router.refresh()
+      }
+    } else {
+      modal.open('error', tc('errors.error'), result.error || t('page.failedUpdate'))
+    }
+
+    isSavingRef.current = false
+    setLoading(false)
+  }
+
+  const handleDelete = async () => {
+    const ok = await confirm({
+      title: t('page.deleteTitle'),
+      description: t('page.deleteDescription'),
+      confirmLabel: tc('buttons.delete'),
+      destructive: true,
+    })
+    if (!ok) return
+    const result = await deleteServiceRecord(record.id)
+    if (result.success) {
+      router.push(`/vehicles/${vehicleId}`)
+      router.refresh()
+    } else {
+      modal.open('error', tc('errors.error'), result.error || t('page.failedDelete'))
+    }
+  }
+
+  const handleDownloadPDF = async () => {
+    setDownloading(true)
+    try {
+      const res = await fetch(`/api/protected/services/${record.id}/pdf`)
+      if (!res.ok) throw new Error('Failed')
+      const blob = await res.blob()
+      const url = URL.createObjectURL(blob)
+      const a = document.createElement('a')
+      a.href = url
+      a.download =
+        res.headers.get('Content-Disposition')?.match(/filename="(.+)"/)?.[1] ||
+        `invoice-${record.invoiceNumber || record.id}.pdf`
+      a.click()
+      URL.revokeObjectURL(url)
+    } catch {
+      modal.open('error', tc('errors.error'), t('payments.failedPdf'))
+    }
+    setDownloading(false)
+  }
+
+  const handleDeleteAttachment = async (id: string) => {
+    const ok = await confirm({
+      title: t('attachments.deleteTitle'),
+      description: t('attachments.deleteDescription'),
+      confirmLabel: tc('buttons.delete'),
+      destructive: true,
+    })
+    if (!ok) return
+    setDeletingAttachment(id)
+    const result = await deleteServiceAttachment(id)
+    if (result.success) {
+      toast.success(t('attachments.deleted'))
+      router.refresh()
+    } else {
+      modal.open('error', tc('errors.error'), result.error || t('attachments.failedDelete'))
+    }
+    setDeletingAttachment(null)
+  }
+
+  const buildPaymentNotifyMessage = async (amount: string) => {
+    const invoiceNum = record.invoiceNumber || `#${record.id.slice(-8).toUpperCase()}`
+    const tplResult = await getSmsTemplates()
+    const tplData = tplResult.success && tplResult.data ? tplResult.data : null
+    const tpl = tplData?.templates[SETTING_KEYS.SMS_TEMPLATE_PAYMENT_RECEIVED] || SMS_TEMPLATE_DEFAULTS[SETTING_KEYS.SMS_TEMPLATE_PAYMENT_RECEIVED] || ''
+    setPaymentNotifyMessage(
+      interpolateSmsTemplate(tpl, {
+        amount,
+        invoice_number: invoiceNum,
+        customer_name: record.vehicle.customer?.name || '',
+        company_name: tplData?.companyName || '',
+        current_user: tplData?.currentUser || '',
+      })
+    )
+    setShowPaymentNotifyDialog(true)
+  }
+
+  const handleCreatePayment = async (data: { amount: number; date: string; method: string; note?: string }) => {
+    setPaymentLoading(true)
+    const result = await createPayment({ serviceRecordId: record.id, ...data })
+    setPaymentLoading(false)
+    if (result.success) {
+      toast.success(t('payments.recorded'))
+      router.refresh()
+      if (record.vehicle.customer) {
+        await buildPaymentNotifyMessage(formatCurrency(data.amount, currencyCode))
+      }
+      return true
+    }
+    modal.open('error', tc('errors.error'), result.error || t('payments.failedRecord'))
+    return false
+  }
+
+  const handleTogglePaid = async () => {
+    setPaymentLoading(true)
+    const result = await toggleManuallyPaid(record.id)
+    setPaymentLoading(false)
+    if (result.success) {
+      toast.success(result.data?.manuallyPaid ? t('payments.markedPaid') : t('payments.markedUnpaid'))
+      router.refresh()
+      if (result.data?.manuallyPaid && record.vehicle.customer) {
+        await buildPaymentNotifyMessage('')
+      }
+    } else {
+      modal.open('error', tc('errors.error'), result.error || t('payments.failedStatus'))
+    }
+  }
+
+  const handleDeletePayment = async (id: string) => {
+    const ok = await confirm({
+      title: t('payments.deleteTitle'),
+      description: t('payments.deleteDescription'),
+      confirmLabel: tc('buttons.delete'),
+      destructive: true,
+    })
+    if (!ok) return
+    setDeletingPayment(id)
+    const result = await deletePayment(id)
+    if (result.success) {
+      toast.success(t('payments.deleted'))
+      router.refresh()
+    } else {
+      modal.open('error', tc('errors.error'), result.error || t('payments.failedDelete'))
+    }
+    setDeletingPayment(null)
+  }
+
+  return {
+    // Action handlers
+    handleSubmit,
+    handleDelete,
+    handleDownloadPDF,
+    handleDeleteAttachment,
+    handleCreatePayment,
+    handleTogglePaid,
+    handleDeletePayment,
+    // Dialog/UI state
+    downloading,
+    deletingAttachment,
+    paymentLoading,
+    deletingPayment,
+    showShareDialog, setShowShareDialog,
+    showEmailDialog, setShowEmailDialog,
+    showPaymentNotifyDialog, setShowPaymentNotifyDialog,
+    paymentNotifyMessage,
+    // Carousel
+    carouselIndex, setCarouselIndex,
+    onImageClick: setCarouselIndex,
+    // Helpers
+    saveNow: formState.saveNow,
+  }
+}

+ 195 - 0
src/features/vehicles/Components/service-page/useServiceFormState.ts

@@ -0,0 +1,195 @@
+import { useState, useCallback, useRef, useEffect } from 'react'
+import type { ServicePartInput, ServiceLaborInput, InitialData } from './service-page-types'
+import type { ServiceDetail } from '../service-detail/types'
+
+export function useServiceFormState({
+  vehicleId,
+  initialData,
+  defaultTaxRate,
+  currentUserName,
+  record,
+}: {
+  vehicleId: string
+  initialData: InitialData
+  defaultTaxRate: number
+  currentUserName: string
+  record: ServiceDetail
+}) {
+  // Form state
+  const [loading, setLoading] = useState(false)
+  const [selectedVehicleId, setSelectedVehicleId] = useState(vehicleId)
+  const [vehicleOpen, setVehicleOpen] = useState(false)
+  const [techName] = useState(initialData.techName || currentUserName)
+  const [type, setType] = useState(initialData.type || 'maintenance')
+  const [status, setStatus] = useState(initialData.status || 'completed')
+  const [partItems, setPartItems] = useState<ServicePartInput[]>(initialData.partItems || [])
+  const [laborItems, setLaborItems] = useState<ServiceLaborInput[]>(initialData.laborItems || [])
+  const [taxRate, setTaxRate] = useState(initialData.taxRate ?? defaultTaxRate)
+  const [discountType, setDiscountType] = useState<string>(initialData.discountType || 'none')
+  const [discountValue, setDiscountValue] = useState(initialData.discountValue ?? 0)
+  const [showInventoryPicker, setShowInventoryPicker] = useState(false)
+
+  // Autosave state
+  const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false)
+  const [showSaved, setShowSaved] = useState(false)
+  const formRef = useRef<HTMLFormElement>(null)
+  const autosaveTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
+  const isSavingRef = useRef(false)
+  const savedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
+
+  const flashSaved = useCallback(() => {
+    if (savedTimerRef.current) clearTimeout(savedTimerRef.current)
+    setShowSaved(true)
+    savedTimerRef.current = setTimeout(() => setShowSaved(false), 2000)
+  }, [])
+
+  const markDirty = useCallback(() => {
+    setHasUnsavedChanges(true)
+    if (autosaveTimer.current) clearTimeout(autosaveTimer.current)
+    autosaveTimer.current = setTimeout(() => {
+      if (!isSavingRef.current && formRef.current) {
+        formRef.current.requestSubmit()
+      }
+    }, 5000)
+  }, [])
+
+  useEffect(() => {
+    if (!hasUnsavedChanges) return
+    const handler = (e: BeforeUnloadEvent) => { e.preventDefault() }
+    window.addEventListener('beforeunload', handler)
+    return () => window.removeEventListener('beforeunload', handler)
+  }, [hasUnsavedChanges])
+
+  const saveNow = async () => {
+    if (!formRef.current || isSavingRef.current) return
+    if (autosaveTimer.current) clearTimeout(autosaveTimer.current)
+    formRef.current.requestSubmit()
+    await new Promise<void>((resolve) => {
+      const check = () => {
+        if (!isSavingRef.current) return resolve()
+        setTimeout(check, 50)
+      }
+      setTimeout(check, 50)
+    })
+  }
+
+  // Notes ref
+  const notesRef = useRef({
+    invoiceNotes: initialData.invoiceNotes || '',
+    diagnosticNotes: initialData.diagnosticNotes || '',
+    description: initialData.description || '',
+  })
+
+  const handleNotesChange = useCallback(
+    (field: 'invoiceNotes' | 'diagnosticNotes' | 'description', value: string) => {
+      notesRef.current[field] = value
+      markDirty()
+    },
+    [markDirty]
+  )
+
+  // Computed totals
+  const partsSubtotal = partItems.reduce((sum, p) => sum + p.total, 0)
+  const laborSubtotal = laborItems.reduce((sum, l) => sum + l.total, 0)
+  const subtotal = partsSubtotal + laborSubtotal
+  const discountAmount =
+    discountType === 'percentage'
+      ? subtotal * (discountValue / 100)
+      : discountType === 'fixed'
+        ? Math.min(discountValue, subtotal)
+        : 0
+  const taxAmount = (subtotal - discountAmount) * (taxRate / 100)
+  const totalAmount = subtotal - discountAmount + taxAmount
+
+  const displayTotal = totalAmount > 0 ? totalAmount : record.cost
+  const paidFromPayments = record.payments?.reduce((sum, p) => sum + p.amount, 0) || 0
+  const totalPaid = record.manuallyPaid ? displayTotal : paidFromPayments
+  const balanceDue = displayTotal - totalPaid
+  const paymentStatus = record.manuallyPaid
+    ? 'paid'
+    : totalPaid === 0
+      ? 'unpaid'
+      : balanceDue <= 0
+        ? 'paid'
+        : 'partial'
+  const imageAttachments = record.attachments?.filter((a) => a.category === 'image') || []
+  const vehicleName = `${record.vehicle.year} ${record.vehicle.make} ${record.vehicle.model}`
+
+  // Part/labor update helpers
+  const updatePart = useCallback(
+    (index: number, field: keyof ServicePartInput, value: string | number) => {
+      setPartItems((prev) => {
+        const updated = [...prev]
+        const part = { ...updated[index], [field]: value }
+        if (field === 'quantity' || field === 'unitPrice') {
+          part.total = Number(part.quantity) * Number(part.unitPrice)
+        }
+        updated[index] = part
+        return updated
+      })
+      markDirty()
+    },
+    [markDirty]
+  )
+
+  const updateLabor = useCallback(
+    (index: number, field: keyof ServiceLaborInput, value: string | number) => {
+      setLaborItems((prev) => {
+        const updated = [...prev]
+        const labor = { ...updated[index], [field]: value }
+        if (field === 'hours' || field === 'rate') {
+          labor.total = Number(labor.hours) * Number(labor.rate)
+        }
+        updated[index] = labor
+        return updated
+      })
+      markDirty()
+    },
+    [markDirty]
+  )
+
+  // Wrapped setters that trigger autosave
+  const dirtySetPartItems: typeof setPartItems = useCallback((action) => {
+    setPartItems(action)
+    markDirty()
+  }, [markDirty])
+
+  const dirtySetLaborItems: typeof setLaborItems = useCallback((action) => {
+    setLaborItems(action)
+    markDirty()
+  }, [markDirty])
+
+  const dirtySetDiscountType = useCallback((v: string) => { setDiscountType(v); markDirty() }, [markDirty])
+  const dirtySetDiscountValue = useCallback((v: number) => { setDiscountValue(v); markDirty() }, [markDirty])
+  const dirtySetTaxRate = useCallback((v: number) => { setTaxRate(v); markDirty() }, [markDirty])
+  const dirtySetType = useCallback((v: string) => { setType(v); markDirty() }, [markDirty])
+  const dirtySetStatus = useCallback((v: string) => { setStatus(v); markDirty() }, [markDirty])
+  const dirtySetSelectedVehicleId = useCallback((v: string) => { setSelectedVehicleId(v); markDirty() }, [markDirty])
+
+  return {
+    // State
+    loading, setLoading,
+    selectedVehicleId, vehicleOpen, setVehicleOpen,
+    techName, type, status,
+    partItems, laborItems,
+    taxRate, discountType, discountValue,
+    showInventoryPicker, setShowInventoryPicker,
+    // Autosave
+    hasUnsavedChanges, setHasUnsavedChanges, showSaved,
+    formRef, autosaveTimer, isSavingRef,
+    flashSaved, markDirty, saveNow,
+    // Notes
+    notesRef, handleNotesChange,
+    // Computed
+    partsSubtotal, laborSubtotal, subtotal,
+    discountAmount, taxAmount, totalAmount,
+    displayTotal, totalPaid, balanceDue, paymentStatus,
+    imageAttachments, vehicleName,
+    // Helpers
+    updatePart, updateLabor,
+    dirtySetPartItems, dirtySetLaborItems,
+    dirtySetDiscountType, dirtySetDiscountValue, dirtySetTaxRate,
+    dirtySetType, dirtySetStatus, dirtySetSelectedVehicleId,
+    initialData,
+  }
+}