Browse Source

dragable section

Bernt Christian Egeland 7 months ago
parent
commit
aadfb0fbd3

+ 11 - 0
package-lock.json

@@ -38,6 +38,7 @@
         "react-day-picker": "^9.13.2",
         "react-dom": "19.2.3",
         "react-hook-form": "^7.71.1",
+        "react-resizable-panels": "^4.6.4",
         "recharts": "^3.7.0",
         "resend": "^6.9.2",
         "sonner": "^2.0.7",
@@ -13498,6 +13499,16 @@
         }
       }
     },
+    "node_modules/react-resizable-panels": {
+      "version": "4.6.4",
+      "resolved": "https://registry.npmjs.org/react-resizable-panels/-/react-resizable-panels-4.6.4.tgz",
+      "integrity": "sha512-E7Szs1xyaMZ7xOI2gG4TECNz4r/gmpV1AsXyZRnER6OQnfFf9uclFmrHHZR3h/iF8vQS+nQ1LKyZv9bzwGxPSg==",
+      "license": "MIT",
+      "peerDependencies": {
+        "react": "^18.0.0 || ^19.0.0",
+        "react-dom": "^18.0.0 || ^19.0.0"
+      }
+    },
     "node_modules/react-style-singleton": {
       "version": "2.2.3",
       "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",

+ 2 - 1
package.json

@@ -43,6 +43,7 @@
     "react-day-picker": "^9.13.2",
     "react-dom": "19.2.3",
     "react-hook-form": "^7.71.1",
+    "react-resizable-panels": "^4.6.4",
     "recharts": "^3.7.0",
     "resend": "^6.9.2",
     "sonner": "^2.0.7",
@@ -67,4 +68,4 @@
     "tw-animate-css": "^1.4.0",
     "typescript": "^5"
   }
-}
+}

+ 53 - 0
src/components/ui/resizable.tsx

@@ -0,0 +1,53 @@
+"use client"
+
+import { GripVerticalIcon } from "lucide-react"
+import * as ResizablePrimitive from "react-resizable-panels"
+
+import { cn } from "@/lib/utils"
+
+function ResizablePanelGroup({
+  className,
+  ...props
+}: ResizablePrimitive.GroupProps) {
+  return (
+    <ResizablePrimitive.Group
+      data-slot="resizable-panel-group"
+      className={cn(
+        "flex h-full w-full aria-[orientation=vertical]:flex-col",
+        className
+      )}
+      {...props}
+    />
+  )
+}
+
+function ResizablePanel({ ...props }: ResizablePrimitive.PanelProps) {
+  return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />
+}
+
+function ResizableHandle({
+  withHandle,
+  className,
+  ...props
+}: ResizablePrimitive.SeparatorProps & {
+  withHandle?: boolean
+}) {
+  return (
+    <ResizablePrimitive.Separator
+      data-slot="resizable-handle"
+      className={cn(
+        "bg-border focus-visible:ring-ring relative flex w-px items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden aria-[orientation=horizontal]:h-px aria-[orientation=horizontal]:w-full aria-[orientation=horizontal]:after:left-0 aria-[orientation=horizontal]:after:h-1 aria-[orientation=horizontal]:after:w-full aria-[orientation=horizontal]:after:translate-x-0 aria-[orientation=horizontal]:after:-translate-y-1/2 [&[aria-orientation=horizontal]>div]:rotate-90",
+        className
+      )}
+      {...props}
+    >
+      {withHandle && (
+        <div className="bg-border z-10 flex h-4 w-3 items-center justify-center rounded-xs border">
+          <GripVerticalIcon className="size-2.5" />
+        </div>
+      )}
+    </ResizablePrimitive.Separator>
+  )
+}
+
+export { ResizableHandle, ResizablePanel, ResizablePanelGroup }

+ 11 - 8
src/features/vehicles/Components/service-detail/ServiceDetailClient.tsx

@@ -20,6 +20,7 @@ import { ServiceSidebar } from "./ServiceSidebar";
 import { ServiceAttachments } from "./ServiceAttachments";
 import { ImageCarousel } from "./ImageCarousel";
 import { ShareDialog } from "./ShareDialog";
