소스 검색

Add parts support to service packages (#147)

* service package

* dialog aria
Bernt Christian Egeland 5 달 전
부모
커밋
b135ed41a5

+ 12 - 2
messages/de/laborPresets.json

@@ -45,7 +45,16 @@
     "itemQty": "Qty",
     "itemQtyOrHours": "Unit / Hours",
     "hourlyTag": "HR",
-    "serviceTag": "SVC"
+    "serviceTag": "SVC",
+    "partsTitle": "Teile",
+    "addPart": "Teil hinzufügen",
+    "partName": "Teilname",
+    "partNumber": "Teil-Nr.",
+    "partQty": "Menge",
+    "partPrice": "Stückpreis",
+    "importFromInventory": "Aus Lager importieren",
+    "searchInventory": "Lager durchsuchen...",
+    "noPartsFound": "Keine Teile gefunden"
   },
   "picker": {
     "title": "Arbeitsvorlage auswählen",
@@ -53,7 +62,8 @@
     "noPackages": "Keine Arbeitsvorlagen gefunden.",
     "items": "{count} Positionen",
     "totalHours": "{hours} Std. gesamt",
-    "totalUnits": "{units} unit(s)"
+    "totalUnits": "{units} unit(s)",
+    "totalParts": "{count} Teile"
   },
   "errors": {
     "error": "Fehler",

+ 12 - 2
messages/en/laborPresets.json

@@ -45,7 +45,16 @@
     "create": "Create Package",
     "saveChanges": "Save Changes",
     "hourlyTag": "HR",
-    "serviceTag": "SVC"
+    "serviceTag": "SVC",
+    "partsTitle": "Parts",
+    "addPart": "Add Part",
+    "partName": "Part name",
+    "partNumber": "Part #",
+    "partQty": "Qty",
+    "partPrice": "Unit price",
+    "importFromInventory": "Import from inventory",
+    "searchInventory": "Search inventory...",
+    "noPartsFound": "No parts found"
   },
   "picker": {
     "title": "Select Labor Package",
@@ -53,7 +62,8 @@
     "noPackages": "No labor packages found.",
     "items": "{count} items",
     "totalHours": "{hours}h total",
-    "totalUnits": "{units} unit(s)"
+    "totalUnits": "{units} unit(s)",
+    "totalParts": "{count} parts"
   },
   "errors": {
     "error": "Error",

+ 12 - 2
messages/es/laborPresets.json

@@ -45,7 +45,16 @@
     "itemQty": "Qty",
     "itemQtyOrHours": "Unit / Hours",
     "hourlyTag": "HR",
-    "serviceTag": "SVC"
+    "serviceTag": "SVC",
+    "partsTitle": "Piezas",
+    "addPart": "Añadir pieza",
+    "partName": "Nombre de pieza",
+    "partNumber": "Pieza #",
+    "partQty": "Cant.",
+    "partPrice": "Precio unitario",
+    "importFromInventory": "Importar del inventario",
+    "searchInventory": "Buscar en inventario...",
+    "noPartsFound": "No se encontraron piezas"
   },
   "picker": {
     "title": "Seleccionar plantilla de mano de obra",
@@ -53,7 +62,8 @@
     "noPackages": "No se encontraron plantillas de mano de obra.",
     "items": "{count} elementos",
     "totalHours": "{hours}h en total",
-    "totalUnits": "{units} unit(s)"
+    "totalUnits": "{units} unit(s)",
+    "totalParts": "{count} piezas"
   },
   "errors": {
     "error": "Error",

+ 12 - 2
messages/fr/laborPresets.json

@@ -45,7 +45,16 @@
     "itemQty": "Qty",
     "itemQtyOrHours": "Unit / Hours",
     "hourlyTag": "HR",
-    "serviceTag": "SVC"
+    "serviceTag": "SVC",
+    "partsTitle": "Pièces",
+    "addPart": "Ajouter une pièce",
+    "partName": "Nom de la pièce",
+    "partNumber": "Pièce #",
+    "partQty": "Qté",
+    "partPrice": "Prix unitaire",
+    "importFromInventory": "Importer de l'inventaire",
+    "searchInventory": "Rechercher dans l'inventaire...",
+    "noPartsFound": "Aucune pièce trouvée"
   },
   "picker": {
     "title": "Sélectionner un modèle de main-d'œuvre",
@@ -53,7 +62,8 @@
     "noPackages": "Aucun modèle de main-d'œuvre trouvé.",
     "items": "{count} éléments",
     "totalHours": "{hours}h au total",
-    "totalUnits": "{units} unit(s)"
+    "totalUnits": "{units} unit(s)",
+    "totalParts": "{count} pièces"
   },
   "errors": {
     "error": "Erreur",

+ 12 - 2
messages/it/laborPresets.json

@@ -45,7 +45,16 @@
     "itemQty": "Qty",
     "itemQtyOrHours": "Unit / Hours",
     "hourlyTag": "HR",
-    "serviceTag": "SVC"
+    "serviceTag": "SVC",
+    "partsTitle": "Ricambi",
+    "addPart": "Aggiungi ricambio",
+    "partName": "Nome ricambio",
+    "partNumber": "Ricambio #",
+    "partQty": "Qtà",
+    "partPrice": "Prezzo unitario",
+    "importFromInventory": "Importa dal magazzino",
+    "searchInventory": "Cerca nel magazzino...",
+    "noPartsFound": "Nessun ricambio trovato"
   },
   "picker": {
     "title": "Seleziona modello di manodopera",
@@ -53,7 +62,8 @@
     "noPackages": "Nessun modello di manodopera trovato.",
     "items": "{count} voci",
     "totalHours": "{hours}h in totale",
-    "totalUnits": "{units} unit(s)"
+    "totalUnits": "{units} unit(s)",
+    "totalParts": "{count} ricambi"
   },
   "errors": {
     "error": "Errore",

+ 12 - 2
messages/lt/laborPresets.json

@@ -45,7 +45,16 @@
     "create": "Sukurti paketą",
     "saveChanges": "Išsaugoti pakeitimus",
     "hourlyTag": "VAL",
-    "serviceTag": "PSL"
+    "serviceTag": "PSL",
+    "partsTitle": "Dalys",
+    "addPart": "Pridėti dalį",
+    "partName": "Dalies pavadinimas",
+    "partNumber": "Dalies #",
+    "partQty": "Kiekis",
+    "partPrice": "Vieneto kaina",
+    "importFromInventory": "Importuoti iš sandėlio",
+    "searchInventory": "Ieškoti sandėlyje...",
+    "noPartsFound": "Dalių nerasta"
   },
   "picker": {
     "title": "Pasirinkti darbų šabloną",
@@ -53,7 +62,8 @@
     "noPackages": "Darbų šablonų nerasta.",
     "items": "{count} elementų",
     "totalHours": "{hours} val. iš viso",
-    "totalUnits": "{units} vnt."
+    "totalUnits": "{units} vnt.",
+    "totalParts": "{count} dalių"
   },
   "errors": {
     "error": "Klaida",

+ 12 - 2
messages/nb/laborPresets.json

@@ -45,7 +45,16 @@
     "itemQty": "Qty",
     "itemQtyOrHours": "Unit / Hours",
     "hourlyTag": "HR",
-    "serviceTag": "SVC"
+    "serviceTag": "SVC",
+    "partsTitle": "Deler",
+    "addPart": "Legg til del",
+    "partName": "Delnavn",
+    "partNumber": "Del #",
+    "partQty": "Ant.",
+    "partPrice": "Enhetspris",
+    "importFromInventory": "Importer fra lager",
+    "searchInventory": "Søk i lager...",
+    "noPartsFound": "Ingen deler funnet"
   },
   "picker": {
     "title": "Velg arbeidsmal",
@@ -53,7 +62,8 @@
     "noPackages": "Ingen arbeidsmaler funnet.",
     "items": "{count} elementer",
     "totalHours": "{hours}t totalt",
-    "totalUnits": "{units} unit(s)"
+    "totalUnits": "{units} unit(s)",
+    "totalParts": "{count} deler"
   },
   "errors": {
     "error": "Feil",

+ 13 - 3
messages/nl/laborPresets.json

@@ -45,7 +45,16 @@
     "itemQty": "Qty",
     "itemQtyOrHours": "Unit / Hours",
     "hourlyTag": "HR",
-    "serviceTag": "SVC"
+    "serviceTag": "SVC",
+    "partsTitle": "Onderdelen",
+    "addPart": "Onderdeel toevoegen",
+    "partName": "Onderdeelnaam",
+    "partNumber": "Onderdeel #",
+    "partQty": "Aantal",
+    "partPrice": "Stuksprijs",
+    "importFromInventory": "Importeren uit voorraad",
+    "searchInventory": "Zoeken in voorraad...",
+    "noPartsFound": "Geen onderdelen gevonden"
   },
   "picker": {
     "title": "Arbeidssjabloon selecteren",
@@ -53,7 +62,8 @@
     "noPackages": "Geen arbeidssjablonen gevonden.",
     "items": "{count} items",
     "totalHours": "{hours}u totaal",
-    "totalUnits": "{units} unit(s)"
+    "totalUnits": "{units} unit(s)",
+    "totalParts": "{count} onderdelen"
   },
   "errors": {
     "error": "Fout",
@@ -62,4 +72,4 @@
     "loadFailed": "Kan arbeidssjabloon niet laden",
     "noItems": "Minimaal één item is vereist"
   }
