Bernt Christian Egeland 5 месяцев назад
Родитель
Сommit
f969378e4f

+ 120 - 86
src/app/(authenticated)/vehicles/[id]/page.tsx

@@ -1,42 +1,53 @@
-import { getTranslations } from "next-intl/server";
-import { getVehicle } from "@/features/vehicles/Actions/vehicleActions";
-import { getServiceRecordsPaginated } from "@/features/vehicles/Actions/serviceActions";
-import { getNotesPaginated } from "@/features/vehicles/Actions/noteActions";
-import { getCustomersList } from "@/features/customers/Actions/customerActions";
-import { getSettings } from "@/features/settings/Actions/settingsActions";
-import { SETTING_KEYS } from "@/features/settings/Schema/settingsSchema";
-import { getVehiclePredictedMileage } from "@/features/vehicles/Actions/predictedMaintenanceActions";
-import { getVehicleInspections } from "@/features/inspections/Actions/inspectionActions";
-import { getTemplates } from "@/features/inspections/Actions/templateActions";
-import { getVehicleQuotes } from "@/features/quotes/Actions/quoteActions";
-import { getVehicleFindings } from "@/features/vehicles/Actions/findingActions";
-import { getFeatures } from "@/lib/features";
-import { getAuthContext } from "@/lib/get-auth-context";
-import { db } from "@/lib/db";
-import { VehicleDetailClient } from "./vehicle-detail-client";
-import { PageHeader } from "@/components/page-header";
+import { getTranslations } from 'next-intl/server'
+import { getVehicle } from '@/features/vehicles/Actions/vehicleActions'
+import { getServiceRecordsPaginated } from '@/features/vehicles/Actions/serviceActions'
+import { getNotesPaginated } from '@/features/vehicles/Actions/noteActions'
+import { getCustomersList } from '@/features/customers/Actions/customerActions'
+import { getSettings } from '@/features/settings/Actions/settingsActions'
+import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
+import { getVehiclePredictedMileage } from '@/features/vehicles/Actions/predictedMaintenanceActions'
+import { getVehicleInspections } from '@/features/inspections/Actions/inspectionActions'
+import { getTemplates } from '@/features/inspections/Actions/templateActions'
+import { getVehicleQuotes } from '@/features/quotes/Actions/quoteActions'
+import { getVehicleFindings } from '@/features/vehicles/Actions/findingActions'
+import { getFeatures } from '@/lib/features'
+import { getAuthContext } from '@/lib/get-auth-context'
+import { db } from '@/lib/db'
+import { VehicleDetailClient } from './vehicle-detail-client'
+import { PageHeader } from '@/components/page-header'
 
 export default async function VehicleDetailPage({
   params,
   searchParams,
 }: {
-  params: Promise<{ id: string }>;
-  searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
+  params: Promise<{ id: string }>
+  searchParams: Promise<{ [key: string]: string | string[] | undefined }>
 }) {
-  const { id } = await params;
-  const sp = await searchParams;
-
-  const page = Number(sp.page) || 1;
-  const pageSize = Number(sp.pageSize) || 10;
-  const search = typeof sp.search === "string" ? sp.search : "";
-  const type = typeof sp.type === "string" ? sp.type : "all";
-
-  const notesPage = Number(sp.notesPage) || 1;
-  const notesPageSize = Number(sp.notesPageSize) || 10;
-  const findingsPage = Number(sp.findingsPage) || 1;
-  const findingsPageSize = Number(sp.findingsPageSize) || 10;
-
-  const [result, customersResult, serviceResult, notesResult, settingsResult, maintenanceSettingsResult, inspectionsResult, templatesResult, quotesResult, findingsResult] = await Promise.all([
+  const { id } = await params
+  const sp = await searchParams
+
+  const page = Number(sp.page) || 1
+  const pageSize = Number(sp.pageSize) || 10
+  const search = typeof sp.search === 'string' ? sp.search : ''
+  const type = typeof sp.type === 'string' ? sp.type : 'all'
+
+  const notesPage = Number(sp.notesPage) || 1
+  const notesPageSize = Number(sp.notesPageSize) || 10
+  const findingsPage = Number(sp.findingsPage) || 1
+  const findingsPageSize = Number(sp.findingsPageSize) || 10
+
+  const [
+    result,
+    customersResult,
+    serviceResult,
+    notesResult,
+    settingsResult,
+    maintenanceSettingsResult,
+    inspectionsResult,
+    templatesResult,
+    quotesResult,
+    findingsResult,
+  ] = await Promise.all([
     getVehicle(id),
     getCustomersList(),
     getServiceRecordsPaginated(id, { page, pageSize, search, type }),
@@ -51,7 +62,7 @@ export default async function VehicleDetailPage({
     getTemplates(),
     getVehicleQuotes(id),
     getVehicleFindings(id, { page: findingsPage, pageSize: findingsPageSize }),
-  ]);
+  ])
 
   if (!result.success || !result.data) {
     return (
@@ -59,59 +70,72 @@ export default async function VehicleDetailPage({
         <PageHeader />
         <div className="flex h-[50vh] items-center justify-center">
           <p className="text-muted-foreground">
-            {result.error || (await getTranslations("vehicles.detail"))("notFound")}
+            {result.error || (await getTranslations('vehicles.detail'))('notFound')}
           </p>
         </div>
       </>
-    );
+    )
   }
 
-  const paginatedServices = serviceResult.success && serviceResult.data
-    ? serviceResult.data
-    : { records: [], total: 0, page: 1, pageSize: 10, totalPages: 0 };
-
-  const paginatedNotes = notesResult.success && notesResult.data
-    ? notesResult.data
-    : { records: [], total: 0, page: 1, pageSize: 10, totalPages: 0 };
-
-  const paginatedFindings = findingsResult.success && findingsResult.data
-    ? findingsResult.data
-    : { records: [], total: 0, page: 1, pageSize: 10, totalPages: 0 };
-
-  const currencySettings = settingsResult.success && settingsResult.data ? settingsResult.data : {};
-  const currencyCode = currencySettings[SETTING_KEYS.CURRENCY_CODE] || "USD";
-  const unitSystem = (currencySettings[SETTING_KEYS.UNIT_SYSTEM] || "imperial") as "metric" | "imperial";
-
-  const maintenanceSettings = maintenanceSettingsResult.success && maintenanceSettingsResult.data
-    ? maintenanceSettingsResult.data
-    : {};
-  const maintenanceEnabled = maintenanceSettings[SETTING_KEYS.PREDICTED_MAINTENANCE_ENABLED] === "true";
-  const serviceInterval = parseInt(maintenanceSettings[SETTING_KEYS.MAINTENANCE_SERVICE_INTERVAL] || "15000", 10);
+  const paginatedServices =
+    serviceResult.success && serviceResult.data
+      ? serviceResult.data
+      : { records: [], total: 0, page: 1, pageSize: 10, totalPages: 0 }
+
+  const paginatedNotes =
+    notesResult.success && notesResult.data
+      ? notesResult.data
+      : { records: [], total: 0, page: 1, pageSize: 10, totalPages: 0 }
+
+  const paginatedFindings =
+    findingsResult.success && findingsResult.data
+      ? findingsResult.data
+      : { records: [], total: 0, page: 1, pageSize: 10, totalPages: 0 }
+
+  const currencySettings = settingsResult.success && settingsResult.data ? settingsResult.data : {}
+  const currencyCode = currencySettings[SETTING_KEYS.CURRENCY_CODE] || 'USD'
+  const unitSystem = (currencySettings[SETTING_KEYS.UNIT_SYSTEM] || 'imperial') as
+    | 'metric'
+    | 'imperial'
+
+  const maintenanceSettings =
+    maintenanceSettingsResult.success && maintenanceSettingsResult.data
+      ? maintenanceSettingsResult.data
+      : {}
+  const maintenanceEnabled =
+    maintenanceSettings[SETTING_KEYS.PREDICTED_MAINTENANCE_ENABLED] === 'true'
+  const serviceInterval = parseInt(
+    maintenanceSettings[SETTING_KEYS.MAINTENANCE_SERVICE_INTERVAL] || '15000',
+    10
+  )
 
   let predictionData: {
-    predictedMileage: number;
-    avgPerDay: number;
-    lastServiceMileage: number;
-    serviceInterval: number;
-    mileageSinceLastService: number;
-    status: "overdue" | "approaching" | "ok" | null;
-    maintenanceDismissed: boolean;
-    confidencePercent: number;
-  } | null = null;
-
-  const predResult = await getVehiclePredictedMileage(id);
+    predictedMileage: number
+    avgPerDay: number
+    lastServiceMileage: number
+    serviceInterval: number
+    mileageSinceLastService: number
+    status: 'overdue' | 'approaching' | 'ok' | null
+    maintenanceDismissed: boolean
+    confidencePercent: number
+  } | null = null
+
+  const predResult = await getVehiclePredictedMileage(id)
   if (predResult.success && predResult.data) {
-    const p = predResult.data;
-    const mileageSinceLastService = p.predictedMileage - p.lastServiceMileage;
+    const p = predResult.data
+    const mileageSinceLastService = p.predictedMileage - p.lastServiceMileage
 
-    let status: "overdue" | "approaching" | "ok" | null = null;
+    let status: 'overdue' | 'approaching' | 'ok' | null = null
     if (maintenanceEnabled) {
-      const approachingThreshold = parseInt(maintenanceSettings[SETTING_KEYS.MAINTENANCE_APPROACHING_THRESHOLD] || "1000", 10);
-      status = "ok";
+      const approachingThreshold = parseInt(
+        maintenanceSettings[SETTING_KEYS.MAINTENANCE_APPROACHING_THRESHOLD] || '1000',
+        10
+      )
+      status = 'ok'
       if (mileageSinceLastService >= serviceInterval) {
-        status = "overdue";
+        status = 'overdue'
       } else if (mileageSinceLastService >= serviceInterval - approachingThreshold) {
-        status = "approaching";
+        status = 'approaching'
       }
     }
 
@@ -124,23 +148,29 @@ export default async function VehicleDetailPage({
       status,
       maintenanceDismissed: result.data.maintenanceDismissed,
       confidencePercent: p.confidencePercent,
-    };
+    }
   }
 
   // Check AI enabled
-  const authContext = await getAuthContext();
-  const orgId = authContext?.organizationId;
-  let aiEnabled = false;
+  const authContext = await getAuthContext()
+  const orgId = authContext?.organizationId
+  let aiEnabled = false
   if (orgId) {
     const [features, aiSettings] = await Promise.all([
       getFeatures(orgId),
       db.appSetting.findMany({
-        where: { organizationId: orgId, key: { in: [SETTING_KEYS.AI_ENABLED, SETTING_KEYS.AI_API_KEY] } },
+        where: {
+          organizationId: orgId,
+          key: { in: [SETTING_KEYS.AI_ENABLED, SETTING_KEYS.AI_API_KEY] },
+        },
         select: { key: true, value: true },
       }),
-    ]);
-    const aiMap = Object.fromEntries(aiSettings.map((s) => [s.key, s.value]));
-    aiEnabled = features?.ai === true && aiMap[SETTING_KEYS.AI_ENABLED] === "true" && !!aiMap[SETTING_KEYS.AI_API_KEY];
+    ])
+    const aiMap = Object.fromEntries(aiSettings.map((s) => [s.key, s.value]))
+    aiEnabled =
+      features?.ai === true &&
+      aiMap[SETTING_KEYS.AI_ENABLED] === 'true' &&
+      !!aiMap[SETTING_KEYS.AI_API_KEY]
   }
 
   return (
@@ -157,13 +187,17 @@ export default async function VehicleDetailPage({
           currencyCode={currencyCode}
           unitSystem={unitSystem}
           predictionData={predictionData}
-          inspections={inspectionsResult.success && inspectionsResult.data ? inspectionsResult.data : []}
-          inspectionTemplates={templatesResult.success && templatesResult.data ? templatesResult.data : []}
+          inspections={
+            inspectionsResult.success && inspectionsResult.data ? inspectionsResult.data : []
+          }
+          inspectionTemplates={
+            templatesResult.success && templatesResult.data ? templatesResult.data : []
+          }
           quotes={quotesResult.success && quotesResult.data ? quotesResult.data : []}
           aiEnabled={aiEnabled}
           paginatedFindings={paginatedFindings}
         />
       </div>
     </>
-  );
+  )
 }

+ 128 - 99
src/app/(authenticated)/vehicles/[id]/service/[serviceId]/page.tsx

@@ -1,51 +1,61 @@
-import { getServiceRecord } from "@/features/vehicles/Actions/serviceActions";
-import { getSettings } from "@/features/settings/Actions/settingsActions";
-import { SETTING_KEYS } from "@/features/settings/Schema/settingsSchema";
-import { getInventoryPartsList } from "@/features/inventory/Actions/inventoryActions";
-import { getLaborPresetsList } from "@/features/labor-presets/Actions/laborPresetActions";
+import { getServiceRecord } from '@/features/vehicles/Actions/serviceActions'
+import { getSettings } from '@/features/settings/Actions/settingsActions'
+import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
+import { getInventoryPartsList } from '@/features/inventory/Actions/inventoryActions'
+import { getLaborPresetsList } from '@/features/labor-presets/Actions/laborPresetActions'
 
-import { getTechnicians, getOrgMembers } from "@/features/workboard/Actions/technicianActions";
-import { getAuthContext } from "@/lib/get-auth-context";
-import { getFeatures } from "@/lib/features";
-import { getStatusReportsForService } from "@/features/status-reports/Actions/getStatusReportsForService";
-import { getServiceFindings } from "@/features/vehicles/Actions/findingActions";
-import { db } from "@/lib/db";
-import { getCachedSession, getCachedMembership } from "@/lib/cached-session";
-import { ServicePageClient } from "@/features/vehicles/Components/service-page/ServicePageClient";
-import { PageHeader } from "@/components/page-header";
-import { getTranslations } from "next-intl/server";
+import { getTechnicians, getOrgMembers } from '@/features/workboard/Actions/technicianActions'
+import { getAuthContext } from '@/lib/get-auth-context'
+import { getFeatures } from '@/lib/features'
+import { getStatusReportsForService } from '@/features/status-reports/Actions/getStatusReportsForService'
+import { getServiceFindings } from '@/features/vehicles/Actions/findingActions'
+import { db } from '@/lib/db'
+import { getCachedSession, getCachedMembership } from '@/lib/cached-session'
+import { ServicePageClient } from '@/features/vehicles/Components/service-page/ServicePageClient'
+import { PageHeader } from '@/components/page-header'
+import { getTranslations } from 'next-intl/server'
 
 export default async function ServiceDetailPage({
   params,
   searchParams,
 }: {
-  params: Promise<{ id: string; serviceId: string }>;
-  searchParams: Promise<Record<string, string | string[] | undefined>>;
+  params: Promise<{ id: string; serviceId: string }>
+  searchParams: Promise<Record<string, string | string[] | undefined>>
 }) {
-  const { id, serviceId } = await params;
-  const sp = await searchParams;
-  const initialTab = typeof sp.tab === "string" ? sp.tab : undefined;
+  const { id, serviceId } = await params
+  const sp = await searchParams
+  const initialTab = typeof sp.tab === 'string' ? sp.tab : undefined
 
-  const [result, settingsResult, inventoryResult, techniciansResult, presetsResult, authContext, session, orgMembersResult, statusReportsResult, findingsResult] =
-    await Promise.all([
-      getServiceRecord(serviceId),
-      getSettings([
-        SETTING_KEYS.CURRENCY_CODE,
-        SETTING_KEYS.UNIT_SYSTEM,
-        SETTING_KEYS.DEFAULT_TAX_RATE,
-        SETTING_KEYS.TAX_ENABLED,
-        SETTING_KEYS.DEFAULT_LABOR_RATE,
-        SETTING_KEYS.INVOICE_DUE_DAYS,
-      ]),
-      getInventoryPartsList(),
-      getTechnicians(),
-      getLaborPresetsList(),
-      getAuthContext(),
-      getCachedSession(),
-      getOrgMembers(),
-      getStatusReportsForService(serviceId),
-      getServiceFindings(serviceId),
-    ]);
+  const [
+    result,
+    settingsResult,
+    inventoryResult,
+    techniciansResult,
+    presetsResult,
+    authContext,
+    session,
+    orgMembersResult,
+    statusReportsResult,
+    findingsResult,
+  ] = await Promise.all([
+    getServiceRecord(serviceId),
+    getSettings([
+      SETTING_KEYS.CURRENCY_CODE,
+      SETTING_KEYS.UNIT_SYSTEM,
+      SETTING_KEYS.DEFAULT_TAX_RATE,
+      SETTING_KEYS.TAX_ENABLED,
+      SETTING_KEYS.DEFAULT_LABOR_RATE,
+      SETTING_KEYS.INVOICE_DUE_DAYS,
+    ]),
+    getInventoryPartsList(),
+    getTechnicians(),
+    getLaborPresetsList(),
+    getAuthContext(),
+    getCachedSession(),
+    getOrgMembers(),
+    getStatusReportsForService(serviceId),
+    getServiceFindings(serviceId),
+  ])
 
   if (!result.success || !result.data) {
     return (
@@ -53,48 +63,38 @@ export default async function ServiceDetailPage({
         <PageHeader />
         <div className="flex h-[50vh] items-center justify-center">
           <p className="text-muted-foreground">
-            {result.error || (await getTranslations("service.page"))("notFound")}
+            {result.error || (await getTranslations('service.page'))('notFound')}
           </p>
         </div>
       </>
-    );
+    )
   }
 
-  const record = result.data;
-  const settings =
-    settingsResult.success && settingsResult.data ? settingsResult.data : {};
-  const currencyCode = settings[SETTING_KEYS.CURRENCY_CODE] || "USD";
-  const unitSystem = (settings[SETTING_KEYS.UNIT_SYSTEM] || "imperial") as
-    | "metric"
-    | "imperial";
-  const taxEnabled = settings[SETTING_KEYS.TAX_ENABLED] !== "false";
-  const defaultTaxRate = taxEnabled
-    ? Number(settings[SETTING_KEYS.DEFAULT_TAX_RATE]) || 0
-    : 0;
-  const defaultLaborRate =
-    Number(settings[SETTING_KEYS.DEFAULT_LABOR_RATE]) || 0;
-  const defaultDueDays = Number(settings[SETTING_KEYS.INVOICE_DUE_DAYS]) || 0;
-  const inventoryParts =
-    inventoryResult.success && inventoryResult.data ? inventoryResult.data : [];
-  const laborPresets =
-    presetsResult.success && presetsResult.data ? presetsResult.data : [];
+  const record = result.data
+  const settings = settingsResult.success && settingsResult.data ? settingsResult.data : {}
+  const currencyCode = settings[SETTING_KEYS.CURRENCY_CODE] || 'USD'
+  const unitSystem = (settings[SETTING_KEYS.UNIT_SYSTEM] || 'imperial') as 'metric' | 'imperial'
+  const taxEnabled = settings[SETTING_KEYS.TAX_ENABLED] !== 'false'
+  const defaultTaxRate = taxEnabled ? Number(settings[SETTING_KEYS.DEFAULT_TAX_RATE]) || 0 : 0
+  const defaultLaborRate = Number(settings[SETTING_KEYS.DEFAULT_LABOR_RATE]) || 0
+  const defaultDueDays = Number(settings[SETTING_KEYS.INVOICE_DUE_DAYS]) || 0
+  const inventoryParts = inventoryResult.success && inventoryResult.data ? inventoryResult.data : []
+  const laborPresets = presetsResult.success && presetsResult.data ? presetsResult.data : []
   const initialVehicle = {
     id: record.vehicle.id,
     make: record.vehicle.make,
     model: record.vehicle.model,
     year: record.vehicle.year,
     licensePlate: record.vehicle.licensePlate,
-  };
+  }
   const boardTechnicians = (
     techniciansResult.success && techniciansResult.data ? techniciansResult.data : []
-  ).map((t) => ({ id: t.id, name: t.name, userId: t.userId }));
-  const organizationId = authContext?.organizationId || "";
+  ).map((t) => ({ id: t.id, name: t.name, userId: t.userId }))
+  const organizationId = authContext?.organizationId || ''
 
   // Fetch team members and features
-  const membership = session?.user?.id
-    ? await getCachedMembership(session.user.id)
-    : null;
-  const orgId = membership?.organizationId;
+  const membership = session?.user?.id ? await getCachedMembership(session.user.id) : null
+  const orgId = membership?.organizationId
 
   const [currentUser, features, aiSettings] = await Promise.all([
     session?.user?.id
@@ -113,34 +113,44 @@ export default async function ServiceDetailPage({
           select: { key: true, value: true },
         })
       : Promise.resolve([]),
-  ]);
+  ])
 
