|
@@ -7,6 +7,7 @@ import {
|
|
|
Camera,
|
|
Camera,
|
|
|
ImageIcon,
|
|
ImageIcon,
|
|
|
Loader2,
|
|
Loader2,
|
|
|
|
|
+ Video,
|
|
|
Wrench,
|
|
Wrench,
|
|
|
Clock,
|
|
Clock,
|
|
|
Pause,
|
|
Pause,
|
|
@@ -16,6 +17,7 @@ import {
|
|
|
} from 'lucide-react'
|
|
} from 'lucide-react'
|
|
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
|
|
import { Button } from '@/components/ui/button'
|
|
import { Button } from '@/components/ui/button'
|
|
|
|
|
+import { ButtonGroup } from '@/components/ui/button-group'
|
|
|
import { Badge } from '@/components/ui/badge'
|
|
import { Badge } from '@/components/ui/badge'
|
|
|
import {
|
|
import {
|
|
|
Drawer,
|
|
Drawer,
|
|
@@ -52,11 +54,15 @@ const STATUS_COLOR: Record<string, string> = {
|
|
|
export function MyActiveJobs({ jobs }: MyActiveJobsProps) {
|
|
export function MyActiveJobs({ jobs }: MyActiveJobsProps) {
|
|
|
const t = useTranslations('dashboard.myJobs')
|
|
const t = useTranslations('dashboard.myJobs')
|
|
|
const router = useRouter()
|
|
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>>(() =>
|
|
const [imageCounts, setImageCounts] = useState<Record<string, number>>(() =>
|
|
|
Object.fromEntries(jobs.map((j) => [j.id, j.imageCount]))
|
|
Object.fromEntries(jobs.map((j) => [j.id, j.imageCount]))
|
|
|
)
|
|
)
|
|
|
const fileInputRefs = useRef<Record<string, HTMLInputElement | null>>({})
|
|
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
|
|
// Barcode scanner state
|
|
|
const [scannerOpen, setScannerOpen] = useState(false)
|
|
const [scannerOpen, setScannerOpen] = useState(false)
|
|
@@ -156,7 +162,7 @@ export function MyActiveJobs({ jobs }: MyActiveJobsProps) {
|
|
|
|
|
|
|
|
const handleFileSelect = async (jobId: string, files: FileList | null) => {
|
|
const handleFileSelect = async (jobId: string, files: FileList | null) => {
|
|
|
if (!files || files.length === 0) return
|
|
if (!files || files.length === 0) return
|
|
|
- setUploading(jobId)
|
|
|
|
|
|
|
+ setUploading({ jobId, type: 'photo' })
|
|
|
|
|
|
|
|
let uploaded = 0
|
|
let uploaded = 0
|
|
|
for (let i = 0; i < files.length; i++) {
|
|
for (let i = 0; i < files.length; i++) {
|
|
@@ -211,6 +217,62 @@ export function MyActiveJobs({ jobs }: MyActiveJobsProps) {
|
|
|
if (input) input.value = ''
|
|
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 (
|
|
return (
|
|
|
<>
|
|
<>
|
|
|
<Card className="border-0 shadow-sm">
|
|
<Card className="border-0 shadow-sm">
|
|
@@ -226,8 +288,11 @@ export function MyActiveJobs({ jobs }: MyActiveJobsProps) {
|
|
|
{jobs.map((job) => {
|
|
{jobs.map((job) => {
|
|
|
const StatusIcon = STATUS_ICON[job.status] || Wrench
|
|
const StatusIcon = STATUS_ICON[job.status] || Wrench
|
|
|
const statusColor = STATUS_COLOR[job.status] || 'bg-muted text-muted-foreground'
|
|
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 imgCount = imageCounts[job.id] || 0
|
|
|
|
|
+ const vidCount = videoCounts[job.id] || 0
|
|
|
const prtCount = partCounts[job.id] || 0
|
|
const prtCount = partCounts[job.id] || 0
|
|
|
|
|
|
|
|
return (
|
|
return (
|
|
@@ -259,6 +324,12 @@ export function MyActiveJobs({ jobs }: MyActiveJobsProps) {
|
|
|
{imgCount}
|
|
{imgCount}
|
|
|
</span>
|
|
</span>
|
|
|
)}
|
|
)}
|
|
|
|
|
+ {vidCount > 0 && (
|
|
|
|
|
+ <span className="flex items-center gap-0.5">
|
|
|
|
|
+ <Video className="h-3 w-3" />
|
|
|
|
|
+ {vidCount}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ )}
|
|
|
{prtCount > 0 && (
|
|
{prtCount > 0 && (
|
|
|
<span className="flex items-center gap-0.5">
|
|
<span className="flex items-center gap-0.5">
|
|
|
<Package className="h-3 w-3" />
|
|
<Package className="h-3 w-3" />
|
|
@@ -273,13 +344,27 @@ export function MyActiveJobs({ jobs }: MyActiveJobsProps) {
|
|
|
disabled={isUploading}
|
|
disabled={isUploading}
|
|
|
onClick={() => handleCameraClick(job.id)}
|
|
onClick={() => handleCameraClick(job.id)}
|
|
|
>
|
|
>
|
|
|
- {isUploading ? (
|
|
|
|
|
|
|
+ {isUploadingPhoto ? (
|
|
|
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
|
|
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
|
|
|
) : (
|
|
) : (
|
|
|
<Camera className="mr-1.5 h-4 w-4" />
|
|
<Camera className="mr-1.5 h-4 w-4" />
|
|
|
)}
|
|
)}
|
|
|
{t('addPhoto')}
|
|
{t('addPhoto')}
|
|
|
</Button>
|
|
</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
|
|
<Button
|
|
|
variant="outline"
|
|
variant="outline"
|
|
|
size="sm"
|
|
size="sm"
|
|
@@ -298,6 +383,12 @@ export function MyActiveJobs({ jobs }: MyActiveJobsProps) {
|
|
|
{imgCount}
|
|
{imgCount}
|
|
|
</span>
|
|
</span>
|
|
|
)}
|
|
)}
|
|
|
|
|
+ {vidCount > 0 && (
|
|
|
|
|
+ <span className="flex items-center gap-0.5">
|
|
|
|
|
+ <Video className="h-3 w-3" />
|
|
|
|
|
+ {vidCount}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ )}
|
|
|
{prtCount > 0 && (
|
|
{prtCount > 0 && (
|
|
|
<span className="flex items-center gap-0.5">
|
|
<span className="flex items-center gap-0.5">
|
|
|
<Package className="h-3 w-3" />
|
|
<Package className="h-3 w-3" />
|
|
@@ -307,30 +398,46 @@ export function MyActiveJobs({ jobs }: MyActiveJobsProps) {
|
|
|
</div>
|
|
</div>
|
|
|
</div>
|
|
</div>
|
|
|
{/* Mobile: action buttons on second line */}
|
|
{/* 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>
|
|
</div>
|
|
|
<input
|
|
<input
|
|
|
ref={(el) => {
|
|
ref={(el) => {
|
|
@@ -343,6 +450,16 @@ export function MyActiveJobs({ jobs }: MyActiveJobsProps) {
|
|
|
className="hidden"
|
|
className="hidden"
|
|
|
onChange={(e) => handleFileSelect(job.id, e.target.files)}
|
|
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>
|
|
</div>
|
|
|
)
|
|
)
|
|
|
})}
|
|
})}
|