feat: async email slanje s cache PDF fallbackom i admin logom

Prebaci slanje emaila putnog naloga u pozadinski task bez blokiranja UI-a i dodaj fallback kad queue nije dostupna. Kod greške slanja spremi generirane PDF-ove u cache i pošalji notifikaciju s download linkovima.

Dodaj EmailDispatchLog model i Django admin pregled za praćenje kome je poslano, što je poslano, status i detalje privitaka.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
mariomitte
2026-07-21 14:41:45 +02:00
parent 42fcd836ed
commit 5945423346
11 changed files with 1338 additions and 85 deletions

View File

@@ -1,4 +1,5 @@
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';
@@ -12,6 +13,7 @@ import {
} from '../../stores/fleetDashboardStore';
import { showToast } from '../../stores/toastStore';
import { formatWorkOrderDisplayCode } from '../../lib/displayIds';
import { $serviceNotes, $serviceNotesLoading, fetchServiceNotes } from '../../stores/serviceNotesStore';
const PURPOSE_OPTIONS = [
{ value: 'defektaza', label: 'Defektaža' },
@@ -20,6 +22,8 @@ const PURPOSE_OPTIONS = [
];
export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmit }) {
const serviceNotes = useStore($serviceNotes);
const serviceNotesLoading = useStore($serviceNotesLoading);
const [isEditing, setIsEditing] = useState(false);
const [form, setForm] = useState({
has_travel_order: false,
@@ -52,6 +56,13 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi
const [emailPayload, setEmailPayload] = useState({
subject: '',
message: '',
includeWorkOrderPdf: true,
includeServiceRecordsPdf: false,
includeInvoicesPdf: false,
includeImages: false,
includeMonthlyTasks: false,
month: new Date().toISOString().slice(0, 7),
serviceNoteId: '',
});
const [invoices, setInvoices] = useState([]);
const [invoiceModalOpen, setInvoiceModalOpen] = useState(false);
@@ -83,6 +94,16 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi
}
return PURPOSE_OPTIONS;
}, [form.purpose]);
const selectableServiceNotes = useMemo(() => {
if (!workOrder?.id) return [];
return serviceNotes.filter((note) => (
!note?.is_closed
&& (
!note?.work_order
|| String(note.work_order) === String(workOrder.id)
)
));
}, [serviceNotes, workOrder?.id]);
useEffect(() => {
if (!open || typeof window === 'undefined') return;
@@ -122,6 +143,13 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi
setEmailPayload({
subject: '',
message: '',
includeWorkOrderPdf: true,
includeServiceRecordsPdf: false,
includeInvoicesPdf: false,
includeImages: false,
includeMonthlyTasks: false,
month: new Date().toISOString().slice(0, 7),
serviceNoteId: '',
});
setInvoices([]);
setInvoiceModalOpen(false);
@@ -148,7 +176,10 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi
setLoadingTeamMembers(true);
(async () => {
try {
const members = await fetchTeamMembers();
const [members] = await Promise.all([
fetchTeamMembers(),
fetchServiceNotes(),
]);
if (!cancelled) {
setTeamMembers(members);
}
@@ -403,29 +434,34 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi
setWidgetError('Odaberite barem jednog člana tima ili uključite slanje svima.');
return;
}
let failed = 0;
for (const recipient of recipients) {
try {
await sendWorkOrderEmail(
workOrder.id,
{
recipient,
subject: emailPayload.subject.trim() || undefined,
message: emailPayload.message.trim() || undefined,
},
{ toast: false }
);
showToast(`Email je poslan: ${recipient}`, 'success');
} catch {
failed += 1;
showToast(`Neuspješno slanje: ${recipient}`, 'error');
}
}
if (failed > 0) {
setWidgetError(`Slanje nije uspjelo za ${failed} primatelja.`);
if (
!emailPayload.includeWorkOrderPdf
&& !emailPayload.includeServiceRecordsPdf
&& !emailPayload.includeInvoicesPdf
&& !emailPayload.includeImages
&& !emailPayload.includeMonthlyTasks
) {
setWidgetError('Odaberite barem jedan PDF/prilog za slanje.');
return;
}
await sendWorkOrderEmail(
workOrder.id,
{
recipients,
subject: emailPayload.subject.trim() || undefined,
message: emailPayload.message.trim() || undefined,
include_work_order_pdf: emailPayload.includeWorkOrderPdf,
include_service_records_pdf: emailPayload.includeServiceRecordsPdf,
include_invoices_pdf: emailPayload.includeInvoicesPdf,
include_images: emailPayload.includeImages,
include_monthly_tasks: emailPayload.includeMonthlyTasks,
month: emailPayload.includeMonthlyTasks ? emailPayload.month : undefined,
service_note_id: emailPayload.serviceNoteId || undefined,
},
{ toast: false }
);
showToast(`Slanje je pokrenuto u pozadini za ${recipients.length} primatelja. Rezultat je u Notifikacijama.`, 'info');
setEmailModalOpen(false);
} catch (err) {
setWidgetError(err?.message || 'Neuspješno slanje emaila za putni nalog.');
@@ -917,6 +953,74 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi
placeholder="Poruka (opcionalno)"
className="min-h-24 w-full rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-sm text-text-main"
/>
<div className="grid gap-2 rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-sm text-text-main sm:grid-cols-2">
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={emailPayload.includeWorkOrderPdf}
onChange={(event) => setEmailPayload({ ...emailPayload, includeWorkOrderPdf: event.currentTarget.checked })}
/>
PDF putnog naloga
</label>
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={emailPayload.includeServiceRecordsPdf}
onChange={(event) => setEmailPayload({ ...emailPayload, includeServiceRecordsPdf: event.currentTarget.checked })}
/>
PDF servisnih zapisa
</label>
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={emailPayload.includeInvoicesPdf}
onChange={(event) => setEmailPayload({ ...emailPayload, includeInvoicesPdf: event.currentTarget.checked })}
/>
PDF računa
</label>
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={emailPayload.includeImages}
onChange={(event) => setEmailPayload({ ...emailPayload, includeImages: event.currentTarget.checked })}
/>
Dodaj slike
</label>
<label className="flex items-center gap-2 sm:col-span-2">
<input
type="checkbox"
checked={emailPayload.includeMonthlyTasks}
onChange={(event) => setEmailPayload({ ...emailPayload, includeMonthlyTasks: event.currentTarget.checked })}
/>
Pošalji popis mjesečnih taskova
</label>
<label className="flex flex-col gap-1 sm:col-span-2">
<span className="text-xs text-text-muted">Mjesec (YYYY-MM)</span>
<input
type="month"
value={emailPayload.month}
disabled={!emailPayload.includeMonthlyTasks}
onInput={(event) => setEmailPayload({ ...emailPayload, month: event.currentTarget.value })}
className="w-full rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-sm text-text-main disabled:bg-canvas-deep"
/>
</label>
<label className="flex flex-col gap-1 sm:col-span-2">
<span className="text-xs text-text-muted">Odaberi kreiranu bilješku (opcionalno)</span>
<select
value={emailPayload.serviceNoteId}
onChange={(event) => setEmailPayload({ ...emailPayload, serviceNoteId: event.currentTarget.value })}
disabled={serviceNotesLoading}
className="w-full rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-sm text-text-main disabled:bg-canvas-deep"
>
<option value="">Bez bilješke</option>
{selectableServiceNotes.map((note) => (
<option key={note.id} value={String(note.id)}>
{(note.note_date || '-') + ' • ' + (note.note || '').slice(0, 70)}
</option>
))}
</select>
</label>
</div>
</div>
<div className="flex justify-end gap-2 border-t border-border-hairline px-4 py-3">
<button

View File

@@ -174,31 +174,24 @@ export default function ServiceRecordDetailModal({
return;
}
let failed = 0;
for (const recipient of recipients) {
try {
await sendServiceRecordEmail(
serviceRecord.id,
{
recipient,
subject: emailPayload.subject.trim() || undefined,
message: emailPayload.message.trim() || undefined,
},
{ toast: false }
);
showToast(`Email je poslan: ${recipient}`, 'success');
} catch {
failed += 1;
showToast(`Neuspješno slanje: ${recipient}`, 'error');
}
}
if (failed > 0) {
setWidgetError(`Slanje nije uspjelo za ${failed} primatelja.`);
return;
}
await sendServiceRecordEmail(
serviceRecord.id,
{
recipients,
subject: emailPayload.subject.trim() || undefined,
message: emailPayload.message.trim() || undefined,
},
{ toast: false }
);
showToast(`Email je poslan za ${recipients.length} primatelja.`, 'success');
setEmailModalOpen(false);
} catch (err) {
setWidgetError(err?.message || 'Neuspješno slanje emaila za servisni zapis.');
const failedCount = Array.isArray(err?.failures) ? err.failures.length : 0;
if (failedCount > 0) {
setWidgetError(`Slanje nije uspjelo za ${failedCount} primatelja.`);
} else {
setWidgetError(err?.message || 'Neuspješno slanje emaila za servisni zapis.');
}
} finally {
setEmailSending(false);
}

View File

@@ -24,6 +24,7 @@ function detectNotificationKind(notification) {
const title = String(notification?.title || '').toLowerCase();
if (title.includes('servisni kontekst')) return 'service_context';
if (title.includes('pdf')) return 'work_order_pdf';
if (title.includes('email')) return 'work_order_email';
if (title.includes('putni nalog kreiran')) return 'work_order_created';
if (title.includes('novi putni nalog')) return 'work_order_assigned';
if (title.includes('putni nalog zatvoren')) return 'work_order_closed';
@@ -167,6 +168,9 @@ export default function NotificationDetailModal({ open, notification, onClose })
notification?.metadata?.stage === 'completed' &&
notification?.metadata?.work_order_id
);
const failedEmailCachedPdfs = Array.isArray(notification?.metadata?.cached_pdfs)
? notification.metadata.cached_pdfs
: [];
if (!open || !notification) {
return null;
@@ -217,6 +221,23 @@ export default function NotificationDetailModal({ open, notification, onClose })
{action.label}
</button>
)}
{failedEmailCachedPdfs.length > 0 && (
<div className="mt-3 space-y-2">
<p className="text-xs font-semibold uppercase tracking-wide text-text-muted">
Cached PDF datoteke
</p>
{failedEmailCachedPdfs.map((item, index) => (
<button
key={`${item.generated_pdf_id || index}-${item.filename || 'pdf'}`}
type="button"
onClick={() => downloadGeneratedPdfByUrl(item.download_url, item.filename || `work-order-${index + 1}.pdf`)}
className="block text-left text-sm font-medium text-indigo-600 underline underline-offset-2 hover:text-indigo-700"
>
{item.filename || `PDF ${index + 1}`}
</button>
))}
</div>
)}
</div>
<div className="rounded-lg border border-border-hairline bg-canvas-base p-3">

View File

@@ -887,9 +887,19 @@ export async function sendWorkOrderEmail(workOrderId, payload = {}, options = {}
if (!workOrderId) {
throw new Error('Work order ID je obavezan.');
}
const response = await api.post(`fleet/work-orders/${encodeURIComponent(workOrderId)}/send-email/`, payload);
const recipients = Array.isArray(payload?.recipients)
? payload.recipients.filter(Boolean)
: [];
const normalizedPayload = {
...payload,
recipients: recipients.length > 0 ? recipients : undefined,
};
if (!normalizedPayload.recipients || normalizedPayload.recipients.length === 0) {
delete normalizedPayload.recipients;
}
const response = await api.post(`fleet/work-orders/${encodeURIComponent(workOrderId)}/send-email/`, normalizedPayload);
if (options.toast !== false) {
showToast('Email za putni nalog je poslan.', 'success');
showToast('Zahtjev za slanje emaila je pokrenut u pozadini.', 'info');
}
return response;
}
@@ -907,6 +917,34 @@ export async function sendServiceRecordEmail(serviceRecordId, payload = {}, opti
if (!serviceRecordId) {
throw new Error('Service record ID je obavezan.');
}
const recipients = Array.isArray(payload?.recipients)
? payload.recipients.filter(Boolean)
: [];
if (recipients.length > 0) {
const failures = [];
for (const recipient of recipients) {
try {
await api.post(`fleet/service-records/${encodeURIComponent(serviceRecordId)}/send-email/`, {
...payload,
recipient,
});
} catch (error) {
failures.push({ recipient, error });
}
}
if (failures.length > 0) {
const firstError = failures[0]?.error;
const message = firstError?.message || `Neuspješno slanje za ${failures.length} primatelja.`;
const batchError = new Error(message);
batchError.failures = failures;
throw batchError;
}
if (options.toast !== false) {
showToast('Email za servisni zapis je poslan.', 'success');
}
return { sent: true, recipients };
}
const response = await api.post(`fleet/service-records/${encodeURIComponent(serviceRecordId)}/send-email/`, payload);
if (options.toast !== false) {
showToast('Email za servisni zapis je poslan.', 'success');