feat: dodaj DOCX akcije i napomene na racunima
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

- dodaje DOCX preuzimanje za putni nalog, servisne zapise i racune na /putni-nalozi/racuni\n- uvodi modal za uredjivanje teksta napomene izvjestaja servisera\n- poboljsava update work order toka i upsert lokalnog stanja\n- ukljucuje TaskCalendar migraciju, dev watch stabilizaciju i deploy upute

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
mariomitte
2026-07-23 13:34:53 +02:00
parent 1055820741
commit ae6ae08fb1
9 changed files with 294 additions and 7 deletions

View File

@@ -1 +1,23 @@
Frontend + Backend + Database + Deployment Frontend + Backend + Database + Deployment
### Deploy na serveru
Nakon što je gitea webhook i testirana skripta za automatsko povlačenje promjena iz repozitorija
```bash
cd /opt/erp/app
git fetch origin
git reset --hard origin/main
# rebuild backend + worker + beat + frontend
docker compose -f docker-compose.yml -f docker-compose.prod.yml build backend worker beat frontend
# digni servise s novim image-ima
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --force-recreate backend worker beat frontend
# OBAVEZNO migracije (scheduled_date)
docker compose -f docker-compose.yml -f docker-compose.prod.yml exec backend python manage.py migrate
# provjera
curl -i https://api-004.captain.mitteworkspace.cloud/api/health/
```

View File

@@ -0,0 +1,10 @@
CONTACT ID,EMAIL,sib_contact_owner,LASTNAME,FIRSTNAME,_DETECTED_LANGUAGE,DOUBLE_OPT-IN,OPT_IN,CONTACT_TIMEZONE,EXT_ID,LINKEDIN,JOB_TITLE,WHATSAPP,LANDLINE_NUMBER,SMS,ADDED_TIME,MODIFIED_TIME
1,mtkalac.mt@gmail.com,,TKALAC,MARIO,,,,,,,,,,385955002483,20-07-2026,20-07-2026
2,domagoj@knezljubo.hr,,Knez,Domagoj,Croatian,No,NO,(GMT+02:00) Zagreb,domagojknezljubo,,direktor,385913228904,,385913228904,20-07-2026,20-07-2026
3,ljubo@knezljubo.hr,,Ljubo,Knez,Croatian,No,NO,(GMT+02:00) Zagreb,ljuboknezljubo,,direktor,385913228902,,385913228902,20-07-2026,20-07-2026
4,mario.tkalac@knezljubo.hr,,Tkalac,Mario,Croatian,No,NO,(GMT+02:00) Zagreb,mariotkalacknezljubo,,serviser,385916303466,,385916303466,20-07-2026,20-07-2026
5,fran@knezljubo.hr,,Mati<EFBFBD>,Fran,Croatian,No,NO,(GMT+02:00) Zagreb,franmaticknezljubo,,prodaja,385916004943,,385916004943,20-07-2026,20-07-2026
6,iva.markic@knezljubo.hr,,Marki<EFBFBD>,Iva,Croatian,No,NO,(GMT+02:00) Zagreb,ivamarkicknezljubo,,knjigovo<EFBFBD>a,385919423525,,385919423525,20-07-2026,20-07-2026
7,ramo@knezljubo.hr,,Husanovi<EFBFBD>,Ramo,Croatian,No,NO,(GMT+02:00) Zagreb,ramohusanovicknezljubo,,serviser,385913228910,,385913228910,20-07-2026,20-07-2026
8,office@knezljubo.hr,,Cepi<EFBFBD>,Dario,Croatian,No,NO,(GMT+02:00) Zagreb,dariocepicknezljubo,logistika,385994067776,,385994067776,20-07-2026,20-07-2026
9,josip@knezljubo.hr,,Pintari<EFBFBD>,Josip,Croatian,No,NO,(GMT+02:00) Zagreb,josippintaricknezljubo,serviser,385913228906,,385913228906,20-07-2026,20-07-2026
1 CONTACT ID,EMAIL,sib_contact_owner,LASTNAME,FIRSTNAME,_DETECTED_LANGUAGE,DOUBLE_OPT-IN,OPT_IN,CONTACT_TIMEZONE,EXT_ID,LINKEDIN,JOB_TITLE,WHATSAPP,LANDLINE_NUMBER,SMS,ADDED_TIME,MODIFIED_TIME
2 1,mtkalac.mt@gmail.com,,TKALAC,MARIO,,,,,,,,,,385955002483,20-07-2026,20-07-2026
3 2,domagoj@knezljubo.hr,,Knez,Domagoj,Croatian,No,NO,(GMT+02:00) Zagreb,domagojknezljubo,,direktor,385913228904,,385913228904,20-07-2026,20-07-2026
4 3,ljubo@knezljubo.hr,,Ljubo,Knez,Croatian,No,NO,(GMT+02:00) Zagreb,ljuboknezljubo,,direktor,385913228902,,385913228902,20-07-2026,20-07-2026
5 4,mario.tkalac@knezljubo.hr,,Tkalac,Mario,Croatian,No,NO,(GMT+02:00) Zagreb,mariotkalacknezljubo,,serviser,385916303466,,385916303466,20-07-2026,20-07-2026
6 5,fran@knezljubo.hr,,Matiæ,Fran,Croatian,No,NO,(GMT+02:00) Zagreb,franmaticknezljubo,,prodaja,385916004943,,385916004943,20-07-2026,20-07-2026
7 6,iva.markic@knezljubo.hr,,Markiæ,Iva,Croatian,No,NO,(GMT+02:00) Zagreb,ivamarkicknezljubo,,knjigovoða,385919423525,,385919423525,20-07-2026,20-07-2026
8 7,ramo@knezljubo.hr,,Husanoviæ,Ramo,Croatian,No,NO,(GMT+02:00) Zagreb,ramohusanovicknezljubo,,serviser,385913228910,,385913228910,20-07-2026,20-07-2026
9 8,office@knezljubo.hr,,Cepiæ,Dario,Croatian,No,NO,(GMT+02:00) Zagreb,dariocepicknezljubo,logistika,385994067776,,385994067776,20-07-2026,20-07-2026
10 9,josip@knezljubo.hr,,Pintariæ,Josip,Croatian,No,NO,(GMT+02:00) Zagreb,josippintaricknezljubo,serviser,385913228906,,385913228906,20-07-2026,20-07-2026

