Преглед изворни кода

Fix: technician name on invoice gets overwritten on autosave (#139)

* apply tax

* create new tax setting page

* translations

* tax report, invoices and digital shared pages

* inclusive tax button

* customer invoice

* translations

* Convert existing records to inclusive mode

* inclusiveMode

* buttons

* tests

* set correct tech name in invoice
Bernt Christian Egeland пре 5 месеци
родитељ
комит
58c4b9df9e

+ 5 - 0
src/app/api/protected/services/[id]/pdf/route.ts

@@ -49,6 +49,11 @@ export async function GET(
           laborItems: true,
           attachments: true,
           payments: { orderBy: { date: "desc" } },
+          // Pull the linked technician's current name as the source of truth.
+          // The denormalized `techName` field can drift out of sync; the FK
+          // relation is always correct. The PDF prefers technician.name and
+          // falls back to techName for legacy records that have no FK.
+          technician: { select: { name: true } },
           vehicle: {
             select: {
               make: true,

+ 4 - 0
src/app/api/public/share/invoice/[orgId]/[token]/pdf/route.ts

@@ -49,6 +49,9 @@ export async function GET(
         partItems: true,
         laborItems: true,
         payments: { orderBy: { date: "desc" } },
+        // Linked technician (FK) is the source of truth for the tech name;
+        // the legacy `techName` string is only a fallback for old records.
+        technician: { select: { name: true } },
         vehicle: {
           select: {
             make: true,
@@ -66,6 +69,7 @@ export async function GET(
                 phone: true,
                 address: true,
                 company: true,
+                taxId: true,
               },
             },
           },

+ 8 - 3
src/features/vehicles/Components/invoice-pdf/InfoSection.tsx

@@ -142,12 +142,17 @@ function renderServiceField(fieldId: string, ctx: ServiceRenderCtx): React.React
           {labels.type ? fillTemplate(labels.type, { type: data.type }) : `Type: ${data.type}`}
         </Text>
       )
-    case 'tech_name':
-      return data.techName ? (
+    case 'tech_name': {
+      // Prefer the linked technician's current name (always correct);
+      // fall back to the legacy denormalized `techName` for records that
+      // were created without a technicianId.
+      const tech = data.technician?.name || data.techName
+      return tech ? (
         <Text key={fieldId} style={styles.infoTextSmall}>
-          {labels.tech ? fillTemplate(labels.tech, { tech: data.techName }) : `Tech: ${data.techName}`}
+          {labels.tech ? fillTemplate(labels.tech, { tech }) : `Tech: ${tech}`}
         </Text>
       ) : null
+    }
     default:
       return null
   }

+ 1 - 0
src/features/vehicles/Components/invoice-pdf/types.ts

@@ -21,6 +21,7 @@ export interface InvoiceData {
   invoiceDueDate?: Date | null
   shopName: string | null
   techName: string | null
+  technician?: { name: string } | null
   mileage: number | null
   diagnosticNotes: string | null
   invoiceNotes: string | null

+ 54 - 17
src/features/vehicles/Components/service-page/useServiceActions.ts

@@ -4,7 +4,12 @@ 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 {
+  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'
@@ -43,12 +48,27 @@ export function useServiceActions({
   const [paymentNotifyMessage, setPaymentNotifyMessage] = useState('')
 
   const {
-    selectedVehicleId, techName, type, status,
-    partItems, laborItems, subtotal, taxRate, taxInclusive, taxAmount, totalAmount,
-    discountType, discountValue, discountAmount,
-    isSavingRef, autosaveTimer, setLoading,
-    setHasUnsavedChanges, flashSaved, notesRef,
-    initialData, customFieldsSaveRef,
+    selectedVehicleId,
+    type,
+    status,
+    partItems,
+    laborItems,
+    subtotal,
+    taxRate,
+    taxInclusive,
+    taxAmount,
+    totalAmount,
+    discountType,
+    discountValue,
+    discountAmount,
+    isSavingRef,
+    autosaveTimer,
+    setLoading,
+    setHasUnsavedChanges,
+    flashSaved,
+    notesRef,
+    initialData,
+    customFieldsSaveRef,
   } = formState
 
   const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
@@ -80,11 +100,11 @@ export function useServiceActions({
     // For hidden inputs (offsetParent is always null), take the last one in DOM order.
     const getVisible = (name: string) => {
       const inputs = Array.from(form.querySelectorAll<HTMLInputElement>(`input[name="${name}"]`))
-      const visible = inputs.find(el => el.offsetParent !== null)
+      const visible = inputs.find((el) => el.offsetParent !== null)
       if (visible) return visible.value
       // All hidden — take last (active layout renders second)
       const last = inputs[inputs.length - 1]
-      return last?.value ?? new FormData(form).get(name) as string
+      return last?.value ?? (new FormData(form).get(name) as string)
     }
     const rawMileage = getVisible('mileage')
     const parsedMileage = rawMileage ? Number(rawMileage) || undefined : undefined
@@ -99,7 +119,10 @@ export function useServiceActions({
       cost: totalAmount,
       mileage: parsedMileage,
       serviceDate: getVisible('serviceDate') || new Date().toISOString(),
-      techName: techName || undefined,
+      // techName intentionally omitted: it has no UI in the form (read-only),
+      // and the Schedule card writes it directly via assignTechnician. Sending
+      // the form-state value here would clobber a tech change made via the
+      // Schedule card with a stale name from initial load.
       diagnosticNotes: notesRef.current.diagnosticNotes || undefined,
       invoiceNotes: notesRef.current.invoiceNotes || undefined,
       invoiceNumber: getVisible('invoiceNumber') || undefined,
@@ -195,7 +218,10 @@ export function useServiceActions({
     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] || ''
+    const tpl =
+      tplData?.templates[SETTING_KEYS.SMS_TEMPLATE_PAYMENT_RECEIVED] ||
+      SMS_TEMPLATE_DEFAULTS[SETTING_KEYS.SMS_TEMPLATE_PAYMENT_RECEIVED] ||
+      ''
     setPaymentNotifyMessage(
       interpolateSmsTemplate(tpl, {
         amount,
@@ -208,7 +234,12 @@ export function useServiceActions({
     setShowPaymentNotifyDialog(true)
   }
 
-  const handleCreatePayment = async (data: { amount: number; date: string; method: string; note?: string }) => {
+  const handleCreatePayment = async (data: {
+    amount: number
+    date: string
+    method: string
+    note?: string
+  }) => {
     setPaymentLoading(true)
     const result = await createPayment({ serviceRecordId: record.id, ...data })
     setPaymentLoading(false)
@@ -230,7 +261,9 @@ export function useServiceActions({
     setPaymentLoading(false)
     if (result.success) {
       formState.setLocalManuallyPaid(!!result.data?.manuallyPaid)
-      toast.success(result.data?.manuallyPaid ? t('payments.markedPaid') : t('payments.markedUnpaid'))
+      toast.success(
+        result.data?.manuallyPaid ? t('payments.markedPaid') : t('payments.markedUnpaid')
+      )
       router.refresh()
       if (result.data?.manuallyPaid && record.vehicle.customer) {
         await buildPaymentNotifyMessage('')
@@ -273,12 +306,16 @@ export function useServiceActions({
     deletingAttachment,
     paymentLoading,
     deletingPayment,
-    showShareDialog, setShowShareDialog,
-    showEmailDialog, setShowEmailDialog,
-    showPaymentNotifyDialog, setShowPaymentNotifyDialog,
+    showShareDialog,
+    setShowShareDialog,
+    showEmailDialog,
+    setShowEmailDialog,
+    showPaymentNotifyDialog,
+    setShowPaymentNotifyDialog,
     paymentNotifyMessage,
     // Carousel
-    carouselIndex, setCarouselIndex,
+    carouselIndex,
+    setCarouselIndex,
     onImageClick: setCarouselIndex,
     // Helpers
     saveNow: formState.saveNow,