Просмотр исходного кода

Open New Quote on the vehicle page with vehicle and customer prefilled (#218)

The quotes tab's New Quote button linked bare to /quotes, losing all
context. The dialog is now a shared NewQuoteDialog component that opens
in place on the vehicle page with the vehicle and customer preselected;
the quotes page uses the same component.
Bernt Christian Egeland 1 месяц назад
Родитель
Сommit
f57e14fcff

+ 3 - 100
src/app/(authenticated)/quotes/quotes-client.tsx

@@ -9,7 +9,6 @@ import { useFormatDate } from '@/lib/use-format-date'
 import { Badge } from '@/components/ui/badge'
 import { Badge } from '@/components/ui/badge'
 import { Button } from '@/components/ui/button'
 import { Button } from '@/components/ui/button'
 import { Input } from '@/components/ui/input'
 import { Input } from '@/components/ui/input'
-import { Label } from '@/components/ui/label'
 import {
 import {
   Table,
   Table,
   TableBody,
   TableBody,
@@ -18,14 +17,10 @@ import {
   TableHeader,
   TableHeader,
   TableRow,
   TableRow,
 } from '@/components/ui/table'
 } from '@/components/ui/table'
-import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
 import { DataTablePagination } from '@/components/data-table-pagination'
 import { DataTablePagination } from '@/components/data-table-pagination'
 import { Loader2, Plus, Search } from 'lucide-react'
 import { Loader2, Plus, Search } from 'lucide-react'
 import { useFormatCurrency } from '@/components/currency-settings-context'
 import { useFormatCurrency } from '@/components/currency-settings-context'
-import { toast } from 'sonner'
-import { createQuote } from '@/features/quotes/Actions/quoteActions'
-import { VehicleCombobox } from '@/features/quotes/Components/VehicleCombobox'
-import { CustomerCombobox } from '@/features/quotes/Components/CustomerCombobox'
+import { NewQuoteDialog } from '@/features/quotes/Components/NewQuoteDialog'
 
 
 interface QuoteRecord {
 interface QuoteRecord {
   id: string
   id: string
@@ -93,10 +88,6 @@ export function QuotesClient({
 
 
   // New quote dialog state
   // New quote dialog state
   const [showNewDialog, setShowNewDialog] = useState(false)
   const [showNewDialog, setShowNewDialog] = useState(false)
-  const [newTitle, setNewTitle] = useState('')
-  const [newVehicleId, setNewVehicleId] = useState('')
-  const [newCustomerId, setNewCustomerId] = useState('')
-  const [creating, setCreating] = useState(false)
 
 
   useEffect(() => {
   useEffect(() => {
     if (searchParams.get('create') === 'true') {
     if (searchParams.get('create') === 'true') {
@@ -134,40 +125,7 @@ export function QuotesClient({
     commitNow: handleSearch,
     commitNow: handleSearch,
   } = useDebouncedSearch(search, (term) => navigate({ search: term }));
   } = useDebouncedSearch(search, (term) => navigate({ search: term }));
 
 
-  const openNewDialog = () => {
-    setNewTitle('')
-    setNewVehicleId('')
-    setNewCustomerId('')
-    setShowNewDialog(true)
-  }
-
-  const handleCreateQuote = async (e: React.FormEvent) => {
-    e.preventDefault()
-    if (!newTitle.trim()) return
-    setCreating(true)
-
-    const result = await createQuote({
-      title: newTitle.trim(),
-      vehicleId: newVehicleId || undefined,
-      customerId: newCustomerId || undefined,
-      status: 'draft',
-      subtotal: 0,
-      taxRate: 0,
-      taxAmount: 0,
-      discountValue: 0,
-      discountAmount: 0,
-      totalAmount: 0,
-    })
-
-    if (result.success && result.data) {
-      toast.success(t('form.created'))
-      setShowNewDialog(false)
-      router.push(`/quotes/${result.data.id}`)
-    } else {
-      toast.error(result.error || t('form.failedCreate'))
-    }
-    setCreating(false)
-  }
+  const openNewDialog = () => setShowNewDialog(true)
 
 
   return (
   return (
     <div className="space-y-4">
     <div className="space-y-4">
@@ -275,62 +233,7 @@ export function QuotesClient({
         onNavigate={navigate}
         onNavigate={navigate}
       />
       />
 
 
-      {/* New Quote Dialog */}
-      <Dialog open={showNewDialog} onOpenChange={setShowNewDialog}>
-        <DialogContent className="sm:max-w-md" aria-describedby={undefined}>
-          <DialogHeader>
-            <DialogTitle>{t('form.newQuote')}</DialogTitle>
-          </DialogHeader>
-          <form onSubmit={handleCreateQuote} className="space-y-4">
-            <div className="space-y-2">
-              <Label htmlFor="new-quote-title">{t('details.titleLabel')}</Label>
-              <Input
-                id="new-quote-title"
-                value={newTitle}
-                onChange={(e) => setNewTitle(e.target.value)}
-                placeholder={t('details.titlePlaceholder')}
-                required
-                autoFocus
-              />
-            </div>
-
-            <div className="space-y-2">
-              <Label>{t('details.vehicle')}</Label>
-              <VehicleCombobox
-                value={newVehicleId}
-                placeholder={t('details.selectVehicle')}
-                noneLabel={t('details.none')}
-                onChange={(id, vehicle) => {
-                  setNewVehicleId(id)
-                  if (vehicle?.customerId) {
-                    setNewCustomerId(vehicle.customerId)
-                  }
-                }}
-              />
-            </div>
-
-            <div className="space-y-2">
-              <Label>{t('details.customer')}</Label>
-              <CustomerCombobox
-                value={newCustomerId}
-                placeholder={t('details.selectCustomer')}
-                noneLabel={t('details.none')}
-                onChange={(id) => setNewCustomerId(id)}
-              />
-            </div>
-
-            <div className="flex justify-end gap-3 pt-2">
-              <Button type="button" variant="outline" onClick={() => setShowNewDialog(false)}>
-                {t('form.cancel')}
-              </Button>
-              <Button type="submit" disabled={creating || !newTitle.trim()}>
-                {creating && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
-                {t('form.createQuote')}
-              </Button>
-            </div>
-          </form>
-        </DialogContent>
-      </Dialog>
+      <NewQuoteDialog open={showNewDialog} onOpenChange={setShowNewDialog} />
     </div>
     </div>
   )
   )
 }
 }

+ 23 - 5
src/app/(authenticated)/vehicles/[id]/vehicle-detail-client.tsx

@@ -69,6 +69,7 @@ import {
   X,
   X,
 } from 'lucide-react'
 } from 'lucide-react'
 import { NewInspectionDialog } from '@/features/inspections/Components/NewInspectionDialog'
 import { NewInspectionDialog } from '@/features/inspections/Components/NewInspectionDialog'
+import { NewQuoteDialog } from '@/features/quotes/Components/NewQuoteDialog'
 import {
 import {
   DropdownMenu,
   DropdownMenu,
   DropdownMenuContent,
   DropdownMenuContent,
@@ -323,6 +324,7 @@ export function VehicleDetailClient({
   const [showImage, setShowImage] = useState(false)
   const [showImage, setShowImage] = useState(false)
   const [selectedNote, setSelectedNote] = useState<PaginatedNotes['records'][number] | null>(null)
   const [selectedNote, setSelectedNote] = useState<PaginatedNotes['records'][number] | null>(null)
   const [showArchiveDialog, setShowArchiveDialog] = useState(false)
   const [showArchiveDialog, setShowArchiveDialog] = useState(false)
+  const [showNewQuoteDialog, setShowNewQuoteDialog] = useState(false)
   const [showDeleteDialog, setShowDeleteDialog] = useState(false)
   const [showDeleteDialog, setShowDeleteDialog] = useState(false)
   const [showNewInspection, setShowNewInspection] = useState(false)
   const [showNewInspection, setShowNewInspection] = useState(false)
   const [isDismissPending, startDismissTransition] = useTransition()
   const [isDismissPending, startDismissTransition] = useTransition()
@@ -1145,13 +1147,29 @@ export function VehicleDetailClient({
         {/* Quotes Tab */}
         {/* Quotes Tab */}
         <TabsContent value="quotes" className="space-y-4">
         <TabsContent value="quotes" className="space-y-4">
           <div className="flex justify-end">
           <div className="flex justify-end">
-            <Button size="sm" asChild>
-              <Link href="/quotes">
-                <Plus className="mr-1 h-3.5 w-3.5" />
-                {tq('newQuote')}
-              </Link>
+            <Button size="sm" onClick={() => setShowNewQuoteDialog(true)}>
+              <Plus className="mr-1 h-3.5 w-3.5" />
+              {tq('newQuote')}
             </Button>
             </Button>
           </div>
           </div>
+          <NewQuoteDialog
+            open={showNewQuoteDialog}
+            onOpenChange={setShowNewQuoteDialog}
+            defaultVehicle={{
+              id: vehicle.id,
+              make: vehicle.make,
+              model: vehicle.model,
+              year: vehicle.year,
+              licensePlate: vehicle.licensePlate,
+              customerId: vehicle.customerId,
+              customer: vehicle.customer ? { id: vehicle.customer.id, name: vehicle.customer.name } : null,
+            }}
+            defaultCustomer={
+              vehicle.customer
+                ? { id: vehicle.customer.id, name: vehicle.customer.name, company: vehicle.customer.company }
+                : null
+            }
+          />
 
 
           {quotes.length === 0 ? (
           {quotes.length === 0 ? (
             <Card className="border-dashed">
             <Card className="border-dashed">

+ 147 - 0
src/features/quotes/Components/NewQuoteDialog.tsx

@@ -0,0 +1,147 @@
+'use client'
+
+import { useState, useEffect } from 'react'
+import { useRouter } from 'next/navigation'
+import { useTranslations } from 'next-intl'
+import { toast } from 'sonner'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
+import { Loader2 } from 'lucide-react'
+import { createQuote } from '../Actions/quoteActions'
+import { VehicleCombobox } from './VehicleCombobox'
+import { CustomerCombobox } from './CustomerCombobox'
+
+interface VehicleOption {
+  id: string
+  make: string
+  model: string
+  year: number
+  licensePlate: string | null
+  customerId: string | null
+  customer: { id: string; name: string } | null
+}
+
+interface CustomerOption {
+  id: string
+  name: string
+  company: string | null
+}
+
+export function NewQuoteDialog({
+  open,
+  onOpenChange,
+  defaultVehicle = null,
+  defaultCustomer = null,
+}: {
+  open: boolean
+  onOpenChange: (open: boolean) => void
+  /** Preselects the vehicle (and its label) when opening, e.g. from a vehicle page */
+  defaultVehicle?: VehicleOption | null
+  defaultCustomer?: CustomerOption | null
+}) {
+  const router = useRouter()
+  const t = useTranslations('quotes')
+  const [title, setTitle] = useState('')
+  const [vehicleId, setVehicleId] = useState(defaultVehicle?.id ?? '')
+  const [customerId, setCustomerId] = useState(defaultCustomer?.id ?? '')
+  const [creating, setCreating] = useState(false)
+
+  // Re-seed on every open so a previous session's selections don't leak in
+  useEffect(() => {
+    if (open) {
+      setTitle('')
+      setVehicleId(defaultVehicle?.id ?? '')
+      setCustomerId(defaultCustomer?.id ?? '')
+    }
+  }, [open, defaultVehicle?.id, defaultCustomer?.id])
+
+  const handleCreate = async (e: React.FormEvent) => {
+    e.preventDefault()
+    if (!title.trim()) return
+    setCreating(true)
+
+    const result = await createQuote({
+      title: title.trim(),
+      vehicleId: vehicleId || undefined,
+      customerId: customerId || undefined,
+      status: 'draft',
+      subtotal: 0,
+      taxRate: 0,
+      taxAmount: 0,
+      discountValue: 0,
+      discountAmount: 0,
+      totalAmount: 0,
+    })
+
+    if (result.success && result.data) {
+      toast.success(t('form.created'))
+      onOpenChange(false)
+      router.push(`/quotes/${result.data.id}`)
+    } else {
+      toast.error(result.error || t('form.failedCreate'))
+    }
+    setCreating(false)
+  }
+
+  return (
+    <Dialog open={open} onOpenChange={onOpenChange}>
+      <DialogContent className="sm:max-w-md" aria-describedby={undefined}>
+        <DialogHeader>
+          <DialogTitle>{t('form.newQuote')}</DialogTitle>
+        </DialogHeader>
+        <form onSubmit={handleCreate} className="space-y-4">
+          <div className="space-y-2">
+            <Label htmlFor="new-quote-title">{t('details.titleLabel')}</Label>
+            <Input
+              id="new-quote-title"
+              value={title}
+              onChange={(e) => setTitle(e.target.value)}
+              placeholder={t('details.titlePlaceholder')}
+              required
+              autoFocus
+            />
+          </div>
+
+          <div className="space-y-2">
+            <Label>{t('details.vehicle')}</Label>
+            <VehicleCombobox
+              value={vehicleId}
+              initialVehicle={vehicleId === defaultVehicle?.id ? defaultVehicle : null}
+              placeholder={t('details.selectVehicle')}
+              noneLabel={t('details.none')}
+              onChange={(id, vehicle) => {
+                setVehicleId(id)
+                if (vehicle?.customerId) {
+                  setCustomerId(vehicle.customerId)
+                }
+              }}
+            />
+          </div>
+
+          <div className="space-y-2">
+            <Label>{t('details.customer')}</Label>
+            <CustomerCombobox
+              value={customerId}
+              initialCustomer={customerId === defaultCustomer?.id ? defaultCustomer : null}
+              placeholder={t('details.selectCustomer')}
+              noneLabel={t('details.none')}
+              onChange={(id) => setCustomerId(id)}
+            />
+          </div>
+
+          <div className="flex justify-end gap-3 pt-2">
+            <Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
+              {t('form.cancel')}
+            </Button>
+            <Button type="submit" disabled={creating || !title.trim()}>
+              {creating && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
+              {t('form.createQuote')}
+            </Button>
+          </div>
+        </form>
+      </DialogContent>
+    </Dialog>
+  )
+}