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>
This commit is contained in:
@@ -9,6 +9,7 @@ import WorkOrderDetailModal from './WorkOrderDetailModal';
|
||||
import SyncLogPanel from './SyncLogPanel';
|
||||
import ServiceRecordForm from '../fleet/ServiceRecordForm';
|
||||
import ServicePhotoUpload from '../fleet/ServicePhotoUpload';
|
||||
import WorkOrderPhotoUpload from '../fleet/WorkOrderPhotoUpload';
|
||||
import ServiceRecordDetailModal from '../fleet/ServiceRecordDetailModal';
|
||||
import ServiceRecordCreateButton from '../fleet/ServiceRecordCreateButton';
|
||||
import ClientsSection from '../crm/ClientsSection';
|
||||
@@ -93,6 +94,7 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
|
||||
const [selectedServiceRecord, setSelectedServiceRecord] = useState(null);
|
||||
// photoUpload: { open: bool, serviceRecordId: string|null }
|
||||
const [photoUpload, setPhotoUpload] = useState({ open: false, serviceRecordId: null });
|
||||
const [workOrderPhotoUpload, setWorkOrderPhotoUpload] = useState({ open: false, workOrderId: null });
|
||||
const loading = useStore($dashboardLoading);
|
||||
const error = useStore($dashboardError);
|
||||
const stats = useStore($dashboardStats);
|
||||
@@ -492,6 +494,7 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
|
||||
const workOrderColumns = [
|
||||
{ key: 'code', label: 'Putni nalog', className: 'px-4 py-3' },
|
||||
{ key: 'vehicle', label: 'Dizalica', className: 'px-4 py-3' },
|
||||
{ key: 'serialNumber', label: 'SN', className: 'px-4 py-3' },
|
||||
{ key: 'createdAt', label: 'Kreirano', className: 'px-4 py-3' },
|
||||
{ key: 'status', label: 'Status', className: 'px-4 py-3' },
|
||||
{ key: 'creator', label: 'Creator', className: 'px-4 py-3' },
|
||||
@@ -756,6 +759,9 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
|
||||
>
|
||||
{[workOrder.craneInfo?.make, workOrder.craneInfo?.model].filter(Boolean).join(' ') || workOrder.craneLabel || workOrder.craneInfo?.registration_number || `#${workOrder.crane || workOrder.vehicle}`}
|
||||
</td>
|
||||
<td className="px-4 py-3 font-mono text-xs text-text-muted">
|
||||
{workOrder.craneInfo?.crane_serial_number || '-'}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{formatDate(workOrder.date)}
|
||||
</td>
|
||||
@@ -773,15 +779,10 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const related = serviceRecords.find((record) => String(record.vehicle) === String(workOrder.vehicle));
|
||||
if (!related) {
|
||||
showToast('Nema servisnog zapisa za ovaj putni nalog. Kreirajte servisni zapis prvo.', 'warning');
|
||||
return;
|
||||
}
|
||||
setPhotoUpload({ open: true, serviceRecordId: related.id });
|
||||
setWorkOrderPhotoUpload({ open: true, workOrderId: workOrder.id });
|
||||
}}
|
||||
className="rounded px-2 py-1 text-xs font-medium text-text-muted hover:bg-canvas-deep hover:text-text-main"
|
||||
title="Upload fotografija"
|
||||
title="Upload fotografija putnog naloga"
|
||||
>
|
||||
📷
|
||||
</button>
|
||||
@@ -913,7 +914,7 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
|
||||
{JSON.stringify(group, null, 2)}
|
||||
</pre> */}
|
||||
<td className="px-4 py-3">{formatDate(record.service_date)}</td>
|
||||
<td className="px-4 py-3">{record.crane_serial_number || record.vehicle_registration || '-'}</td>
|
||||
<td className="px-4 py-3">{record.crane_serial_number || '-'}</td>
|
||||
<td className="max-w-md truncate px-4 py-3" title={record.description || ''}>
|
||||
{record.description || '-'}
|
||||
</td>
|
||||
@@ -1094,6 +1095,13 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
|
||||
onSuccess={() => fetchFleetDashboardData({ silent: true })}
|
||||
/>
|
||||
|
||||
<WorkOrderPhotoUpload
|
||||
open={workOrderPhotoUpload.open}
|
||||
workOrderId={workOrderPhotoUpload.workOrderId}
|
||||
onClose={() => setWorkOrderPhotoUpload({ open: false, workOrderId: null })}
|
||||
onSuccess={() => fetchFleetDashboardData({ silent: true })}
|
||||
/>
|
||||
|
||||
<ServiceRecordDetailModal
|
||||
open={!!selectedServiceRecord}
|
||||
serviceRecord={selectedServiceRecord}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useMemo, useState } from 'preact/hooks';
|
||||
import { useStore } from '@nanostores/preact';
|
||||
import WorkOrderImageCarousel from './WorkOrderImageCarousel';
|
||||
import WorkOrderPhotoUpload from '../fleet/WorkOrderPhotoUpload';
|
||||
import ModalShell from '../ui/ModalShell';
|
||||
import {
|
||||
createWorkOrderInvoice,
|
||||
@@ -55,6 +56,7 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi
|
||||
});
|
||||
const [invoices, setInvoices] = useState([]);
|
||||
const [invoiceModalOpen, setInvoiceModalOpen] = useState(false);
|
||||
const [photoUploadOpen, setPhotoUploadOpen] = useState(false);
|
||||
const [invoiceSaving, setInvoiceSaving] = useState(false);
|
||||
const [invoiceForm, setInvoiceForm] = useState({
|
||||
naziv_racuna: '',
|
||||
@@ -133,6 +135,7 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi
|
||||
});
|
||||
setInvoices([]);
|
||||
setInvoiceModalOpen(false);
|
||||
setPhotoUploadOpen(false);
|
||||
setInvoiceSaving(false);
|
||||
setInvoiceForm({
|
||||
naziv_racuna: '',
|
||||
@@ -811,14 +814,23 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi
|
||||
Izmijeni
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={submit}
|
||||
disabled={submitting}
|
||||
className="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-700 disabled:opacity-60"
|
||||
>
|
||||
{submitting ? 'Spremam...' : 'Spremi izmjene'}
|
||||
</button>
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPhotoUploadOpen(true)}
|
||||
className="rounded-lg border border-border-hairline px-4 py-2 text-sm font-medium text-text-main hover:bg-canvas-deep"
|
||||
>
|
||||
📷 Dodaj fotografije
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={submit}
|
||||
disabled={submitting}
|
||||
className="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-700 disabled:opacity-60"
|
||||
>
|
||||
{submitting ? 'Spremam...' : 'Spremi izmjene'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
@@ -1110,6 +1122,20 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi
|
||||
</div>
|
||||
</ModalShell>
|
||||
)}
|
||||
<WorkOrderPhotoUpload
|
||||
open={photoUploadOpen}
|
||||
workOrderId={workOrder?.id}
|
||||
onClose={() => setPhotoUploadOpen(false)}
|
||||
onBack={() => setPhotoUploadOpen(false)}
|
||||
onSuccess={() => {
|
||||
setPhotoUploadOpen(false);
|
||||
if (workOrder?.id) {
|
||||
fetchWorkOrderImages(workOrder.id, { w: 1280, q: 75, fmt: 'webp' })
|
||||
.then((files) => setImageFiles(files.map((item) => item.optimized_url || item.original_url).filter(Boolean)))
|
||||
.catch(() => {});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
247
frontend/src/components/fleet/WorkOrderPhotoUpload.jsx
Normal file
247
frontend/src/components/fleet/WorkOrderPhotoUpload.jsx
Normal file
@@ -0,0 +1,247 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -607,6 +607,17 @@ export async function uploadServicePhoto(serviceRecordId, imageFile, description
|
||||
return response;
|
||||
}
|
||||
|
||||
export async function uploadWorkOrderPhoto(workOrderId, imageFile, description = '', signal = null) {
|
||||
const formData = new FormData();
|
||||
formData.append('image', imageFile);
|
||||
if (description.trim()) {
|
||||
formData.append('description', description.trim());
|
||||
}
|
||||
const options = signal ? { signal } : {};
|
||||
const response = await api.post(`fleet/work-orders/${encodeURIComponent(workOrderId)}/images/`, formData, options);
|
||||
return response;
|
||||
}
|
||||
|
||||
export async function uploadServiceAttachment(serviceRecordId, file, description = '', signal = null) {
|
||||
const formData = new FormData();
|
||||
formData.append('service_record', serviceRecordId);
|
||||
|
||||
Reference in New Issue
Block a user