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

+ 183 - 2
src/app/(authenticated)/settings/account/account-settings.tsx

@@ -17,14 +17,29 @@ import { Input } from '@/components/ui/input'
 import { Label } from '@/components/ui/label'
 import { Button } from '@/components/ui/button'
 import { Separator } from '@/components/ui/separator'
+import { Checkbox } from '@/components/ui/checkbox'
 import { AlertTriangle, Check, Copy, KeyRound, Loader2, Save, Shield, ShieldOff, Trash2, User } from 'lucide-react'
 import { QRCodeSVG } from 'qrcode.react'
 import { updateEmail } from '@/features/settings/Actions/accountActions'
 import { deleteAccount } from '@/features/settings/Actions/deleteAccount'
+import { deleteContent } from '@/features/settings/Actions/deleteContent'
 import { signOut } from '@/lib/auth-client'
 import { useRouter } from 'next/navigation'
 
-export function AccountSettings({ twoFactorEnabled: initialTwoFactorEnabled }: { twoFactorEnabled: boolean }) {
+interface ContentCounts {
+  vehicles: number
+  customers: number
+  quotes: number
+  inventory: number
+}
+
+export function AccountSettings({
+  twoFactorEnabled: initialTwoFactorEnabled,
+  contentCounts,
+}: {
+  twoFactorEnabled: boolean
+  contentCounts: ContentCounts
+}) {
   const { data: session } = useSession()
   const router = useRouter()
   const [name, setName] = useState('')
@@ -60,6 +75,17 @@ export function AccountSettings({ twoFactorEnabled: initialTwoFactorEnabled }: {
   const [deleteConfirmText, setDeleteConfirmText] = useState('')
   const [deleting, setDeleting] = useState(false)
 
+  // Delete content state
+  const [contentDialogOpen, setContentDialogOpen] = useState(false)
+  const [contentConfirmText, setContentConfirmText] = useState('')
+  const [deletingContent, setDeletingContent] = useState(false)
+  const [contentSelections, setContentSelections] = useState({
+    vehicles: false,
+    customers: false,
+    quotes: false,
+    inventory: false,
+  })
+
   const handleDeleteAccount = async () => {
     if (deleteConfirmText !== 'delete me') return
     setDeleting(true)
@@ -78,6 +104,29 @@ export function AccountSettings({ twoFactorEnabled: initialTwoFactorEnabled }: {
     }
   }
 
+  const selectedContentCount = Object.values(contentSelections).filter(Boolean).length
+
+  const handleDeleteContent = async () => {
+    if (contentConfirmText !== 'delete my data') return
+    if (selectedContentCount === 0) return
+    setDeletingContent(true)
+    try {
+      const result = await deleteContent(contentSelections)
+      if (result.success) {
+        toast.success('Selected content has been permanently deleted')
+        setContentDialogOpen(false)
+        setContentConfirmText('')
+        setContentSelections({ vehicles: false, customers: false, quotes: false, inventory: false })
+        router.refresh()
+      } else {
+        toast.error(result.error || 'Failed to delete content')
+      }
+    } catch {
+      toast.error('Failed to delete content')
+    }
+    setDeletingContent(false)
+  }
+
   const handleEnable2FA = async () => {
     if (!twoFactorPassword) {
       toast.error('Please enter your password')
@@ -546,7 +595,33 @@ export function AccountSettings({ twoFactorEnabled: initialTwoFactorEnabled }: {
           <AlertTriangle className="h-5 w-5 text-destructive" />
           <CardTitle className="text-lg text-destructive">Danger Zone</CardTitle>
         </CardHeader>
-        <CardContent className="space-y-4">
+        <CardContent className="space-y-6">
+          {/* Delete Content */}
+          <div className="flex items-start justify-between gap-4">
+            <div>
+              <p className="font-medium">Delete Content</p>
+              <p className="text-sm text-muted-foreground">
+                Selectively delete organization data such as vehicles, customers, quotes, or inventory.
+                Your account and settings will remain intact.
+              </p>
+            </div>
+            <Button
+              variant="outline"
+              className="border-destructive/30 text-destructive hover:bg-destructive/10 hover:text-destructive"
+              onClick={() => {
+                setContentConfirmText('')
+                setContentSelections({ vehicles: false, customers: false, quotes: false, inventory: false })
+                setContentDialogOpen(true)
+              }}
+            >
+              <Trash2 className="mr-2 h-4 w-4" />
+              Delete Content
+            </Button>
+          </div>
+
+          <Separator />
+
+          {/* Delete Account */}
           <div className="flex items-start justify-between gap-4">
             <div>
               <p className="font-medium">Delete Account</p>
@@ -609,6 +684,112 @@ export function AccountSettings({ twoFactorEnabled: initialTwoFactorEnabled }: {
           </DialogFooter>
         </DialogContent>
       </Dialog>
+
+      {/* Delete Content Confirmation Dialog */}
+      <Dialog open={contentDialogOpen} onOpenChange={setContentDialogOpen}>
+        <DialogContent className="sm:max-w-md">
+          <DialogHeader>
+            <DialogTitle className="text-destructive">Delete Content</DialogTitle>
+            <DialogDescription>
+              Select the content you want to permanently delete. This action cannot be undone.
+            </DialogDescription>
+          </DialogHeader>
+          <div className="space-y-4 py-2">
+            <div className="space-y-3">
+              {([
+                {
+                  key: 'vehicles' as const,
+                  label: 'Vehicles',
+                  count: contentCounts.vehicles,
+                  description: 'All vehicles and their service records, notes, reminders, fuel logs, and recurring invoices',
+                },
+                {
+                  key: 'customers' as const,
+                  label: 'Customers',
+                  count: contentCounts.customers,
+                  description: 'All customer records. Vehicles linked to deleted customers will be unlinked.',
+                },
+                {
+                  key: 'quotes' as const,
+                  label: 'Quotes',
+                  count: contentCounts.quotes,
+                  description: 'All quotes and their line items',
+                },
+                {
+                  key: 'inventory' as const,
+                  label: 'Inventory',
+                  count: contentCounts.inventory,
+                  description: 'All inventory parts and their images',
+                },
+              ]).map((item) => (
+                <label
+                  key={item.key}
+                  className={`flex items-start gap-3 rounded-lg border p-3 cursor-pointer transition-colors ${
+                    contentSelections[item.key]
+                      ? 'border-destructive/50 bg-destructive/5'
+                      : 'hover:bg-muted/50'
+                  } ${item.count === 0 ? 'opacity-50 cursor-not-allowed' : ''}`}
+                >
+                  <Checkbox
+                    checked={contentSelections[item.key]}
+                    disabled={item.count === 0}
+                    onCheckedChange={(checked) =>
+                      setContentSelections((prev) => ({ ...prev, [item.key]: checked === true }))
+                    }
+                    className="mt-0.5"
+                  />
+                  <div className="flex-1 min-w-0">
+                    <div className="flex items-center gap-2">
+                      <span className="text-sm font-medium">{item.label}</span>
+                      <span className="text-xs text-muted-foreground font-mono">
+                        {item.count}
+                      </span>
+                    </div>
+                    <p className="text-xs text-muted-foreground mt-0.5">{item.description}</p>
+                  </div>
+                </label>
+              ))}
+            </div>
+
+            {selectedContentCount > 0 && (
+              <>
+                <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-3">
+                  <p className="text-sm font-medium text-destructive">
+                    Type <span className="font-mono font-bold">delete my data</span> to confirm
+                  </p>
+                </div>
+                <Input
+                  value={contentConfirmText}
+                  onChange={(e) => setContentConfirmText(e.target.value)}
+                  placeholder="delete my data"
+                  autoComplete="off"
+                />
+              </>
+            )}
+          </div>
+          <DialogFooter>
+            <Button variant="outline" onClick={() => setContentDialogOpen(false)} disabled={deletingContent}>
+              Cancel
+            </Button>
+            <Button
+              variant="destructive"
+              onClick={handleDeleteContent}
+              disabled={
+                selectedContentCount === 0 ||
+                contentConfirmText !== 'delete my data' ||
+                deletingContent
+              }
+            >
+              {deletingContent ? (
+                <Loader2 className="mr-2 h-4 w-4 animate-spin" />
+              ) : (
+                <Trash2 className="mr-2 h-4 w-4" />
+              )}
+              {deletingContent ? 'Deleting...' : `Delete ${selectedContentCount} selected`}
+            </Button>
+          </DialogFooter>
+        </DialogContent>
+      </Dialog>
     </div>
   )
 }

+ 18 - 5
src/app/(authenticated)/settings/account/page.tsx

@@ -3,15 +3,28 @@ import { auth } from "@/lib/auth";
 import { db } from "@/lib/db";
 import { redirect } from "next/navigation";
 import { AccountSettings } from "./account-settings";
+import { getContentCounts } from "@/features/settings/Actions/deleteContent";
 
 export default async function AccountSettingsPage() {
   const session = await auth.api.getSession({ headers: await headers() });
   if (!session) redirect("/auth/sign-in");
 
-  const user = await db.user.findUnique({
-    where: { id: session.user.id },
-    select: { twoFactorEnabled: true },
-  });
+  const [user, countsResult] = await Promise.all([
+    db.user.findUnique({
+      where: { id: session.user.id },
+      select: { twoFactorEnabled: true },
+    }),
+    getContentCounts(),
+  ]);
 
-  return <AccountSettings twoFactorEnabled={user?.twoFactorEnabled ?? false} />;
+  const contentCounts = countsResult.success && countsResult.data
+    ? countsResult.data
+    : { vehicles: 0, customers: 0, quotes: 0, inventory: 0 };
+
+  return (
+    <AccountSettings
+      twoFactorEnabled={user?.twoFactorEnabled ?? false}
+      contentCounts={contentCounts}
+    />
+  );
 }

+ 123 - 0
src/features/settings/Actions/deleteContent.ts

@@ -0,0 +1,123 @@
+"use server";
+
+import { db } from "@/lib/db";
+import { withAuth } from "@/lib/with-auth";
+import { resolveUploadPath } from "@/lib/resolve-upload-path";
+import { unlink } from "fs/promises";
+import { revalidatePath } from "next/cache";
+import { z } from "zod";
+
+const deleteContentSchema = z.object({
+  vehicles: z.boolean().default(false),
+  customers: z.boolean().default(false),
+  quotes: z.boolean().default(false),
+  inventory: z.boolean().default(false),
+});
+
+export async function deleteContent(input: unknown) {
+  return withAuth(async ({ organizationId }) => {
+    if (!organizationId) {
+      throw new Error("No organization found");
+    }
+
+    const selections = deleteContentSchema.parse(input);
+
+    if (!selections.vehicles && !selections.customers && !selections.quotes && !selections.inventory) {
+      throw new Error("No content types selected");
+    }
+
+    const deleted: string[] = [];
+    const filesToClean: string[] = [];
+
+    // --- Vehicles ---
+    // Cascades: ServiceRecord (-> PartItem, LaborItem, ServiceAttachment, Payment),
+    //           Note, FuelLog, Reminder, Quote (vehicle-linked), RecurringInvoice
+    if (selections.vehicles) {
+      // Collect files to clean up
+      const vehicleImages = await db.vehicle.findMany({
+        where: { organizationId },
+        select: { imageUrl: true },
+      });
+      for (const v of vehicleImages) {
+        if (v.imageUrl) filesToClean.push(resolveUploadPath(v.imageUrl));
+      }
+
+      const attachments = await db.serviceAttachment.findMany({
+        where: { serviceRecord: { vehicle: { organizationId } } },
+        select: { fileUrl: true },
+      });
+      for (const att of attachments) {
+        filesToClean.push(resolveUploadPath(att.fileUrl));
+      }
+
+      await db.vehicle.deleteMany({ where: { organizationId } });
+      deleted.push("vehicles");
+    }
+
+    // --- Quotes ---
+    // Cascades: QuotePart, QuoteLabor
+    // If vehicles were already deleted, vehicle-linked quotes are gone via cascade.
+    // This handles org-level quotes (and any remaining if vehicles weren't deleted).
+    if (selections.quotes) {
+      await db.quote.deleteMany({ where: { organizationId } });
+      deleted.push("quotes");
+    }
+
+    // --- Customers ---
+    // Vehicles with this customer get customerId set to null (SetNull).
+    // Quotes with this customer get customerId set to null (SetNull).
+    if (selections.customers) {
+      await db.customer.deleteMany({ where: { organizationId } });
+      deleted.push("customers");
+    }
+
+    // --- Inventory ---
+    if (selections.inventory) {
+      const parts = await db.inventoryPart.findMany({
+        where: { organizationId },
+        select: { imageUrl: true },
+      });
+      for (const part of parts) {
+        if (part.imageUrl) filesToClean.push(resolveUploadPath(part.imageUrl));
+      }
+
+      await db.inventoryPart.deleteMany({ where: { organizationId } });
+      deleted.push("inventory");
+    }
+
+    // Clean up files from disk (best effort)
+    for (const filePath of filesToClean) {
+      try {
+        await unlink(filePath);
+      } catch {
+        // File may already be missing
+      }
+    }
+
+    revalidatePath("/");
+    revalidatePath("/vehicles");
+    revalidatePath("/customers");
+    revalidatePath("/quotes");
+    revalidatePath("/inventory");
+    revalidatePath("/settings/account");
+
+    return { deleted };
+  });
+}
+
+export async function getContentCounts() {
+  return withAuth(async ({ organizationId }) => {
+    if (!organizationId) {
+      return { vehicles: 0, customers: 0, quotes: 0, inventory: 0 };
+    }
+
+    const [vehicles, customers, quotes, inventory] = await Promise.all([
+      db.vehicle.count({ where: { organizationId } }),
+      db.customer.count({ where: { organizationId } }),
+      db.quote.count({ where: { organizationId } }),
+      db.inventoryPart.count({ where: { organizationId } }),
+    ]);
+
+    return { vehicles, customers, quotes, inventory };
+  });
+}