Parcourir la source

invoice paid status

Bernt Christian Egeland il y a 7 mois
Parent
commit
98a56c3974

+ 2 - 0
prisma/migrations/20260217163702_invoice_paid_status/migration.sql

@@ -0,0 +1,2 @@
+-- AlterTable
+ALTER TABLE "service_records" ADD COLUMN     "manuallyPaid" BOOLEAN NOT NULL DEFAULT false;

+ 29 - 28
prisma/schema.prisma

@@ -170,6 +170,7 @@ model ServiceRecord {
   discountType    String?
   discountValue   Float    @default(0)
   discountAmount  Float    @default(0)
+  manuallyPaid    Boolean  @default(false)
   publicToken     String?  @unique
   createdAt       DateTime @default(now())
   updatedAt       DateTime @updatedAt
@@ -550,8 +551,8 @@ model TeamInvitation {
   organization   Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
   invitedById    String
 
-  roleId         String?
-  customRole     Role?        @relation(fields: [roleId], references: [id], onDelete: SetNull)
+  roleId     String?
+  customRole Role?   @relation(fields: [roleId], references: [id], onDelete: SetNull)
 
   @@unique([email, organizationId])
   @@index([token])
@@ -598,27 +599,27 @@ model Subscription {
 }
 
 model RecurringInvoice {
-  id            String    @id @default(cuid())
-  title         String
-  description   String?
-  frequency     String    // "weekly" | "biweekly" | "monthly" | "quarterly" | "yearly"
-  nextRunDate   DateTime
-  endDate       DateTime?
-  isActive      Boolean   @default(true)
-  lastRunAt     DateTime?
-  runCount      Int       @default(0)
+  id          String    @id @default(cuid())
+  title       String
+  description String?
+  frequency   String // "weekly" | "biweekly" | "monthly" | "quarterly" | "yearly"
+  nextRunDate DateTime
+  endDate     DateTime?
+  isActive    Boolean   @default(true)
+  lastRunAt   DateTime?
+  runCount    Int       @default(0)
 
   // Template fields (copied to ServiceRecord on generation)
-  type          String    @default("maintenance")
-  cost          Float     @default(0)
-  taxRate       Float     @default(0)
-  invoiceNotes  String?
+  type         String  @default("maintenance")
+  cost         Float   @default(0)
+  taxRate      Float   @default(0)
+  invoiceNotes String?
 
-  vehicleId     String
-  vehicle       Vehicle   @relation(fields: [vehicleId], references: [id], onDelete: Cascade)
+  vehicleId String
+  vehicle   Vehicle @relation(fields: [vehicleId], references: [id], onDelete: Cascade)
 
-  createdAt     DateTime  @default(now())
-  updatedAt     DateTime  @updatedAt
+  createdAt DateTime @default(now())
+  updatedAt DateTime @updatedAt
 
   templateParts RecurringPart[]
   templateLabor RecurringLabor[]
@@ -629,11 +630,11 @@ model RecurringInvoice {
 }
 
 model RecurringPart {
-  id                 String  @id @default(cuid())
-  name               String
-  partNumber         String?
-  quantity           Int     @default(1)
-  unitPrice          Float   @default(0)
+  id         String  @id @default(cuid())
+  name       String
+  partNumber String?
+  quantity   Int     @default(1)
+  unitPrice  Float   @default(0)
 
   recurringInvoiceId String
   recurringInvoice   RecurringInvoice @relation(fields: [recurringInvoiceId], references: [id], onDelete: Cascade)
@@ -642,10 +643,10 @@ model RecurringPart {
 }
 
 model RecurringLabor {
-  id                 String  @id @default(cuid())
-  description        String
-  hours              Float   @default(0)
-  rate               Float   @default(0)
+  id          String @id @default(cuid())
+  description String
+  hours       Float  @default(0)
+  rate        Float  @default(0)
 
   recurringInvoiceId String
   recurringInvoice   RecurringInvoice @relation(fields: [recurringInvoiceId], references: [id], onDelete: Cascade)

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

@@ -452,6 +452,26 @@ export async function updateServiceStatus(recordId: string, status: string) {
   }, { requiredPermissions: [{ action: PermissionAction.UPDATE, subject: PermissionSubject.SERVICES }] });
 }
 
+export async function toggleManuallyPaid(recordId: string) {
+  return withAuth(async ({ organizationId }) => {
+    const record = await db.serviceRecord.findFirst({
+      where: { id: recordId, vehicle: { organizationId } },
+    });
+    if (!record) throw new Error("Record not found");
+
+    await db.serviceRecord.update({
+      where: { id: recordId },
+      data: { manuallyPaid: !record.manuallyPaid },
+    });
+
+    revalidatePath("/");
+    revalidatePath("/services");
+    revalidatePath(`/vehicles/${record.vehicleId}`);
+    revalidatePath(`/vehicles/${record.vehicleId}/service/${recordId}`);
+    return { success: true, manuallyPaid: !record.manuallyPaid };
+  }, { requiredPermissions: [{ action: PermissionAction.UPDATE, subject: PermissionSubject.SERVICES }] });
+}
+
 export async function getWorkOrders(params: {
   page?: number;
   pageSize?: number;

+ 21 - 18
src/features/vehicles/Components/service-detail/PaymentsSection.tsx

@@ -12,7 +12,7 @@ import {
   SelectTrigger,
   SelectValue,
 } from "@/components/ui/select";
-import { Check, CreditCard, Loader2, Plus, Trash2 } from "lucide-react";
+import { Check, CreditCard, Loader2, Plus, Trash2, X } from "lucide-react";
 import { formatCurrency } from "@/lib/format";
 import { useFormatDate } from "@/lib/use-format-date";
 import { paymentStatusColors, paymentStatusLabels } from "./types";
@@ -21,12 +21,14 @@ import type { Payment } from "./types";
 interface PaymentsSectionProps {
   payments: Payment[];
   paymentStatus: string;
+  manuallyPaid: boolean;
   totalPaid: number;
   displayTotal: number;
   balanceDue: number;
   currencyCode: string;
   onCreatePayment: (data: { amount: number; date: string; method: string; note?: string }) => Promise<boolean>;
   onDeletePayment: (id: string) => void;
+  onTogglePaid: () => void;
   paymentLoading: boolean;
   deletingPayment: string | null;
 }
@@ -34,12 +36,14 @@ interface PaymentsSectionProps {
 export function PaymentsSection({
   payments,
   paymentStatus,
+  manuallyPaid,
   totalPaid,
   displayTotal,
   balanceDue,
   currencyCode,
   onCreatePayment,
   onDeletePayment,
+  onTogglePaid,
   paymentLoading,
   deletingPayment,
 }: PaymentsSectionProps) {
@@ -71,23 +75,22 @@ export function PaymentsSection({
           </Badge>
         </div>
         <div className="flex items-center gap-2">
-          {paymentStatus !== "paid" && balanceDue > 0 && (
-            <Button
-              variant="outline"
-              size="sm"
-              className="h-7 text-xs"
-              disabled={paymentLoading}
-              onClick={() => onCreatePayment({
-                amount: balanceDue,
-                date: new Date().toISOString(),
-                method: "other",
-                note: "Marked as paid",
-              })}
-            >
-              {paymentLoading ? <Loader2 className="mr-1 h-3 w-3 animate-spin" /> : <Check className="mr-1 h-3 w-3" />}
-              Mark as Paid
-            </Button>
-          )}
+          <Button
+            variant="outline"
+            size="sm"
+            className="h-7 text-xs"
+            disabled={paymentLoading}
+            onClick={onTogglePaid}
+          >
+            {paymentLoading ? (
+              <Loader2 className="mr-1 h-3 w-3 animate-spin" />
+            ) : manuallyPaid ? (
+              <X className="mr-1 h-3 w-3" />
+            ) : (
+              <Check className="mr-1 h-3 w-3" />
+            )}
+            {manuallyPaid ? "Mark as Unpaid" : "Mark as Paid"}
+          </Button>
           <Button variant="outline" size="sm" className="h-7 text-xs" onClick={() => setShowForm(!showForm)}>
             <Plus className="mr-1 h-3 w-3" />
             Record Payment

+ 17 - 4
src/features/vehicles/Components/service-detail/ServiceDetailClient.tsx

@@ -5,7 +5,7 @@ import { useRouter } from "next/navigation";
 import { useGlassModal } from "@/components/glass-modal";
 import { useConfirm } from "@/components/confirm-dialog";
 import { toast } from "sonner";
-import { deleteServiceRecord, deleteServiceAttachment } from "@/features/vehicles/Actions/serviceActions";
+import { deleteServiceRecord, deleteServiceAttachment, toggleManuallyPaid } from "@/features/vehicles/Actions/serviceActions";
 import { createPayment, deletePayment } from "@/features/payments/Actions/paymentActions";
 import { sendInvoiceEmail } from "@/features/email/Actions/emailActions";
 import { SendEmailDialog } from "@/features/email/Components/SendEmailDialog";
@@ -55,7 +55,7 @@ export function ServiceDetailClient({
   const displayTotal = record.totalAmount > 0 ? record.totalAmount : record.cost;
   const totalPaid = record.payments?.reduce((sum, p) => sum + p.amount, 0) || 0;
   const balanceDue = displayTotal - totalPaid;
-  const paymentStatus = totalPaid === 0 ? "unpaid" : balanceDue <= 0 ? "paid" : "partial";
+  const paymentStatus = record.manuallyPaid ? "paid" : totalPaid === 0 ? "unpaid" : balanceDue <= 0 ? "paid" : "partial";
   const imageAttachments = record.attachments?.filter((a) => a.category === "image") || [];
   const vehicleName = `${record.vehicle.year} ${record.vehicle.make} ${record.vehicle.model}`;
 
@@ -111,6 +111,18 @@ export function ServiceDetailClient({
     return false;
   };
 
+  const handleTogglePaid = async () => {
+    setPaymentLoading(true);
+    const result = await toggleManuallyPaid(record.id);
+    setPaymentLoading(false);
+    if (result.success) {
+      toast.success(result.data?.manuallyPaid ? "Marked as paid" : "Marked as unpaid");
+      router.refresh();
+    } else {
+      modal.open("error", "Error", result.error || "Failed to update payment status");
+    }
+  };
+
   const handleDeletePayment = async (id: string) => {
     const ok = await confirm({
       title: "Delete Payment",
@@ -158,10 +170,11 @@ export function ServiceDetailClient({
             />
             <PaymentsSection
               payments={record.payments || []} paymentStatus={paymentStatus}
+              manuallyPaid={record.manuallyPaid}
               totalPaid={totalPaid} displayTotal={displayTotal} balanceDue={balanceDue}
               currencyCode={currencyCode} onCreatePayment={handleCreatePayment}
-              onDeletePayment={handleDeletePayment} paymentLoading={paymentLoading}
-              deletingPayment={deletingPayment}
+              onDeletePayment={handleDeletePayment} onTogglePaid={handleTogglePaid}
+              paymentLoading={paymentLoading} deletingPayment={deletingPayment}
             />
             <ServiceNotesDisplay
               invoiceNotes={record.invoiceNotes}

+ 1 - 0
src/features/vehicles/Components/service-detail/types.ts

@@ -39,6 +39,7 @@ export interface ServiceDetail {
   discountType: string | null;
   discountValue: number;
   discountAmount: number;
+  manuallyPaid: boolean;
   publicToken: string | null;
   partItems: PartItem[];
   laborItems: LaborItem[];