-}
+}

+ 12 - 2
messages/pl/laborPresets.json

@@ -45,7 +45,16 @@
     "itemQty": "Qty",
     "itemQtyOrHours": "Unit / Hours",
     "hourlyTag": "HR",
-    "serviceTag": "SVC"
+    "serviceTag": "SVC",
+    "partsTitle": "Części",
+    "addPart": "Dodaj część",
+    "partName": "Nazwa części",
+    "partNumber": "Część #",
+    "partQty": "Ilość",
+    "partPrice": "Cena jedn.",
+    "importFromInventory": "Importuj z magazynu",
+    "searchInventory": "Szukaj w magazynie...",
+    "noPartsFound": "Nie znaleziono części"
   },
   "picker": {
     "title": "Wybierz szablon robocizny",
@@ -53,7 +62,8 @@
     "noPackages": "Nie znaleziono szablonów robocizny.",
     "items": "{count} pozycji",
     "totalHours": "{hours} godz. łącznie",
-    "totalUnits": "{units} unit(s)"
+    "totalUnits": "{units} unit(s)",
+    "totalParts": "{count} części"
   },
   "errors": {
     "error": "Błąd",

+ 12 - 2
messages/pt-BR/laborPresets.json

@@ -45,7 +45,16 @@
     "itemQty": "Qty",
     "itemQtyOrHours": "Unit / Hours",
     "hourlyTag": "HR",
-    "serviceTag": "SVC"
+    "serviceTag": "SVC",
+    "partsTitle": "Peças",
+    "addPart": "Adicionar peça",
+    "partName": "Nome da peça",
+    "partNumber": "Peça #",
+    "partQty": "Qtd.",
+    "partPrice": "Preço unitário",
+    "importFromInventory": "Importar do estoque",
+    "searchInventory": "Buscar no estoque...",
+    "noPartsFound": "Nenhuma peça encontrada"
   },
   "picker": {
     "title": "Selecionar modelo de mão de obra",
@@ -53,7 +62,8 @@
     "noPackages": "Nenhum modelo de mão de obra encontrado.",
     "items": "{count} itens",
     "totalHours": "{hours}h no total",
-    "totalUnits": "{units} unit(s)"
+    "totalUnits": "{units} unit(s)",
+    "totalParts": "{count} peças"
   },
   "errors": {
     "error": "Erro",

+ 12 - 2
messages/ru/laborPresets.json

@@ -45,7 +45,16 @@
     "itemQty": "Кол-во",
     "itemQtyOrHours": "Кол-во / Часы",
     "hourlyTag": "н/ч",
-    "serviceTag": "усл."
+    "serviceTag": "усл.",
+    "partsTitle": "Запчасти",
+    "addPart": "Добавить запчасть",
+    "partName": "Название запчасти",
+    "partNumber": "Запчасть #",
+    "partQty": "Кол-во",
+    "partPrice": "Цена за ед.",
+    "importFromInventory": "Импорт со склада",
+    "searchInventory": "Поиск на складе...",
+    "noPartsFound": "Запчасти не найдены"
   },
   "picker": {
     "title": "Выбрать шаблон работ",
@@ -53,7 +62,8 @@
     "noPackages": "Шаблоны работ не найдены.",
     "items": "{count} поз.",
     "totalHours": "{hours}ч всего",
-    "totalUnits": "{units} усл."
+    "totalUnits": "{units} усл.",
+    "totalParts": "{count} запчастей"
   },
   "errors": {
     "error": "Ошибка",

+ 12 - 2
messages/tr/laborPresets.json

@@ -45,7 +45,16 @@
     "itemQty": "Qty",
     "itemQtyOrHours": "Unit / Hours",
     "hourlyTag": "HR",
-    "serviceTag": "SVC"
+    "serviceTag": "SVC",
+    "partsTitle": "Parçalar",
+    "addPart": "Parça ekle",
+    "partName": "Parça adı",
+    "partNumber": "Parça #",
+    "partQty": "Adet",
+    "partPrice": "Birim fiyat",
+    "importFromInventory": "Envanterden içe aktar",
+    "searchInventory": "Envanterde ara...",
+    "noPartsFound": "Parça bulunamadı"
   },
   "picker": {
     "title": "İşçilik Şablonu Seç",
@@ -53,7 +62,8 @@
     "noPackages": "İşçilik şablonu bulunamadı.",
     "items": "{count} kalem",
     "totalHours": "toplam {hours}s",
-    "totalUnits": "{units} unit(s)"
+    "totalUnits": "{units} unit(s)",
+    "totalParts": "{count} parça"
   },
   "errors": {
     "error": "Hata",

+ 19 - 0
prisma/migrations/20260412140256_service_package/migration.sql

@@ -0,0 +1,19 @@
+-- CreateTable
+CREATE TABLE "labor_preset_parts" (
+    "id" TEXT NOT NULL,
+    "name" TEXT NOT NULL,
+    "partNumber" TEXT,
+    "quantity" DOUBLE PRECISION NOT NULL DEFAULT 1,
+    "unitPrice" DOUBLE PRECISION NOT NULL DEFAULT 0,
+    "inventoryPartId" TEXT,
+    "sortOrder" INTEGER NOT NULL DEFAULT 0,
+    "presetId" TEXT NOT NULL,
+
+    CONSTRAINT "labor_preset_parts_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE INDEX "labor_preset_parts_presetId_idx" ON "labor_preset_parts"("presetId");
+
+-- AddForeignKey
+ALTER TABLE "labor_preset_parts" ADD CONSTRAINT "labor_preset_parts_presetId_fkey" FOREIGN KEY ("presetId") REFERENCES "labor_presets"("id") ON DELETE CASCADE ON UPDATE CASCADE;

+ 17 - 0
prisma/schema.prisma

@@ -419,6 +419,7 @@ model LaborPreset {
   organizationId String
   organization   Organization      @relation(fields: [organizationId], references: [id], onDelete: Cascade)
   items          LaborPresetItem[]
+  parts          LaborPresetPart[]
 
   @@index([organizationId])
   @@map("labor_presets")
@@ -438,6 +439,22 @@ model LaborPresetItem {
   @@map("labor_preset_items")
 }
 
+model LaborPresetPart {
+  id              String  @id @default(cuid())
+  name            String
+  partNumber      String?
+  quantity        Float   @default(1)
+  unitPrice       Float   @default(0)
+  inventoryPartId String?
+  sortOrder       Int     @default(0)
+
+  presetId String
+  preset   LaborPreset @relation(fields: [presetId], references: [id], onDelete: Cascade)
+
+  @@index([presetId])
+  @@map("labor_preset_parts")
+}
+
 model InventoryPart {
   id            String   @id @default(cuid())
   partNumber    String?

+ 1 - 0
src/__tests__/multitenancy/labor-preset-isolation.test.ts

@@ -139,6 +139,7 @@ describe("updateLaborPreset — cross-org isolation", () => {
     vi.mocked(db.laborPreset.findFirst).mockResolvedValue({ id: "preset-a" } as any);
     const mockTx = {
       laborPresetItem: { deleteMany: vi.fn().mockResolvedValue({ count: 1 }) },
+      laborPresetPart: { deleteMany: vi.fn().mockResolvedValue({ count: 0 }) },
       laborPreset: {
         update: vi.fn().mockResolvedValue({
           id: "preset-a",

+ 39 - 6
src/features/labor-presets/Actions/laborPresetActions.ts

@@ -49,7 +49,7 @@ export async function getLaborPresetsPaginated(params: {
         skip,
         take: pageSize,
         include: {
-          _count: { select: { items: true } },
+          _count: { select: { items: true, parts: true } },
           items: { select: { hours: true, pricingType: true } },
         },
       }),
@@ -72,6 +72,7 @@ export async function getLaborPreset(presetId: string) {
       where: { id: presetId, organizationId },
       include: {
         items: { orderBy: { sortOrder: "asc" } },
+        parts: { orderBy: { sortOrder: "asc" } },
       },
     });
     if (!preset) throw new Error("Preset not found");
@@ -98,8 +99,18 @@ export async function createLaborPreset(input: unknown) {
             sortOrder: index,
           })),
         },
+        parts: data.parts?.length ? {
+          create: data.parts.map((part, index) => ({
+            name: part.name,
+            partNumber: part.partNumber || null,
+            quantity: part.quantity,
+            unitPrice: part.unitPrice,
+            inventoryPartId: part.inventoryPartId || null,
+            sortOrder: index,
+          })),
+        } : undefined,
       },
-      include: { items: true },
+      include: { items: true, parts: true },
     });
     revalidatePath("/labor-presets");
     return preset;
@@ -118,7 +129,7 @@ export async function createLaborPreset(input: unknown) {
 export async function updateLaborPreset(input: unknown) {
   return withAuth(async ({ userId, organizationId }) => {
     const data = updateLaborPresetSchema.parse(input);
-    const { id, items, ...updateData } = data;
+    const { id, items, parts, ...updateData } = data;
     // Resolve name from first item if not provided
     if (updateData.name !== undefined) {
       updateData.name = updateData.name?.trim() || items?.[0]?.description || "Untitled";
@@ -130,10 +141,11 @@ export async function updateLaborPreset(input: unknown) {
     if (!existing) throw new Error("Preset not found");
 
     await db.$transaction(async (tx) => {
-      // Delete old items
+      // Delete old items and parts
       await tx.laborPresetItem.deleteMany({ where: { presetId: id } });
+      await tx.laborPresetPart.deleteMany({ where: { presetId: id } });
 
-      // Update preset and create new items
+      // Update preset and create new items/parts
       return tx.laborPreset.update({
         where: { id },
         data: {
@@ -148,8 +160,18 @@ export async function updateLaborPreset(input: unknown) {
               sortOrder: index,
             })),
           } : undefined,
+          parts: parts?.length ? {
+            create: parts.map((part, index) => ({
+              name: part.name,
+              partNumber: part.partNumber || null,
+              quantity: part.quantity,
+              unitPrice: part.unitPrice,
+              inventoryPartId: part.inventoryPartId || null,
+              sortOrder: index,
+            })),
+          } : undefined,
         },
-        include: { items: true },
+        include: { items: true, parts: true },
       });
     });
 
@@ -205,6 +227,17 @@ export async function getLaborPresetsList() {
           },
           orderBy: { sortOrder: "asc" },
         },
+        parts: {
+          select: {
+            name: true,
+            partNumber: true,
+            quantity: true,
+            unitPrice: true,
+            inventoryPartId: true,
+            sortOrder: true,
+          },
+          orderBy: { sortOrder: "asc" },
+        },
       },
       orderBy: { name: "asc" },
     });

+ 101 - 0
src/features/labor-presets/Components/InventorySearchDialog.tsx

@@ -0,0 +1,101 @@
+"use client";
+
+import { useState } from "react";
+import { useTranslations } from "next-intl";
+import { Input } from "@/components/ui/input";
+import {
+  Dialog,
+  DialogContent,
+  DialogHeader,
+  DialogTitle,
+} from "@/components/ui/dialog";
+import { Search } from "lucide-react";
+
+export interface InventoryPartOption {
+  id: string;
+  name: string;
+  partNumber: string | null;
+  sellPrice: number;
+  unitCost: number;
+}
+
+interface InventorySearchDialogProps {
+  open: boolean;
+  onOpenChange: (v: boolean) => void;
+  inventoryParts: InventoryPartOption[];
+  onSelect: (part: InventoryPartOption) => void;
+}
+
+export function InventorySearchDialog({
+  open,
+  onOpenChange,
+  inventoryParts,
+  onSelect,
+}: InventorySearchDialogProps) {
+  const t = useTranslations("laborPresets");
+  const [search, setSearch] = useState("");
+
+  const filtered = inventoryParts.filter((p) => {
+    if (!search) return true;
+    const q = search.toLowerCase();
+    return (
+      p.name.toLowerCase().includes(q) ||
+      (p.partNumber && p.partNumber.toLowerCase().includes(q))
+    );
+  });
+
+  return (
+    <Dialog
+      open={open}
+      onOpenChange={(v) => {
+        onOpenChange(v);
+        if (!v) setSearch("");
+      }}
+    >
+      <DialogContent className="sm:max-w-lg" aria-describedby={undefined}>
+        <DialogHeader>
+          <DialogTitle>{t("form.importFromInventory")}</DialogTitle>
+        </DialogHeader>
+        <div className="relative">
+          <Search className="absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
+          <Input
+            placeholder={t("form.searchInventory")}
+            value={search}
+            onChange={(e) => setSearch(e.target.value)}
+            className="pl-9"
+          />
+        </div>
+        <div className="max-h-60 overflow-y-auto space-y-1">
+          {filtered.map((ip) => (
+            <button
+              key={ip.id}
+              type="button"
+              className="w-full text-left rounded-md px-2.5 py-1.5 hover:bg-accent transition-colors flex items-center justify-between gap-4"
+              onClick={() => {
+                onSelect(ip);
+                onOpenChange(false);
+              }}
+            >
+              <div className="min-w-0">
+                <span className="font-medium text-sm truncate">{ip.name}</span>
+                {ip.partNumber && (
+                  <span className="ml-2 text-xs font-mono text-muted-foreground">
+                    {ip.partNumber}
+                  </span>
+                )}
+              </div>
+              <span className="shrink-0 text-xs text-muted-foreground">
+                {ip.sellPrice > 0 ? ip.sellPrice.toFixed(2) : ip.unitCost.toFixed(2)}
+              </span>
+            </button>
+          ))}
+          {filtered.length === 0 && (
+            <p className="py-6 text-center text-sm text-muted-foreground">
+              {t("form.noPartsFound")}
+            </p>
+          )}
+        </div>
+      </DialogContent>
+    </Dialog>
+  );
+}

+ 62 - 35
src/features/labor-presets/Components/LaborPresetForm.tsx

@@ -16,6 +16,8 @@ import {
 import { useGlassModal } from "@/components/glass-modal";
 import { createLaborPreset, updateLaborPreset } from "../Actions/laborPresetActions";
 import { Loader2, Plus, Trash2 } from "lucide-react";
+import { PresetPartsEditor } from "./PresetPartsEditor";
+import type { PresetPartItem } from "./PresetPartsEditor";
 
 type PricingType = "hourly" | "service";
 
@@ -39,6 +41,7 @@ interface LaborPresetData {
   name: string;
   description: string | null;
   items: { description: string; hours: number; rate: number; pricingType?: string; sortOrder: number }[];
+  parts?: { name: string; partNumber: string | null; quantity: number; unitPrice: number; inventoryPartId: string | null; sortOrder: number }[];
 }
 
 type PresetMode = "single" | "package";
@@ -48,6 +51,8 @@ interface LaborPresetFormProps {
   onOpenChange: (open: boolean) => void;
   preset?: LaborPresetData;
   defaultLaborRate?: number;
+  inventoryParts?: { id: string; name: string; partNumber: string | null; sellPrice: number; unitCost: number }[];
+  currencyCode?: string;
 }
 
 function detectMode(preset?: LaborPresetData): PresetMode {
@@ -72,7 +77,7 @@ function PricingTypeToggle({ value, onChange, tHourly, tService }: { value: Pric
   );
 }
 
-export function LaborPresetForm({ open, onOpenChange, preset, defaultLaborRate = 0 }: LaborPresetFormProps) {
+export function LaborPresetForm({ open, onOpenChange, preset, defaultLaborRate = 0, inventoryParts, currencyCode }: LaborPresetFormProps) {
   const router = useRouter();
   const modal = useGlassModal();
   const t = useTranslations("laborPresets");
@@ -96,6 +101,16 @@ export function LaborPresetForm({ open, onOpenChange, preset, defaultLaborRate =
     return [{ name: "", description: "", hours: 0, rate: defaultLaborRate, pricingType: "hourly" }];
   });
 
+  const [parts, setParts] = useState<PresetPartItem[]>(
+    preset?.parts?.map((p) => ({
+      name: p.name,
+      partNumber: p.partNumber || "",
+      quantity: p.quantity,
+      unitPrice: p.unitPrice,
+      inventoryPartId: p.inventoryPartId || "",
+    })) ?? []
+  );
+
   const handleOpenChange = (isOpen: boolean) => {
     if (isOpen) {
       const detectedMode = detectMode(preset);
@@ -112,6 +127,15 @@ export function LaborPresetForm({ open, onOpenChange, preset, defaultLaborRate =
       } else {
         setSingleItems([{ name: "", description: "", hours: 0, rate: defaultLaborRate, pricingType: "hourly" }]);
       }
+      setParts(
+        preset?.parts?.map((p) => ({
+          name: p.name,
+          partNumber: p.partNumber || "",
+          quantity: p.quantity,
+          unitPrice: p.unitPrice,
+          inventoryPartId: p.inventoryPartId || "",
+        })) ?? []
+      );
     }
     onOpenChange(isOpen);
   };
@@ -154,9 +178,20 @@ export function LaborPresetForm({ open, onOpenChange, preset, defaultLaborRate =
     setSingleItems((prev) => prev.filter((_, i) => i !== index));
   };
 
+  const buildPartsPayload = () =>
+    parts.filter((p) => p.name.trim()).map((part, index) => ({
+      name: part.name,
+      partNumber: part.partNumber || undefined,
+      quantity: Number(part.quantity) || 1,
+      unitPrice: Number(part.unitPrice) || 0,
+      inventoryPartId: part.inventoryPartId || undefined,
+      sortOrder: index,
+    }));
+
   const handleSubmit = async (e: React.FormEvent) => {
     e.preventDefault();
     setLoading(true);
+    const partsPayload = buildPartsPayload();
 
     if (mode === "single") {
       const validItems = singleItems.filter((i) => i.name.trim());
@@ -167,42 +202,30 @@ export function LaborPresetForm({ open, onOpenChange, preset, defaultLaborRate =
       }
 
       if (preset) {
-        // Editing: update the single preset
         const item = validItems[0];
         const result = await updateLaborPreset({
           id: preset.id,
           name: "",
           description: item.description || undefined,
           items: [{ description: item.name, hours: Number(item.hours) || 0, rate: Number(item.rate) || 0, pricingType: item.pricingType, sortOrder: 0 }],
+          parts: partsPayload,
         });
-        if (result.success) {
-          onOpenChange(false);
-          router.refresh();
-        } else {
-          modal.open("error", t("errors.error"), result.error || t("errors.saveFailed"));
-        }
+        if (result.success) { onOpenChange(false); router.refresh(); }
+        else { modal.open("error", t("errors.error"), result.error || t("errors.saveFailed")); }
       } else {
-        // Creating: create one preset per single item
         let allSuccess = true;
         for (const item of validItems) {
           const result = await createLaborPreset({
             name: "",
             description: item.description || undefined,
             items: [{ description: item.name, hours: Number(item.hours) || 0, rate: Number(item.rate) || 0, pricingType: item.pricingType, sortOrder: 0 }],
+            parts: partsPayload,
           });
-          if (!result.success) {
-            modal.open("error", t("errors.error"), result.error || t("errors.saveFailed"));
-            allSuccess = false;
-            break;
-          }
-        }
-        if (allSuccess) {
-          onOpenChange(false);
-          router.refresh();
+          if (!result.success) { modal.open("error", t("errors.error"), result.error || t("errors.saveFailed")); allSuccess = false; break; }
         }
+        if (allSuccess) { onOpenChange(false); router.refresh(); }
       }
     } else {
-      // Package mode
       const validItems = items.filter((i) => i.description.trim());
       if (validItems.length === 0) {
         modal.open("error", t("errors.error"), t("errors.noItems"));
@@ -214,24 +237,14 @@ export function LaborPresetForm({ open, onOpenChange, preset, defaultLaborRate =
         name,
         description: description || undefined,
         items: validItems.map((item, index) => ({
-          description: item.description,
-          hours: Number(item.hours) || 0,
-          rate: Number(item.rate) || 0,
-          pricingType: item.pricingType,
-          sortOrder: index,
+          description: item.description, hours: Number(item.hours) || 0, rate: Number(item.rate) || 0, pricingType: item.pricingType, sortOrder: index,
         })),
+        parts: partsPayload,
       };
 
-      const result = preset
-        ? await updateLaborPreset({ ...data, id: preset.id })
-        : await createLaborPreset(data);
-
-      if (result.success) {
-        onOpenChange(false);
-        router.refresh();
-      } else {
-        modal.open("error", t("errors.error"), result.error || t("errors.saveFailed"));
-      }
+      const result = preset ? await updateLaborPreset({ ...data, id: preset.id }) : await createLaborPreset(data);
+      if (result.success) { onOpenChange(false); router.refresh(); }
+      else { modal.open("error", t("errors.error"), result.error || t("errors.saveFailed")); }
     }
 
     setLoading(false);
@@ -239,7 +252,7 @@ export function LaborPresetForm({ open, onOpenChange, preset, defaultLaborRate =
 
   return (
     <Dialog open={open} onOpenChange={handleOpenChange}>
-      <DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-2xl">
+      <DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-2xl" aria-describedby={undefined}>
         <DialogHeader>
           <DialogTitle>
             {preset ? t("form.editPackage") : t("form.addPackage")}
@@ -341,6 +354,13 @@ export function LaborPresetForm({ open, onOpenChange, preset, defaultLaborRate =
               >
                 <Plus className="h-4 w-4" />
               </button>
+
+              <PresetPartsEditor
+                parts={parts}
+                onPartsChange={setParts}
+                inventoryParts={inventoryParts}
+                currencyCode={currencyCode}
+              />
             </div>
           ) : (
             <>
@@ -434,6 +454,13 @@ export function LaborPresetForm({ open, onOpenChange, preset, defaultLaborRate =
                   <Plus className="h-4 w-4" />
                 </button>
               </div>
+
+              <PresetPartsEditor
+                parts={parts}
+                onPartsChange={setParts}
+                inventoryParts={inventoryParts}
+                currencyCode={currencyCode}
+              />
             </>
           )}
 

+ 19 - 5
src/features/labor-presets/Components/LaborPresetPickerDialog.tsx

@@ -11,6 +11,7 @@ export interface LaborPresetOption {
   name: string;
   description: string | null;
   items: { description: string; hours: number; rate: number; pricingType?: string; sortOrder: number }[];
+  parts?: { name: string; partNumber: string | null; quantity: number; unitPrice: number; inventoryPartId: string | null; sortOrder: number }[];
 }
 
 interface LaborPresetPickerDialogProps {
@@ -50,7 +51,7 @@ export function LaborPresetPickerDialog({
         }
       }}
     >
-      <DialogContent className="sm:max-w-2xl">
+      <DialogContent className="sm:max-w-2xl" aria-describedby={undefined}>
         <DialogHeader>
           <DialogTitle>{t("title")}</DialogTitle>
         </DialogHeader>
@@ -70,7 +71,7 @@ export function LaborPresetPickerDialog({
             const totalHours = hourlyItems.reduce((sum, i) => sum + i.hours, 0);
             const totalUnits = serviceItems.reduce((sum, i) => sum + i.hours, 0);
             const isExpanded = expandedId === preset.id;
-            const isSingleItem = preset.items.length === 1;
+            const hasDetails = preset.items.length > 1 || (preset.parts && preset.parts.length > 0);
 
             return (
               <div key={preset.id} className="rounded-md border border-transparent hover:border-border">
@@ -99,10 +100,13 @@ export function LaborPresetPickerDialog({
                     )}
                   </div>
                   <div className="flex items-center gap-3 shrink-0 text-xs text-muted-foreground">
-                    {!isSingleItem && <span>{t("items", { count: preset.items.length })}</span>}
+                    {hasDetails && <span>{t("items", { count: preset.items.length })}</span>}
                     {totalHours > 0 && <span>{t("totalHours", { hours: totalHours.toFixed(1) })}</span>}
                     {totalUnits > 0 && <span>{t("totalUnits", { units: totalUnits })}</span>}
-                    {!isSingleItem && (
+                    {preset.parts && preset.parts.length > 0 && (
+                      <span>{t("totalParts", { count: preset.parts.length })}</span>
+                    )}
+                    {hasDetails && (
                       <button
                         type="button"
                         className="p-1 hover:text-foreground"
@@ -116,7 +120,7 @@ export function LaborPresetPickerDialog({
                     )}
                   </div>
                 </div>
-                {isExpanded && !isSingleItem && (
+                {isExpanded && hasDetails && (
                   <div className="px-3 pb-2 space-y-0.5">
                     {preset.items.map((item, i) => (
                       <div key={i} className="flex items-center justify-between text-xs text-muted-foreground py-0.5 pl-2 border-l-2 border-muted">
@@ -124,6 +128,16 @@ export function LaborPresetPickerDialog({
                         <span className="shrink-0 ml-2">{item.pricingType === "service" ? `${item.hours} unit${item.hours !== 1 ? "s" : ""}` : `${item.hours}h`}</span>
                       </div>
                     ))}
+                    {preset.parts && preset.parts.length > 0 && (
+                      <>
+                        {preset.parts.map((part, i) => (
+                          <div key={`part-${i}`} className="flex items-center justify-between text-xs text-muted-foreground py-0.5 pl-2 border-l-2 border-blue-400/50">
+                            <span className="truncate">{part.name}{part.partNumber ? ` (${part.partNumber})` : ''}</span>
+                            <span className="shrink-0 ml-2">x{part.quantity}</span>
+                          </div>
+                        ))}
+                      </>
+                    )}
                   </div>
                 )}
               </div>

+ 148 - 0
src/features/labor-presets/Components/PresetPartsEditor.tsx

@@ -0,0 +1,148 @@
+"use client";
+
+import { useState } from "react";
+import { useTranslations } from "next-intl";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Package, Plus, Search, Trash2 } from "lucide-react";
+import { InventorySearchDialog, type InventoryPartOption } from "./InventorySearchDialog";
+
+export interface PresetPartItem {
+  name: string;
+  partNumber: string;
+  quantity: number;
+  unitPrice: number;
+  inventoryPartId: string;
+}
+
+interface PresetPartsEditorProps {
+  parts: PresetPartItem[];
+  onPartsChange: (parts: PresetPartItem[]) => void;
+  inventoryParts?: InventoryPartOption[];
+  currencyCode?: string;
+}
+
+export function PresetPartsEditor({
+  parts,
+  onPartsChange,
+  inventoryParts,
+}: PresetPartsEditorProps) {
+  const t = useTranslations("laborPresets");
+  const [pickerOpen, setPickerOpen] = useState(false);
+
+  const updatePart = (index: number, field: keyof PresetPartItem, value: string | number) => {
+    const updated = [...parts];
+    updated[index] = { ...updated[index], [field]: value };
+    onPartsChange(updated);
+  };
+
+  const addPart = () => {
+    onPartsChange([...parts, { name: "", partNumber: "", quantity: 1, unitPrice: 0, inventoryPartId: "" }]);
+  };
+
+  const removePart = (index: number) => {
+    onPartsChange(parts.filter((_, i) => i !== index));
+  };
+
+  const handleInventorySelect = (ip: InventoryPartOption) => {
+    const price = ip.sellPrice > 0 ? ip.sellPrice : ip.unitCost;
+    onPartsChange([
+      ...parts,
+      { name: ip.name, partNumber: ip.partNumber || "", quantity: 1, unitPrice: price, inventoryPartId: ip.id },
+    ]);
+  };
+
+  return (
+    <div className="space-y-2">
+      <div className="flex items-center justify-between">
+        <Label className="flex items-center gap-1.5">
+          <Package className="h-3.5 w-3.5" />
+          {t("form.partsTitle")}
+        </Label>
+        <div className="flex gap-1.5">
+          {inventoryParts && inventoryParts.length > 0 && (
+            <Button type="button" variant="outline" size="sm" onClick={() => setPickerOpen(true)}>
+              <Search className="mr-1 h-3.5 w-3.5" />
+              {t("form.importFromInventory")}
+            </Button>
+          )}
+          <Button type="button" variant="outline" size="sm" onClick={addPart}>
+            <Plus className="mr-1 h-3.5 w-3.5" />
+            {t("form.addPart")}
+          </Button>
+        </div>
+      </div>
+
+      {parts.length > 0 && (
+        <>
+          <div className="hidden grid-cols-[2fr_1fr_0.5fr_1fr_auto] gap-2 text-xs font-medium text-muted-foreground sm:grid">
+            <span>{t("form.partName")}</span>
+            <span>{t("form.partNumber")}</span>
+            <span>{t("form.partQty")}</span>
+            <span>{t("form.partPrice")}</span>
+            <span />
+          </div>
+
+          {parts.map((part, i) => (
+            <div key={i} className="grid grid-cols-2 gap-2 sm:grid-cols-[2fr_1fr_0.5fr_1fr_auto]">
+              <Input
+                placeholder={t("form.partName")}
+                value={part.name}
+                onChange={(e) => updatePart(i, "name", e.target.value)}
+                className="col-span-2 sm:col-span-1"
+              />
+              <Input
+                placeholder={t("form.partNumber")}
+                value={part.partNumber}
+                onChange={(e) => updatePart(i, "partNumber", e.target.value)}
+              />
+              <Input
+                type="number"
+                min="0"
+                step="1"
+                placeholder={t("form.partQty")}
+                value={part.quantity}
+                onChange={(e) => updatePart(i, "quantity", e.target.value)}
+              />
+              <Input
+                type="number"
+                min="0"
+                step="0.01"
+                placeholder={t("form.partPrice")}
+                value={part.unitPrice}
+                onChange={(e) => updatePart(i, "unitPrice", e.target.value)}
+              />
+              <Button
+                type="button"
+                variant="ghost"
+                size="icon"
+                className="h-9 w-9 text-muted-foreground hover:text-destructive"
+                onClick={() => removePart(i)}
+              >
+                <Trash2 className="h-4 w-4" />
+              </Button>
+            </div>
+          ))}
+        </>
+      )}
+
+      <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={addPart}
+      >
+        <Plus className="h-4 w-4" />
+      </button>
+
+      {inventoryParts && (
+        <InventorySearchDialog
+          open={pickerOpen}
+          onOpenChange={setPickerOpen}
+          inventoryParts={inventoryParts}
+          onSelect={handleInventorySelect}
+        />
+      )}
+    </div>
+  );
+}

+ 12 - 0
src/features/labor-presets/Schema/laborPresetSchema.ts

@@ -8,10 +8,22 @@ export const laborPresetItemSchema = z.object({
   sortOrder: z.coerce.number().int().min(0).default(0),
 });
 
+export const laborPresetPartSchema = z.object({
+  name: z.string().min(1, "Part name is required"),
+  partNumber: z.string().optional(),
+  quantity: z.coerce.number().min(0, "Quantity must be 0 or more").default(1),
+  unitPrice: z.coerce.number().min(0, "Price must be 0 or more").default(0),
+  inventoryPartId: z.string().optional(),
+  sortOrder: z.coerce.number().int().min(0).default(0),
+});
+
+export type LaborPresetPartInput = z.infer<typeof laborPresetPartSchema>;
+
 export const createLaborPresetSchema = z.object({
   name: z.string().optional(),
   description: z.string().optional(),
   items: z.array(laborPresetItemSchema).min(1, "At least one item is required"),
+  parts: z.array(laborPresetPartSchema).optional().default([]),
 });
 
 export const updateLaborPresetSchema = createLaborPresetSchema.partial().extend({

+ 12 - 0
src/features/quotes/Components/QuotePageClient.tsx

@@ -108,6 +108,18 @@ export function QuotePageClient({
         excluded: false,
       }))
       state.addLaborBulk(newItems)
+
+      if (preset.parts?.length) {
+        const newParts = preset.parts.map((part) => ({
+          name: part.name,
+          partNumber: part.partNumber || '',
+          quantity: part.quantity,
+          unitPrice: part.unitPrice,
+          total: part.quantity * part.unitPrice,
+          excluded: false,
+        }))
+        state.addPartBulk(newParts)
+      }
     },
     [defaultLaborRate, state]
   )

+ 6 - 1
src/features/quotes/Components/useQuoteFormState.ts

@@ -193,6 +193,11 @@ export function useQuoteFormState({
     markDirty();
   }, [markDirty]);
 
+  const addPartBulk = useCallback((items: QuotePartInput[]) => {
+    setPartItems((prev) => [...prev, ...items]);
+    markDirty();
+  }, [markDirty]);
+
   const deletePart = useCallback((index: number) => {
     setPartItems((prev) => prev.filter((_, j) => j !== index));
     markDirty();
@@ -337,7 +342,7 @@ export function useQuoteFormState({
     partsSubtotal, laborSubtotal, subtotal, discountAmount, taxAmount, totalAmount,
     selectedVehicle, setSelectedVehicle, selectedCustomer, setSelectedCustomer,
     // Callbacks
-    markDirty, updatePart, updateLabor, addPart, addLabor, addService, addLaborBulk, deletePart, deleteLabor,
+    markDirty, updatePart, updateLabor, addPart, addPartBulk, addLabor, addService, addLaborBulk, deletePart, deleteLabor,
     handleSubmit, handleDelete, handleDownloadPDF,
     handleConvert, handleResolveResponse, saveNow,
   };

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

@@ -220,6 +220,19 @@ export function ServicePageClient({
       pricingType: (item.pricingType as 'hourly' | 'service') || 'hourly',
     }))
     formState.dirtySetLaborItems((prev) => [...newItems, ...prev])
+
+    if (preset.parts?.length) {
+      const newParts = preset.parts.map((part) => ({
+        name: part.name,
+        partNumber: part.partNumber || '',
+        quantity: part.quantity,
+        unitPrice: part.unitPrice,
+        total: part.quantity * part.unitPrice,
+        unitCost: 0,
+        inventoryPartId: part.inventoryPartId || '',
+      }))
+      formState.dirtySetPartItems((prev) => [...newParts, ...prev])
+    }
   }
 
   const actions = useServiceActions({