Browse Source

Add video upload to My Active Jobs dashboard (#100)

* workorder dates

* service details

* added set paid button in sidebar

* cleanup

* video upload

* lint
Bernt Christian Egeland 6 months ago
parent
commit
251fb3a22e

+ 1 - 0
.devcontainer/Dockerfile

@@ -3,6 +3,7 @@ FROM node:20-bookworm
 RUN apt-get update && apt-get install -y \
     git \
     curl \
+    ffmpeg \
     && rm -rf /var/lib/apt/lists/*
 
 # Set up non-root user

+ 1 - 1
Dockerfile

@@ -39,7 +39,7 @@ COPY --from=builder --chown=nextjs:nodejs /app/package.json ./package.json
 RUN npm install @prisma/client@6.19.2 prisma@6.19.2 @prisma/adapter-pg@6.19.2 pg && \
     chown -R nextjs:nodejs /app/node_modules
 
-RUN apk add --no-cache bash
+RUN apk add --no-cache bash ffmpeg
 
 COPY --from=builder /app/public ./public
 COPY --from=builder /app/prisma ./prisma

+ 5 - 0
messages/de/dashboard.json

@@ -6,7 +6,12 @@
     "uploadSuccess": "{count} Bild(er) hochgeladen",
     "takePhoto": "Foto aufnehmen",
     "addPhoto": "Foto hinzufügen",
+    "addVideo": "Video hinzufügen",
+    "videoUploadSuccess": "{count} Video(s) hochgeladen",
     "scanPart": "Teil scannen",
+    "photo": "Foto",
+    "video": "Video",
+    "parts": "Teile",
     "partAdded": "\"{name}\" zum Auftrag hinzugefügt",
     "partAddFailed": "Teil konnte nicht hinzugefügt werden",
     "inStock": "Auf Lager: {quantity}",

+ 5 - 0
messages/en/dashboard.json

@@ -6,7 +6,12 @@
     "uploadSuccess": "{count} image(s) uploaded",
     "takePhoto": "Take photo",
     "addPhoto": "Add photo",
+    "addVideo": "Add video",
+    "videoUploadSuccess": "{count} video(s) uploaded",
     "scanPart": "Scan part",
+    "photo": "Photo",
+    "video": "Video",
+    "parts": "Parts",
     "partAdded": "Added \"{name}\" to work order",
     "partAddFailed": "Failed to add part",
     "inStock": "In stock: {quantity}",

+ 5 - 0
messages/es/dashboard.json

@@ -6,7 +6,12 @@
     "uploadSuccess": "{count} imagen(es) subida(s)",
     "takePhoto": "Tomar foto",
     "addPhoto": "Añadir foto",
+    "addVideo": "Añadir vídeo",
+    "videoUploadSuccess": "{count} vídeo(s) subido(s)",
     "scanPart": "Escanear pieza",
+    "photo": "Foto",
+    "video": "Vídeo",
+    "parts": "Piezas",
     "partAdded": "\"{name}\" añadido a la orden de trabajo",
     "partAddFailed": "Error al añadir la pieza",
     "inStock": "En stock: {quantity}",

+ 5 - 0
messages/fr/dashboard.json

@@ -6,7 +6,12 @@
     "uploadSuccess": "{count} image(s) téléversée(s)",
     "takePhoto": "Prendre une photo",
     "addPhoto": "Ajouter photo",
+    "addVideo": "Ajouter vidéo",
+    "videoUploadSuccess": "{count} vidéo(s) téléversée(s)",
     "scanPart": "Scanner la pièce",
+    "photo": "Photo",
+    "video": "Vidéo",
+    "parts": "Pièces",
     "partAdded": "\"{name}\" ajouté à l'ordre de travail",
     "partAddFailed": "Échec de l'ajout de la pièce",
     "inStock": "En stock : {quantity}",

+ 5 - 0
messages/it/dashboard.json

@@ -6,7 +6,12 @@
     "uploadSuccess": "{count} immagine/i caricata/e",
     "takePhoto": "Scatta foto",
     "addPhoto": "Aggiungi foto",
+    "addVideo": "Aggiungi video",
+    "videoUploadSuccess": "{count} video caricato/i",
     "scanPart": "Scansiona ricambio",
+    "photo": "Foto",
+    "video": "Video",
+    "parts": "Ricambi",
     "partAdded": "\"{name}\" aggiunto all'ordine di lavoro",
     "partAddFailed": "Impossibile aggiungere il ricambio",
     "inStock": "Disponibile: {quantity}",

+ 5 - 0
messages/nb/dashboard.json

@@ -6,7 +6,12 @@
     "uploadSuccess": "{count} bilde(r) lastet opp",
     "takePhoto": "Ta bilde",
     "addPhoto": "Legg til bilde",
+    "addVideo": "Legg til video",
+    "videoUploadSuccess": "{count} video(er) lastet opp",
     "scanPart": "Skann del",
+    "photo": "Bilde",
+    "video": "Video",
+    "parts": "Deler",
     "partAdded": "\"{name}\" lagt til i arbeidsordre",
     "partAddFailed": "Kunne ikke legge til del",
     "inStock": "På lager: {quantity}",

+ 5 - 0
messages/nl/dashboard.json

@@ -6,7 +6,12 @@
     "uploadSuccess": "{count} afbeelding(en) geüpload",
     "takePhoto": "Foto maken",
     "addPhoto": "Foto toevoegen",
+    "addVideo": "Video toevoegen",
+    "videoUploadSuccess": "{count} video('s) geüpload",
     "scanPart": "Onderdeel scannen",
+    "photo": "Foto",
+    "video": "Video",
+    "parts": "Onderdelen",
     "partAdded": "\"{name}\" toegevoegd aan werkorder",
     "partAddFailed": "Onderdeel toevoegen mislukt",
     "inStock": "Op voorraad: {quantity}",

+ 5 - 0
messages/pl/dashboard.json

@@ -6,7 +6,12 @@
     "uploadSuccess": "Przesłano {count} zdjęcie/zdjęcia",
     "takePhoto": "Zrób zdjęcie",
     "addPhoto": "Dodaj zdjęcie",
+    "addVideo": "Dodaj wideo",
+    "videoUploadSuccess": "Przesłano {count} wideo",
     "scanPart": "Skanuj część",
+    "photo": "Zdjęcie",
+    "video": "Wideo",
+    "parts": "Części",
     "partAdded": "Dodano \"{name}\" do zlecenia",
     "partAddFailed": "Nie udało się dodać części",
     "inStock": "Na stanie: {quantity}",

+ 5 - 0
messages/pt-BR/dashboard.json

@@ -6,7 +6,12 @@
     "uploadSuccess": "{count} imagem(ns) enviada(s)",
     "takePhoto": "Tirar foto",
     "addPhoto": "Adicionar foto",
+    "addVideo": "Adicionar vídeo",
+    "videoUploadSuccess": "{count} vídeo(s) enviado(s)",
     "scanPart": "Escanear peça",
+    "photo": "Foto",
+    "video": "Vídeo",
+    "parts": "Peças",
     "partAdded": "\"{name}\" adicionado à ordem de serviço",
     "partAddFailed": "Falha ao adicionar peça",
     "inStock": "Em estoque: {quantity}",

+ 5 - 0
messages/ru/dashboard.json

@@ -6,7 +6,12 @@
     "uploadSuccess": "Загружено изображений: {count}",
     "takePhoto": "Сделать фото",
     "addPhoto": "Добавить фото",
+    "addVideo": "Добавить видео",
+    "videoUploadSuccess": "Загружено видео: {count}",
     "scanPart": "Сканировать запчасть",
+    "photo": "Фото",
+    "video": "Видео",
+    "parts": "Запчасти",
     "partAdded": "\"{name}\" добавлено в заказ-наряд",
     "partAddFailed": "Не удалось добавить запчасть",
     "inStock": "В наличии: {quantity}",

+ 5 - 0
messages/tr/dashboard.json

@@ -6,7 +6,12 @@
     "uploadSuccess": "{count} görsel yüklendi",
     "takePhoto": "Fotoğraf çek",
     "addPhoto": "Fotoğraf ekle",
+    "addVideo": "Video ekle",
+    "videoUploadSuccess": "{count} video yüklendi",
     "scanPart": "Parça tara",
+    "photo": "Fotoğraf",
+    "video": "Video",
+    "parts": "Parçalar",
     "partAdded": "\"{name}\" iş emrine eklendi",
     "partAddFailed": "Parça eklenemedi",
     "inStock": "Stokta: {quantity}",

+ 52 - 7
src/app/api/protected/upload/service-files/route.ts

@@ -1,8 +1,12 @@
 import { NextRequest, NextResponse } from "next/server";
 import { getAuthContext } from "@/lib/get-auth-context";
-import { writeFile, mkdir } from "fs/promises";
+import { writeFile, mkdir, unlink, stat } from "fs/promises";
 import path from "path";
 import crypto from "crypto";
+import { execFile } from "child_process";
+import { promisify } from "util";
+
+const execFileAsync = promisify(execFile);
 
 const ALLOWED_TYPES = [
   "image/jpeg",
@@ -16,7 +20,28 @@ const ALLOWED_TYPES = [
   "video/quicktime",
 ];
 
-const MAX_SIZE = 10 * 1024 * 1024; // 10MB
+const MAX_SIZE = 500 * 1024 * 1024; // 500MB
+const VIDEO_TYPES = ["video/mp4", "video/webm", "video/quicktime"];
+
+async function compressVideo(inputPath: string, outputPath: string): Promise<boolean> {
+  try {
+    await execFileAsync("ffmpeg", [
+      "-i", inputPath,
+      "-vf", "scale=-2:720",       // Scale to 720p, keep aspect ratio
+      "-c:v", "libx264",
+      "-preset", "fast",
+      "-crf", "28",                 // Good quality/size balance
+      "-c:a", "aac",
+      "-b:a", "128k",
+      "-movflags", "+faststart",    // Web-optimized
+      "-y",                         // Overwrite
+      outputPath,
+    ], { timeout: 300000 });        // 5 min timeout
+    return true;
+  } catch {
+    return false;
+  }
+}
 
 export async function POST(request: NextRequest) {
   const ctx = await getAuthContext();
@@ -41,24 +66,44 @@ export async function POST(request: NextRequest) {
 
   if (file.size > MAX_SIZE) {
     return NextResponse.json(
-      { error: "File size must be under 10MB" },
+      { error: "File size must be under 500MB" },
       { status: 400 }
     );
   }
 
-  const ext = file.name.split(".").pop() || "bin";
+  const isVideo = VIDEO_TYPES.includes(file.type);
+  const ext = isVideo ? "mp4" : (file.name.split(".").pop() || "bin");
   const filename = `${crypto.randomUUID()}.${ext}`;
   const uploadDir = path.join(process.cwd(), "data", "uploads", ctx.organizationId, "services");
 
   await mkdir(uploadDir, { recursive: true });
 
   const bytes = new Uint8Array(await file.arrayBuffer());
-  await writeFile(path.join(uploadDir, filename), bytes);
+  const finalPath = path.join(uploadDir, filename);
+
+  if (isVideo) {
+    // Write original to temp file, compress, then clean up
+    const tempFilename = `${crypto.randomUUID()}_orig.${file.name.split(".").pop() || "mp4"}`;
+    const tempPath = path.join(uploadDir, tempFilename);
+    await writeFile(tempPath, bytes);
+
+    const compressed = await compressVideo(tempPath, finalPath);
+    await unlink(tempPath).catch(() => { /* temp file cleanup */ });
+
+    if (!compressed) {
+      // Fallback: save original as-is
+      await writeFile(finalPath, bytes);
+    }
+  } else {
+    await writeFile(finalPath, bytes);
+  }
+
+  const finalStat = await stat(finalPath);
 
   return NextResponse.json({
     url: `/api/protected/files/${ctx.organizationId}/services/${filename}`,
     fileName: file.name,
-    fileType: file.type,
-    fileSize: file.size,
+    fileType: isVideo ? "video/mp4" : file.type,
+    fileSize: finalStat.size,
   });
 }

