Files
004.erp.generic/frontend/src/components/fleet/WorkOrderPhotoUpload.jsx
mariomitte 8e4b3f8aea
Some checks failed
ERP CI/CD Pipeline / test (push) Has been cancelled
ERP CI/CD Pipeline / Deploy (server git pull + compose) (push) Has been cancelled
feat: WorkOrderPhoto izolacija, upload modal i SN stupac u tablici putnih naloga
- Dodan WorkOrderPhoto model (FK na WorkOrder, odvojen od VehicleServicePhoto)
- Migracija 0022_workorderphoto
- WorkOrderViewSet.images action refaktoriran: GET/POST koristi WorkOrderPhoto
- WorkOrderPhotoSerializer dodan u serializers.py
- WorkOrderPhotoInline dodan u admin.py
- Novi WorkOrderPhotoUpload.jsx modal s podrskom za onBack prop i z-[60]
- WorkOrderDetailModal: gumb Dodaj fotografije u editMode, photoUploadOpen state,
  ispravljen onSuccess callback (map na URL stringove), reset pri zatvaranju
- FleetDashboardShell: stupac SN u tablici putnih naloga (craneInfo.crane_serial_number)
- fleetDashboardStore: uklonjen dupli toast iz uploadWorkOrderPhoto
- Dodan test test_work_order_images_endpoint.py

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-16 06:33:08 +02:00

248 lines
11 KiB
JavaScript

