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

Put customer above vehicle in quote forms and filter vehicles by customer (#230)

* Put customer above vehicle in quote forms and filter vehicles by customer

* Auto-select the vehicle when the chosen customer has exactly one
Bernt Christian Egeland 1 месяц назад
Родитель
Сommit
3ca55d0d1a

+ 22 - 11
src/features/quotes/Components/NewQuoteDialog.tsx

@@ -45,6 +45,7 @@ export function NewQuoteDialog({
   const t = useTranslations('quotes')
   const [title, setTitle] = useState('')
   const [vehicleId, setVehicleId] = useState(defaultVehicle?.id ?? '')
+  const [vehicleCustomerId, setVehicleCustomerId] = useState(defaultVehicle?.customerId ?? null)
   const [customerId, setCustomerId] = useState(defaultCustomer?.id ?? '')
   const [creating, setCreating] = useState(false)
 
@@ -53,6 +54,7 @@ export function NewQuoteDialog({
     if (open) {
       setTitle('')
       setVehicleId(defaultVehicle?.id ?? '')
+      setVehicleCustomerId(defaultVehicle?.customerId ?? null)
       setCustomerId(defaultCustomer?.id ?? '')
     }
   }, [open, defaultVehicle?.id, defaultCustomer?.id])
@@ -104,15 +106,35 @@ export function NewQuoteDialog({
             />
           </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)
+                // A vehicle belonging to another customer no longer fits
+                if (id && vehicleId && vehicleCustomerId !== id) {
+                  setVehicleId('')
+                  setVehicleCustomerId(null)
+                }
+              }}
+            />
+          </div>
+
           <div className="space-y-2">
             <Label>{t('details.vehicle')}</Label>
             <VehicleCombobox
               value={vehicleId}
+              customerId={customerId || undefined}
               initialVehicle={vehicleId === defaultVehicle?.id ? defaultVehicle : null}
               placeholder={t('details.selectVehicle')}
               noneLabel={t('details.none')}
               onChange={(id, vehicle) => {
                 setVehicleId(id)
+                setVehicleCustomerId(vehicle?.customerId ?? null)
                 if (vehicle?.customerId) {
                   setCustomerId(vehicle.customerId)
                 }
@@ -120,17 +142,6 @@ export function NewQuoteDialog({
             />
           </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')}

+ 51 - 45
src/features/quotes/Components/QuoteRightColumn.tsx

@@ -83,10 +83,61 @@ export const QuoteRightColumn = memo(function QuoteRightColumn({
 
       {/* Vehicle & Customer */}
       <div className="rounded-lg border p-3 space-y-3">
+        <div className="space-y-1">
+          <Label className="text-xs">{t('details.customer')}</Label>
+          <CustomerCombobox
+            value={state.customerId}
+            initialCustomer={state.selectedCustomer}
+            placeholder={t('details.selectCustomer')}
+            noneLabel={t('details.none')}
+            onChange={(id, customer) => {
+              state.setCustomerId(id)
+              state.setSelectedCustomer(customer)
+              // A vehicle belonging to another customer no longer fits
+              if (id && state.selectedVehicle && state.selectedVehicle.customerId !== id) {
+                state.setVehicleId('')
+                state.setSelectedVehicle(null)
+              }
+              state.markDirty()
+            }}
+          />
+        </div>
+        {state.selectedCustomer && (
+          <div className="flex items-center gap-2 rounded-md bg-muted/50 px-3 py-2">
+            <Users className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
+            <Link
+              href={`/customers/${state.selectedCustomer.id}`}
+              target="_blank"
+              className="min-w-0 flex-1 text-sm hover:underline"
+            >
+              <span className="font-medium">{state.selectedCustomer.name}</span>
+              {state.selectedCustomer.company && (
+                <span className="ml-1.5 text-muted-foreground">
+                  {state.selectedCustomer.company}
+                </span>
+              )}
+            </Link>
+            <Button
+              type="button"
+              variant="ghost"
+              size="icon"
+              className="h-6 w-6 shrink-0 text-muted-foreground hover:text-foreground"
+              onClick={() => {
+                state.setCustomerId('')
+                state.setSelectedCustomer(null)
+                state.markDirty()
+              }}
+              aria-label={t('details.clearCustomer')}
+            >
+              <X className="h-3 w-3" />
+            </Button>
+          </div>
+        )}
         <div className="space-y-1">
           <Label className="text-xs">{t('details.vehicle')}</Label>
           <VehicleCombobox
             value={state.vehicleId}
+            customerId={state.customerId || undefined}
             initialVehicle={state.selectedVehicle}
             placeholder={t('details.selectVehicle')}
             noneLabel={t('details.none')}
@@ -141,51 +192,6 @@ export const QuoteRightColumn = memo(function QuoteRightColumn({
             </Button>
           </div>
         )}
-        <div className="space-y-1">
-          <Label className="text-xs">{t('details.customer')}</Label>
-          <CustomerCombobox
-            value={state.customerId}
-            initialCustomer={state.selectedCustomer}
-            placeholder={t('details.selectCustomer')}
-            noneLabel={t('details.none')}
-            onChange={(id, customer) => {
-              state.setCustomerId(id)
-              state.setSelectedCustomer(customer)
-              state.markDirty()
-            }}
-          />
-        </div>
-        {state.selectedCustomer && (
-          <div className="flex items-center gap-2 rounded-md bg-muted/50 px-3 py-2">
-            <Users className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
-            <Link
-              href={`/customers/${state.selectedCustomer.id}`}
-              target="_blank"
-              className="min-w-0 flex-1 text-sm hover:underline"
-            >
-              <span className="font-medium">{state.selectedCustomer.name}</span>
-              {state.selectedCustomer.company && (
-                <span className="ml-1.5 text-muted-foreground">
-                  {state.selectedCustomer.company}
-                </span>
-              )}
-            </Link>
-            <Button
-              type="button"
-              variant="ghost"
-              size="icon"
-              className="h-6 w-6 shrink-0 text-muted-foreground hover:text-foreground"
-              onClick={() => {
-                state.setCustomerId('')
-                state.setSelectedCustomer(null)
-                state.markDirty()
-              }}
-              aria-label={t('details.clearCustomer')}
-            >
-              <X className="h-3 w-3" />
-            </Button>
-          </div>
-        )}
       </div>
 
       {/* Quote Details */}

+ 32 - 6
src/features/quotes/Components/VehicleCombobox.tsx

@@ -37,6 +37,8 @@ interface VehicleComboboxProps {
   initialVehicle?: VehicleOption | null;
   placeholder?: string;
   noneLabel?: string;
+  /** When set, only this customer's vehicles are offered */
+  customerId?: string;
 }
 
 function formatVehicle(v: VehicleOption) {
@@ -49,6 +51,7 @@ export function VehicleCombobox({
   initialVehicle,
   placeholder = "Select vehicle...",
   noneLabel = "None",
+  customerId,
 }: VehicleComboboxProps) {
   const [open, setOpen] = useState(false);
   const [search, setSearch] = useState("");
@@ -62,6 +65,10 @@ export function VehicleCombobox({
   const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
   const listRef = useRef<HTMLDivElement | null>(null);
   const loadingMoreRef = useRef(false);
+  const valueRef = useRef(value);
+  valueRef.current = value;
+  const onChangeRef = useRef(onChange);
+  onChangeRef.current = onChange;
 
   const mapVehicle = (v: VehicleOption) => ({
     id: v.id,
@@ -81,10 +88,11 @@ export function VehicleCombobox({
         loadingMoreRef.current = true;
       }
 
-      const result = await searchVehicles(query || undefined, PAGE_SIZE, offset);
+      const result = await searchVehicles(query || undefined, PAGE_SIZE, offset, customerId || undefined);
 
+      let mapped: VehicleOption[] = [];
       if (result.success && result.data) {
-        const mapped = result.data.map(mapVehicle);
+        mapped = result.data.map(mapVehicle);
         if (append) {
           setOptions((prev) => [...prev, ...mapped]);
         } else {
@@ -96,14 +104,27 @@ export function VehicleCombobox({
       setSearching(false);
       setLoadingMore(false);
       loadingMoreRef.current = false;
+      return mapped;
     },
-    []
+    [customerId]
   );
 
-  // Prefetch on mount
+  // Prefetch on mount and whenever the customer filter changes. When a
+  // customer filter is active and they own exactly one vehicle, select it
+  // automatically so the user doesn't have to.
   useEffect(() => {
-    loadOptions();
-  }, [loadOptions]);
+    let cancelled = false;
+    loadOptions().then((opts) => {
+      if (cancelled || !customerId) return;
+      if (opts.length === 1 && !valueRef.current) {
+        setSelected(opts[0]);
+        onChangeRef.current(opts[0].id, opts[0]);
+      }
+    });
+    return () => {
+      cancelled = true;
+    };
+  }, [loadOptions, customerId]);
 
   // Debounced search — resets to page 0
   useEffect(() => {
@@ -125,6 +146,11 @@ export function VehicleCombobox({
     if (initialVehicle) setSelected(initialVehicle);
   }, [initialVehicle]);
 
+  // Parent can clear the selection (e.g. customer changed); drop the label too
+  useEffect(() => {
+    if (!value) setSelected(null);
+  }, [value]);
+
   // Infinite scroll
   const handleScroll = useCallback(() => {
     const el = listRef.current;

+ 2 - 1
src/features/vehicles/Actions/vehicleActions.ts

@@ -289,10 +289,11 @@ export async function deleteVehicle(vehicleId: string) {
   });
 }
 
-export async function searchVehicles(search?: string, limit = 20, offset = 0) {
+export async function searchVehicles(search?: string, limit = 20, offset = 0, customerId?: string) {
   return withAuth(async ({ organizationId }) => {
     // eslint-disable-next-line @typescript-eslint/no-explicit-any
     const where: any = { organizationId, isArchived: false };
+    if (customerId) where.customerId = customerId;
     if (search) {
       const words = search.trim().split(/\s+/).filter(Boolean);
       const fieldMatch = (word: string) => {