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

+ 4 - 4
src/app/(authenticated)/vehicles/[id]/service/[serviceId]/edit/layout.tsx

@@ -37,12 +37,12 @@ export default async function EditServiceLayout({
         <div className="mb-2 flex items-center justify-between">
           <Link
             href={`/vehicles/${id}/service/${serviceId}`}
-            className="flex items-center gap-3 text-foreground transition-colors hover:text-muted-foreground"
+            className="flex min-w-0 items-center gap-3 text-foreground transition-colors hover:text-muted-foreground"
           >
             <ArrowLeft className="h-4 w-4 shrink-0" />
-            <div>
-              <h1 className="text-lg font-semibold leading-tight">Edit Service Record</h1>
-              <p className="text-xs text-muted-foreground">{vehicleName}</p>
+            <div className="min-w-0">
+              <h1 className="truncate text-lg font-semibold leading-tight">Edit Service Record</h1>
+              <p className="truncate text-xs text-muted-foreground">{vehicleName}</p>
             </div>
           </Link>
           <EditActions serviceRecordId={serviceId} />

+ 3 - 1
src/features/inventory/Components/InventoryPartForm.tsx

@@ -16,6 +16,7 @@ import { toast } from "sonner";
 import { useGlassModal } from "@/components/glass-modal";
 import { createInventoryPart, updateInventoryPart } from "../Actions/inventoryActions";
 import { ExternalLink, ImageIcon, Loader2, Upload, X } from "lucide-react";
+import { compressImage } from "@/lib/compress-image";
 
 interface InventoryPartFormProps {
   open: boolean;
@@ -64,8 +65,9 @@ export function InventoryPartForm({ open, onOpenChange, part }: InventoryPartFor
     setUploading(true);
     const toastId = toast.loading("Uploading image...");
     try {
+      const compressed = await compressImage(file);
       const formData = new FormData();
-      formData.append("file", file);
+      formData.append("file", compressed);
       const res = await fetch("/api/upload/inventory", {
         method: "POST",
         body: formData,

+ 3 - 1
src/features/vehicles/Components/VehicleForm.tsx

@@ -36,6 +36,7 @@ import { toast } from "sonner";
 import { useGlassModal } from "@/components/glass-modal";
 import { createVehicle, updateVehicle } from "../Actions/vehicleActions";
 import { Camera, Check, ChevronsUpDown, Loader2, X } from "lucide-react";
+import { compressImage } from "@/lib/compress-image";
 import type { CreateVehicleInput } from "../Schema/vehicleSchema";
 
 interface VehicleFormProps {
@@ -108,8 +109,9 @@ export function VehicleForm({ open, onOpenChange, vehicle, customers }: VehicleF
     if (!imageFile) return preview ?? undefined;
 
     const toastId = toast.loading("Uploading image...");
+    const compressed = await compressImage(imageFile);
     const formData = new FormData();
-    formData.append("file", imageFile);
+    formData.append("file", compressed);
 
     const res = await fetch("/api/upload", { method: "POST", body: formData });
     if (!res.ok) {

+ 36 - 37
src/features/vehicles/Components/service-detail/ServiceDetailHeader.tsx

@@ -1,33 +1,25 @@
-"use client";
+'use client'
 
-import Link from "next/link";
-import { Badge } from "@/components/ui/badge";
-import { Button } from "@/components/ui/button";
-import {
-  ArrowLeft,
-  Download,
-  Globe,
-  Loader2,
-  Mail,
-  Pencil,
-  Trash2,
-} from "lucide-react";
-import { statusColors, paymentStatusColors, paymentStatusLabels } from "./types";
+import Link from 'next/link'
+import { Badge } from '@/components/ui/badge'
+import { Button } from '@/components/ui/button'
+import { ArrowLeft, Download, Globe, Loader2, Mail, Pencil, Trash2 } from 'lucide-react'
+import { statusColors, paymentStatusColors, paymentStatusLabels } from './types'
 
 interface ServiceDetailHeaderProps {
-  vehicleId: string;
-  recordId: string;
-  title: string;
-  status: string;
-  serviceDate: string;
-  shopName: string | null;
-  techName: string | null;
-  paymentStatus: string;
-  downloading: boolean;
-  onDownloadPDF: () => void;
-  onDelete: () => void;
-  onShowEmail: () => void;
-  onShowShare: () => void;
+  vehicleId: string
+  recordId: string
+  title: string
+  status: string
+  serviceDate: string
+  shopName: string | null
+  techName: string | null
+  paymentStatus: string
+  downloading: boolean
+  onDownloadPDF: () => void
+  onDelete: () => void
+  onShowEmail: () => void
+  onShowShare: () => void
 }
 
 export function ServiceDetailHeader({
@@ -47,23 +39,26 @@ export function ServiceDetailHeader({
 }: ServiceDetailHeaderProps) {
   return (
     <div className="shrink-0 border-b bg-background px-4 pt-2 pb-2">
-      <div className="flex items-center justify-between">
+      <div className="grid grid-cols-[1fr_auto] items-center gap-4">
         <Link
           href={`/vehicles/${vehicleId}`}
-          className="flex items-center gap-3 text-foreground transition-colors hover:text-muted-foreground"
+          className="flex items-center gap-3 overflow-hidden text-foreground transition-colors hover:text-muted-foreground"
         >
           <ArrowLeft className="h-4 w-4 shrink-0" />
-          <div>
+          <div className="overflow-hidden">
             <div className="flex items-center gap-2">
-              <Badge variant="outline" className={`text-xs ${paymentStatusColors[paymentStatus] || ""}`}>
-                {paymentStatusLabels[paymentStatus] || "Unpaid"}
+              <Badge
+                variant="outline"
+                className={`shrink-0 text-xs ${paymentStatusColors[paymentStatus] || ''}`}
+              >
+                {paymentStatusLabels[paymentStatus] || 'Unpaid'}
               </Badge>
-              <Badge variant="outline" className={`text-xs ${statusColors[status] || ""}`}>
+              <Badge variant="outline" className={`shrink-0 text-xs ${statusColors[status] || ''}`}>
                 {status}
               </Badge>
-              <h1 className="text-lg font-semibold leading-tight">{title}</h1>
+              <h1 className="truncate text-lg font-semibold leading-tight">{title}</h1>
             </div>
-            <p className="text-xs text-muted-foreground">
+            <p className="truncate text-xs text-muted-foreground">
               {serviceDate}
               {shopName && ` · ${shopName}`}
               {techName && ` · Tech: ${techName}`}
@@ -78,7 +73,11 @@ export function ServiceDetailHeader({
             </Link>
           </Button>
           <Button variant="outline" size="sm" onClick={onDownloadPDF} disabled={downloading}>
-            {downloading ? <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" /> : <Download className="mr-1 h-3.5 w-3.5" />}
+            {downloading ? (
+              <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
+            ) : (
+              <Download className="mr-1 h-3.5 w-3.5" />
+            )}
             PDF
           </Button>
           <Button variant="outline" size="sm" onClick={onShowEmail}>
@@ -101,5 +100,5 @@ export function ServiceDetailHeader({
         </div>
       </div>
     </div>
-  );
+  )
 }

+ 3 - 49
src/features/vehicles/Components/service-images-manager.tsx

@@ -1,12 +1,13 @@
 "use client";
 
-import { useState, useCallback, useRef } from "react";
+import { useState, useRef, useCallback } from "react";
 import { Button } from "@/components/ui/button";
 import { Input } from "@/components/ui/input";
 import { Switch } from "@/components/ui/switch";
 import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
 import { toast } from "sonner";
 import { Camera, Image as ImageIcon, Loader2, X } from "lucide-react";
+import { compressImage } from "@/lib/compress-image";
 import { addServiceAttachment } from "@/features/vehicles/Actions/addServiceAttachment";
 import { updateServiceAttachment } from "@/features/vehicles/Actions/updateServiceAttachment";
 import { deleteServiceAttachment } from "@/features/vehicles/Actions/serviceActions";
@@ -35,53 +36,6 @@ export function ServiceImagesManager({
   const [uploading, setUploading] = useState(false);
   const inputRef = useRef<HTMLInputElement>(null);
 
-  const compressImage = useCallback(
-    (file: File, maxWidth = 1920, quality = 0.8): Promise<File> => {
-      return new Promise((resolve) => {
-        if (!file.type.startsWith("image/") || file.size < 500 * 1024) {
-          resolve(file);
-          return;
-        }
-        const img = new window.Image();
-        img.onload = () => {
-          let { width, height } = img;
-          if (width > maxWidth) {
-            height = Math.round(height * (maxWidth / width));
-            width = maxWidth;
-          }
-          const canvas = document.createElement("canvas");
-          canvas.width = width;
-          canvas.height = height;
-          const ctx = canvas.getContext("2d");
-          if (!ctx) {
-            resolve(file);
-            return;
-          }
-          ctx.drawImage(img, 0, 0, width, height);
-          canvas.toBlob(
-            (blob) => {
-              if (!blob || blob.size >= file.size) {
-                resolve(file);
-                return;
-              }
-              const compressed = new File(
-                [blob],
-                file.name.replace(/\.\w+$/, ".jpg"),
-                { type: "image/jpeg" }
-              );
-              resolve(compressed);
-            },
-            "image/jpeg",
-            quality
-          );
-        };
-        img.onerror = () => resolve(file);
-        img.src = URL.createObjectURL(file);
-      });
-    },
-    []
-  );
-
   const handleUpload = useCallback(
     async (files: FileList | File[]) => {
       setUploading(true);
@@ -143,7 +97,7 @@ export function ServiceImagesManager({
       }
       setUploading(false);
     },
-    [compressImage, serviceRecordId]
+    [serviceRecordId]
   );
 
   const handleDrop = useCallback(

+ 57 - 0
src/lib/compress-image.ts

@@ -0,0 +1,57 @@
+/**
+ * Client-side image compression using canvas.
+ * Resizes to maxWidth (maintaining aspect ratio) and converts to JPEG.
+ *
+ * Defaults tuned for invoice/history usage where full-resolution originals
+ * are unnecessary — 1200px covers print-quality PDFs and on-screen viewing.
+ */
+export function compressImage(
+  file: File,
+  maxWidth = 1200,
+  quality = 0.7,
+): Promise<File> {
+  return new Promise((resolve) => {
+    if (!file.type.startsWith("image/") || file.size < 200 * 1024) {
+      resolve(file);
+      return;
+    }
+
+    const img = new window.Image();
+    img.onload = () => {
+      let { width, height } = img;
+      if (width > maxWidth) {
+        height = Math.round(height * (maxWidth / width));
+        width = maxWidth;
+      }
+
+      const canvas = document.createElement("canvas");
+      canvas.width = width;
+      canvas.height = height;
+      const ctx = canvas.getContext("2d");
+      if (!ctx) {
+        resolve(file);
+        return;
+      }
+
+      ctx.drawImage(img, 0, 0, width, height);
+      canvas.toBlob(
+        (blob) => {
+          if (!blob || blob.size >= file.size) {
+            resolve(file);
+            return;
+          }
+          const compressed = new File(
+            [blob],
+            file.name.replace(/\.\w+$/, ".jpg"),
+            { type: "image/jpeg" },
+          );
+          resolve(compressed);
+        },
+        "image/jpeg",
+        quality,
+      );
+    };
+    img.onerror = () => resolve(file);
+    img.src = URL.createObjectURL(file);
+  });
+}