import { useState, useEffect, useRef } from 'preact/hooks';
import { useStore } from '@nanostores/preact';
import ModalShell from '../ui/ModalShell';
import { $isOffline } from '../../stores/networkStore.js';
import { uploadWorkOrderPhoto } from '../../stores/fleetDashboardStore.js';
import { showToast } from '../../stores/toastStore.js';
const MAX_FILE_SIZE_MB = 10;
const MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024;
const ACCEPTED_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/heic'];
export default function WorkOrderPhotoUpload({ open, workOrderId, onClose, onSuccess, onBack }) {
const isOffline = useStore($isOffline);
const [images, setImages] = useState([]);
const [description, setDescription] = useState('');
const [submitting, setSubmitting] = useState(false);
const [progress, setProgress] = useState({ current: 0, total: 0 });
const [error, setError] = useState('');
const [isMounted, setIsMounted] = useState(false);
const abortRef = useRef(null);
useEffect(() => {
setIsMounted(true);
return () => {
abortRef.current?.abort();
};
}, []);
useEffect(() => {
if (!open) {
abortRef.current?.abort();
abortRef.current = null;
setImages([]);
setDescription('');
setError('');
setSubmitting(false);
setProgress({ current: 0, total: 0 });
}
}, [open]);
if (!open || !isMounted) return null;
const handleFileChange = (e) => {
const files = Array.from(e.target.files ?? []);
const oversized = files.filter((f) => f.size > MAX_FILE_SIZE_BYTES);
const invalidType = files.filter((f) => !ACCEPTED_TYPES.includes(f.type));
if (oversized.length > 0) {
setError(`Sljedeće datoteke prelaze ${MAX_FILE_SIZE_MB} MB: ${oversized.map((f) => f.name).join(', ')}`);
return;
}
if (invalidType.length > 0) {
setError(`Nepodržan format: ${invalidType.map((f) => f.name).join(', ')}. Koristite JPG, PNG, WEBP ili HEIC.`);
return;
}
setError('');
setImages(files);
};
const handleSubmit = async (e) => {
e.preventDefault();
setError('');
if (!workOrderId) {
setError('Nije poznat putni nalog za koji se upload vrši.');
return;
}
if (images.length === 0) {
setError('Odaberite barem jednu fotografiju.');
return;
}
if (isOffline) {
showToast('Upload fotografija nije dostupan u offline modu.', 'error');
return;
}
abortRef.current?.abort();
abortRef.current = new AbortController();
const { signal } = abortRef.current;
setSubmitting(true);
setProgress({ current: 0, total: images.length });
let successCount = 0;
try {
for (const [index, file] of images.entries()) {
if (signal.aborted) break;
setProgress({ current: index + 1, total: images.length });
await uploadWorkOrderPhoto(workOrderId, file, description, signal);
successCount++;
}
if (!signal.aborted) {
showToast(`${successCount} fotografija putnog naloga je uspješno uploadano.`, 'success');
e.target.reset();
setImages([]);
setDescription('');
onSuccess?.();
onClose?.();
}
} catch (err) {
if (err?.name === 'AbortError') {
return;
}
setError(err?.message || 'Greška pri uploadu fotografija putnog naloga.');
if (successCount > 0) {
showToast(`${successCount} od ${images.length} fotografija uploadano.`, 'warning');
}
} finally {
if (!signal?.aborted) {
setSubmitting(false);
setProgress({ current: 0, total: 0 });
abortRef.current = null;
}
}
};
const handleCancel = () => {
abortRef.current?.abort();
abortRef.current = null;
onClose?.();
};
return (
<ModalShell
onClose={handleCancel}
overlayClassName="z-[60] p-4"
contentClassName="flex items-center justify-center min-h-full"
panelClassName={`w-full max-w-lg rounded-xl border border-border-hairline bg-canvas-elevated shadow-xl ${isOffline ? 'pointer-events-none opacity-50' : ''}`}
>
<div>
<div className="flex items-center justify-between border-b border-border-hairline px-5 py-4">
<div className="flex items-center gap-3">
{onBack && (
<button
type="button"
onClick={onBack}
className="rounded-md px-2 py-1 text-sm text-text-muted hover:bg-canvas-deep"
aria-label="Natrag"
>
Natrag
</button>
)}
<div>
<h3 className="text-lg font-semibold text-text-main">Upload fotografija putnog naloga</h3>
<p className="text-xs text-text-muted mt-0.5">
Putni nalog #{workOrderId}
</p>
</div>
</div>
<button
type="button"
onClick={handleCancel}
className="rounded-md px-2 py-1 text-sm text-text-muted hover:bg-canvas-deep"
aria-label="Zatvori"
>
</button>
</div>
<form onSubmit={handleSubmit} className="space-y-4 px-5 py-4">
<div>
<label className="block mb-1.5 text-[11px] font-semibold text-text-muted uppercase tracking-wide">
Fotografije putnog naloga ({images.length} odabrano)
</label>
<input
type="file"
accept={ACCEPTED_TYPES.join(',')}
multiple
onChange={handleFileChange}
disabled={submitting}
className="block w-full text-xs text-text-muted file:mr-4 file:py-1.5 file:px-3 file:rounded-md file:border-0 file:text-xs file:font-mono file:bg-indigo-50 file:text-indigo-700 hover:file:bg-indigo-100 file:cursor-pointer transition-all disabled:opacity-50"
/>
<p className="mt-1 text-[11px] text-text-muted">
JPG, PNG, WEBP, HEIC max {MAX_FILE_SIZE_MB} MB po datoteci
</p>
</div>
{images.length > 0 && (
<div className="rounded-lg border border-border-hairline bg-canvas-deep p-2 max-h-32 overflow-y-auto space-y-1">
{images.map((file, i) => (
<p key={i} className="text-[10px] font-mono text-text-muted truncate">
📎 [{i + 1}] {file.name}{' '}
<span className="text-text-muted/60">
({(file.size / 1024).toFixed(1)} KB)
</span>
</p>
))}
</div>
)}
<label className="flex flex-col gap-1 text-sm">
<span className="font-medium text-text-main">Opis (primjenjuje se na sve slike)</span>
<input
type="text"
value={description}
onInput={(e) => setDescription(e.currentTarget.value)}
disabled={submitting}
placeholder="Npr. Slike stanja na lokaciji prije/poslije intervencije"
className="rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-sm text-text-main placeholder-text-muted/40 focus:outline-none disabled:opacity-50"
/>
</label>
{submitting && progress.total > 1 && (
<div>
<div className="flex justify-between text-[11px] text-text-muted mb-1">
<span>Uploading...</span>
<span>{progress.current}/{progress.total}</span>
</div>
<div className="h-1.5 w-full rounded-full bg-canvas-deep overflow-hidden">
<div
className="h-full rounded-full bg-indigo-500 transition-all duration-300"
style={{ width: `${(progress.current / progress.total) * 100}%` }}
/>
</div>
</div>
)}
{error && (
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
{error}
</div>
)}
<div className="flex justify-end gap-2 border-t border-border-hairline pt-3">
<button
type="button"
onClick={handleCancel}
className="rounded-lg border border-border-hairline px-4 py-2 text-sm font-medium text-text-main hover:bg-canvas-deep"
>
{submitting ? 'Prekini upload' : 'Odustani'}
</button>
<button
type="submit"
disabled={submitting || images.length === 0 || isOffline}
className="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-700 disabled:opacity-60"
>
{submitting
? `Uploading ${progress.current}/${progress.total}...`
: `Upload ${images.length > 0 ? images.length + ' slike' : 'fotografija'}`}
</button>
</div>
</form>
</div>
</ModalShell>
);
}