View File

@@ -0,0 +1,26 @@
# Generated by Django 5.2.15 on 2026-07-21 11:51
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('task_management', '0010_alter_tasktemplate_status'),
]
operations = [
migrations.CreateModel(
name='TaskCalendar',
fields=[
],
options={
'verbose_name': 'Kalendar zadatka',
'verbose_name_plural': 'Kalendar zadataka',
'proxy': True,
'indexes': [],
'constraints': [],
},
bases=('task_management.task',),
),
]

View File

@@ -50,6 +50,7 @@ pytesseract==0.3.13
pytest>=9.1.1 pytest>=9.1.1
pytest-django>=4.12.0 pytest-django>=4.12.0
python-dateutil==2.9.0.post0 python-dateutil==2.9.0.post0
python-docx==1.2.0
python-dotenv==1.2.2 python-dotenv==1.2.2
pusher>=3.3.4 pusher>=3.3.4
redis==8.0.0 redis==8.0.0

View File

@@ -22,7 +22,8 @@ services:
build: build:
context: ./frontend context: ./frontend
dockerfile: Dockerfile dockerfile: Dockerfile
command: npm run dev -- --host 0.0.0.0 --force working_dir: /app
command: sh -c "rm -rf /app/.astro /app/node_modules/.vite && npm run dev -- --host 0.0.0.0 --force"
ports: ports:
- "4321:4321" - "4321:4321"
volumes: volumes:
@@ -30,6 +31,9 @@ services:
- /app/node_modules - /app/node_modules
environment: environment:
- NODE_ENV=development - NODE_ENV=development
- CHOKIDAR_USEPOLLING=true
- CHOKIDAR_INTERVAL=1000
- WATCHPACK_POLLING=true
- PUBLIC_ALLOWED_HOSTS=localhost,127.0.0.1 - PUBLIC_ALLOWED_HOSTS=localhost,127.0.0.1
- PUBLIC_HMR_HOST=localhost - PUBLIC_HMR_HOST=localhost
- PUBLIC_HMR_PROTOCOL=ws - PUBLIC_HMR_PROTOCOL=ws

