Files
GSB-Construction/resources/js/Components/TaskEvidenceUploader.tsx
Ajjj ccbd23d474
Some checks failed
Tests / PHP 8.3 (push) Has been cancelled
Tests / PHP 8.4 (push) Has been cancelled
Tests / PHP 8.5 (push) Has been cancelled
feat: enhance module RBAC permissions, financial billing workflows, task evidence uploading, and role analytics
2026-08-13 18:46:26 +08:00

223 lines
10 KiB
TypeScript

import React, { useState } from 'react';
import { Button } from '@/Components/ui/button';
import { Badge } from '@/Components/ui/badge';
import { Camera, Image as ImageIcon, Trash2, X, Maximize2, UploadCloud } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/Components/ui/dialog';
export interface EvidenceMedia {
id: number;
name: string;
file_name: string;
original_url: string;
mime_type: string;
size: number;
}
interface Props {
existingEvidence?: EvidenceMedia[];
onFilesSelected?: (files: File[]) => void;
onUploadNewFiles?: (files: File[]) => void;
onDeleteExistingMedia?: (mediaId: number) => void;
readOnly?: boolean;
compact?: boolean;
label?: string;
uploading?: boolean;
}
export default function TaskEvidenceUploader({
existingEvidence = [],
onFilesSelected,
onUploadNewFiles,
onDeleteExistingMedia,
readOnly = false,
compact = false,
label = 'Evidence Photos',
uploading = false,
}: Props) {
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
const [previews, setPreviews] = useState<string[]>([]);
const [lightboxUrl, setLightboxUrl] = useState<string | null>(null);
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (!e.target.files) return;
const newFiles = Array.from(e.target.files);
const updatedFiles = [...selectedFiles, ...newFiles];
setSelectedFiles(updatedFiles);
// Generate preview URLs
const newPreviews = newFiles.map(file => URL.createObjectURL(file));
setPreviews(prev => [...prev, ...newPreviews]);
if (onFilesSelected) {
onFilesSelected(updatedFiles);
}
};
const removeNewFile = (index: number) => {
const updatedFiles = selectedFiles.filter((_, i) => i !== index);
const updatedPreviews = previews.filter((_, i) => i !== index);
// Revoke Object URL
URL.revokeObjectURL(previews[index]);
setSelectedFiles(updatedFiles);
setPreviews(updatedPreviews);
if (onFilesSelected) {
onFilesSelected(updatedFiles);
}
};
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<label className="text-xs font-semibold text-slate-700 flex items-center gap-1.5">
<Camera className="h-4 w-4 text-indigo-600" />
{label}
{(existingEvidence.length > 0 || selectedFiles.length > 0) && (
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 font-mono">
{existingEvidence.length + selectedFiles.length} photos
</Badge>
)}
</label>
{!readOnly && (
<label className="cursor-pointer inline-flex items-center gap-1.5 text-xs font-semibold text-indigo-600 hover:text-indigo-800 bg-indigo-50 hover:bg-indigo-100 px-3 py-1.5 rounded-lg border border-indigo-200/60 transition-colors">
<UploadCloud className="h-4 w-4" />
<span>Add Photos</span>
<input
type="file"
multiple
accept="image/*"
onChange={handleFileChange}
className="hidden"
/>
</label>
)}
</div>
{/* Photos Grid */}
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-6 gap-2.5">
{/* Existing Saved Evidence Media */}
{existingEvidence.map((media) => (
<div key={media.id} className="group relative rounded-xl border border-slate-200 bg-slate-100 overflow-hidden aspect-square shadow-xs hover:shadow-md transition-all">
<img
src={media.original_url}
alt={media.name}
className="w-full h-full object-cover cursor-pointer hover:scale-105 transition-transform"
onClick={() => setLightboxUrl(media.original_url)}
/>
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-1">
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => setLightboxUrl(media.original_url)}
className="h-7 w-7 text-white hover:bg-white/20 rounded-full"
>
<Maximize2 className="h-3.5 w-3.5" />
</Button>
{!readOnly && onDeleteExistingMedia && (
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => onDeleteExistingMedia(media.id)}
className="h-7 w-7 text-rose-400 hover:text-rose-200 hover:bg-rose-900/40 rounded-full"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
)}
</div>
</div>
))}
{/* Newly Selected Preview Images */}
{previews.map((src, idx) => (
<div key={idx} className="group relative rounded-xl border-2 border-dashed border-indigo-400 bg-indigo-50/50 overflow-hidden aspect-square shadow-xs">
<img
src={src}
alt={`Preview ${idx + 1}`}
className="w-full h-full object-cover cursor-pointer"
onClick={() => setLightboxUrl(src)}
/>
<Badge className="absolute top-1 left-1 text-[9px] px-1 py-0 bg-indigo-600 text-white font-bold">
NEW
</Badge>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => removeNewFile(idx)}
className="absolute top-1 right-1 h-6 w-6 bg-slate-900/70 text-white hover:bg-rose-600 rounded-full p-1"
>
<X className="h-3 w-3" />
</Button>
</div>
))}
{/* Empty Dropzone Placeholder */}
{!readOnly && existingEvidence.length === 0 && selectedFiles.length === 0 && (
<label className="col-span-full border-2 border-dashed border-slate-200 rounded-xl p-4 flex flex-col items-center justify-center text-center cursor-pointer hover:border-indigo-300 hover:bg-indigo-50/20 transition-all">
<ImageIcon className="h-8 w-8 text-slate-300 mb-1" />
<span className="text-xs font-medium text-slate-600">No evidence photos attached yet</span>
<span className="text-[10px] text-slate-400 mt-0.5">Click or drag images here to upload task evidence (.jpg, .png, .webp)</span>
<input
type="file"
multiple
accept="image/*"
onChange={handleFileChange}
className="hidden"
/>
</label>
)}
</div>
{/* Action button to save/upload newly selected evidence photos */}
{selectedFiles.length > 0 && onUploadNewFiles && (
<div className="flex justify-end pt-1">
<Button
type="button"
size="sm"
disabled={uploading}
onClick={() => {
onUploadNewFiles(selectedFiles);
setSelectedFiles([]);
setPreviews([]);
}}
className="bg-indigo-600 hover:bg-indigo-700 text-white font-semibold shadow-xs flex items-center gap-1.5"
>
<UploadCloud className="h-4 w-4" />
<span>Upload {selectedFiles.length} Evidence {selectedFiles.length === 1 ? 'Photo' : 'Photos'}</span>
</Button>
</div>
)}
{/* Image Preview Lightbox Dialog */}
<Dialog open={!!lightboxUrl} onOpenChange={() => setLightboxUrl(null)}>
<DialogContent className="max-w-4xl bg-slate-950/95 border-slate-800 text-white p-4">
<DialogHeader className="flex flex-row items-center justify-between pb-2 border-b border-slate-800">
<DialogTitle className="text-sm font-semibold flex items-center gap-2 text-slate-200">
<Camera className="h-4 w-4 text-indigo-400" /> Evidence Photo Preview
</DialogTitle>
</DialogHeader>
{lightboxUrl && (
<div className="flex items-center justify-center py-4 max-h-[80vh]">
<img
src={lightboxUrl}
alt="Evidence full view"
className="max-h-[75vh] max-w-full object-contain rounded-lg shadow-2xl border border-slate-800"
/>
</div>
)}
</DialogContent>
</Dialog>
</div>
);
}