feat: update service report exports and calendar bulk downloads
Align service-record PDF/DOCX generation with task-level data and naming. - Use Task.service_report_note as report note source - Use Task.scheduled_date for service-record dates - Rename per-task exports to MT...SN... format - Add monthly SN/PN ZIP download endpoints with 7-day retention - Add calendar 'Preuzmi sve' modal with both bulk download actions - Prefill work-hours day/date from scheduled_date for empty tables - Remove duplicate note table and clean extra DOCX page breaks Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -1,8 +1,10 @@
|
||||
import { useEffect, useMemo, useState } from 'preact/hooks';
|
||||
import { getStatusLabel } from '../../stores/taskStore';
|
||||
import {
|
||||
downloadMonthlyServiceTasksArchive,
|
||||
downloadMonthlyCostsReport,
|
||||
downloadMonthlyServiserReport,
|
||||
downloadMonthlyWorkOrdersArchive,
|
||||
fetchMonthlyCostInvoices,
|
||||
fetchMonthlyServicerEntries,
|
||||
upsertMonthlyServicerEntry,
|
||||
@@ -89,6 +91,9 @@ export default function TaskCalendarWidget({ tasks = [], notes = [], workOrders
|
||||
const [manualEntryDraft, setManualEntryDraft] = useState(null);
|
||||
const [manualEntryError, setManualEntryError] = useState('');
|
||||
const [savingManualEntry, setSavingManualEntry] = useState(false);
|
||||
const [bulkDownloadOpen, setBulkDownloadOpen] = useState(false);
|
||||
const [downloadingAllTasks, setDownloadingAllTasks] = useState(false);
|
||||
const [downloadingAllWorkOrders, setDownloadingAllWorkOrders] = useState(false);
|
||||
|
||||
const reportOpen = reportType !== null;
|
||||
const panelWidth = reportOpen ? 920 : 300;
|
||||
@@ -341,6 +346,9 @@ export default function TaskCalendarWidget({ tasks = [], notes = [], workOrders
|
||||
function toggleReport(type) {
|
||||
setReportType((prev) => (prev === type ? null : type));
|
||||
setManualEntryTarget(null);
|
||||
if (type !== 'servicer') {
|
||||
setBulkDownloadOpen(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDownload() {
|
||||
@@ -357,6 +365,28 @@ export default function TaskCalendarWidget({ tasks = [], notes = [], workOrders
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDownloadAllTasksArchive() {
|
||||
if (downloadingAllTasks) return;
|
||||
setDownloadingAllTasks(true);
|
||||
try {
|
||||
await downloadMonthlyServiceTasksArchive(viewYear, viewMonth + 1);
|
||||
setBulkDownloadOpen(false);
|
||||
} finally {
|
||||
setDownloadingAllTasks(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDownloadAllWorkOrdersArchive() {
|
||||
if (downloadingAllWorkOrders) return;
|
||||
setDownloadingAllWorkOrders(true);
|
||||
try {
|
||||
await downloadMonthlyWorkOrdersArchive(viewYear, viewMonth + 1);
|
||||
setBulkDownloadOpen(false);
|
||||
} finally {
|
||||
setDownloadingAllWorkOrders(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleServicerRowClick(row) {
|
||||
if (!row?.clickable) return;
|
||||
setManualEntryTarget(row);
|
||||
@@ -631,8 +661,17 @@ export default function TaskCalendarWidget({ tasks = [], notes = [], workOrders
|
||||
disabled={downloading}
|
||||
className="rounded-md bg-indigo-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-indigo-700 disabled:opacity-60"
|
||||
>
|
||||
{downloading ? 'Preuzimanje...' : 'Preuzmi DOCX'}
|
||||
{downloading ? 'Preuzimanje...' : reportType === 'servicer' ? 'Preuzmi mjesečni izvještaj' : 'Preuzmi DOCX'}
|
||||
</button>
|
||||
{reportType === 'servicer' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setBulkDownloadOpen(true)}
|
||||
className="ml-2 rounded-md border border-border-hairline px-3 py-1.5 text-xs font-medium text-text-main hover:bg-canvas-deep"
|
||||
>
|
||||
Preuzmi sve
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="border-b border-border-hairline bg-canvas-deep px-4 py-1.5 text-[11px] text-text-muted">
|
||||
@@ -825,6 +864,48 @@ export default function TaskCalendarWidget({ tasks = [], notes = [], workOrders
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{bulkDownloadOpen && reportType === 'servicer' && (
|
||||
<div className="absolute inset-0 z-20 flex items-center justify-center bg-black/30 px-4">
|
||||
<div className="w-full max-w-md rounded-xl border border-border-hairline bg-canvas-elevated p-4 shadow-2xl">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-text-main">Preuzmi sve</h3>
|
||||
<p className="mt-1 text-xs text-text-muted">
|
||||
{MONTH_NAMES[viewMonth]} {viewYear}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setBulkDownloadOpen(false)}
|
||||
className="rounded p-1 text-text-muted hover:bg-canvas-deep"
|
||||
aria-label="Zatvori"
|
||||
>
|
||||
x
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={downloadingAllTasks || downloadingAllWorkOrders}
|
||||
onClick={handleDownloadAllTasksArchive}
|
||||
className="rounded-lg border border-border-hairline px-3 py-2 text-left text-sm hover:bg-indigo-50 disabled:opacity-60"
|
||||
>
|
||||
{downloadingAllTasks ? 'Generiranje...' : 'Preuzmi sve pojedinačne servisne taskove (SN)'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={downloadingAllTasks || downloadingAllWorkOrders}
|
||||
onClick={handleDownloadAllWorkOrdersArchive}
|
||||
className="rounded-lg border border-border-hairline px-3 py-2 text-left text-sm hover:bg-indigo-50 disabled:opacity-60"
|
||||
>
|
||||
{downloadingAllWorkOrders ? 'Generiranje...' : 'Preuzmi sve putne naloge + račune (PN)'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</LeftSlideDrawer>
|
||||
|
||||
@@ -116,6 +116,7 @@ const FIELD_CONFIGS = [
|
||||
];
|
||||
|
||||
const DAY_OPTIONS = ['', 'PON', 'UTO', 'SRI', 'ČET', 'PET', 'SUB', 'NED'];
|
||||
const DAY_LABELS_BY_INDEX = ['NED', 'PON', 'UTO', 'SRI', 'ČET', 'PET', 'SUB'];
|
||||
|
||||
const FB_INPUT = [
|
||||
'block w-full rounded-lg border border-gray-300 bg-gray-50 px-2.5 py-2 text-xs',
|
||||
@@ -145,15 +146,45 @@ export default function TaskWorkHoursTableModal({
|
||||
}) {
|
||||
const [rows, setRows] = useState([]);
|
||||
|
||||
const getDefaultDayDate = (fallbackToToday = false) => {
|
||||
const raw = String(task?.scheduled_date || '').trim();
|
||||
const match = raw.match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
||||
if (match) {
|
||||
const year = Number(match[1]);
|
||||
const month = Number(match[2]);
|
||||
const day = Number(match[3]);
|
||||
const date = new Date(year, month - 1, day);
|
||||
if (!Number.isNaN(date.getTime())) {
|
||||
return {
|
||||
day: DAY_LABELS_BY_INDEX[date.getDay()] || '',
|
||||
date: `${String(day).padStart(2, '0')}.${String(month).padStart(2, '0')}.${year}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
if (!fallbackToToday) {
|
||||
return { day: '', date: '' };
|
||||
}
|
||||
const today = new Date();
|
||||
return {
|
||||
day: DAY_LABELS_BY_INDEX[today.getDay()] || '',
|
||||
date: `${String(today.getDate()).padStart(2, '0')}.${String(today.getMonth() + 1).padStart(2, '0')}.${today.getFullYear()}`,
|
||||
};
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) { setRows([]); return; }
|
||||
const incoming = Array.isArray(task?.work_hours_table?.rows) ? task.work_hours_table.rows : [];
|
||||
const defaultDayDate = getDefaultDayDate(false);
|
||||
setRows(
|
||||
incoming.length > 0
|
||||
? incoming.map((r) => ({ ...EMPTY_ROW, ...(r || {}) }))
|
||||
: [{ ...EMPTY_ROW }, { ...EMPTY_ROW }, { ...EMPTY_ROW }],
|
||||
: [
|
||||
{ ...EMPTY_ROW, ...defaultDayDate },
|
||||
{ ...EMPTY_ROW, ...defaultDayDate },
|
||||
{ ...EMPTY_ROW, ...defaultDayDate },
|
||||
],
|
||||
);
|
||||
}, [open, task?.id, task?.work_hours_table]);
|
||||
}, [open, task?.id, task?.scheduled_date, task?.work_hours_table]);
|
||||
|
||||
if (!open || !task) return null;
|
||||
|
||||
@@ -252,20 +283,15 @@ export default function TaskWorkHoursTableModal({
|
||||
const addRow = () => setRows((prev) => [...prev, { ...EMPTY_ROW }]);
|
||||
|
||||
const addThreeRows = () => {
|
||||
const today = new Date();
|
||||
const dd = String(today.getDate()).padStart(2, '0');
|
||||
const mm = String(today.getMonth() + 1).padStart(2, '0');
|
||||
const dateStr = `${dd}.${mm}.${today.getFullYear()}`;
|
||||
const dayLabels = ['NED', 'PON', 'UTO', 'SRI', 'ČET', 'PET', 'SUB'];
|
||||
const dayLabel = dayLabels[today.getDay()];
|
||||
const defaults = getDefaultDayDate(true);
|
||||
const gid = newGroupId();
|
||||
const loc = defaultLocation || '';
|
||||
|
||||
setRows((prev) => [
|
||||
...prev,
|
||||
{ ...EMPTY_ROW, day: dayLabel, date: dateStr, departure_place: 'Zagreb', arrival_place: loc, _groupId: gid, _role: 'departure' },
|
||||
{ ...EMPTY_ROW, day: dayLabel, date: dateStr, _groupId: gid, _role: 'work' },
|
||||
{ ...EMPTY_ROW, day: dayLabel, date: dateStr, departure_place: loc, arrival_place: 'Zagreb', _groupId: gid, _role: 'return' },
|
||||
{ ...EMPTY_ROW, day: defaults.day, date: defaults.date, departure_place: 'Zagreb', arrival_place: loc, _groupId: gid, _role: 'departure' },
|
||||
{ ...EMPTY_ROW, day: defaults.day, date: defaults.date, _groupId: gid, _role: 'work' },
|
||||
{ ...EMPTY_ROW, day: defaults.day, date: defaults.date, departure_place: loc, arrival_place: 'Zagreb', _groupId: gid, _role: 'return' },
|
||||
]);
|
||||
};
|
||||
|
||||
|
||||
@@ -399,7 +399,14 @@ export default function WorkOrderInvoicesPdfPage() {
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => downloadWorkOrderServiceRecordsPdf(workOrderId, task.id)}
|
||||
onClick={() => downloadWorkOrderServiceRecordsPdf(
|
||||
workOrderId,
|
||||
task.id,
|
||||
{
|
||||
workOrderDisplayCode: workOrder?.display_code,
|
||||
taskTitle: task.title,
|
||||
},
|
||||
)}
|
||||
disabled={!workOrderId || !task.id}
|
||||
className="rounded-lg bg-indigo-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-indigo-700 disabled:opacity-60"
|
||||
>
|
||||
@@ -407,7 +414,14 @@ export default function WorkOrderInvoicesPdfPage() {
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => downloadWorkOrderServiceRecordsDocx(workOrderId, task.id)}
|
||||
onClick={() => downloadWorkOrderServiceRecordsDocx(
|
||||
workOrderId,
|
||||
task.id,
|
||||
{
|
||||
workOrderDisplayCode: workOrder?.display_code,
|
||||
taskTitle: task.title,
|
||||
},
|
||||
)}
|
||||
disabled={!workOrderId || !task.id}
|
||||
className="rounded-lg border border-border-hairline px-3 py-1.5 text-xs font-semibold text-text-main hover:bg-canvas-deep disabled:opacity-60"
|
||||
>
|
||||
|
||||
@@ -865,7 +865,7 @@ export async function downloadWorkOrderInvoicesPdf(workOrderId) {
|
||||
return payload;
|
||||
}
|
||||
|
||||
export async function downloadWorkOrderServiceRecordsPdf(workOrderId, taskId = null) {
|
||||
export async function downloadWorkOrderServiceRecordsPdf(workOrderId, taskId = null, taskOptions = {}) {
|
||||
if (!workOrderId) {
|
||||
throw new Error('Work order ID je obavezan.');
|
||||
}
|
||||
@@ -875,7 +875,12 @@ export async function downloadWorkOrderServiceRecordsPdf(workOrderId, taskId = n
|
||||
`fleet/work-orders/${encodeURIComponent(workOrderId)}/service-records-pdf/?task_id=${encodeURIComponent(taskId)}`,
|
||||
{ responseType: 'blob' },
|
||||
);
|
||||
saveBlobToFile(blob, `${workOrderId}.task-${taskId}.work-order-service-records.pdf`);
|
||||
const workOrderDisplayCode = String(taskOptions?.workOrderDisplayCode || '').trim().toUpperCase() || String(workOrderId);
|
||||
const taskTitle = String(taskOptions?.taskTitle || '').trim()
|
||||
.replace(/\s+/g, '_')
|
||||
.replace(/[^A-Za-z0-9_-]+/g, '')
|
||||
.replace(/^[_\-.]+|[_\-.]+$/g, '') || `task-${taskId}`;
|
||||
saveBlobToFile(blob, `${workOrderDisplayCode}.SN-${taskTitle}.pdf`);
|
||||
showToast('PDF servisnih zapisa za odabrani task je preuzet.', 'success');
|
||||
return;
|
||||
} catch (err) {
|
||||
@@ -912,7 +917,7 @@ export async function downloadWorkOrderDocx(workOrderId) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function downloadWorkOrderServiceRecordsDocx(workOrderId, taskId = null) {
|
||||
export async function downloadWorkOrderServiceRecordsDocx(workOrderId, taskId = null, taskOptions = {}) {
|
||||
if (!workOrderId) {
|
||||
throw new Error('Work order ID je obavezan.');
|
||||
}
|
||||
@@ -923,7 +928,12 @@ export async function downloadWorkOrderServiceRecordsDocx(workOrderId, taskId =
|
||||
{ responseType: 'blob' },
|
||||
);
|
||||
if (taskId) {
|
||||
saveBlobToFile(blob, `${workOrderId}.task-${taskId}.work-order-service-records.docx`);
|
||||
const workOrderDisplayCode = String(taskOptions?.workOrderDisplayCode || '').trim().toUpperCase() || String(workOrderId);
|
||||
const taskTitle = String(taskOptions?.taskTitle || '').trim()
|
||||
.replace(/\s+/g, '_')
|
||||
.replace(/[^A-Za-z0-9_-]+/g, '')
|
||||
.replace(/^[_\-.]+|[_\-.]+$/g, '') || `task-${taskId}`;
|
||||
saveBlobToFile(blob, `${workOrderDisplayCode}.SN-${taskTitle}.docx`);
|
||||
showToast('DOCX servisnih zapisa za odabrani task je preuzet.', 'success');
|
||||
} else {
|
||||
saveBlobToFile(blob, `${workOrderId}.work-order-service-records.docx`);
|
||||
@@ -1010,6 +1020,34 @@ export async function downloadMonthlyCostsReport(year, month) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function downloadMonthlyServiceTasksArchive(year, month) {
|
||||
try {
|
||||
const blob = await api.get(
|
||||
`fleet/reports/monthly-service-tasks-archive/?year=${year}&month=${month}`,
|
||||
{ responseType: 'blob' },
|
||||
);
|
||||
saveBlobToFile(blob, `MT-${String(month).padStart(2, '0')}-${year}-SN.zip`);
|
||||
showToast('ZIP pojedinačnih servisnih taskova je preuzet.', 'success');
|
||||
} catch (err) {
|
||||
showToast(err?.message || 'Preuzimanje ZIP-a servisnih taskova nije uspjelo.', 'error');
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export async function downloadMonthlyWorkOrdersArchive(year, month) {
|
||||
try {
|
||||
const blob = await api.get(
|
||||
`fleet/reports/monthly-work-orders-archive/?year=${year}&month=${month}`,
|
||||
{ responseType: 'blob' },
|
||||
);
|
||||
saveBlobToFile(blob, `MT-${String(month).padStart(2, '0')}-${year}-PN.zip`);
|
||||
showToast('ZIP putnih naloga i računa je preuzet.', 'success');
|
||||
} catch (err) {
|
||||
showToast(err?.message || 'Preuzimanje ZIP-a putnih naloga nije uspjelo.', 'error');
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export async function downloadGeneratedPdfByUrl(downloadUrl, filename = 'document.pdf') {
|
||||
if (!downloadUrl) {
|
||||
showToast('Nedostaje URL za preuzimanje PDF-a.', 'error');
|
||||
|
||||
Reference in New Issue
Block a user