Ver código fonte

work order flow

Bernt Christian Egeland 7 meses atrás
pai
commit
1f6cbd5cf1

+ 1 - 0
.github/workflows/docker-publish.yml

@@ -37,6 +37,7 @@ jobs:
         with:
           images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
           tags: |
+            type=raw,value=latest
             type=ref,event=branch
             type=semver,pattern={{version}}
             type=semver,pattern={{major}}.{{minor}}

+ 10 - 43
src/app/(authenticated)/vehicles/[id]/service/new/page.tsx

@@ -1,9 +1,5 @@
-import { getVehicle } from "@/features/vehicles/Actions/vehicleActions";
-import { getSettings } from "@/features/settings/Actions/settingsActions";
-import { SETTING_KEYS } from "@/features/settings/Schema/settingsSchema";
-import { getInventoryPartsList } from "@/features/inventory/Actions/inventoryActions";
-import { ServiceRecordForm } from "./service-record-form";
-import { PageHeader } from "@/components/page-header";
+import { redirect } from "next/navigation";
+import { createDraftServiceRecord } from "@/features/vehicles/Actions/createDraftServiceRecord";
 
 export default async function NewServicePage({
   params,
@@ -11,46 +7,17 @@ export default async function NewServicePage({
   params: Promise<{ id: string }>;
 }) {
   const { id } = await params;
-  const [result, settingsResult, inventoryResult] = await Promise.all([
-    getVehicle(id),
-    getSettings([SETTING_KEYS.DEFAULT_TAX_RATE, SETTING_KEYS.TAX_ENABLED, SETTING_KEYS.CURRENCY_CODE, SETTING_KEYS.DEFAULT_LABOR_RATE]),
-    getInventoryPartsList(),
-  ]);
+  const result = await createDraftServiceRecord(id);
 
-  if (!result.success || !result.data) {
-    return (
-      <>
-        <PageHeader />
-        <div className="flex h-[50vh] items-center justify-center">
-          <p className="text-muted-foreground">
-            {result.error || "Vehicle not found"}
-          </p>
-        </div>
-      </>
-    );
+  if (result.success && result.data?.id) {
+    redirect(`/vehicles/${id}/service/${result.data.id}/edit`);
   }
 
-  const settings = settingsResult.success && settingsResult.data ? settingsResult.data : {};
-  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 currencyCode = settings[SETTING_KEYS.CURRENCY_CODE] || "USD";
-  const inventoryParts = inventoryResult.success && inventoryResult.data ? inventoryResult.data : [];
-
   return (
-    <>
-      <PageHeader />
-      <div className="flex flex-1 flex-col gap-4 p-4 pt-0">
-        <ServiceRecordForm
-          vehicleId={id}
-          vehicleName={`${result.data.year} ${result.data.make} ${result.data.model}`}
-          defaultTaxRate={defaultTaxRate}
-          taxEnabled={taxEnabled}
-          defaultLaborRate={defaultLaborRate}
-          currencyCode={currencyCode}
-          inventoryParts={inventoryParts}
-        />
-      </div>
-    </>
+    <div className="flex h-[50vh] items-center justify-center">
+      <p className="text-muted-foreground">
+        {result.error || "Failed to create service record"}
+      </p>
+    </div>
   );
 }

+ 3 - 292
src/app/(authenticated)/vehicles/[id]/service/new/service-record-form.tsx

@@ -39,7 +39,6 @@ import {
 } from '@/components/ui/command'
 import { Switch } from '@/components/ui/switch'
 import {
-  ArrowLeft,
   Camera,
   Check,
   ChevronsUpDown,
@@ -460,6 +459,9 @@ export function ServiceRecordForm({
         } else {
           router.refresh()
         }
+      } else if (result.data?.id) {
+        router.push(`/vehicles/${vehicleId}/service/${result.data.id}/edit`)
+        router.refresh()
       } else {
         router.push(`/vehicles/${vehicleId}`)
         router.refresh()
@@ -477,22 +479,6 @@ export function ServiceRecordForm({
 
   return (
     <div className="space-y-6">
-      {!isEdit && (
-        <div>
-          <Link
-            href={`/vehicles/${vehicleId}`}
-            className="mb-4 inline-flex items-center gap-1 text-sm text-muted-foreground transition-colors hover:text-foreground"
-          >
-            <ArrowLeft className="h-4 w-4" />
-            Back to {vehicleName}
-          </Link>
-          <div>
-            <h1 className="text-2xl font-bold">New Service Record</h1>
-            <p className="mt-1 text-sm text-muted-foreground">{vehicleName}</p>
-          </div>
-        </div>
-      )}
-
       <form id="service-record-form" onSubmit={handleSubmit} className="space-y-6">
         {/* Top row: Basic Info | (Diagnostic Notes if new) | Totals */}
         <div className={`grid grid-cols-1 gap-6 ${isEdit ? 'lg:grid-cols-2' : 'lg:grid-cols-3'}`}>
@@ -750,281 +736,6 @@ export function ServiceRecordForm({
           </Card>
         </div>
 
-        {/* Service Images — only shown for new records (managed on Images tab for edits) */}
-        {!isEdit && (
-          <Card className="border-0 shadow-sm">
-            <CardHeader>
-              <CardTitle className="flex items-center gap-2 text-base">
-                <Camera className="h-4 w-4" />
-                Service Images
-              </CardTitle>
-            </CardHeader>
-            <CardContent className="space-y-4">
-              <div
-                onDrop={handleDropImages}
-                onDragOver={(e) => e.preventDefault()}
-                onClick={() => imageInputRef.current?.click()}
-                className="flex cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed border-muted-foreground/25 p-6 transition-colors hover:border-muted-foreground/50"
-              >
-                <ImageIcon className="mb-2 h-8 w-8 text-muted-foreground/50" />
-                <p className="text-sm font-medium">
-                  {uploadingImages ? 'Uploading...' : 'Drop images here or click to browse'}
-                </p>
-                <p className="mt-1 text-xs text-muted-foreground">JPG, PNG, WebP — max 10MB each</p>
-                <input
-                  ref={imageInputRef}
-                  type="file"
-                  multiple
-                  accept=".jpg,.jpeg,.png,.webp"
-                  className="hidden"
-                  onChange={(e) => {
-                    if (e.target.files && e.target.files.length > 0) {
-                      handleFileUpload(e.target.files, 'image')
-                      e.target.value = ''
-                    }
-                  }}
-                />
-              </div>
-
-              {uploadingImages && (
-                <div className="flex items-center gap-2 text-sm text-muted-foreground">
-                  <Loader2 className="h-4 w-4 animate-spin" />
-                  Uploading images...
-                </div>
-              )}
-
-              {serviceImages.length > 0 && (
-                <div className="grid grid-cols-3 gap-3 sm:grid-cols-4 md:grid-cols-6">
-                  {serviceImages.map((file, i) => (
-                    <div key={i} className="group overflow-hidden rounded-lg border">
-                      <div className="relative">
-                        <img
-                          src={file.fileUrl}
-                          alt={file.description || file.fileName}
-                          className="aspect-square w-full object-cover"
-                        />
-                        <Button
-                          type="button"
-                          variant="secondary"
-                          size="icon"
-                          className="absolute right-1 top-1 h-6 w-6 opacity-0 transition-opacity group-hover:opacity-100"
-                          onClick={() => removeServiceImage(i)}
-                        >
-                          <X className="h-3 w-3" />
-                        </Button>
-                      </div>
-                      <div className="space-y-1 p-1.5">
-                        <Input
-                          placeholder="Description..."
-                          value={file.description || ''}
-                          onChange={(e) => {
-                            setServiceImages((prev) => {
-                              const updated = [...prev]
-                              updated[i] = { ...updated[i], description: e.target.value }
-                              return updated
-                            })
-                          }}
-                          className="h-7 text-xs"
-                        />
-                        <label className="flex items-center gap-1.5 text-xs text-muted-foreground">
-                          <Switch
-                            checked={file.includeInInvoice !== false}
-                            onCheckedChange={(checked) => {
-                              setServiceImages((prev) => {
-                                const updated = [...prev]
-                                updated[i] = { ...updated[i], includeInInvoice: checked }
-                                return updated
-                              })
-                            }}
-                            className="scale-75"
-                          />
-                          Invoice
-                        </label>
-                      </div>
-                    </div>
-                  ))}
-                </div>
-              )}
-            </CardContent>
-          </Card>
-        )}
-
-        {/* Diagnostic Reports + Documents — only shown for new records */}
-        {/* In edit mode, both are managed on the Documents tab */}
-        {!isEdit && (
-          <div className="grid grid-cols-1 gap-6 md:grid-cols-2">
-            {/* Diagnostic Reports */}
-            <Card className="border-0 shadow-sm">
-              <CardHeader>
-                <CardTitle className="flex items-center gap-2 text-base">
-                  <FileText className="h-4 w-4" />
-                  Diagnostic Reports
-                </CardTitle>
-              </CardHeader>
-              <CardContent className="space-y-4">
-                <div
-                  onDrop={handleDropReports}
-                  onDragOver={(e) => e.preventDefault()}
-                  onClick={() => reportInputRef.current?.click()}
-                  className="flex cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed border-muted-foreground/25 p-6 transition-colors hover:border-muted-foreground/50"
-                >
-                  <Upload className="mb-2 h-8 w-8 text-muted-foreground/50" />
-                  <p className="text-sm font-medium">
-                    {uploadingReports ? 'Uploading...' : 'Drop files here or click to browse'}
-                  </p>
-                  <p className="mt-1 text-xs text-muted-foreground">
-                    PDF, CSV, TXT — max 10MB each
-                  </p>
-                  <input
-                    ref={reportInputRef}
-                    type="file"
-                    multiple
-                    accept=".pdf,.csv,.txt"
-                    className="hidden"
-                    onChange={(e) => {
-                      if (e.target.files && e.target.files.length > 0) {
-                        handleFileUpload(e.target.files, 'diagnostic')
-                        e.target.value = ''
-                      }
-                    }}
-                  />
-                </div>
-
-                {uploadingReports && (
-                  <div className="flex items-center gap-2 text-sm text-muted-foreground">
-                    <Loader2 className="h-4 w-4 animate-spin" />
-                    Uploading reports...
-                  </div>
-                )}
-
-                {diagnosticReports.length > 0 && (
-                  <div className="space-y-2">
-                    {diagnosticReports.map((file, i) => (
-                      <div key={i} className="flex items-center gap-3 rounded-md border p-2.5">
-                        {getFileIcon(file.fileType)}
-                        <div className="min-w-0 flex-1">
-                          <p className="truncate text-sm font-medium">{file.fileName}</p>
-                          <p className="text-xs text-muted-foreground">
-                            {formatFileSize(file.fileSize)}
-                          </p>
-                        </div>
-                        <label className="flex shrink-0 items-center gap-1.5 text-xs text-muted-foreground">
-                          <Switch
-                            checked={file.includeInInvoice !== false}
-                            onCheckedChange={(checked) => {
-                              setDiagnosticReports((prev) => {
-                                const updated = [...prev]
-                                updated[i] = { ...updated[i], includeInInvoice: checked }
-                                return updated
-                              })
-                            }}
-                            className="scale-75"
-                          />
-                          Invoice
-                        </label>
-                        <Button
-                          type="button"
-                          variant="ghost"
-                          size="icon"
-                          className="h-7 w-7 text-muted-foreground hover:text-destructive"
-                          onClick={() => removeDiagnosticReport(i)}
-                        >
-                          <X className="h-3.5 w-3.5" />
-                        </Button>
-                      </div>
-                    ))}
-                  </div>
-                )}
-              </CardContent>
-            </Card>
-
-            {/* Documents */}
-            <Card className="border-0 shadow-sm">
-              <CardHeader>
-                <CardTitle className="flex items-center gap-2 text-base">
-                  <Paperclip className="h-4 w-4" />
-                  Documents
-                </CardTitle>
-              </CardHeader>
-              <CardContent className="space-y-4">
-                <div
-                  onDrop={handleDropDocuments}
-                  onDragOver={(e) => e.preventDefault()}
-                  onClick={() => documentInputRef.current?.click()}
-                  className="flex cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed border-muted-foreground/25 p-6 transition-colors hover:border-muted-foreground/50"
-                >
-                  <Upload className="mb-2 h-8 w-8 text-muted-foreground/50" />
-                  <p className="text-sm font-medium">
-                    {uploadingDocuments ? 'Uploading...' : 'Drop files here or click to browse'}
-                  </p>
-                  <p className="mt-1 text-xs text-muted-foreground">
-                    PDF, DOCX, XLSX, CSV, TXT — max 10MB each
-                  </p>
-                  <input
-                    ref={documentInputRef}
-                    type="file"
-                    multiple
-                    accept=".pdf,.doc,.docx,.xls,.xlsx,.csv,.txt"
-                    className="hidden"
-                    onChange={(e) => {
-                      if (e.target.files && e.target.files.length > 0) {
-                        handleFileUpload(e.target.files, 'document')
-                        e.target.value = ''
-                      }
-                    }}
-                  />
-                </div>
-
-                {uploadingDocuments && (
-                  <div className="flex items-center gap-2 text-sm text-muted-foreground">
-                    <Loader2 className="h-4 w-4 animate-spin" />
-                    Uploading documents...
-                  </div>
-                )}
-
-                {documents.length > 0 && (
-                  <div className="space-y-2">
-                    {documents.map((file, i) => (
-                      <div key={i} className="flex items-center gap-3 rounded-md border p-2.5">
-                        {getFileIcon(file.fileType)}
-                        <div className="min-w-0 flex-1">
-                          <p className="truncate text-sm font-medium">{file.fileName}</p>
-                          <p className="text-xs text-muted-foreground">
-                            {formatFileSize(file.fileSize)}
-                          </p>
-                        </div>
-                        <label className="flex shrink-0 items-center gap-1.5 text-xs text-muted-foreground">
-                          <Switch
-                            checked={file.includeInInvoice !== false}
-                            onCheckedChange={(checked) => {
-                              setDocuments((prev) => {
-                                const updated = [...prev]
-                                updated[i] = { ...updated[i], includeInInvoice: checked }
-                                return updated
-                              })
-                            }}
-                            className="scale-75"
-                          />
-                          Invoice
-                        </label>
-                        <Button
-                          type="button"
-                          variant="ghost"
-                          size="icon"
-                          className="h-7 w-7 text-muted-foreground hover:text-destructive"
-                          onClick={() => removeDocument(i)}
-                        >
-                          <X className="h-3.5 w-3.5" />
-                        </Button>
-                      </div>
-                    ))}
-                  </div>
-                )}
-              </CardContent>
-            </Card>
-          </div>
-        )}
-
         {/* Parts Table — full width */}
         <Card className="border-0 shadow-sm">
           <CardHeader className="flex flex-row items-center justify-between">

+ 0 - 21
src/components/app-sidebar.tsx

@@ -5,7 +5,6 @@ import Link from 'next/link'
 import Image from 'next/image'
 import { usePathname, useRouter } from 'next/navigation'
 import { signOut, useSession } from '@/lib/auth-client'
-import { useTheme } from '@/components/theme-provider'
 import { Avatar, AvatarFallback } from '@/components/ui/avatar'
 import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
 import {
@@ -41,13 +40,11 @@ import {
   LayoutDashboard,
   Loader2,
   LogOut,
-  Moon,
   Package,
   Plus,
   Receipt,
   Settings,
   ShieldCheck,
-  Sun,
   Users,
 } from 'lucide-react'
 import { switchOrganization } from '@/features/team/Actions/switchOrganization'
@@ -86,12 +83,9 @@ export function AppSidebar({
   const pathname = usePathname()
   const router = useRouter()
   const { data: session } = useSession()
-  const { theme, setTheme } = useTheme()
-  const [mounted, setMounted] = React.useState(false)
   const [showCreateOrg, setShowCreateOrg] = React.useState(false)
   const [newOrgName, setNewOrgName] = React.useState('')
   const [creatingOrg, setCreatingOrg] = React.useState(false)
-  React.useEffect(() => setMounted(true), [])
 
   const navItems = [
     ...baseNavItems.filter((item) => {
@@ -237,21 +231,6 @@ export function AppSidebar({
       </SidebarContent>
       <SidebarFooter>
         <SidebarMenu>
-          <SidebarMenuItem>
-            <SidebarMenuButton
-              size="sm"
-              onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
-              className="text-muted-foreground"
-              suppressHydrationWarning
-            >
-              {mounted && theme === 'dark' ? (
-                <Sun className="size-4" />
-              ) : (
-                <Moon className="size-4" />
-              )}
-              {mounted ? (theme === 'dark' ? 'Light Mode' : 'Dark Mode') : 'Toggle Theme'}
-            </SidebarMenuButton>
-          </SidebarMenuItem>
           <SidebarMenuItem>
             <DropdownMenu>
               <DropdownMenuTrigger asChild>

+ 92 - 0
src/features/vehicles/Actions/createDraftServiceRecord.ts

@@ -0,0 +1,92 @@
+"use server";
+
+import { db } from "@/lib/db";
+import { withAuth } from "@/lib/with-auth";
+import { PermissionAction, PermissionSubject } from "@/lib/permissions";
+
+export async function createDraftServiceRecord(vehicleId: string) {
+  return withAuth(
+    async ({ organizationId }) => {
+      const vehicle = await db.vehicle.findFirst({
+        where: { id: vehicleId, organizationId },
+      });
+      if (!vehicle) throw new Error("Vehicle not found");
+
+      // Auto-populate shop name and invoice number
+      const [settings, org] = await Promise.all([
+        db.appSetting.findMany({
+          where: {
+            organizationId,
+            key: {
+              in: ["workshop.invoicePrefix", "workshop.invoiceStartNumber"],
+            },
+          },
+        }),
+        db.organization.findUnique({
+          where: { id: organizationId },
+          select: { name: true },
+        }),
+      ]);
+      const settingsMap: Record<string, string> = {};
+      for (const s of settings) settingsMap[s.key] = s.value;
+
+      const shopName = org?.name || undefined;
+
+      // Resolve invoice prefix
+      const rawPrefix = settingsMap["workshop.invoicePrefix"] || "{year}-";
+      const now = new Date();
+      const prefix = rawPrefix
+        .replace("{year}", now.getFullYear().toString())
+        .replace("{month}", String(now.getMonth() + 1).padStart(2, "0"));
+
+      // Generate sequential invoice number
+      const startNumber = parseInt(
+        settingsMap["workshop.invoiceStartNumber"] || "0",
+        10
+      );
+      const lastRecord = await db.serviceRecord.findFirst({
+        where: { vehicle: { organizationId } },
+        orderBy: { createdAt: "desc" },
+        select: { invoiceNumber: true },
+      });
+      let nextNum = startNumber || 1001;
+      if (lastRecord?.invoiceNumber) {
+        const match = lastRecord.invoiceNumber.match(/(\d+)$/);
+        if (match) {
+          const lastNum = parseInt(match[1], 10) + 1;
+          nextNum = Math.max(nextNum, lastNum);
+        }
+      }
+      const invoiceNumber = `${prefix}${nextNum}`;
+
+      if (startNumber && nextNum === startNumber) {
+        await db.appSetting.updateMany({
+          where: { organizationId, key: "workshop.invoiceStartNumber" },
+          data: { value: "" },
+        });
+      }
+
+      const record = await db.serviceRecord.create({
+        data: {
+          title: "New Service Record",
+          type: "maintenance",
+          status: "in_progress",
+          vehicleId,
+          shopName,
+          invoiceNumber,
+          serviceDate: now,
+        },
+      });
+
+      return record;
+    },
+    {
+      requiredPermissions: [
+        {
+          action: PermissionAction.CREATE,
+          subject: PermissionSubject.SERVICES,
+        },
+      ],
+    }
+  );
+}

+ 0 - 1
src/features/vehicles/Actions/serviceActions.ts

@@ -199,7 +199,6 @@ export async function getServiceRecord(recordId: string) {
       },
     });
 
-    if (!record) throw new Error("Service record not found");
     return record;
   }, { requiredPermissions: [{ action: PermissionAction.READ, subject: PermissionSubject.SERVICES }] });
 }