+import { ServiceDetailContent } from "./ServiceDetailContent";
 
 export function ServiceDetailClient({
   record,
@@ -132,9 +133,9 @@ export function ServiceDetailClient({
         downloading={downloading} onDownloadPDF={handleDownloadPDF} onDelete={handleDelete}
         onShowEmail={() => setShowEmailDialog(true)} onShowShare={() => setShowShareDialog(true)}
       />
-      <div className="flex-1 overflow-y-auto p-4">
-        <div className="grid grid-cols-1 gap-4 lg:grid-cols-[1fr_340px]">
-          <div className="space-y-3">
+      <ServiceDetailContent
+        leftColumn={
+          <>
             <PartsTable parts={record.partItems} partsSubtotal={partsSubtotal} currencyCode={currencyCode} />
             <LaborTable laborItems={record.laborItems} laborSubtotal={laborSubtotal} currencyCode={currencyCode} />
             {record.partItems.length === 0 && record.laborItems.length === 0 && (record.parts || record.laborHours) && (
@@ -161,8 +162,10 @@ export function ServiceDetailClient({
               onDeletePayment={handleDeletePayment} paymentLoading={paymentLoading}
               deletingPayment={deletingPayment}
             />
-          </div>
-          <div className="space-y-3">
+          </>
+        }
+        rightColumn={
+          <>
             <ServiceSidebar
               recordId={record.id} vehicle={record.vehicle} vehicleName={vehicleName}
               distUnit={distUnit} mileage={record.mileage} invoiceNotes={record.invoiceNotes}
@@ -173,9 +176,9 @@ export function ServiceDetailClient({
               onImageClick={setCarouselIndex} onDeleteAttachment={handleDeleteAttachment}
               deletingAttachment={deletingAttachment}
             />
-          </div>
-        </div>
-      </div>
+          </>
+        }
+      />
       <ImageCarousel
         images={imageAttachments} currentIndex={carouselIndex}
         onClose={() => setCarouselIndex(null)} onChangeIndex={setCarouselIndex}

+ 54 - 0
src/features/vehicles/Components/service-detail/ServiceDetailContent.tsx

@@ -0,0 +1,54 @@
+'use client'
+
+import { useEffect, useState } from 'react'
+import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from '@/components/ui/resizable'
+
+const LG_BREAKPOINT = 1024
+
+function useIsLargeScreen() {
+  const [isLarge, setIsLarge] = useState(false)
+  useEffect(() => {
+    const mql = window.matchMedia(`(min-width: ${LG_BREAKPOINT}px)`)
+    const onChange = () => setIsLarge(mql.matches)
+    mql.addEventListener('change', onChange)
+    setIsLarge(mql.matches)
+    return () => mql.removeEventListener('change', onChange)
+  }, [])
+  return isLarge
+}
+
+interface ServiceDetailContentProps {
+  leftColumn: React.ReactNode
+  rightColumn: React.ReactNode
+}
+
+export function ServiceDetailContent({ leftColumn, rightColumn }: ServiceDetailContentProps) {
+  const isLarge = useIsLargeScreen()
+
+  if (!isLarge) {
+    return (
+      <div className="flex-1 overflow-y-auto p-4">
+        <div className="space-y-3 pb-40">
+          {leftColumn}
+          {rightColumn}
+        </div>
+      </div>
+    )
+  }
+
+  return (
+    <ResizablePanelGroup orientation="horizontal" className="flex-1 overflow-hidden">
+      <ResizablePanel defaultSize={75} minSize={40}>
+        <div className="h-full overflow-y-auto p-4 pr-2">
+          <div className="space-y-3 pb-40">{leftColumn}</div>
+        </div>
+      </ResizablePanel>
+      <ResizableHandle withHandle />
+      <ResizablePanel defaultSize={25} minSize={15}>
+        <div className="h-full overflow-y-auto p-4 pl-2">
+          <div className="space-y-3 pb-40">{rightColumn}</div>
+        </div>
+      </ResizablePanel>
+    </ResizablePanelGroup>
+  )
+}