-  const currentUserName = currentUser?.name || "";
-  const aiSettingsMap = Object.fromEntries(aiSettings.map((s) => [s.key, s.value]));
-  const aiEnabled = features?.ai === true && aiSettingsMap[SETTING_KEYS.AI_ENABLED] === "true" && !!aiSettingsMap[SETTING_KEYS.AI_API_KEY];
+  const currentUserName = currentUser?.name || ''
+  const aiSettingsMap = Object.fromEntries(aiSettings.map((s) => [s.key, s.value]))
+  const aiEnabled =
+    features?.ai === true &&
+    aiSettingsMap[SETTING_KEYS.AI_ENABLED] === 'true' &&
+    !!aiSettingsMap[SETTING_KEYS.AI_API_KEY]
 
   const initialData = {
     id: record.id,
     title: record.title,
-    description: record.description || "",
+    description: record.description || '',
     type: record.type,
     status: record.status,
     mileage: record.mileage,
-    serviceDate: new Date(record.serviceDate).toISOString().split("T")[0],
+    serviceDate: new Date(record.serviceDate).toISOString().split('T')[0],
     startDateTime: record.startDateTime?.toISOString() ?? null,
     endDateTime: record.endDateTime?.toISOString() ?? null,
-    techName: record.techName || "",
-    diagnosticNotes: record.diagnosticNotes || "",
-    invoiceNotes: record.invoiceNotes || "",
-    invoiceNumber: record.invoiceNumber || "",
-    invoiceDate: (record.invoiceDate ?? record.startDateTime ?? record.serviceDate).toISOString().split("T")[0],
+    techName: record.techName || '',
+    diagnosticNotes: record.diagnosticNotes || '',
+    invoiceNotes: record.invoiceNotes || '',
+    invoiceNumber: record.invoiceNumber || '',
+    invoiceDate: (record.invoiceDate ?? record.startDateTime ?? record.serviceDate)
+      .toISOString()
+      .split('T')[0],
     invoiceDueDate: record.invoiceDueDate
-      ? record.invoiceDueDate.toISOString().split("T")[0]
+      ? record.invoiceDueDate.toISOString().split('T')[0]
       : defaultDueDays > 0
-        ? new Date((record.invoiceDate ?? record.startDateTime ?? record.serviceDate).getTime() + defaultDueDays * 86400000).toISOString().split("T")[0]
-        : "",
+        ? new Date(
+            (record.invoiceDate ?? record.startDateTime ?? record.serviceDate).getTime() +
+              defaultDueDays * 86400000
+          )
+            .toISOString()
+            .split('T')[0]
+        : '',
     partItems: record.partItems.map((p) => ({
-      partNumber: p.partNumber || "",
+      partNumber: p.partNumber || '',
       name: p.name,
       quantity: p.quantity,
       unitPrice: p.unitPrice,
@@ -166,35 +176,42 @@ export default async function ServiceDetailPage({
     warrantyMonths: record.warrantyMonths ?? null,
     warrantyMileage: record.warrantyMileage ?? null,
     warrantyNotes: record.warrantyNotes ?? null,
-  };
+  }
 
   // Prepare media attachments for managers
   // The Prisma query returns includeInInvoice on each attachment; cast to include it
-  const allAttachments = record.attachments as (typeof record.attachments[number] & { includeInInvoice: boolean })[] || [];
+  const allAttachments =
+    (record.attachments as ((typeof record.attachments)[number] & {
+      includeInInvoice: boolean
+    })[]) || []
   const imageAttachmentsForManager = allAttachments
-    .filter((a) => a.category === "image")
-    .map((a) => ({ ...a, includeInInvoice: a.includeInInvoice ?? true }));
+    .filter((a) => a.category === 'image')
+    .map((a) => ({ ...a, includeInInvoice: a.includeInInvoice ?? true }))
   const videoAttachments = allAttachments
-    .filter((a) => a.category === "video")
-    .map((a) => ({ ...a, includeInInvoice: a.includeInInvoice ?? true }));
+    .filter((a) => a.category === 'video')
+    .map((a) => ({ ...a, includeInInvoice: a.includeInInvoice ?? true }))
   const documentAttachments = allAttachments
-    .filter((a) => a.category === "document" || a.category === "diagnostic")
-    .map((a) => ({ ...a, includeInInvoice: a.includeInInvoice ?? true }));
+    .filter((a) => a.category === 'document' || a.category === 'diagnostic')
+    .map((a) => ({ ...a, includeInInvoice: a.includeInInvoice ?? true }))
 
   // Fetch notification history for this service record
   const notificationHistory = await db.smsMessage.findMany({
-    where: { relatedEntityId: serviceId, relatedEntityType: "service-record", direction: "outbound" },
+    where: {
+      relatedEntityId: serviceId,
+      relatedEntityType: 'service-record',
+      direction: 'outbound',
+    },
     select: { id: true, body: true, status: true, createdAt: true, toNumber: true },
-    orderBy: { createdAt: "desc" },
+    orderBy: { createdAt: 'desc' },
     take: 10,
-  });
+  })
 
   // Fetch open observations for this vehicle (not just this service)
   const openObservations = await db.vehicleFinding.findMany({
-    where: { vehicleId: id, status: { not: "resolved" } },
+    where: { vehicleId: id, status: { not: 'resolved' } },
     select: { id: true, description: true, severity: true, notes: true, serviceRecordId: true },
-    orderBy: { createdAt: "desc" },
-  });
+    orderBy: { createdAt: 'desc' },
+  })
 
   return (
     <div className="flex h-svh flex-col overflow-hidden">
@@ -227,11 +244,23 @@ export default async function ServiceDetailPage({
         telegramEnabled={features?.telegram ?? false}
         aiEnabled={aiEnabled}
         defaultDueDays={defaultDueDays}
-        statusReports={(statusReportsResult.success && statusReportsResult.data ? statusReportsResult.data : []).map(r => ({ ...r, createdAt: r.createdAt.toISOString(), expiresAt: r.expiresAt?.toISOString() || null, feedbackAt: r.feedbackAt?.toISOString() || null, sentAt: r.sentAt?.toISOString() || null }))}
+        statusReports={(statusReportsResult.success && statusReportsResult.data
+          ? statusReportsResult.data
+          : []
+        ).map((r) => ({
+          ...r,
+          createdAt: r.createdAt.toISOString(),
+          expiresAt: r.expiresAt?.toISOString() || null,
+          feedbackAt: r.feedbackAt?.toISOString() || null,
+          sentAt: r.sentAt?.toISOString() || null,
+        }))}
         findings={findingsResult.success && findingsResult.data ? findingsResult.data : []}
         openObservations={openObservations}
-        notificationHistory={notificationHistory.map(n => ({ ...n, createdAt: n.createdAt.toISOString() }))}
+        notificationHistory={notificationHistory.map((n) => ({
+          ...n,
+          createdAt: n.createdAt.toISOString(),
+        }))}
       />
     </div>
-  );
+  )
 }

