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:
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user