Sfoglia il codice sorgente

Add drag-to-reorder, image lightbox, and scroll-safe number inputs (#108)

* add parts and labor at top

* Fix number inputs changing value on scroll/swipe
Bernt Christian Egeland 6 mesi fa
parent
commit
bf6e5a30ab

+ 9 - 1
src/components/ui/input.tsx

@@ -2,7 +2,14 @@ import * as React from "react"
 
 import { cn } from "@/lib/utils"
 
-function Input({ className, type, ...props }: React.ComponentProps<"input">) {
+function Input({ className, type, onWheel, ...props }: React.ComponentProps<"input">) {
+  const handleWheel = type === "number"
+    ? (e: React.WheelEvent<HTMLInputElement>) => {
+        e.currentTarget.blur()
+        onWheel?.(e)
+      }
+    : onWheel
+
   return (
     <input
       type={type}
@@ -13,6 +20,7 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
         "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
         className
       )}
+      onWheel={handleWheel}
       {...props}
     />
   )

+ 196 - 64
src/features/vehicles/Components/service-edit/LaborEditor.tsx

@@ -1,13 +1,31 @@
 'use client'
 
+import { useRef, useCallback } from 'react'
 import { Button } from '@/components/ui/button'
 import { Input } from '@/components/ui/input'
 import { Textarea } from '@/components/ui/textarea'
-import { Layers, Plus, Trash2, Wrench } from 'lucide-react'
+import { GripVertical, Layers, Plus, Trash2, Wrench } from 'lucide-react'
 import { formatCurrency, getCurrencySymbol } from '@/lib/format'
 import { useTranslations } from 'next-intl'
 import type { ServiceLaborInput } from '@/features/vehicles/Schema/serviceSchema'
 import { makeEmptyLabor, makeEmptyService } from './form-types'
+import {
+  DndContext,
+  closestCenter,
+  PointerSensor,
+  KeyboardSensor,
+  useSensor,
+  useSensors,
+  type DragEndEvent,
+} from '@dnd-kit/core'
+import {
+  SortableContext,
+  sortableKeyboardCoordinates,
+  verticalListSortingStrategy,
+  useSortable,
+  arrayMove,
+} from '@dnd-kit/sortable'
+import { CSS } from '@dnd-kit/utilities'
 
 interface LaborEditorProps {
   laborItems: ServiceLaborInput[]
@@ -20,6 +38,107 @@ interface LaborEditorProps {
   onOpenPresets?: () => void
 }
 
+function SortableLaborRow({
+  id,
+  labor,
+  index,
+  updateLabor,
+  onDelete,
+  currencyCode,
+  t,
+}: {
+  id: string
+  labor: ServiceLaborInput
+  index: number
+  updateLabor: (index: number, field: keyof ServiceLaborInput, value: string | number) => void
+  onDelete: () => void
+  currencyCode: string
+  t: (key: string) => string
+}) {
+  const {
+    attributes,
+    listeners,
+    setNodeRef,
+    transform,
+    transition,
+    isDragging,
+  } = useSortable({ id })
+
+  const style = {
+    transform: CSS.Transform.toString(transform),
+    transition,
+  }
+
+  const isService = labor.pricingType === 'service'
+
+  return (
+    <div
+      ref={setNodeRef}
+      style={style}
+      className={`grid grid-cols-[auto_1fr] gap-2 sm:grid-cols-[auto_2fr_1fr_1fr_1fr_auto] ${isDragging ? 'z-10 opacity-75' : ''}`}
+    >
+      <button
+        type="button"
+        className="flex h-9 w-6 cursor-grab items-center justify-center text-muted-foreground hover:text-foreground active:cursor-grabbing"
+        {...attributes}
+        {...listeners}
+      >
+        <GripVertical className="h-4 w-4" />
+      </button>
+      <div className="grid grid-cols-2 gap-2 sm:contents">
+        <div className="col-span-2 flex gap-2 sm:col-span-1">
+          <Textarea
+            placeholder={t('descriptionPlaceholder')}
+            value={labor.description}
+            onChange={(e) => updateLabor(index, 'description', e.target.value)}
+            rows={1}
+            className="min-h-9 flex-1 resize-none"
+          />
+          <button
+            type="button"
+            className={`shrink-0 rounded-md border px-2 text-[10px] font-medium transition-all ${
+              isService
+                ? 'border-blue-500/30 bg-blue-500/10 text-blue-600 hover:bg-blue-500/20 hover:border-blue-500/50'
+                : 'border-muted text-muted-foreground hover:bg-muted hover:text-foreground hover:border-foreground/20'
+            }`}
+            onClick={() => updateLabor(index, 'pricingType', isService ? 'hourly' : 'service')}
+            title={isService ? t('switchToHourlyHint') : t('switchToServiceHint')}
+          >
+            {isService ? t('serviceTag') : t('hourlyTag')}
+          </button>
+        </div>
+        <Input
+          type="number"
+          min="0"
+          step={isService ? '1' : 'any'}
+          placeholder={isService ? t('qty') : t('hours')}
+          value={labor.hours}
+          onChange={(e) => updateLabor(index, 'hours', e.target.value)}
+        />
+        <Input
+          type="number"
+          min="0"
+          step="0.01"
+          value={labor.rate}
+          onChange={(e) => updateLabor(index, 'rate', e.target.value)}
+        />
+        <div className="flex items-center rounded-md bg-muted/50 px-3 text-sm font-medium">
+          {formatCurrency(labor.total, currencyCode)}
+        </div>
+        <Button
+          type="button"
+          variant="ghost"
+          size="icon"
+          className="h-9 w-9 text-muted-foreground hover:text-destructive"
+          onClick={onDelete}
+        >
+          <Trash2 className="h-4 w-4" />
+        </Button>
+      </div>
+    </div>
+  )
+}
+
 export function LaborEditor({
   laborItems,
   setLaborItems,
@@ -32,6 +151,60 @@ export function LaborEditor({
 }: LaborEditorProps) {
   const t = useTranslations('service.labor')
   const cs = getCurrencySymbol(currencyCode)
+  const keyCounterRef = useRef(0)
+  const keysRef = useRef<string[]>([])
+
+  // Keep keys array in sync with items length
+  while (keysRef.current.length < laborItems.length) {
+    keysRef.current.push(`labor-${keyCounterRef.current++}`)
+  }
+  if (keysRef.current.length > laborItems.length) {
+    keysRef.current = keysRef.current.slice(0, laborItems.length)
+  }
+
+  const sensors = useSensors(
+    useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
+    useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
+  )
+
+  const handleDragEnd = useCallback((event: DragEndEvent) => {
+    const { active, over } = event
+    if (!over || active.id === over.id) return
+    const oldIndex = keysRef.current.indexOf(active.id as string)
+    const newIndex = keysRef.current.indexOf(over.id as string)
+    if (oldIndex === -1 || newIndex === -1) return
+    keysRef.current = arrayMove(keysRef.current, oldIndex, newIndex)
+    setLaborItems((prev) => arrayMove(prev, oldIndex, newIndex))
+  }, [setLaborItems])
+
+  const addLaborAtStart = useCallback(() => {
+    const key = `labor-${keyCounterRef.current++}`
+    keysRef.current = [key, ...keysRef.current]
+    setLaborItems((prev) => [makeEmptyLabor(defaultLaborRate), ...prev])
+  }, [setLaborItems, defaultLaborRate])
+
+  const addServiceAtStart = useCallback(() => {
+    const key = `labor-${keyCounterRef.current++}`
+    keysRef.current = [key, ...keysRef.current]
+    setLaborItems((prev) => [makeEmptyService(), ...prev])
+  }, [setLaborItems])
+
+  const addLaborAtEnd = useCallback(() => {
+    const key = `labor-${keyCounterRef.current++}`
+    keysRef.current = [...keysRef.current, key]
+    setLaborItems((prev) => [...prev, makeEmptyLabor(defaultLaborRate)])
+  }, [setLaborItems, defaultLaborRate])
+
+  const addServiceAtEnd = useCallback(() => {
+    const key = `labor-${keyCounterRef.current++}`
+    keysRef.current = [...keysRef.current, key]
+    setLaborItems((prev) => [...prev, makeEmptyService()])
+  }, [setLaborItems])
+
+  const deleteLabor = useCallback((index: number) => {
+    keysRef.current = keysRef.current.filter((_, j) => j !== index)
+    setLaborItems((prev) => prev.filter((_, j) => j !== index))
+  }, [setLaborItems])
 
   return (
     <div className="rounded-lg border p-3 space-y-2">
@@ -48,7 +221,7 @@ export function LaborEditor({
             type="button"
             variant="outline"
             size="sm"
-            onClick={() => setLaborItems((prev) => [...prev, makeEmptyLabor(defaultLaborRate)])}
+            onClick={addLaborAtStart}
           >
             <Plus className="mr-1 h-3.5 w-3.5" />
             <span className="hidden sm:inline">{t('addLabor')}</span>
@@ -57,7 +230,7 @@ export function LaborEditor({
             type="button"
             variant="outline"
             size="sm"
-            onClick={() => setLaborItems((prev) => [...prev, makeEmptyService()])}
+            onClick={addServiceAtStart}
           >
             <Wrench className="mr-1 h-3.5 w-3.5" />
             <span className="hidden sm:inline">{t('addService')}</span>
@@ -67,75 +240,34 @@ export function LaborEditor({
 
       {laborItems.length > 0 && (
         <>
-          <div className="hidden grid-cols-[2fr_1fr_1fr_1fr_auto] gap-2 text-xs font-medium text-muted-foreground sm:grid">
+          <div className="hidden grid-cols-[auto_2fr_1fr_1fr_1fr_auto] gap-2 text-xs font-medium text-muted-foreground sm:grid">
+            <span className="w-6" />
             <span>{t('description')}</span>
             <span>{t('qtyOrHours')}</span>
             <span>{t('rate', { currency: cs })}</span>
             <span>{t('total')}</span>
             <span />
           </div>
-          {laborItems.map((labor, i) => {
-            const isService = labor.pricingType === 'service'
-            return (
-              <div
-                key={i}
-                className="grid grid-cols-2 gap-2 sm:grid-cols-[2fr_1fr_1fr_1fr_auto]"
-              >
-                <div className="col-span-2 flex gap-2 sm:col-span-1">
-                  <Textarea
-                    placeholder={t('descriptionPlaceholder')}
-                    value={labor.description}
-                    onChange={(e) => updateLabor(i, 'description', e.target.value)}
-                    rows={1}
-                    className="min-h-9 flex-1 resize-none"
-                  />
-                  <button
-                    type="button"
-                    className={`shrink-0 rounded-md border px-2 text-[10px] font-medium transition-all ${
-                      isService
-                        ? 'border-blue-500/30 bg-blue-500/10 text-blue-600 hover:bg-blue-500/20 hover:border-blue-500/50'
-                        : 'border-muted text-muted-foreground hover:bg-muted hover:text-foreground hover:border-foreground/20'
-                    }`}
-                    onClick={() => updateLabor(i, 'pricingType', isService ? 'hourly' : 'service')}
-                    title={isService ? t('switchToHourlyHint') : t('switchToServiceHint')}
-                  >
-                    {isService ? t('serviceTag') : t('hourlyTag')}
-                  </button>
-                </div>
-                <Input
-                  type="number"
-                  min="0"
-                  step={isService ? '1' : 'any'}
-                  placeholder={isService ? t('qty') : t('hours')}
-                  value={labor.hours}
-                  onChange={(e) => updateLabor(i, 'hours', e.target.value)}
-                />
-                <Input
-                  type="number"
-                  min="0"
-                  step="0.01"
-                  value={labor.rate}
-                  onChange={(e) => updateLabor(i, 'rate', e.target.value)}
+          <DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
+            <SortableContext items={keysRef.current} strategy={verticalListSortingStrategy}>
+              {laborItems.map((labor, i) => (
+                <SortableLaborRow
+                  key={keysRef.current[i]}
+                  id={keysRef.current[i]}
+                  labor={labor}
+                  index={i}
+                  updateLabor={updateLabor}
+                  onDelete={() => deleteLabor(i)}
+                  currencyCode={currencyCode}
+                  t={t}
                 />
-                <div className="flex items-center rounded-md bg-muted/50 px-3 text-sm font-medium">
-                  {formatCurrency(labor.total, currencyCode)}
-                </div>
-                <Button
-                  type="button"
-                  variant="ghost"
-                  size="icon"
-                  className="h-9 w-9 text-muted-foreground hover:text-destructive"
-                  onClick={() => setLaborItems((prev) => prev.filter((_, j) => j !== i))}
-                >
-                  <Trash2 className="h-4 w-4" />
-                </Button>
-              </div>
-            )
-          })}
+              ))}
+            </SortableContext>
+          </DndContext>
           <button
             type="button"
             className="flex w-full items-center justify-center rounded-md border border-dashed border-muted-foreground/25 py-1.5 text-muted-foreground transition-colors hover:border-muted-foreground/50 hover:text-foreground"
-            onClick={() => setLaborItems((prev) => [...prev, makeEmptyLabor(defaultLaborRate)])}
+            onClick={addLaborAtEnd}
           >
             <Plus className="h-4 w-4" />
           </button>
@@ -152,7 +284,7 @@ export function LaborEditor({
           <button
             type="button"
             className="flex flex-1 items-center justify-center rounded-md border border-dashed border-muted-foreground/25 py-1.5 text-muted-foreground transition-colors hover:border-muted-foreground/50 hover:text-foreground"
-            onClick={() => setLaborItems((prev) => [...prev, makeEmptyLabor(defaultLaborRate)])}
+            onClick={addLaborAtEnd}
           >
             <Plus className="mr-1 h-4 w-4" />
             <span className="text-sm">{t('addLabor')}</span>
@@ -160,7 +292,7 @@ export function LaborEditor({
           <button
             type="button"
             className="flex flex-1 items-center justify-center rounded-md border border-dashed border-muted-foreground/25 py-1.5 text-muted-foreground transition-colors hover:border-muted-foreground/50 hover:text-foreground"
-            onClick={() => setLaborItems((prev) => [...prev, makeEmptyService()])}
+            onClick={addServiceAtEnd}
           >
             <Wrench className="mr-1 h-4 w-4" />
             <span className="text-sm">{t('addService')}</span>

+ 171 - 50
src/features/vehicles/Components/service-edit/PartsEditor.tsx

@@ -1,13 +1,31 @@
 'use client'
 
+import { useRef, useCallback } from 'react'
 import { Button } from '@/components/ui/button'
 import { Input } from '@/components/ui/input'
 import { Textarea } from '@/components/ui/textarea'
-import { Package, Plus, ScanBarcode, Trash2 } from 'lucide-react'
+import { GripVertical, Package, Plus, ScanBarcode, Trash2 } from 'lucide-react'
 import { formatCurrency } from '@/lib/format'
 import { useTranslations } from 'next-intl'
 import type { ServicePartInput } from '@/features/vehicles/Schema/serviceSchema'
 import { emptyPart } from './form-types'
+import {
+  DndContext,
+  closestCenter,
+  PointerSensor,
+  KeyboardSensor,
+  useSensor,
+  useSensors,
+  type DragEndEvent,
+} from '@dnd-kit/core'
+import {
+  SortableContext,
+  sortableKeyboardCoordinates,
+  verticalListSortingStrategy,
+  useSortable,
+  arrayMove,
+} from '@dnd-kit/sortable'
+import { CSS } from '@dnd-kit/utilities'
 
 interface PartsEditorProps {
   partItems: ServicePartInput[]
@@ -20,6 +38,95 @@ interface PartsEditorProps {
   onScanBarcode?: () => void
 }
 
+function SortablePartRow({
+  id,
+  part,
+  index,
+  updatePart,
+  onDelete,
+  currencyCode,
+  t,
+}: {
+  id: string
+  part: ServicePartInput
+  index: number
+  updatePart: (index: number, field: keyof ServicePartInput, value: string | number) => void
+  onDelete: () => void
+  currencyCode: string
+  t: (key: string) => string
+}) {
+  const {
+    attributes,
+    listeners,
+    setNodeRef,
+    transform,
+    transition,
+    isDragging,
+  } = useSortable({ id })
+
+  const style = {
+    transform: CSS.Transform.toString(transform),
+    transition,
+  }
+
+  return (
+    <div
+      ref={setNodeRef}
+      style={style}
+      className={`grid grid-cols-[auto_1fr] gap-2 sm:grid-cols-[auto_1fr_2fr_0.7fr_1fr_1fr_auto] ${isDragging ? 'z-10 opacity-75' : ''}`}
+    >
+      <button
+        type="button"
+        className="flex h-9 w-6 cursor-grab items-center justify-center text-muted-foreground hover:text-foreground active:cursor-grabbing"
+        {...attributes}
+        {...listeners}
+      >
+        <GripVertical className="h-4 w-4" />
+      </button>
+      <div className="grid grid-cols-2 gap-2 sm:contents">
+        <Input
+          placeholder={t('partNumber')}
+          value={part.partNumber ?? ''}
+          onChange={(e) => updatePart(index, 'partNumber', e.target.value)}
+        />
+        <Textarea
+          placeholder={t('namePlaceholder')}
+          value={part.name}
+          onChange={(e) => updatePart(index, 'name', e.target.value)}
+          rows={1}
+          className="min-h-9 resize-none"
+        />
+        <Input
+          type="number"
+          min="0"
+          step="0.01"
+          value={part.quantity}
+          onChange={(e) => updatePart(index, 'quantity', e.target.value)}
+        />
+        <Input
+          type="number"
+          min="0"
+          step="0.01"
+          value={part.unitPrice}
+          onChange={(e) => updatePart(index, 'unitPrice', e.target.value)}
+        />
+        <div className="flex items-center rounded-md bg-muted/50 px-3 text-sm font-medium">
+          {formatCurrency(part.total, currencyCode)}
+        </div>
+        <Button
+          type="button"
+          variant="ghost"
+          size="icon"
+          className="h-9 w-9 text-muted-foreground hover:text-destructive"
+          onClick={onDelete}
+        >
+          <Trash2 className="h-4 w-4" />
+        </Button>
+      </div>
+    </div>
+  )
+}
+
 export function PartsEditor({
   partItems,
   setPartItems,
@@ -31,6 +138,48 @@ export function PartsEditor({
   onScanBarcode,
 }: PartsEditorProps) {
   const t = useTranslations('service.parts')
+  const keyCounterRef = useRef(0)
+  const keysRef = useRef<string[]>([])
+
+  // Keep keys array in sync with items length
+  while (keysRef.current.length < partItems.length) {
+    keysRef.current.push(`part-${keyCounterRef.current++}`)
+  }
+  if (keysRef.current.length > partItems.length) {
+    keysRef.current = keysRef.current.slice(0, partItems.length)
+  }
+
+  const sensors = useSensors(
+    useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
+    useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
+  )
+
+  const handleDragEnd = useCallback((event: DragEndEvent) => {
+    const { active, over } = event
+    if (!over || active.id === over.id) return
+    const oldIndex = keysRef.current.indexOf(active.id as string)
+    const newIndex = keysRef.current.indexOf(over.id as string)
+    if (oldIndex === -1 || newIndex === -1) return
+    keysRef.current = arrayMove(keysRef.current, oldIndex, newIndex)
+    setPartItems((prev) => arrayMove(prev, oldIndex, newIndex))
+  }, [setPartItems])
+
+  const addPartAtStart = useCallback(() => {
+    const key = `part-${keyCounterRef.current++}`
+    keysRef.current = [key, ...keysRef.current]
+    setPartItems((prev) => [emptyPart(), ...prev])
+  }, [setPartItems])
+
+  const addPartAtEnd = useCallback(() => {
+    const key = `part-${keyCounterRef.current++}`
+    keysRef.current = [...keysRef.current, key]
+    setPartItems((prev) => [...prev, emptyPart()])
+  }, [setPartItems])
+
+  const deletePart = useCallback((index: number) => {
+    keysRef.current = keysRef.current.filter((_, j) => j !== index)
+    setPartItems((prev) => prev.filter((_, j) => j !== index))
+  }, [setPartItems])
 
   return (
     <div className="rounded-lg border p-3 space-y-2">
@@ -53,7 +202,7 @@ export function PartsEditor({
             type="button"
             variant="outline"
             size="sm"
-            onClick={() => setPartItems((prev) => [...prev, emptyPart()])}
+            onClick={addPartAtStart}
           >
             <Plus className="h-3.5 w-3.5 sm:mr-1" />
             <span className="hidden sm:inline">{t('addPart')}</span>
@@ -63,7 +212,8 @@ export function PartsEditor({
 
       {partItems.length > 0 && (
         <>
-          <div className="hidden grid-cols-[1fr_2fr_0.7fr_1fr_1fr_auto] gap-2 text-xs font-medium text-muted-foreground sm:grid">
+          <div className="hidden grid-cols-[auto_1fr_2fr_0.7fr_1fr_1fr_auto] gap-2 text-xs font-medium text-muted-foreground sm:grid">
+            <span className="w-6" />
             <span>{t('partNumber')}</span>
             <span>{t('name')}</span>
             <span>{t('qty')}</span>
@@ -71,55 +221,26 @@ export function PartsEditor({
             <span>{t('total')}</span>
             <span />
           </div>
-          {partItems.map((part, i) => (
-            <div
-              key={i}
-              className="grid grid-cols-2 gap-2 sm:grid-cols-[1fr_2fr_0.7fr_1fr_1fr_auto]"
-            >
-              <Input
-                placeholder={t('partNumber')}
-                value={part.partNumber ?? ''}
-                onChange={(e) => updatePart(i, 'partNumber', e.target.value)}
-              />
-              <Textarea
-                placeholder={t('namePlaceholder')}
-                value={part.name}
-                onChange={(e) => updatePart(i, 'name', e.target.value)}
-                rows={1}
-                className="min-h-9 resize-none"
-              />
-              <Input
-                type="number"
-                min="0"
-                step="0.01"
-                value={part.quantity}
-                onChange={(e) => updatePart(i, 'quantity', e.target.value)}
-              />
-              <Input
-                type="number"
-                min="0"
-                step="0.01"
-                value={part.unitPrice}
-                onChange={(e) => updatePart(i, 'unitPrice', e.target.value)}
-              />
-              <div className="flex items-center rounded-md bg-muted/50 px-3 text-sm font-medium">
-                {formatCurrency(part.total, currencyCode)}
-              </div>
-              <Button
-                type="button"
-                variant="ghost"
-                size="icon"
-                className="h-9 w-9 text-muted-foreground hover:text-destructive"
-                onClick={() => setPartItems((prev) => prev.filter((_, j) => j !== i))}
-              >
-                <Trash2 className="h-4 w-4" />
-              </Button>
-            </div>
-          ))}
+          <DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
+            <SortableContext items={keysRef.current} strategy={verticalListSortingStrategy}>
+              {partItems.map((part, i) => (
+                <SortablePartRow
+                  key={keysRef.current[i]}
+                  id={keysRef.current[i]}
+                  part={part}
+                  index={i}
+                  updatePart={updatePart}
+                  onDelete={() => deletePart(i)}
+                  currencyCode={currencyCode}
+                  t={t}
+                />
+              ))}
+            </SortableContext>
+          </DndContext>
           <button
             type="button"
             className="flex w-full items-center justify-center rounded-md border border-dashed border-muted-foreground/25 py-1.5 text-muted-foreground transition-colors hover:border-muted-foreground/50 hover:text-foreground"
-            onClick={() => setPartItems((prev) => [...prev, emptyPart()])}
+            onClick={addPartAtEnd}
           >
             <Plus className="h-4 w-4" />
           </button>
@@ -135,7 +256,7 @@ export function PartsEditor({
         <button
           type="button"
           className="flex w-full items-center justify-center rounded-md border border-dashed border-muted-foreground/25 py-1.5 text-muted-foreground transition-colors hover:border-muted-foreground/50 hover:text-foreground"
-          onClick={() => setPartItems((prev) => [...prev, emptyPart()])}
+          onClick={addPartAtEnd}
         >
           <Plus className="mr-1 h-4 w-4" />
           <span className="text-sm">{t('addPart')}</span>

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

@@ -135,7 +135,7 @@ export function ServicePageClient({
     if (result.success && result.data) {
       const part = result.data
       const price = part.sellPrice > 0 ? part.sellPrice : part.unitCost
-      formState.dirtySetPartItems((prev) => [...prev, {
+      formState.dirtySetPartItems((prev) => [{
         partNumber: part.partNumber || '',
         name: part.name,
         quantity: 1,
@@ -143,7 +143,7 @@ export function ServicePageClient({
         total: price,
         unitCost: part.unitCost,
         inventoryPartId: part.id,
-      }])
+      }, ...prev])
       toast.success(t('parts.partFound', { name: part.name }))
     } else {
       toast.error(t('parts.partNotFound', { barcode }))
@@ -160,7 +160,7 @@ export function ServicePageClient({
       total: item.hours * (item.rate > 0 ? item.rate : (item.pricingType === 'service' ? 0 : defaultLaborRate)),
       pricingType: (item.pricingType as 'hourly' | 'service') || 'hourly',
     }))
-    formState.dirtySetLaborItems((prev) => [...prev, ...newItems])
+    formState.dirtySetLaborItems((prev) => [...newItems, ...prev])
   }
 
   const actions = useServiceActions({
@@ -287,7 +287,7 @@ export function ServicePageClient({
         onOpenChange={formState.setShowInventoryPicker}
         inventoryParts={inventoryParts}
         currencyCode={currencyCode}
-        onSelectPart={(part) => formState.dirtySetPartItems((prev) => [...prev, part])}
+        onSelectPart={(part) => formState.dirtySetPartItems((prev) => [part, ...prev])}
       />
 
       <LaborPresetPickerDialog