+ 8 - 0
src/app/globals.css

@@ -126,6 +126,14 @@
   }
 }
 
+/* Hide scrollbar utility */
+.scrollbar-none {
+  scrollbar-width: none;
+}
+.scrollbar-none::-webkit-scrollbar {
+  display: none;
+}
+
 /* Custom scrollbar */
 ::-webkit-scrollbar {
   width: 6px;

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

@@ -31,6 +31,7 @@ import { getCurrencySymbol } from '@/lib/format'
 import type { QuoteAttachment, QuoteRecord, TabType } from './quote-page-types'
 import { statusColors } from './quote-page-types'
 import { useQuoteFormState } from './useQuoteFormState'
+import { useSaveShortcut } from '@/hooks/use-save-shortcut'
 import { LaborPresetPickerDialog, type LaborPresetOption } from '@/features/labor-presets/Components/LaborPresetPickerDialog'
 import { QuotePartsEditor } from './QuotePartsEditor'
 import { QuoteLaborEditor } from './QuoteLaborEditor'
@@ -95,6 +96,10 @@ export function QuotePageClient({
     t,
   })
 
+  useSaveShortcut(() => {
+    if (state.hasUnsavedChanges) return state.saveNow()
+  })
+
   const [showPresetPicker, setShowPresetPicker] = useState(false)
 
   const handleSelectPreset = useCallback(

+ 30 - 15
src/features/vehicles/Components/service-detail/ServiceDetailContent.tsx

@@ -9,6 +9,12 @@ interface ServiceDetailContentProps {
   rightColumn: React.ReactNode
 }
 
+// Bulletproof layout shell. The outer is a relative box that fills its flex parent.
+// Each scroll region is `position: absolute; inset: 0` inside its own relative cell,
+// so its size is dictated entirely by the cell's geometry (grid track width / parent
+// height) and never by its content's intrinsic min-size. This sidesteps the
+// `min-height: auto` flex-item rule that was letting tall right-column content push
+// the body taller than the viewport.
 export function ServiceDetailContent({ leftColumn, rightColumn }: ServiceDetailContentProps) {
   const [sidebarWidth, setSidebarWidth] = useState<number | null>(null)
   const [isDragging, setIsDragging] = useState(false)
@@ -32,38 +38,47 @@ export function ServiceDetailContent({ leftColumn, rightColumn }: ServiceDetailC
     }
   }, [isDragging])
 
+  const rightColTrack =
+    sidebarWidth != null ? `${sidebarWidth}px` : `minmax(${SIDEBAR_MIN_W}px, 22vw)`
+
   return (
-    <>
-      {/* Mobile: stacked layout */}
-      <div className="flex-1 overflow-y-auto overscroll-contain p-4 lg:hidden">
+    <div className="relative min-h-0 flex-1">
+      {/* Mobile: stacked, page scrolls inside the absolute layer */}
+      <div className="absolute inset-0 overflow-y-auto overscroll-contain p-4 lg:hidden">
         <div className="space-y-3 pb-40">
           {leftColumn}
           {rightColumn}
         </div>
       </div>
 
-      {/* Desktop: side-by-side with resizable sidebar */}
-      <div className="hidden lg:flex flex-1 overflow-hidden">
-        <div className="flex-1 min-w-0 overflow-y-auto overscroll-contain p-4 pr-2">
-          <div className="space-y-3 pb-40">{leftColumn}</div>
+      {/* Desktop: 3-track grid (left | resize handle | right sidebar) */}
+      <div
+        className="absolute inset-0 hidden lg:grid"
+        style={{ gridTemplateColumns: `minmax(0, 1fr) 6px ${rightColTrack}` }}
+      >
+        <div className="relative">
+          <div className="absolute inset-0 overflow-y-auto overscroll-contain p-4 pr-2">
+            <div className="space-y-3 pb-40">{leftColumn}</div>
+          </div>
         </div>
+
         <div
-          className="w-1.5 shrink-0 cursor-col-resize bg-border hover:bg-primary/30 transition-colors relative group"
+          className="relative cursor-col-resize bg-border transition-colors hover:bg-primary/30"
           onMouseDown={() => setIsDragging(true)}
         >
-          <div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 h-8 w-4 flex items-center justify-center rounded-sm border bg-background shadow-sm">
+          <div className="absolute top-1/2 left-1/2 flex h-8 w-4 -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-sm border bg-background shadow-sm">
             <svg width="6" height="14" viewBox="0 0 6 14" className="text-muted-foreground">
               <path d="M1 0v14M5 0v14" stroke="currentColor" strokeWidth="1" />
             </svg>
           </div>
         </div>
-        <div
-          className="shrink-0 overflow-y-auto overscroll-contain p-4 pl-2"
-          style={sidebarWidth != null ? { width: sidebarWidth } : { width: '22vw', minWidth: SIDEBAR_MIN_W }}
-        >
-          <div className="space-y-3 pb-40">{rightColumn}</div>
+
+        <div className="relative">
+          <div className="absolute inset-0 overflow-y-auto overscroll-contain p-4 pl-2">
+            <div className="space-y-3 pb-40">{rightColumn}</div>
+          </div>
         </div>
       </div>
-    </>
+    </div>
   )
 }

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

