|
@@ -1,13 +1,31 @@
|
|
|
'use client'
|
|
'use client'
|
|
|
|
|
|
|
|
|
|
+import { useRef, useCallback } from 'react'
|
|
|
import { Button } from '@/components/ui/button'
|
|
import { Button } from '@/components/ui/button'
|
|
|
import { Input } from '@/components/ui/input'
|
|
import { Input } from '@/components/ui/input'
|
|
|
import { Textarea } from '@/components/ui/textarea'
|
|
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 { formatCurrency, getCurrencySymbol } from '@/lib/format'
|
|
|
import { useTranslations } from 'next-intl'
|
|
import { useTranslations } from 'next-intl'
|
|
|
import type { ServiceLaborInput } from '@/features/vehicles/Schema/serviceSchema'
|
|
import type { ServiceLaborInput } from '@/features/vehicles/Schema/serviceSchema'
|
|
|
import { makeEmptyLabor, makeEmptyService } from './form-types'
|
|
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 {
|
|
interface LaborEditorProps {
|
|
|
laborItems: ServiceLaborInput[]
|
|
laborItems: ServiceLaborInput[]
|
|
@@ -20,6 +38,107 @@ interface LaborEditorProps {
|
|
|
onOpenPresets?: () => void
|
|
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({
|
|
export function LaborEditor({
|
|
|
laborItems,
|
|
laborItems,
|
|
|
setLaborItems,
|
|
setLaborItems,
|
|
@@ -32,6 +151,60 @@ export function LaborEditor({
|
|
|
}: LaborEditorProps) {
|
|
}: LaborEditorProps) {
|
|
|
const t = useTranslations('service.labor')
|
|
const t = useTranslations('service.labor')
|
|
|
const cs = getCurrencySymbol(currencyCode)
|
|
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 (
|
|
return (
|
|
|
<div className="rounded-lg border p-3 space-y-2">
|
|
<div className="rounded-lg border p-3 space-y-2">
|
|
@@ -48,7 +221,7 @@ export function LaborEditor({
|
|
|
type="button"
|
|
type="button"
|
|
|
variant="outline"
|
|
variant="outline"
|
|
|
size="sm"
|
|
size="sm"
|
|
|
- onClick={() => setLaborItems((prev) => [...prev, makeEmptyLabor(defaultLaborRate)])}
|
|
|
|
|
|
|
+ onClick={addLaborAtStart}
|
|
|
>
|
|
>
|
|
|
<Plus className="mr-1 h-3.5 w-3.5" />
|
|
<Plus className="mr-1 h-3.5 w-3.5" />
|
|
|
<span className="hidden sm:inline">{t('addLabor')}</span>
|
|
<span className="hidden sm:inline">{t('addLabor')}</span>
|
|
@@ -57,7 +230,7 @@ export function LaborEditor({
|
|
|
type="button"
|
|
type="button"
|
|
|
variant="outline"
|
|
variant="outline"
|
|
|
size="sm"
|
|
size="sm"
|
|
|
- onClick={() => setLaborItems((prev) => [...prev, makeEmptyService()])}
|
|
|
|
|
|
|
+ onClick={addServiceAtStart}
|
|
|
>
|
|
>
|
|
|
<Wrench className="mr-1 h-3.5 w-3.5" />
|
|
<Wrench className="mr-1 h-3.5 w-3.5" />
|
|
|
<span className="hidden sm:inline">{t('addService')}</span>
|
|
<span className="hidden sm:inline">{t('addService')}</span>
|
|
@@ -67,75 +240,34 @@ export function LaborEditor({
|
|
|
|
|
|
|
|
{laborItems.length > 0 && (
|
|
{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('description')}</span>
|
|
|
<span>{t('qtyOrHours')}</span>
|
|
<span>{t('qtyOrHours')}</span>
|
|
|
<span>{t('rate', { currency: cs })}</span>
|
|
<span>{t('rate', { currency: cs })}</span>
|
|
|
<span>{t('total')}</span>
|
|
<span>{t('total')}</span>
|
|
|
<span />
|
|
<span />
|
|
|
</div>
|
|
</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
|
|
<button
|
|
|
type="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"
|
|
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" />
|
|
<Plus className="h-4 w-4" />
|
|
|
</button>
|
|
</button>
|
|
@@ -152,7 +284,7 @@ export function LaborEditor({
|
|
|
<button
|
|
<button
|
|
|
type="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"
|
|
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" />
|
|
<Plus className="mr-1 h-4 w-4" />
|
|
|
<span className="text-sm">{t('addLabor')}</span>
|
|
<span className="text-sm">{t('addLabor')}</span>
|
|
@@ -160,7 +292,7 @@ export function LaborEditor({
|
|
|
<button
|
|
<button
|
|
|
type="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"
|
|
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" />
|
|
<Wrench className="mr-1 h-4 w-4" />
|
|
|
<span className="text-sm">{t('addService')}</span>
|
|
<span className="text-sm">{t('addService')}</span>
|