+ 6 - 4
src/features/vehicles/Actions/getMyActiveJobs.ts

@@ -16,6 +16,7 @@ export interface MyActiveJob {
     licensePlate: string | null;
   };
   imageCount: number;
+  videoCount: number;
   partCount: number;
 }
 
@@ -51,11 +52,11 @@ export async function getMyActiveJobs() {
               licensePlate: true,
             },
           },
+          attachments: {
+            select: { category: true },
+          },
           _count: {
             select: {
-              attachments: {
-                where: { category: "image" },
-              },
               partItems: true,
             },
           },
@@ -70,7 +71,8 @@ export async function getMyActiveJobs() {
         status: r.status,
         vehicleId: r.vehicleId,
         vehicle: r.vehicle,
-        imageCount: r._count.attachments,
+        imageCount: r.attachments.filter((a) => a.category === "image").length,
+        videoCount: r.attachments.filter((a) => a.category === "video").length,
         partCount: r._count.partItems,
       }));
     },

+ 145 - 28
src/features/vehicles/Components/MyActiveJobs.tsx

@@ -7,6 +7,7 @@ import {
   Camera,
   ImageIcon,
   Loader2,
+  Video,
   Wrench,
   Clock,
   Pause,
@@ -16,6 +17,7 @@ import {
 } from 'lucide-react'
 import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
 import { Button } from '@/components/ui/button'
+import { ButtonGroup } from '@/components/ui/button-group'
 import { Badge } from '@/components/ui/badge'
 import {
   Drawer,
@@ -52,11 +54,15 @@ const STATUS_COLOR: Record<string, string> = {
 export function MyActiveJobs({ jobs }: MyActiveJobsProps) {
   const t = useTranslations('dashboard.myJobs')
   const router = useRouter()
-  const [uploading, setUploading] = useState<string | null>(null)
+  const [uploading, setUploading] = useState<{ jobId: string; type: 'photo' | 'video' } | null>(null)
   const [imageCounts, setImageCounts] = useState<Record<string, number>>(() =>
     Object.fromEntries(jobs.map((j) => [j.id, j.imageCount]))
   )
   const fileInputRefs = useRef<Record<string, HTMLInputElement | null>>({})
+  const videoInputRefs = useRef<Record<string, HTMLInputElement | null>>({})
+  const [videoCounts, setVideoCounts] = useState<Record<string, number>>(() =>
+    Object.fromEntries(jobs.map((j) => [j.id, j.videoCount]))
+  )
 
   // Barcode scanner state
   const [scannerOpen, setScannerOpen] = useState(false)
@@ -156,7 +162,7 @@ export function MyActiveJobs({ jobs }: MyActiveJobsProps) {
 
   const handleFileSelect = async (jobId: string, files: FileList | null) => {
     if (!files || files.length === 0) return
-    setUploading(jobId)
+    setUploading({ jobId, type: 'photo' })
 
     let uploaded = 0
     for (let i = 0; i < files.length; i++) {
@@ -211,6 +217,62 @@ export function MyActiveJobs({ jobs }: MyActiveJobsProps) {
     if (input) input.value = ''
   }
 
+  const handleVideoSelect = async (jobId: string, files: FileList | null) => {
+    if (!files || files.length === 0) return
+    setUploading({ jobId, type: 'video' })
+
+    let uploaded = 0
+    for (let i = 0; i < files.length; i++) {
+      const file = files[i]
+      if (!file.type.startsWith('video/')) continue
+
+      try {
+        const formData = new FormData()
+        formData.append('file', file)
+
+        const res = await fetch('/api/protected/upload/service-files', {
+          method: 'POST',
+          body: formData,
+        })
+
+        if (!res.ok) {
+          const err = await res.json()
+          toast.error(err.error || t('uploadFailed'))
+          continue
+        }
+
+        const data = await res.json()
+        await addServiceAttachment({
+          serviceRecordId: jobId,
+          attachment: {
+            fileName: data.fileName,
+            fileUrl: data.url,
+            fileType: data.fileType,
+            fileSize: data.fileSize,
+            category: 'video',
+            includeInInvoice: false,
+          },
+        })
+        uploaded++
+      } catch {
+        toast.error(t('uploadFailed'))
+      }
+    }
+
+    if (uploaded > 0) {
+      toast.success(t('videoUploadSuccess', { count: uploaded }))
+      setVideoCounts((prev) => ({
+        ...prev,
+        [jobId]: (prev[jobId] || 0) + uploaded,
+      }))
+      router.refresh()
+    }
+
+    setUploading(null)
+    const vinput = videoInputRefs.current[jobId]
+    if (vinput) vinput.value = ''
+  }
+
   return (
     <>
       <Card className="border-0 shadow-sm">
@@ -226,8 +288,11 @@ export function MyActiveJobs({ jobs }: MyActiveJobsProps) {
             {jobs.map((job) => {
               const StatusIcon = STATUS_ICON[job.status] || Wrench
               const statusColor = STATUS_COLOR[job.status] || 'bg-muted text-muted-foreground'
-              const isUploading = uploading === job.id
+              const isUploadingPhoto = uploading?.jobId === job.id && uploading.type === 'photo'
+              const isUploadingVideo = uploading?.jobId === job.id && uploading.type === 'video'
+              const isUploading = uploading?.jobId === job.id
               const imgCount = imageCounts[job.id] || 0
+              const vidCount = videoCounts[job.id] || 0
               const prtCount = partCounts[job.id] || 0
 
               return (
@@ -259,6 +324,12 @@ export function MyActiveJobs({ jobs }: MyActiveJobsProps) {
                             {imgCount}
                           </span>
                         )}
+                        {vidCount > 0 && (
+                          <span className="flex items-center gap-0.5">
+                            <Video className="h-3 w-3" />
+                            {vidCount}
+                          </span>
+                        )}
                         {prtCount > 0 && (
                           <span className="flex items-center gap-0.5">
                             <Package className="h-3 w-3" />
@@ -273,13 +344,27 @@ export function MyActiveJobs({ jobs }: MyActiveJobsProps) {
                         disabled={isUploading}
                         onClick={() => handleCameraClick(job.id)}
                       >
-                        {isUploading ? (
+                        {isUploadingPhoto ? (
                           <Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
                         ) : (
                           <Camera className="mr-1.5 h-4 w-4" />
                         )}
                         {t('addPhoto')}
                       </Button>
+                      <Button
+                        variant="outline"
+                        size="sm"
+                        className="h-9"
+                        disabled={isUploading}
+                        onClick={() => videoInputRefs.current[job.id]?.click()}
+                      >
+                        {isUploadingVideo ? (
+                          <Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
+                        ) : (
+                          <Video className="mr-1.5 h-4 w-4" />
+                        )}
+                        {t('addVideo')}
+                      </Button>
                       <Button
                         variant="outline"
                         size="sm"
@@ -298,6 +383,12 @@ export function MyActiveJobs({ jobs }: MyActiveJobsProps) {
                           {imgCount}
                         </span>
                       )}
+                      {vidCount > 0 && (
+                        <span className="flex items-center gap-0.5">
+                          <Video className="h-3 w-3" />
+                          {vidCount}
+                        </span>
+                      )}
                       {prtCount > 0 && (
                         <span className="flex items-center gap-0.5">
                           <Package className="h-3 w-3" />
@@ -307,30 +398,46 @@ export function MyActiveJobs({ jobs }: MyActiveJobsProps) {
                     </div>
                   </div>
                   {/* Mobile: action buttons on second line */}
-                  <div className="flex gap-2 mt-2 sm:hidden">
-                    <Button
-                      variant="outline"
-                      size="sm"
-                      className="flex-1 h-9"
-                      disabled={isUploading}
-                      onClick={() => handleCameraClick(job.id)}
-                    >
-                      {isUploading ? (
-                        <Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
-                      ) : (
-                        <Camera className="mr-1.5 h-4 w-4" />
-                      )}
-                      {t('takePhoto')}
-                    </Button>
-                    <Button
-                      variant="outline"
-                      size="sm"
-                      className="flex-1 h-9"
-                      onClick={() => handleScanClick(job.id)}
-                    >
-                      <ScanBarcode className="mr-1.5 h-4 w-4" />
-                      {t('scanPart')}
-                    </Button>
+                  <div className="mt-2 sm:hidden">
+                    <ButtonGroup className="w-full">
+                      <Button
+                        variant="outline"
+                        size="sm"
+                        className="flex-1 h-9"
+                        disabled={isUploading}
+                        onClick={() => handleCameraClick(job.id)}
+                      >
+                        {isUploadingPhoto ? (
+                          <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
+                        ) : (
+                          <Camera className="mr-1 h-3.5 w-3.5" />
+                        )}
+                        {t('photo')}
+                      </Button>
+                      <Button
+                        variant="outline"
+                        size="sm"
+                        className="flex-1 h-9"
+                        disabled={isUploading}
+                        onClick={() => videoInputRefs.current[job.id]?.click()}
+                      >
+                        {isUploadingVideo ? (
+                          <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
+                        ) : (
+                          <Video className="mr-1 h-3.5 w-3.5" />
+                        )}
+                        {t('video')}
+                      </Button>
+                      <Button
+                        variant="outline"
+                        size="sm"
+                        className="flex-1 h-9"
+                        onClick={() => handleScanClick(job.id)}
+                      >
+                        <ScanBarcode className="mr-1 h-3.5 w-3.5" />
+                        {t('parts')}
+                      </Button>
+                    </ButtonGroup>
                   </div>
                   <input
                     ref={(el) => {
@@ -343,6 +450,16 @@ export function MyActiveJobs({ jobs }: MyActiveJobsProps) {
                     className="hidden"
                     onChange={(e) => handleFileSelect(job.id, e.target.files)}
                   />
+                  <input
+                    ref={(el) => {
+                      videoInputRefs.current[job.id] = el
+                    }}
+                    type="file"
+                    accept="video/*"
+                    capture="environment"
+                    className="hidden"
+                    onChange={(e) => handleVideoSelect(job.id, e.target.files)}
+                  />
                 </div>
               )
             })}