@@ -23,10 +23,22 @@ interface DetailsRightColumnProps {
   organizationId: string
   currencyCode: string
   taxEnabled: boolean
-  initialVehicle: { id: string; make: string; model: string; year: number; licensePlate: string | null }
+  initialVehicle: {
+    id: string
+    make: string
+    model: string
+    year: number
+    licensePlate: string | null
+  }
   boardTechnicians: BoardTechnicianOption[]
   orgMembers?: OrgMemberOption[]
-  notificationHistory?: { id: string; body: string; status: string; createdAt: string; toNumber: string }[]
+  notificationHistory?: {
+    id: string
+    body: string
+    status: string
+    createdAt: string
+    toNumber: string
+  }[]
 }
 
 export function DetailsRightColumn({
@@ -136,7 +148,11 @@ export function DetailsRightColumn({
   )
 }
 
-function NotificationHistory({ notifications }: { notifications: { id: string; body: string; status: string; createdAt: string; toNumber: string }[] }) {
+function NotificationHistory({
+  notifications,
+}: {
+  notifications: { id: string; body: string; status: string; createdAt: string; toNumber: string }[]
+}) {
   const t = useTranslations('service.notifications')
 
   function timeAgo(dateStr: string) {
@@ -159,7 +175,9 @@ function NotificationHistory({ notifications }: { notifications: { id: string; b
       <div className="space-y-2">
         {notifications.map((n) => (
           <div key={n.id} className="text-xs">
-            <p className="text-muted-foreground">{timeAgo(n.createdAt)} · {t('viaSms')}</p>
+            <p className="text-muted-foreground">
+              {timeAgo(n.createdAt)} · {t('viaSms')}
+            </p>
             <p className="mt-0.5 truncate">{n.body}</p>
           </div>
         ))}

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

@@ -13,6 +13,7 @@ import { NotifyCustomerDialog } from '@/components/notify-customer-dialog'
 import { InventoryPickerDialog } from '../service-edit/InventoryPickerDialog'
 import { BarcodeScannerDialog } from '@/components/barcode-scanner-dialog'
 import { useHardwareScanner } from '@/hooks/use-hardware-scanner'
+import { useSaveShortcut } from '@/hooks/use-save-shortcut'
 import { lookupPartByBarcode } from '@/features/inventory/Actions/lookupPartByBarcode'
 import { toast } from 'sonner'
 import { Button } from '@/components/ui/button'
@@ -45,9 +46,9 @@ import { useServiceActions } from './useServiceActions'
 import { DetailsLeftColumn } from './DetailsLeftColumn'
 import { DetailsRightColumn } from './DetailsRightColumn'
 import { ObservationsManager, type ObservationsControls } from './ObservationsManager'
+import type { ServicePageClientProps } from './service-page-types'
 
 export type { ServicePageClientProps, BoardTechnicianOption } from './service-page-types'
-import type { ServicePageClientProps } from './service-page-types'
 
 export function ServicePageClient({
   record,
@@ -86,7 +87,10 @@ export function ServicePageClient({
   const router = useRouter()
 
   const validTabs: ServiceTab[] = ['details', 'images', 'video', 'documents', 'statusReports']
-  const resolvedInitialTab = initialTab && validTabs.includes(initialTab as ServiceTab) ? (initialTab as ServiceTab) : 'details'
+  const resolvedInitialTab =
+    initialTab && validTabs.includes(initialTab as ServiceTab)
+      ? (initialTab as ServiceTab)
+      : 'details'
 
   const [activeTab, setActiveTab] = useState<ServiceTab>(resolvedInitialTab)
 
@@ -102,13 +106,16 @@ export function ServicePageClient({
   const [showDateCheck, setShowDateCheck] = useState(false)
   const dateCheckResolveRef = useRef<((proceed: boolean) => void) | null>(null)
   const today = new Date(new Date().toISOString().split('T')[0])
-  const suggestedDueDate = defaultDueDays > 0 ? new Date(today.getTime() + defaultDueDays * 86400000) : today
+  const suggestedDueDate =
+    defaultDueDays > 0 ? new Date(today.getTime() + defaultDueDays * 86400000) : today
   const [pendingInvoiceDate, setPendingInvoiceDate] = useState<Date>(today)
   const [pendingDueDate, setPendingDueDate] = useState<Date>(suggestedDueDate)
   const [updatingDates, setUpdatingDates] = useState(false)
 
-  const formatDate = (date: Date) => date.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
-  const toISODate = (date: Date) => `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
+  const formatDate = (date: Date) =>
+    date.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
+  const toISODate = (date: Date) =>
+    `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
 
   const areDatesExpired = useMemo(() => {
     const invoiceDateStr = initialData.invoiceDate
@@ -130,33 +137,48 @@ export function ServicePageClient({
   const checkDates = useCallback(async () => {
     if (!areDatesExpired || formState.paymentStatus === 'paid') return true
     // Show current (expired) dates so user sees what's wrong
-    setPendingInvoiceDate(initialData.invoiceDate ? new Date(initialData.invoiceDate + 'T00:00:00') : today)
-    setPendingDueDate(initialData.invoiceDueDate ? new Date(initialData.invoiceDueDate + 'T00:00:00') : today)
+    setPendingInvoiceDate(
+      initialData.invoiceDate ? new Date(initialData.invoiceDate + 'T00:00:00') : today
+    )
+    setPendingDueDate(
+      initialData.invoiceDueDate ? new Date(initialData.invoiceDueDate + 'T00:00:00') : today
+    )
     setShowDateCheck(true)
     return new Promise<boolean>((resolve) => {
       dateCheckResolveRef.current = resolve
     })
-  }, [areDatesExpired, formState.paymentStatus, initialData.invoiceDate, initialData.invoiceDueDate])
-
-  const handleBarcodeScan = useCallback(async (barcode: string) => {
-    const result = await lookupPartByBarcode(barcode)
-    if (result.success && result.data) {
-      const part = result.data
-      const price = part.sellPrice > 0 ? part.sellPrice : part.unitCost
-      formState.dirtySetPartItems((prev) => [{
-        partNumber: part.partNumber || '',
-        name: part.name,
-        quantity: 1,
-        unitPrice: price,
-        total: price,
-        unitCost: part.unitCost,
-        inventoryPartId: part.id,
-      }, ...prev])
-      toast.success(t('parts.partFound', { name: part.name }))
-    } else {
-      toast.error(t('parts.partNotFound', { barcode }))
-    }
-  }, [formState, t])
+  }, [
+    areDatesExpired,
+    formState.paymentStatus,
+    initialData.invoiceDate,
+    initialData.invoiceDueDate,
+  ])
+
+  const handleBarcodeScan = useCallback(
+    async (barcode: string) => {
+      const result = await lookupPartByBarcode(barcode)
+      if (result.success && result.data) {
+        const part = result.data
+        const price = part.sellPrice > 0 ? part.sellPrice : part.unitCost
+        formState.dirtySetPartItems((prev) => [
+          {
+            partNumber: part.partNumber || '',
+            name: part.name,
+            quantity: 1,
+            unitPrice: price,
+            total: price,
+            unitCost: part.unitCost,
+            inventoryPartId: part.id,
+          },
+          ...prev,
+        ])
+        toast.success(t('parts.partFound', { name: part.name }))
+      } else {
+        toast.error(t('parts.partNotFound', { barcode }))
+      }
+    },
+    [formState, t]
+  )
 
   useHardwareScanner({ onScan: handleBarcodeScan, enabled: activeTab === 'details' })
 
@@ -164,14 +186,15 @@ export function ServicePageClient({
   const statusTemplateKeys: Record<string, string> = {
     'in-progress': SETTING_KEYS.SMS_TEMPLATE_STATUS_IN_PROGRESS,
     'waiting-parts': SETTING_KEYS.SMS_TEMPLATE_STATUS_WAITING_PARTS,
-    'completed': SETTING_KEYS.SMS_TEMPLATE_STATUS_READY,
+    completed: SETTING_KEYS.SMS_TEMPLATE_STATUS_READY,
   }
   const [showNotifyDialog, setShowNotifyDialog] = useState(false)
   const [notifyMessage, setNotifyMessage] = useState('')
 
   const handleNotifyCustomer = useCallback(async () => {
     if (!record.vehicle.customer) return
-    const templateKey = statusTemplateKeys[formState.status] || SETTING_KEYS.SMS_TEMPLATE_STATUS_READY
+    const templateKey =
+      statusTemplateKeys[formState.status] || SETTING_KEYS.SMS_TEMPLATE_STATUS_READY
     const tplResult = await getSmsTemplates()
     const tplData = tplResult.success && tplResult.data ? tplResult.data : null
     const tpl = tplData?.templates[templateKey] || SMS_TEMPLATE_DEFAULTS[templateKey] || ''
@@ -184,7 +207,13 @@ export function ServicePageClient({
     })
     setNotifyMessage(message)
     setShowNotifyDialog(true)
-  }, [formState.status, record.vehicle.customer, record.vehicle.year, record.vehicle.make, record.vehicle.model]) // eslint-disable-line react-hooks/exhaustive-deps
+  }, [
+    formState.status,
+    record.vehicle.customer,
+    record.vehicle.year,
+    record.vehicle.make,
+    record.vehicle.model,
+  ]) // eslint-disable-line react-hooks/exhaustive-deps
 
   // Observations state
   const tf = useTranslations('vehicles.findings')
@@ -215,8 +244,10 @@ export function ServicePageClient({
     const newItems = preset.items.map((item) => ({
       description: item.description,
       hours: item.hours,
-      rate: item.rate > 0 ? item.rate : (item.pricingType === 'service' ? 0 : defaultLaborRate),
-      total: item.hours * (item.rate > 0 ? item.rate : (item.pricingType === 'service' ? 0 : defaultLaborRate)),
+      rate: item.rate > 0 ? item.rate : item.pricingType === 'service' ? 0 : defaultLaborRate,
+      total:
+        item.hours *
+        (item.rate > 0 ? item.rate : item.pricingType === 'service' ? 0 : defaultLaborRate),
       pricingType: (item.pricingType as 'hourly' | 'service') || 'hourly',
     }))
     formState.dirtySetLaborItems((prev) => [...newItems, ...prev])
@@ -242,6 +273,10 @@ export function ServicePageClient({
     formState,
   })
 
+  useSaveShortcut(() => {
+    if (formState.hasUnsavedChanges) return actions.saveNow()
+  })
+
   return (
     <div className="flex min-h-0 flex-1 flex-col overflow-hidden">
       <UnifiedServiceHeader
@@ -262,17 +297,35 @@ export function ServicePageClient({
         saving={formState.loading}
         hasUnsavedChanges={formState.hasUnsavedChanges}
         showSaved={formState.showSaved}
-        onDownloadPDF={async () => { if (!await checkDates()) return; if (formState.hasUnsavedChanges) await actions.saveNow(); actions.handleDownloadPDF() }}
+        onDownloadPDF={async () => {
+          if (!(await checkDates())) return
+          if (formState.hasUnsavedChanges) await actions.saveNow()
+          actions.handleDownloadPDF()
+        }}
         onDelete={actions.handleDelete}
-        onShowEmail={async () => { if (!await checkDates()) return; if (formState.hasUnsavedChanges) await actions.saveNow(); actions.setShowEmailDialog(true) }}
-        onShowShare={async () => { if (!await checkDates()) return; if (formState.hasUnsavedChanges) await actions.saveNow(); actions.setShowShareDialog(true) }}
+        onShowEmail={async () => {
+          if (!(await checkDates())) return
+          if (formState.hasUnsavedChanges) await actions.saveNow()
+          actions.setShowEmailDialog(true)
+        }}
+        onShowShare={async () => {
+          if (!(await checkDates())) return
+          if (formState.hasUnsavedChanges) await actions.saveNow()
+          actions.setShowShareDialog(true)
+        }}
         onNotifyCustomer={handleNotifyCustomer}
         hasCustomer={!!record.vehicle.customer}
       />
 
       {activeTab === 'details' && (
         <>
-          <form id="service-record-form" ref={formState.formRef} onSubmit={actions.handleSubmit} onInput={formState.markDirty} className="flex min-h-0 flex-1 flex-col">
+          <form
+            id="service-record-form"
+            ref={formState.formRef}
+            onSubmit={actions.handleSubmit}
+            onInput={formState.markDirty}
+            className="flex min-h-0 flex-1 flex-col"
+          >
             <ServiceDetailContent
               leftColumn={
                 <DetailsLeftColumn
@@ -291,7 +344,9 @@ export function ServicePageClient({
                   onAddFinding={() => obsControlsRef.current?.onAddFinding()}
                   onEditFinding={(f) => obsControlsRef.current?.onEditFinding(f)}
                   openObservationsCount={otherObsCount}
-                  onShowExistingObservations={() => obsControlsRef.current?.onShowExistingObservations()}
+                  onShowExistingObservations={() =>
+                    obsControlsRef.current?.onShowExistingObservations()
+                  }
                 />
               }
               rightColumn={
@@ -317,7 +372,9 @@ export function ServicePageClient({
             openObservations={openObservations}
             onAddObservations={handleAddObservationsToWorkOrder}
             addingObservations={addingObservations}
-            onControlsReady={(c) => { obsControlsRef.current = c }}
+            onControlsReady={(c) => {
+              obsControlsRef.current = c
+            }}
           />
         </>
       )}
@@ -334,10 +391,7 @@ export function ServicePageClient({
 
       {activeTab === 'video' && (
         <div className="flex-1 overflow-y-auto overscroll-contain p-4">
-          <ServiceVideoManager
-            serviceRecordId={record.id}
-            initialVideos={videoAttachments}
-          />
+          <ServiceVideoManager serviceRecordId={record.id} initialVideos={videoAttachments} />
         </div>
       )}
 
@@ -358,13 +412,17 @@ export function ServicePageClient({
             serviceRecordId={record.id}
             organizationId={organizationId}
             vehicleName={formState.vehicleName}
-            customer={record.vehicle.customer ? {
-              id: record.vehicle.customer.id,
-              name: record.vehicle.customer.name,
-              email: record.vehicle.customer.email,
-              phone: record.vehicle.customer.phone,
-              telegramChatId: record.vehicle.customer.telegramChatId || null,
-            } : null}
+            customer={
+              record.vehicle.customer
+                ? {
+                    id: record.vehicle.customer.id,
+                    name: record.vehicle.customer.name,
+                    email: record.vehicle.customer.email,
+                    phone: record.vehicle.customer.phone,
+                    telegramChatId: record.vehicle.customer.telegramChatId || null,
+                  }
+                : null
+            }
             smsEnabled={smsEnabled}
             emailEnabled={emailEnabled}
             telegramEnabled={telegramEnabled}
@@ -451,22 +509,23 @@ export function ServicePageClient({
       )}
 
       {/* Expired dates check dialog */}
-      <Dialog open={showDateCheck} onOpenChange={(open) => {
-        if (!open) {
-          dateCheckResolveRef.current?.(false)
-          dateCheckResolveRef.current = null
-        }
-        setShowDateCheck(open)
-      }}>
+      <Dialog
+        open={showDateCheck}
+        onOpenChange={(open) => {
+          if (!open) {
+            dateCheckResolveRef.current?.(false)
+            dateCheckResolveRef.current = null
+          }
+          setShowDateCheck(open)
+        }}
+      >
         <DialogContent className="sm:max-w-sm">
           <DialogHeader>
             <DialogTitle className="flex items-center gap-2">
               <AlertTriangle className="h-5 w-5 text-amber-500" />
               {t('page.datesExpiredTitle')}
             </DialogTitle>
-            <DialogDescription>
-              {t('page.datesExpiredDescription')}
-            </DialogDescription>
+            <DialogDescription>{t('page.datesExpiredDescription')}</DialogDescription>
           </DialogHeader>
 
           <div className="space-y-3">
@@ -477,7 +536,11 @@ export function ServicePageClient({
               onClick={() => {
                 const now = new Date(new Date().toISOString().split('T')[0])
                 setPendingInvoiceDate(now)
-                setPendingDueDate(defaultDueDays > 0 ? new Date(now.getTime() + defaultDueDays * 86400000) : new Date(now.getTime() + 14 * 86400000))
+                setPendingDueDate(
+                  defaultDueDays > 0
+                    ? new Date(now.getTime() + defaultDueDays * 86400000)
+                    : new Date(now.getTime() + 14 * 86400000)
+                )
               }}
             >
               {t('page.datesExpiredSetToday')}
@@ -487,13 +550,20 @@ export function ServicePageClient({
               <Label className="text-xs">{t('basicInfo.invoiceDate')}</Label>
               <Popover>
                 <PopoverTrigger asChild>
-                  <Button variant="outline" className="w-full justify-start text-left font-normal h-9 text-sm">
+                  <Button
+                    variant="outline"
+                    className="w-full justify-start text-left font-normal h-9 text-sm"
+                  >
                     <CalendarIcon className="mr-2 h-3.5 w-3.5" />
                     <span suppressHydrationWarning>{formatDate(pendingInvoiceDate)}</span>
                   </Button>
                 </PopoverTrigger>
                 <PopoverContent className="w-auto p-0" align="start">
-                  <Calendar mode="single" selected={pendingInvoiceDate} onSelect={(d) => d && setPendingInvoiceDate(d)} />
+                  <Calendar
+                    mode="single"
+                    selected={pendingInvoiceDate}
+                    onSelect={(d) => d && setPendingInvoiceDate(d)}
+                  />
                 </PopoverContent>
               </Popover>
             </div>
@@ -502,13 +572,20 @@ export function ServicePageClient({
               <Label className="text-xs">{t('basicInfo.invoiceDueDate')}</Label>
               <Popover>
                 <PopoverTrigger asChild>
-                  <Button variant="outline" className="w-full justify-start text-left font-normal h-9 text-sm">
+                  <Button
+                    variant="outline"
+                    className="w-full justify-start text-left font-normal h-9 text-sm"
+                  >
                     <CalendarIcon className="mr-2 h-3.5 w-3.5" />
                     <span suppressHydrationWarning>{formatDate(pendingDueDate)}</span>
                   </Button>
                 </PopoverTrigger>
                 <PopoverContent className="w-auto p-0" align="start">
-                  <Calendar mode="single" selected={pendingDueDate} onSelect={(d) => d && setPendingDueDate(d)} />
+                  <Calendar
+                    mode="single"
+                    selected={pendingDueDate}
+                    onSelect={(d) => d && setPendingDueDate(d)}
+                  />
                 </PopoverContent>
               </Popover>
             </div>

+ 17 - 0
src/hooks/use-save-shortcut.ts

@@ -0,0 +1,17 @@
+'use client'
+
+import { useEffect } from 'react'
+
+export function useSaveShortcut(onSave: () => void | Promise<void>, enabled = true) {
+  useEffect(() => {
+    if (!enabled) return
+    const handler = (e: KeyboardEvent) => {
+      if ((e.ctrlKey || e.metaKey) && (e.key === 's' || e.key === 'S')) {
+        e.preventDefault()
+        void onSave()
+      }
+    }
+    window.addEventListener('keydown', handler)
+    return () => window.removeEventListener('keydown', handler)
+  }, [onSave, enabled])
+}