View File

@@ -68,7 +68,12 @@ export default defineConfig({
}, },
watch: { watch: {
usePolling: true, usePolling: true,
interval: 100, interval: 1000,
awaitWriteFinish: {
stabilityThreshold: 1200,
pollInterval: 200,
},
ignored: ['**/.git/**', '**/node_modules/**', '**/.astro/**', '**/dist/**'],
}, },
} }
: { : {

View File

@@ -1,22 +1,28 @@
import { useEffect, useMemo, useState } from 'preact/hooks'; import { useEffect, useMemo, useState } from 'preact/hooks';
import { useStore } from '@nanostores/preact'; import { useStore } from '@nanostores/preact';
import { import {
downloadWorkOrderDocx,
downloadWorkOrderPdf, downloadWorkOrderPdf,
downloadWorkOrderInvoicesDocx,
downloadWorkOrderInvoicesPdf, downloadWorkOrderInvoicesPdf,
downloadWorkOrderServiceRecordsDocx,
downloadWorkOrderServiceRecordsPdf, downloadWorkOrderServiceRecordsPdf,
fetchWorkOrderAdditionalCostsTable, fetchWorkOrderAdditionalCostsTable,
fetchWorkOrderById, fetchWorkOrderById,
fetchWorkOrderInvoices, fetchWorkOrderInvoices,
fetchWorkOrderTaskServiceContext, fetchWorkOrderTaskServiceContext,
updateTaskWorkHoursTable, updateTaskWorkHoursTable,
updateWorkOrder,
updateWorkOrderAdditionalCostsTable, updateWorkOrderAdditionalCostsTable,
} from '../../stores/fleetDashboardStore'; } from '../../stores/fleetDashboardStore';
import { $accessToken, $authReady, hydrateAuthFromStorage } from '../../stores/authStore'; import { $accessToken, $authReady, hydrateAuthFromStorage } from '../../stores/authStore';
import { fetchNotifications, connectNotifications } from '../../stores/notificationStore'; import { fetchNotifications, connectNotifications } from '../../stores/notificationStore';
import { formatWorkOrderDisplayCode } from '../../lib/displayIds'; import { formatWorkOrderDisplayCode } from '../../lib/displayIds';
import { showToast } from '../../stores/toastStore';
import { resolveMediaUrl, useAuthenticatedMediaSources } from './WorkOrderImageCarousel'; import { resolveMediaUrl, useAuthenticatedMediaSources } from './WorkOrderImageCarousel';
import TaskWorkHoursTableModal from './TaskWorkHoursTableModal'; import TaskWorkHoursTableModal from './TaskWorkHoursTableModal';
import WorkOrderAdditionalCostsModal from './WorkOrderAdditionalCostsModal'; import WorkOrderAdditionalCostsModal from './WorkOrderAdditionalCostsModal';
import WorkOrderServiceNotesModal from './WorkOrderServiceNotesModal';
function readWorkOrderIdFromQuery() { function readWorkOrderIdFromQuery() {
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
@@ -77,6 +83,9 @@ export default function WorkOrderInvoicesPdfPage() {
const [additionalCostsTable, setAdditionalCostsTable] = useState({ data: { rows: [] }, total_for_payout: '0.00' }); const [additionalCostsTable, setAdditionalCostsTable] = useState({ data: { rows: [] }, total_for_payout: '0.00' });
const [editingAdditionalCosts, setEditingAdditionalCosts] = useState(false); const [editingAdditionalCosts, setEditingAdditionalCosts] = useState(false);
const [savingAdditionalCosts, setSavingAdditionalCosts] = useState(false); const [savingAdditionalCosts, setSavingAdditionalCosts] = useState(false);
const [editingServiceReportNote, setEditingServiceReportNote] = useState(false);
const [savingServiceReportNote, setSavingServiceReportNote] = useState(false);
const [serviceReportNoteError, setServiceReportNoteError] = useState('');
useEffect(() => { useEffect(() => {
hydrateAuthFromStorage(); hydrateAuthFromStorage();
@@ -207,6 +216,24 @@ export default function WorkOrderInvoicesPdfPage() {
} }
}; };
const handleSaveServiceReportNote = async (notes) => {
if (!workOrderId) return;
setSavingServiceReportNote(true);
setServiceReportNoteError('');
try {
const updated = await updateWorkOrder(workOrderId, { notes: String(notes || '') });
setWorkOrder(updated);
setEditingServiceReportNote(false);
showToast('Napomena za izvještaj servisera je spremljena.', 'success');
} catch (err) {
const message = err?.message || 'Spremanje napomene nije uspjelo.';
setServiceReportNoteError(message);
showToast(message, 'error');
} finally {
setSavingServiceReportNote(false);
}
};
return ( return (
<section className="mx-auto w-full max-w-5xl space-y-4 p-4 sm:p-6"> <section className="mx-auto w-full max-w-5xl space-y-4 p-4 sm:p-6">
<div className="rounded-xl border border-border-hairline bg-canvas-elevated p-4"> <div className="rounded-xl border border-border-hairline bg-canvas-elevated p-4">
@@ -225,6 +252,14 @@ export default function WorkOrderInvoicesPdfPage() {
> >
Preuzmi PDF putnog naloga Preuzmi PDF putnog naloga
</button> </button>
<button
type="button"
onClick={() => downloadWorkOrderDocx(workOrderId)}
disabled={!workOrderId}
className="rounded-lg border border-border-hairline px-4 py-2 text-sm font-semibold text-text-main hover:bg-canvas-deep disabled:opacity-60"
>
Preuzmi DOCX putnog naloga
</button>
</div> </div>
</div> </div>
@@ -244,6 +279,14 @@ export default function WorkOrderInvoicesPdfPage() {
> >
Uredi tablicu radnih sati Uredi tablicu radnih sati
</button> </button>
<button
type="button"
onClick={() => setEditingServiceReportNote(true)}
disabled={!workOrderId}
className="rounded-lg border border-border-hairline px-4 py-2 text-sm font-semibold text-text-main hover:bg-canvas-deep disabled:opacity-60"
>
Uredi tekst napomene
</button>
<button <button
type="button" type="button"
onClick={() => downloadWorkOrderServiceRecordsPdf(workOrderId)} onClick={() => downloadWorkOrderServiceRecordsPdf(workOrderId)}
@@ -252,6 +295,14 @@ export default function WorkOrderInvoicesPdfPage() {
> >
Preuzmi PDF servisnih zapisa Preuzmi PDF servisnih zapisa
</button> </button>
<button
type="button"
onClick={() => downloadWorkOrderServiceRecordsDocx(workOrderId)}
disabled={!workOrderId}
className="rounded-lg border border-border-hairline px-4 py-2 text-sm font-semibold text-text-main hover:bg-canvas-deep disabled:opacity-60"
>
Preuzmi DOCX servisnih zapisa
</button>
</div> </div>
</div> </div>
{loading && <p className="text-sm text-text-muted">Učitavanje task konteksta...</p>} {loading && <p className="text-sm text-text-muted">Učitavanje task konteksta...</p>}
@@ -331,6 +382,14 @@ export default function WorkOrderInvoicesPdfPage() {
> >
Preuzmi račune putnog naloga Preuzmi račune putnog naloga
</button> </button>
<button
type="button"
onClick={() => downloadWorkOrderInvoicesDocx(workOrderId)}
disabled={!workOrderId}
className="rounded-lg border border-border-hairline px-4 py-2 text-sm font-semibold text-text-main hover:bg-canvas-deep disabled:opacity-60"
>
Preuzmi DOCX računa
</button>
</div> </div>
</div> </div>
{!loading && !error && ( {!loading && !error && (
@@ -385,6 +444,17 @@ export default function WorkOrderInvoicesPdfPage() {
onClose={() => setEditingAdditionalCosts(false)} onClose={() => setEditingAdditionalCosts(false)}
onSave={handleSaveAdditionalCostsTable} onSave={handleSaveAdditionalCostsTable}
/> />
<WorkOrderServiceNotesModal
open={editingServiceReportNote}
workOrder={workOrder}
saving={savingServiceReportNote}
error={serviceReportNoteError}
onClose={() => {
setEditingServiceReportNote(false);
setServiceReportNoteError('');
}}
onSave={handleSaveServiceReportNote}
/>
</section> </section>
); );
} }

View File

@@ -0,0 +1,93 @@
import { useEffect, useState } from 'preact/hooks';
import ModalShell from '../ui/ModalShell';
export default function WorkOrderServiceNotesModal({
open,
workOrder,
saving = false,
error = '',
onClose,
onSave,
}) {
const [notes, setNotes] = useState('');
useEffect(() => {
if (!open) {
setNotes('');
return;
}
setNotes(String(workOrder?.notes || ''));
}, [open, workOrder?.id, workOrder?.notes]);
if (!open) return null;
const handleSave = async () => {
await onSave?.(notes);
};
return (
<ModalShell
onClose={onClose}
overlayClassName="z-[70] overflow-y-auto p-4 pt-16"
contentClassName="flex min-h-full items-start justify-center"
panelClassName="w-full max-w-2xl rounded-xl border border-border-hairline bg-canvas-elevated shadow-xl overflow-hidden max-h-[calc(100vh-2rem)]"
>
<div className="flex w-full flex-col">
<div className="flex items-center justify-between border-b border-border-hairline px-5 py-3">
<div>
<h3 className="text-base font-semibold text-text-main">UREDI TEKST NAPOMENE</h3>
<p className="text-xs text-text-muted">
Tekst se prikazuje u PDF-u "Izvještaj servisera" na prvoj stranici.
</p>
</div>
<button
type="button"
onClick={onClose}
aria-label="Zatvori"
className="ml-4 rounded-lg p-1.5 text-gray-400 hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-700"
>
</button>
</div>
<div className="space-y-2 px-5 py-4">
{error ? (
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
{error}
</div>
) : null}
<label htmlFor="work-order-notes" className="text-sm font-medium text-text-main">
Napomena
</label>
<textarea
id="work-order-notes"
value={notes}
onInput={(event) => setNotes(event.currentTarget.value)}
rows={8}
className="w-full rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-sm text-text-main focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Unesite tekst napomene za PDF izvještaj servisera..."
/>
</div>
<div className="flex items-center justify-end gap-3 border-t border-border-hairline px-5 py-3">
<button
type="button"
onClick={onClose}
disabled={saving}
className="rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-50"
>
Odustani
</button>
<button
type="button"
onClick={handleSave}
disabled={saving}
className="rounded-lg bg-blue-600 px-5 py-2 text-sm font-semibold text-white hover:bg-blue-700 disabled:opacity-60"
>
{saving ? 'Spremam…' : 'Spremi napomenu'}
</button>
</div>
</div>
</ModalShell>
);
}

View File

@@ -257,6 +257,16 @@ function replaceWorkOrderItem(workOrderId, nextValue) {
); );
} }
function upsertWorkOrderItem(workOrderId, nextValue) {
const current = $workOrders.get();
const exists = current.some((item) => String(item.id) === String(workOrderId));
if (exists) {
replaceWorkOrderItem(workOrderId, nextValue);
return;
}
$workOrders.set([nextValue, ...current]);
}
function appendLocalDraft(entity, payload, tempId) { function appendLocalDraft(entity, payload, tempId) {
const base = { const base = {
...payload, ...payload,
@@ -547,15 +557,12 @@ export async function createWorkOrder(data) {
export async function updateWorkOrder(workOrderId, changes) { export async function updateWorkOrder(workOrderId, changes) {
const existing = $workOrders.get().find((item) => String(item.id) === String(workOrderId)); const existing = $workOrders.get().find((item) => String(item.id) === String(workOrderId));
if (!existing) {
throw new Error('Work order nije pronađen u lokalnom stanju.');
}
const canSendNow = isBrowser() && navigator.onLine && !!$accessToken.get(); const canSendNow = isBrowser() && navigator.onLine && !!$accessToken.get();
if (canSendNow) { if (canSendNow) {
try { try {
const response = await api.patch(`fleet/work-orders/${workOrderId}/`, changes); const response = await api.patch(`fleet/work-orders/${workOrderId}/`, changes);
replaceWorkOrderItem(workOrderId, response); upsertWorkOrderItem(workOrderId, response);
await persistDashboardCache(); await persistDashboardCache();
await logSync( await logSync(
{ entity: 'work_orders', endpoint: `fleet/work-orders/${workOrderId}/`, method: 'PATCH' }, { entity: 'work_orders', endpoint: `fleet/work-orders/${workOrderId}/`, method: 'PATCH' },
@@ -575,6 +582,10 @@ export async function updateWorkOrder(workOrderId, changes) {
} }
} }
if (!existing) {
throw new Error('Work order nije dostupan offline. Otvorite dashboard ili pokušajte ponovno kada ste online.');
}
const optimistic = { const optimistic = {
...existing, ...existing,
...changes, ...changes,
@@ -849,6 +860,51 @@ export async function downloadWorkOrderServiceRecordsPdf(workOrderId) {
return payload; return payload;
} }
export async function downloadWorkOrderDocx(workOrderId) {
if (!workOrderId) {
throw new Error('Work order ID je obavezan.');
}
try {
const blob = await api.get(`fleet/work-orders/${encodeURIComponent(workOrderId)}/docx/`, { responseType: 'blob' });
saveBlobToFile(blob, `${workOrderId}.work-order.docx`);
showToast('DOCX putnog naloga je preuzet.', 'success');
} catch (err) {
const msg = err?.message || 'Preuzimanje DOCX putnog naloga nije uspjelo.';
showToast(msg, 'error');
throw err;
}
}
export async function downloadWorkOrderServiceRecordsDocx(workOrderId) {
if (!workOrderId) {
throw new Error('Work order ID je obavezan.');
}
try {
const blob = await api.get(`fleet/work-orders/${encodeURIComponent(workOrderId)}/service-records-docx/`, { responseType: 'blob' });
saveBlobToFile(blob, `${workOrderId}.work-order-service-records.docx`);
showToast('DOCX servisnih zapisa je preuzet.', 'success');
} catch (err) {
const msg = err?.message || 'Preuzimanje DOCX servisnih zapisa nije uspjelo.';
showToast(msg, 'error');
throw err;
}
}
export async function downloadWorkOrderInvoicesDocx(workOrderId) {
if (!workOrderId) {
throw new Error('Work order ID je obavezan.');
}
try {
const blob = await api.get(`fleet/work-orders/${encodeURIComponent(workOrderId)}/invoices-docx/`, { responseType: 'blob' });
saveBlobToFile(blob, `${workOrderId}.work-order-invoices.docx`);
showToast('DOCX računa putnog naloga je preuzet.', 'success');
} catch (err) {
const msg = err?.message || 'Preuzimanje DOCX računa nije uspjelo.';
showToast(msg, 'error');
throw err;
}
}
export async function downloadGeneratedPdfByUrl(downloadUrl, filename = 'document.pdf') { export async function downloadGeneratedPdfByUrl(downloadUrl, filename = 'document.pdf') {
if (!downloadUrl) { if (!downloadUrl) {
showToast('Nedostaje URL za preuzimanje PDF-a.', 'error'); showToast('Nedostaje URL za preuzimanje PDF-a.', 'error');