59 Commits

Author SHA1 Message Date
mariomitte
242c8dc9b2 .zip error creation due to ownership
Some checks are pending
ERP CI/CD Pipeline / test (push) Waiting to run
ERP CI/CD Pipeline / Deploy (server git pull + compose) (push) Blocked by required conditions
detected with ai agent
2026-09-08 14:45:37 +02:00
mariomitte
1172dfabd7 fix: include user-owned work orders in monthly archive
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
Ensure monthly ZIP generation includes work orders owned by the user even when the task is assigned to someone else, and add a regression spec covering the user-owned work-order case.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-09-08 11:11:25 +02:00
mariomitte
fc2a683b9c fix: restore monthly archive temp file builders
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
The monthly ZIP job was still being requested normally, but the backend archive task could not create its temp archive files because the helper functions were missing. Restore those builders so the archive task can complete and emit the final generated_archive notification.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-09-08 10:32:49 +02:00
mariomitte
f243bad6db fix: restore archive completion notification polling
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
Track monthly archive generation by generated_archive_id and poll notifications until completed/failed (up to timeout) instead of short PDF polling only. This restores completed ZIP notifications for calendar archive actions when generation takes longer.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-09-08 08:33:20 +02:00
mariomitte
334d1acd37 fix: restore monthly archive download exports
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
Re-add missing monthly archive export functions in fleetDashboardStore used by TaskCalendarWidget so the frontend Docker build resolves imports and starts cleanly.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-09-08 06:59:53 +02:00
mariomitte
dd27e59c2c fix: restore generated archive download export
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
Re-add downloadGeneratedArchiveByUrl in fleet dashboard store so notification detail modal imports resolve during frontend build.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-09-08 06:54:39 +02:00
mariomitte
3931c9065b fix: restore missing fleet dashboard store exports
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
Re-add missing travel-expenses and service-note store exports used by the invoices PDF page, and include travel_expenses_table in service-context fallback. This fixes the frontend Docker build failure caused by missing named exports.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-09-08 06:46:16 +02:00
mariomitte
5af66c31b6 fix: add total rows to service-record hours tables
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
Add a final 'Ukupno' row to the exported work-hours tables so PDF and DOCX service records include both work and travel totals. This keeps the generated reports aligned with the underlying task data.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-09-07 16:37:37 +02:00
mariomitte
b6fe8d8969 Refactor fleet module and add cleanup script
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
- Update Celery configuration in celery.py
- Modify base settings for improved performance
- Enhance task management in fleet tasks.py
- Revise fleet views.py for better data handling
- Add cleanup_tmp_archives.sh script for temporary file management
- Adjust docker-compose.prod.yml for consistency
- Optimize fleetDashboardStore.js with reduced code complexity
2026-09-05 08:35:39 +02:00
mariomitte
b985c285f9 fix: apply EXIF orientation to service record images in PDF/DOCX
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
Smartphone photos often have EXIF orientation metadata (tags 6, 8, 3)
that rotates the display but doesn't transform the pixel data. When
PDFs/DOCX embedded images without applying this metadata, they appear
rotated 90/180/270 degrees.

Use ImageOps.exif_transpose(img) before resize/convert in both
_compress_image_for_pdf() and _compress_image_for_docx() to read EXIF
orientation and transpose the actual pixel data accordingly. This is
a standard Pillow function and is a no-op for images without EXIF.

Add regression test test_compress_image_for_docx_handles_exif_orientation
to verify images with EXIF tag 0x0112 (orientation=6) are transposed.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-31 11:08:16 +02:00
mariomitte
aa690d4ac4 fix: increase image megapixel limit from 8 to 30 MP to include standard phone photos
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
Previous limit of 8 MP was rejecting standard smartphone photos (12-20 MP),
causing service record PDFs/DOCX documents to be generated without photos.

This was a regression from the image timeout fix: the megapixel guard was
designed to prevent processing of pathologically large files (preventing
worker timeouts), but the threshold was set too aggressively.

Increase limit to 30 MP to allow standard device cameras while still
rejecting extreme outliers that would cause timeout/OOM.

Add regression test test_compress_image_for_docx_keeps_standard_phone_photos
to verify 12 MP photos are accepted.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-31 10:54:40 +02:00
mariomitte
52408bc5a9 fix: BILINEAR+BaseException catch to fix image worker timeout in PDF/DOCX
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
Root cause was WORKER TIMEOUT, not OOM:
- Gunicorn sends SIGABRT to workers that exceed 60s timeout
- SIGABRT handler calls sys.exit(1) raising SystemExit(BaseException)
- except Exception does NOT catch SystemExit, so the worker crashes

Two fixes:
1. except BaseException — catches SystemExit so the worker survives
   and gracefully skips the image (returns None) instead of dying
2. Image.BILINEAR instead of LANCZOS — orders of magnitude faster
   for large images, ensures processing completes well within 60s
3. Reduce max_width 1600->800, megapixel limit 20->8 MP, optimize=True removed
   (these reduce processing time further)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-31 10:43:27 +02:00
mariomitte
8e9d23fcfa fix: thumbnail-first + megapixel guard to prevent image OOM in PDF/DOCX
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
Previous draft()+convert()+thumbnail() ordering still caused OOM for
non-JPEG formats (PNG/HEIC) because draft() is a no-op for those formats,
and convert('RGB') forces a full pixel decode regardless.

Replace with thumbnail()-first ordering:
- thumbnail() internally calls draft() for JPEG before decoding
- thumbnail() performs in-place resize without allocating a second
  full-resolution buffer
- convert('RGB') is then called on the already-small image (safe for
  any format)

Add _COMPRESS_IMAGE_MAX_MEGAPIXELS guard (20 MP): read image dimensions
from headers only (no pixel decode) and return None for images that
exceed the limit. This prevents OOM even for pathologically large files
where draft() provides no benefit (e.g. PNG, TIFF, HEIC).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-31 10:10:01 +02:00
mariomitte
a7220d27f5 fix: use draft()+thumbnail() to prevent OOM kill on large image resize
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
PIL img.resize() on high-resolution photos was exhausting worker RAM,
causing Gunicorn to SIGKILL the worker mid-request (production OOM crash).

Replace the explicit resize() path in both _compress_image_for_pdf() and
_compress_image_for_docx() with:
  - Image.draft() — hints the JPEG decoder to decode at a lower resolution
  - Image.thumbnail() — in-place resize that avoids allocating a second
    full-resolution buffer

This keeps peak memory proportional to the output size instead of the
original file size, preventing the worker from being killed when
processing multi-megapixel service-record photos.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-31 09:51:21 +02:00
mariomitte
1e68c663f2 fix: scope inferred work-order tasks to the current work order
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
Prevent service-record inference from pulling tasks from other work orders
that share the same vehicle. This keeps task-service-context, service
reports, and related work-order widgets isolated to the selected nalog.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-31 08:32:41 +02:00
mariomitte
2826ee9bab fix: invalidate stale service records PDF cache on service record changes
Ensure admin/API deletes and updates of service records, photos, and
attachments invalidate the cached service-records PDF. Also harden cached
PDF lookup/download paths so a missing file is treated as stale cache
instead of a 500.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-31 07:32:05 +02:00
mariomitte
9bd5f53675 fix: normalize task datetimes before travel expense min/max
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
Prevent 500 errors on travel-expenses-table when work-hour entries mix
naive and timezone-aware datetimes. Normalize all candidate datetimes to
a consistent timezone before min/max comparisons.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-10 08:17:49 +02:00
mariomitte
d2dc37aef9 fix: repair service-worker.js duplicate install listener causing script eval failure
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
apply_patch previously duplicated the install event listener, causing a
ServiceWorker script evaluation error on load. Deduplicate and hoist
precacheShell() before the install listener.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-10 07:53:09 +02:00
mariomitte
e3b3ff90a9 manifest manifest.webmanifest
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
2026-08-10 07:45:08 +02:00
mariomitte
024bf59733 fix: add 0037 migration to align WorkOrderTravelExpensesTable BaseModel fields
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
BaseModel defines id with uuid4 default, help_text, and verbose_name on
created_at/updated_at/is_active. Migration 0036 omitted these, causing
Django to detect pending model changes and refuse to migrate on the server.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-08 05:41:20 +02:00
mariomitte
8577aaa01b fix: add editable work order travel expenses
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
Introduce a dedicated travel-expenses table for work orders so Broj sati, Količina dnevnica, and Iznos dnevnice can be reviewed and edited before PDF generation. The PDF now uses the stored travel-expenses values, with default HR rate and cache invalidation on updates.
2026-08-08 03:52:41 +02:00
mariomitte
a9ab83c202 fix: apply travel-day quantity rules
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
Use business-rule based daily quantity for travel expense calculations and cover it with regression tests.
2026-08-08 03:09:24 +02:00
mariomitte
681f73d85a fix: calculate PREKOVREMENI as total hours minus regular 8h
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
PREKOVREMENI (overtime hours) is now correctly calculated as the difference between total work+travel hours and the standard 8-hour workday.
2026-08-08 01:50:11 +02:00
mariomitte
181cab1f45 fix: sync multi-day work hours accounting
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
Aggregate task work-hour rows into travel-cost calculations and monthly servicer reporting, and keep the frontend calendar aligned with the task work-hours source.
2026-08-08 01:32:17 +02:00
mariomitte
38f1786469 predzadnji unstagaeni dijelovi
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
2026-08-07 10:27:16 +02:00
mariomitte
f37d130bc7 Reapply "predzadnji unstagaeni dijelovi"
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
This reverts commit fe4ba3b5dd.
2026-08-07 10:20:32 +02:00
mariomitte
fe4ba3b5dd Revert "predzadnji unstagaeni dijelovi"
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
This reverts commit 1f6c0d6086.
2026-08-07 10:15:35 +02:00
mariomitte
1f6c0d6086 predzadnji unstagaeni dijelovi
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
2026-08-07 10:10:37 +02:00
mariomitte
3aecbdd6ea fix: correct mojibake characters in dashboard UI labels and messages
Replace corrupted UTF-8 sequences with proper Croatian characters and symbols in FleetDashboardShell: sorting arrows, action labels, loading messages, pagination buttons, and error toast messages now display correctly.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-07 10:09:10 +02:00
mariomitte
ee18af7877 fix: correct work-order navbar state and actions
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
Use prefix path matching so nested putni-nalozi routes highlight the correct nav item, load current user data in Navbar when auth token exists, and group PDF/DOCX work-order download buttons in one action block.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-07 07:36:58 +02:00
mariomitte
27b7c31c13 fix: restore crane edit modal in task service context
Expose task details from the service-context task list and restore visible crane data editing within the task flow.

Add crane working-hours and mileage editing to the task creation modal when a service context is selected, and improve the task details modal action so servicers can update crane data directly.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-07 07:19:32 +02:00
mariomitte
7053ec32f8 fix: recover stale pending monthly ZIP requests
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
Mark stale pending fleet archives as failed after a timeout window and create a fresh request so users receive completion notifications and can download ZIP files again.

Add regression coverage for replacing stale pending service-task archive requests.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-07 06:34:05 +02:00
mariomitte
bf381dcf13 feat: calendar ZIP UX grouping, archive notification polling, service records pagination
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
- Group 'Preuzmi mjesečni izvještaj' and 'Preuzmi ZIP' buttons together in the calendar toolbar
- Close the ZIP modal immediately on action click so the user is no longer blocked in the sidebar
- Backend: include generated_archive_id in the 'requested' notification so the frontend can track the specific archive lifecycle
- Frontend: refresh notifications immediately after archive request and start targeted polling (every 10s) until a completed/failed notification arrives for that archive ID
- Add previous/next pagination to the service records table (page mode) when no service context is selected, showing all records 10 per page (matching the work orders table pattern)
- Tests: assert generated_archive_id is present in both requested and completed notifications

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-07 06:08:53 +02:00
mariomitte
cb165df30c fix: use task-specific service report context
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
Ensure service report DOCX/PDF headers and monthly ZIP archives use task/work-order context, with async fallback when the broker is unavailable.
2026-08-07 01:15:31 +02:00
mariomitte
8b90916641 fix: regenerate SN archive with task-specific DOCX headers
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
Invalidate cached monthly service-task ZIP archives so exports are rebuilt with the latest task/work-order/vehicle header data. Keep stale detection for work-order archives and add regression tests for per-task header isolation and stale cache replacement.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-06 20:58:57 +02:00
mariomitte
8ad1f40601 fix: restore calendar ZIP archive actions
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
Bring back the calendar widget controls for triggering monthly ZIP archive generation for service tasks and work orders, including the modal actions and loading states.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-06 20:46:20 +02:00
mariomitte
925cd25d98 fix: restore archive downloads and task report context
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
Restore the monthly fleet archive task and routes so generated ZIP downloads work again. Also populate service report DOCX headers from the selected task/work-order context and cover the direct and archived export flows with regression tests.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-06 20:37:30 +02:00
mariomitte
ed71bf500f fix: use task work order data in service report context
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
Ensure /putni-nalozi/racuni reads service-report context from the selected task's own work order instead of reusing the parent page work-order data for every task.

- task-service-context now returns task_work_order metadata per task
- work hours modal reads location and travel data from task_work_order
- tasks without a linked work order fall back to None

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-06 19:53:40 +02:00
mariomitte
18c65a6f84 fix: stabilize generated ZIP download for large archives
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
Avoid HTTP/2 streaming failures on large archive downloads by returning the stored ZIP as a buffered HttpResponse with explicit Content-Length.

Also update archive download tests to support both buffered and streaming response objects.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-06 11:17:15 +02:00
mariomitte
456c9e4c3e feat: add async monthly ZIP generation with notifications
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
Implement background generation for monthly SN/PN ZIP archives and expose completion through in-app notifications.

- add GeneratedFleetArchive persistence model with expiry metadata
- add archive request/download endpoints and Celery background tasks
- emit notification stages for requested/completed/failed archive jobs
- update calendar bulk download actions to trigger async requests
- add notification modal actions to download generated ZIP files
- extend backend tests for async archive request and download flows

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-06 10:59:19 +02:00
mariomitte
594881f6cd feat: update service report exports and calendar bulk downloads
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
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>
2026-08-06 10:32:03 +02:00
mariomitte
9a7c7e3163 feat: add per-task PDF/DOCX export for service records
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
Enable generating service-record documents for a single task on /putni-nalozi/racuni while preserving existing full work-order export.

- Backend service-records PDF/DOCX endpoints now accept optional task_id and validate the task belongs to the selected work order.
- Document builders support scoped task lists, and per-task downloads use task-specific filenames.
- Frontend task cards now include per-task PDF/DOCX actions, while top-level buttons are clarified as exporting all service records.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-03 00:44:15 +02:00
mariomitte
7da1c84139 feat: per-task work hours table, notes, and read-only work order data in PDF page
Backend:
- Added service_report_note TextField to Task model (migration 0012)
- Exposed service_report_note in TaskSerializer
- task_service_context: fixed service record filter from
  work_order.vehicle_id -> task.vehicle_id (bug: cross-crane work orders
  returned wrong records for non-primary crane)
- task_service_context: include service_report_note in each task payload
- _build_work_order_service_records_docx_bytes: use per-task
  service_report_note (fallback: work_order.notes); fix service records
  query to use task vehicle ids for cross-crane support
- PDF generator: same per-task notes and cross-crane vehicle fix

Frontend:
- WorkOrderInvoicesPdfPage: removed global 'Uredi tablicu radnih sati' /
  'Uredi tekst napomene' buttons that always opened tasks[0]; replaced
  with per-task buttons inside each task article
- WorkOrderInvoicesPdfPage: separate editingTaskNote state / handler
  that calls updateTaskServiceReportNote (PATCH task.service_report_note)
- WorkOrderServiceNotesModal: now receives task prop instead of workOrder;
  reads/writes task.service_report_note; shows task title in header
- TaskWorkHoursTableModal: added workOrder prop; renders read-only amber
  info block with travel dates, mileage, and servicer vehicle data
- fleetDashboardStore: added updateTaskServiceReportNote() function
- WorkOrderInvoicesPdfPage: show saved service_report_note inline on
  each task card for quick reference

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-03 00:28:15 +02:00
mariomitte
e8272a00bb fix: navbar mobile dropdown clipped left edge - use left-0 instead of right-0
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
The dropdown was anchored to right-0 of the <details> element.
On mobile, the trigger button sits near the left side of the viewport
so right-0 caused the panel to extend leftward off-screen.
Changing to left-0 opens the panel rightward from the button, keeping
it fully visible.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-02 23:31:10 +02:00
mariomitte
d49ec15cc7 Mobile UX: navbar dropdown, compact KPI, table scrolling, remove test toast
1. Navbar mobile dropdown
   - Fixed potential clipping: added z-50 and top-full to dropdown panel
   - Added overflow-visible to nav wrapper so dropdown is not cut off
   - Wider panel (min-w-52), larger tap targets (py-2.5 per link)
   - Active route indicated by colored dot + bold text
   - Trigger button now shows hamburger icon + page name

2. KPI cards (section-dashboard)
   - grid-cols-2 on mobile (was single column), sm:3, xl:5
   - Reduced padding px-3 py-2.5 (was p-4) and font text-xl (was text-2xl)
   - flex-col gap-0.5 layout for tighter vertical rhythm

3. Test Toast removed
   - Deleted ToastTrigger.jsx component entirely
   - Removed import and conditional render from Layout.astro
   - Removed unused isDev variable from Layout.astro

4. AnimatedDataTable improvements
   - Replaced plain-text loading placeholder with animated skeleton rows
     (pulse animation with variable-width grey bars per column)
   - Added horizontal scroll fade indicators: left/right gradient overlays
     appear automatically via ResizeObserver + scroll event listener
   - scroll-smooth touch scrolling on mobile (WebkitOverflowScrolling, scrollbarWidth)
   - Added scope=col on th elements and aria-label / aria-busy props
   - Slightly snappier spring config (tension 260, friction 28)
   - leave animation reduced to 4px shift (was 8px) for less jarring removal

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-02 23:17:38 +02:00
mariomitte
1d7d631fbd Refactor dashboard sections: KPI cards, filters, fallbacks
- Replace 4 static KPI cards with 5 interactive cards
  - 'Otvoreni nalozi' and 'Servisi danas' are now clickable links
  - Added context-aware subtotals (u kontekstu) when crane context is set
  - Replaced 'Upozorenja' (mileage-based, irrelevant for cranes) with 'Bez putnog naloga'
  - 'Dovrseni nalozi' now shows completion percentage
  - Added new card 'Moji zadaci danas' (tasks assigned to current user, today)
- Added crane context filter toggle on the tasks table (Sve dizalice / Odabrani kontekst)
- Service records page now shows last 10 records as fallback when no crane context is selected
- Moved SyncLogPanel into a collapsible <details> element
- Removed dead code: PAGE_MODE_TO_SECTION, activeSection/setActiveSection state,
  navbar:navigate event handler, and DashboardTopbar nav tab block (showSectionNav
  was always false; navigation is handled by Navbar.jsx)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-02 23:06:33 +02:00
mariomitte
45142bba4b Fix task refresh after work order close
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
Keep the linked work order visible in task details and force-refresh tasks immediately after work order changes so the dashboard table updates without a hard refresh.
2026-08-01 10:36:20 +02:00
mariomitte
e7641e7be0 fix: proširi bilješke na članove tima i osvježi kalendar
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
Widget Bilješke sada omogućuje odabir više članova tima (ne samo
servisera), a backend validacija i testovi su usklađeni.

Dodatno, nakon realtime service_note notifikacije sada se osvježava
service-notes store, te create flow više ne briše lokalne bilješke kada
odgovor vrati praznu listu.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-01 09:48:44 +02:00
mariomitte
52f54e3a75 feat: prikaži ime tvrtke uz ime dizalice u stupcu DIZALICA
Uz naziv dizalice (make model) u zagradi prikazuje ime klijenta/tvrtke
za svaki redak (sharedCranes i fallback slučaj).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-01 08:52:30 +02:00
mariomitte
a8f441c150 fix: prikaži svaku dizalicu i SN u zasebnom retku u tablici WO
Umjesto odvajanja s ' • ' u istom retku, svaka dizalica i njen SN
prikazuju se u zasebnom <span> retku unutar flex-col containera.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-01 08:35:23 +02:00
mariomitte
2a9cac0c81 fix: prikaži sve povezane dizalice i SN u tablici putnih naloga
Kada putni nalog dijele taskovi više dizalica, stupci DIZALICA i SN
u tablici Putni nalozi dizalica prikazuju sve uključene dizalice
odvojene s ' • ' umjesto samo one za koju je WO kreiran.

- Dodana workOrderSharedCranes mapa (workOrderId -> {vehicleId -> crane})
  izgrađena iz task.work_order + task.vehicle veza
- Render stupaca DIZALICA i SN koristi mapu ako postoje shared cranes,
  inače fallback na craneInfo s originalnog WO-a

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-01 08:27:54 +02:00
mariomitte
79145c58e4 fix: prikaži shared putne naloge u kontekstu i filtriraj zatvorene u dropdownu
- scopedWorkOrders: u context modu uključi i putne naloge koji su dodijeljeni
  taskovima kontekstne dizalice (cross-crane shared WO), a ne samo one čiji
  vehicle === hydratedSelectedVehicleId
- selectedTaskWorkOrders: prikaži samo otvorene naloge (status != closed)
  u dropdownu za dodjelu putnog naloga tasku
- Ažuriran emptyMessage tablice da sugerira prebacivanje na 'svi nalozi'

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-01 08:09:45 +02:00
mariomitte
fd8c6d8040 feat: dozvoli dijeljenje putnog naloga izmedju razlicitih dizalica
Problem: serviser nije mogao odabrati putni nalog kreiran za drugu dizalicu
jer je frontend filtrirao naloge po aktivnom crane kontekstu, a backend
serializer je bacao ValidationError ako WO.vehicle != task.vehicle.

Promjene:
- TaskSerializer: uklonjena cross-crane validacija - WO moze pripadati
  drugoj dizalici od one na tasku (cross-crane sharing)
- FleetDashboardShell: serviceRecordWorkOrders i selectedTaskWorkOrders
  vise ne filtriraju po vehicle ID-u, prikazuju sve otvorene naloge
- TaskServiceRecordsModal: gumb i modal za izmjenu podataka dizalice
  (radni sati, kilometraza) premjesten iz WorkOrderDetailModal
- WorkOrderDetailModal: uklonjen gumb 'Izmijeni podatke dizalice'
- Dodana 3 nova backend testa za cross-crane WO dodjelu (15/15 prolaze)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-01 07:54:57 +02:00
mariomitte
06321fc3d1 feat: auto-odabir i datum u dropdownu putnih naloga za taskove
- TaskCreateModal: auto-pretselektira jedini otvoreni nalog za danas pri otvaranju modala
- Dropdown labele prikazuju datum naloga i oznacavaju danasnji nalog sa zvjezdicom (★)
- Dodana napomena da vise taskova moze dijeliti isti putni nalog
- TaskServiceRecordsModal: danasnji nalozi sortirani na vrh liste
- Datum prikazan u dropdownu i u prikazu trenutno odabranog naloga

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-01 07:15:39 +02:00
mariomitte
6470a2996c fix: ukloni DOCX zaštitu za uređivanje izvještaja
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
Generirani servisni DOCX preuzimao je document/write zaštitu iz predloška pa je Word datoteka bila neurediva.\n\nDodano je uklanjanje documentProtection/writeProtection/readOnlyRecommended postavki pri kreiranju DOCX dokumenta te regresijski test koji provjerava da zaštita nije prisutna u word/settings.xml.\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-31 18:47:37 +02:00
mariomitte
16264cb8a9 fix: dodaj fotografije servisnih zapisa u DOCX
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
DOCX izvjestaj servisnih zapisa sada ugradjuje povezane fotografije nakon kompresije slike za manju velicinu dokumenta.

Dodan je regresijski test koji provjerava da generirani DOCX sadrzi embedded media datoteke.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-31 18:09:16 +02:00
mariomitte
51a65f9b80 fix: omoguci rucni unos vremena u mjesecnom izvjestaju
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 uredski unos pocetka i kraja rada u sidebaru mjesecnog izvjestaja servisera i automatski racuna redovan rad iz unesenog raspona. Zadrzava postojece predpopunjavanje za rad u uredu i validira neispravan vremenski raspon.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-31 17:37:44 +02:00
mariomitte
882e255523 fix: dohvat mjesecnih racuna za troskove
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
Uklanja ovisnost o workOrders.invoices i dohvaća račune direktno kroz API filtriran po mjesecu. Dodaje i display code putnog naloga u serializer kako bi prikaz u izvještaju ostao potpun.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-31 17:05:56 +02:00
mariomitte
3a33751012 feat: dnevni unosi i svi dani u mjesecnom izvjestaju servisera
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
- Dodan model MonthlyServicerDayEntry s automatskom popunom prema tipu:
  office (08:00-16:00, Zagreb ured), vacation i sick_leave (8h bez vremena)
- Migracija 0034_monthlyservicerdayentry za novu tablicu s UniqueConstraint
- Novi ViewSet + serializer + endpoint fleet/monthly-servicer-day-entries/
  (upsert: POST za novi dan ili update za isti dan ako vec postoji)
- Backend DOCX report (_build_monthly_servicer_report_rows) sad generira
  redak za SVAKI dan u mjesecu: taskovi > rucni unos > prazan red
- TaskCalendarWidget potpuno prepisan bez mojibake znakova
  (zamjena UTF-8 specijalnih znakova ASCII ekvivalentima u string literalima)
- Tablica izvjestaja servisera: klikom na red bez naloga otvara se overlay
  s tri opcije (Rad u uredu / Bolovanje / Godisnji)
- TaskCalendarPortal: dodan calendarWorkOrders computed (svi statusi)
  kako bi report imao potpune podatke o radnom vremenu
- fetchMonthlyServicerEntries i upsertMonthlyServicerEntry u store-u

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-31 16:42:07 +02:00
43 changed files with 5469 additions and 661 deletions

View File

@@ -7,7 +7,7 @@ Nakon što je gitea webhook i testirana skripta za automatsko povlačenje promje
```bash
cd /opt/erp/app
git fetch origin
git reset --hard origin/main
git reset --hard origin/main ili git pull --ff-only origin main
# rebuild backend + worker + beat + frontend
docker compose -f docker-compose.yml -f docker-compose.prod.yml build backend worker beat frontend

View File

@@ -2,11 +2,33 @@
import os
from celery import Celery
from celery import Task
import gc
import logging
# Postavi Django settings modul
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings')
app = Celery('core')
logger = logging.getLogger(__name__)
class ResourceAwareTask(Task):
"""Celery base task s hookovima za memory-heavy async operacije."""
abstract = True
def on_success(self, retval, task_id, args, kwargs):
logger.info("Celery task uspješan: %s (%s)", self.name, task_id)
super().on_success(retval, task_id, args, kwargs)
def on_failure(self, exc, task_id, args, kwargs, einfo):
logger.exception("Celery task neuspješan: %s (%s): %s", self.name, task_id, exc)
super().on_failure(exc, task_id, args, kwargs, einfo)
def after_return(self, status, retval, task_id, args, kwargs, einfo):
gc.collect()
super().after_return(status, retval, task_id, args, kwargs, einfo)
# Koristi konfiguraciju iz settings.py s prefiksom 'CELERY_'
app.config_from_object('django.conf:settings', namespace='CELERY')

View File

@@ -154,6 +154,10 @@ REDIS_URL = os.environ.get('REDIS_URL', 'redis://localhost:6379/0')
CELERY_BROKER_URL = REDIS_URL
CELERY_RESULT_BACKEND = REDIS_URL
CELERY_WORKER_PREFETCH_MULTIPLIER = 1
CELERY_TASK_ACKS_LATE = True
CELERY_WORKER_MAX_TASKS_PER_CHILD = 20
CELERY_WORKER_MAX_MEMORY_PER_CHILD = 300000
# Ako želiš koristiti Redis kao brzi cache sustav unutar Djanga (izvrsno za ERP performanse)
# Za ovo ti je potreban paket 'django-redis' u requirements.txt
@@ -175,6 +179,14 @@ CELERY_BEAT_SCHEDULE = {
'task': 'modules.fleet.tasks.cleanup_expired_generated_pdfs_task',
'schedule': crontab(minute=0),
},
'cleanup-expired-generated-archives-hourly': {
'task': 'modules.fleet.tasks.cleanup_expired_generated_archives_task',
'schedule': crontab(minute=10),
},
'cleanup-stale-tmp-archives-daily': {
'task': 'modules.fleet.tasks.cleanup_stale_tmp_archives_task',
'schedule': crontab(hour=3, minute=30),
},
'notify-upcoming-tasks-daily': {
'task': 'modules.task_management.tasks.notify_upcoming_tasks',
'schedule': crontab(hour=8, minute=0),

View File

@@ -0,0 +1,45 @@
# Generated by Django 5.2.15 on 2026-07-31 12:30
import uuid
from decimal import Decimal
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('fleet', '0033_emaildispatchlog'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='MonthlyServicerDayEntry',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='Unikatni identifikator entiteta (UUID).', primary_key=True, serialize=False)),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Vrijeme kreiranja')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Vrijeme zadnje izmjene')),
('is_active', models.BooleanField(default=True, verbose_name='Aktivan zapis')),
('entry_date', models.DateField(db_index=True, verbose_name='Datum')),
('entry_type', models.CharField(choices=[('office', 'Rad u uredu'), ('sick_leave', 'Bolovanje'), ('vacation', 'Godišnji odmor')], max_length=24, verbose_name='Vrsta unosa')),
('description', models.CharField(blank=True, max_length=255, verbose_name='Opis posla')),
('location', models.CharField(blank=True, max_length=255, verbose_name='Mjesto rada')),
('start_time', models.TimeField(blank=True, null=True, verbose_name='Početak rada')),
('end_time', models.TimeField(blank=True, null=True, verbose_name='Kraj rada')),
('regular_hours', models.DecimalField(decimal_places=2, default=Decimal('8.00'), max_digits=5, verbose_name='Redovan rad')),
('overtime_hours', models.DecimalField(decimal_places=2, default=Decimal('0.00'), max_digits=5, verbose_name='Prekovremeni rad')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='monthly_servicer_day_entries', to=settings.AUTH_USER_MODEL, verbose_name='Serviser')),
],
options={
'verbose_name': 'Dnevni unos mjesečnog izvještaja servisera',
'verbose_name_plural': 'Dnevni unosi mjesečnog izvještaja servisera',
'ordering': ['entry_date'],
},
),
migrations.AddConstraint(
model_name='monthlyservicerdayentry',
constraint=models.UniqueConstraint(fields=('user', 'entry_date', 'is_active'), name='uniq_monthly_servicer_day_entry_user_date_active'),
),
]

View File

@@ -0,0 +1,39 @@
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import modules.fleet.models
import uuid
class Migration(migrations.Migration):
dependencies = [
('fleet', '0034_monthlyservicerdayentry'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='GeneratedFleetArchive',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='Unikatni identifikator entiteta (UUID).', primary_key=True, serialize=False)),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Vrijeme kreiranja')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Vrijeme zadnje izmjene')),
('is_active', models.BooleanField(default=True, verbose_name='Aktivan zapis')),
('archive_type', models.CharField(choices=[('service_tasks', 'Pojedinačni servisni taskovi'), ('work_orders', 'Putni nalozi i računi')], db_index=True, max_length=32, verbose_name='Tip ZIP arhive')),
('year', models.PositiveSmallIntegerField(db_index=True, verbose_name='Godina')),
('month', models.PositiveSmallIntegerField(db_index=True, verbose_name='Mjesec')),
('status', models.CharField(choices=[('pending', 'U obradi'), ('ready', 'Spremno'), ('failed', 'Neuspješno')], db_index=True, default='pending', max_length=16, verbose_name='Status')),
('file', models.FileField(blank=True, null=True, upload_to=modules.fleet.models._generated_fleet_archive_upload_to, verbose_name='ZIP datoteka')),
('filename', models.CharField(blank=True, max_length=255, verbose_name='Naziv datoteke')),
('expires_at', models.DateTimeField(blank=True, db_index=True, null=True, verbose_name='Vrijedi do')),
('error_message', models.TextField(blank=True, verbose_name='Poruka greške')),
('requested_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='requested_generated_fleet_archives', to=settings.AUTH_USER_MODEL, verbose_name='Zatražio')),
],
options={
'verbose_name': 'Generirana ZIP arhiva',
'verbose_name_plural': 'Generirane ZIP arhive',
'ordering': ['-created_at'],
},
),
]

View File

@@ -0,0 +1,34 @@
from decimal import Decimal
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('fleet', '0035_generatedfleetarchive'),
]
operations = [
migrations.CreateModel(
name='WorkOrderTravelExpensesTable',
fields=[
('id', models.UUIDField(editable=False, primary_key=True, serialize=False)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('is_active', models.BooleanField(default=True)),
('broj_sati', models.DecimalField(decimal_places=2, default=Decimal('0.00'), max_digits=10, verbose_name='Broj sati')),
('kolicina_dnevnica', models.DecimalField(decimal_places=2, default=Decimal('0.00'), max_digits=10, verbose_name='Količina dnevnica')),
('iznos_dnevnica', models.DecimalField(decimal_places=2, default=Decimal('30.00'), max_digits=10, verbose_name='Iznos dnevnice')),
('daily_rate_country', models.CharField(choices=[('HR', 'Hrvatska'), ('BIH', 'BiH'), ('SI', 'Slovenija'), ('CG', 'Crna Gora')], default='HR', max_length=8, verbose_name='Država dnevnice')),
('total_for_payout', models.DecimalField(decimal_places=2, default=Decimal('0.00'), max_digits=12, verbose_name='Ukupan iznos')),
('work_order', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='travel_expenses_table', to='fleet.workorder', verbose_name='Putni nalog')),
],
options={
'verbose_name': 'Tablica obračuna putnih troškova',
'verbose_name_plural': 'Tablice obračuna putnih troškova',
'ordering': ['-updated_at'],
},
),
]

View File

@@ -0,0 +1,32 @@
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fleet', '0036_workordertravelexpenses_table'),
]
operations = [
migrations.AlterField(
model_name='workordertravelexpensestable',
name='created_at',
field=models.DateTimeField(auto_now_add=True, verbose_name='Vrijeme kreiranja'),
),
migrations.AlterField(
model_name='workordertravelexpensestable',
name='id',
field=models.UUIDField(default=uuid.uuid4, editable=False, help_text='Unikatni identifikator entiteta (UUID).', primary_key=True, serialize=False),
),
migrations.AlterField(
model_name='workordertravelexpensestable',
name='is_active',
field=models.BooleanField(default=True, verbose_name='Aktivan zapis'),
),
migrations.AlterField(
model_name='workordertravelexpensestable',
name='updated_at',
field=models.DateTimeField(auto_now=True, verbose_name='Vrijeme zadnje izmjene'),
),
]

View File

@@ -1,10 +1,13 @@
import uuid
import re
from datetime import time
from django.db import models
from django.db.models.signals import post_save, pre_delete
from django.conf import settings
from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _
from django.utils import timezone
from django.dispatch import receiver
from core.base_models import BaseModel
from decimal import Decimal
@@ -64,6 +67,16 @@ def _generated_work_order_pdf_upload_to(instance, filename):
return f"fleet/{vehicle_reg}/generated_pdfs/{unique_name}"
def _generated_fleet_archive_upload_to(instance, filename):
requester = 'unknown'
try:
requester = str(instance.requested_by_id or 'unknown')
except Exception:
requester = 'unknown'
unique_name = f"{uuid.uuid4().hex}_{filename}"
return f"fleet/generated_archives/{requester}/{unique_name}"
class VehicleServicePhoto(BaseModel):
"""
Fotografije kvarova / radova povezane s VehicleServiceRecord.
@@ -131,6 +144,67 @@ class VehicleServiceAttachment(BaseModel):
def __str__(self):
return f"Attachment {self.pk} for {self.service_record} ({self.file.name if self.file else 'no-file'})"
def _service_record_work_order_id(instance):
if isinstance(instance, VehicleServiceRecord):
task = getattr(instance, 'task', None)
if task and getattr(task, 'work_order_id', None):
return task.work_order_id
if getattr(instance, 'task_id', None):
return VehicleServiceRecord.objects.filter(pk=instance.pk).values_list('task__work_order_id', flat=True).first()
return None
service_record = getattr(instance, 'service_record', None)
if service_record and getattr(service_record, 'task', None) and getattr(service_record.task, 'work_order_id', None):
return service_record.task.work_order_id
service_record_id = getattr(instance, 'service_record_id', None)
if not service_record_id:
return None
return VehicleServiceRecord.objects.filter(pk=service_record_id).values_list('task__work_order_id', flat=True).first()
def _invalidate_service_records_pdf_cache(work_order_id):
if not work_order_id:
return
cached_pdfs = GeneratedWorkOrderPdf.objects.filter(
is_active=True,
work_order_id=work_order_id,
pdf_type='service_records',
)
for cached in cached_pdfs:
file_name = getattr(cached.file, 'name', '')
if file_name:
try:
if cached.file.storage.exists(file_name):
cached.file.delete(save=False)
except (FileNotFoundError, OSError, ValueError):
pass
cached.file = None
cached.is_active = False
cached.status = 'failed'
cached.error_message = 'PDF cache invalidiran zbog promjene servisnih zapisa.'
cached.save(update_fields=['file', 'is_active', 'status', 'error_message', 'updated_at'])
@receiver(post_save, sender='fleet.VehicleServiceRecord')
@receiver(pre_delete, sender='fleet.VehicleServiceRecord')
def _invalidate_service_records_pdf_for_service_record(sender, instance, **kwargs):
_invalidate_service_records_pdf_cache(_service_record_work_order_id(instance))
@receiver(post_save, sender='fleet.VehicleServicePhoto')
@receiver(pre_delete, sender='fleet.VehicleServicePhoto')
def _invalidate_service_records_pdf_for_service_photo(sender, instance, **kwargs):
_invalidate_service_records_pdf_cache(_service_record_work_order_id(instance))
@receiver(post_save, sender='fleet.VehicleServiceAttachment')
@receiver(pre_delete, sender='fleet.VehicleServiceAttachment')
def _invalidate_service_records_pdf_for_service_attachment(sender, instance, **kwargs):
_invalidate_service_records_pdf_cache(_service_record_work_order_id(instance))
class Vehicle(BaseModel):
ASSET_TYPE_CHOICES = [
('vehicle', _("Vozilo")),
@@ -406,6 +480,63 @@ class WorkOrderAdditionalCostsTable(BaseModel):
return f"Additional costs table for work order {self.work_order_id}"
class WorkOrderTravelExpensesTable(BaseModel):
RATE_CHOICES = [
('HR', _("Hrvatska")),
('BIH', _("BiH")),
('SI', _("Slovenija")),
('CG', _("Crna Gora")),
]
DEFAULT_RATE_COUNTRY = 'HR'
DEFAULT_RATE_AMOUNT = Decimal('30.00')
work_order = models.OneToOneField(
WorkOrder,
on_delete=models.CASCADE,
related_name='travel_expenses_table',
verbose_name=_("Putni nalog"),
)
broj_sati = models.DecimalField(
max_digits=10,
decimal_places=2,
default=Decimal('0.00'),
verbose_name=_("Broj sati"),
)
kolicina_dnevnica = models.DecimalField(
max_digits=10,
decimal_places=2,
default=Decimal('0.00'),
verbose_name=_("Količina dnevnica"),
)
iznos_dnevnica = models.DecimalField(
max_digits=10,
decimal_places=2,
default=DEFAULT_RATE_AMOUNT,
verbose_name=_("Iznos dnevnice"),
)
daily_rate_country = models.CharField(
max_length=8,
choices=RATE_CHOICES,
default=DEFAULT_RATE_COUNTRY,
verbose_name=_("Država dnevnice"),
)
total_for_payout = models.DecimalField(
max_digits=12,
decimal_places=2,
default=Decimal('0.00'),
verbose_name=_("Ukupan iznos"),
)
class Meta:
ordering = ['-updated_at']
verbose_name = _("Tablica obračuna putnih troškova")
verbose_name_plural = _("Tablice obračuna putnih troškova")
def __str__(self):
return f"Travel expenses table for work order {self.work_order_id}"
class WorkOrderPhoto(BaseModel):
work_order = models.ForeignKey(
WorkOrder,
@@ -479,6 +610,59 @@ class GeneratedWorkOrderPdf(BaseModel):
return f"{self.pdf_type}:{self.work_order_id}:{self.status}"
class GeneratedFleetArchive(BaseModel):
ARCHIVE_TYPE_CHOICES = [
('service_tasks', _("Pojedinačni servisni taskovi")),
('work_orders', _("Putni nalozi i računi")),
]
STATUS_CHOICES = [
('pending', _("U obradi")),
('ready', _("Spremno")),
('failed', _("Neuspješno")),
]
requested_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name='requested_generated_fleet_archives',
verbose_name=_("Zatražio"),
)
archive_type = models.CharField(
max_length=32,
choices=ARCHIVE_TYPE_CHOICES,
db_index=True,
verbose_name=_("Tip ZIP arhive"),
)
year = models.PositiveSmallIntegerField(db_index=True, verbose_name=_("Godina"))
month = models.PositiveSmallIntegerField(db_index=True, verbose_name=_("Mjesec"))
status = models.CharField(
max_length=16,
choices=STATUS_CHOICES,
default='pending',
db_index=True,
verbose_name=_("Status"),
)
file = models.FileField(
upload_to=_generated_fleet_archive_upload_to,
null=True,
blank=True,
verbose_name=_("ZIP datoteka"),
)
filename = models.CharField(max_length=255, blank=True, verbose_name=_("Naziv datoteke"))
expires_at = models.DateTimeField(null=True, blank=True, db_index=True, verbose_name=_("Vrijedi do"))
error_message = models.TextField(blank=True, verbose_name=_("Poruka greške"))
class Meta:
verbose_name = _("Generirana ZIP arhiva")
verbose_name_plural = _("Generirane ZIP arhive")
ordering = ['-created_at']
def __str__(self):
return f"{self.archive_type}:{self.year}-{self.month:02d}:{self.status}"
class VehicleServiceRecord(BaseModel):
"""
Zapisi o servisima/popravcima na vozilu.
@@ -694,4 +878,106 @@ class ServiceContextNote(BaseModel):
if self.task.work_order_id != self.work_order_id:
raise ValidationError({
'task': _("Radni zadatak mora biti povezan s istim putnim nalogom kao bilješka."),
})
})
class MonthlyServicerDayEntry(BaseModel):
ENTRY_TYPE_CHOICES = [
('office', _('Rad u uredu')),
('sick_leave', _('Bolovanje')),
('vacation', _('Godišnji odmor')),
]
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name='monthly_servicer_day_entries',
verbose_name=_('Serviser'),
)
entry_date = models.DateField(db_index=True, verbose_name=_('Datum'))
entry_type = models.CharField(
max_length=24,
choices=ENTRY_TYPE_CHOICES,
verbose_name=_('Vrsta unosa'),
)
description = models.CharField(max_length=255, blank=True, verbose_name=_('Opis posla'))
location = models.CharField(max_length=255, blank=True, verbose_name=_('Mjesto rada'))
start_time = models.TimeField(null=True, blank=True, verbose_name=_('Početak rada'))
end_time = models.TimeField(null=True, blank=True, verbose_name=_('Kraj rada'))
regular_hours = models.DecimalField(
max_digits=5,
decimal_places=2,
default=Decimal('8.00'),
verbose_name=_('Redovan rad'),
)
overtime_hours = models.DecimalField(
max_digits=5,
decimal_places=2,
default=Decimal('0.00'),
verbose_name=_('Prekovremeni rad'),
)
class Meta:
verbose_name = _('Dnevni unos mjesečnog izvještaja servisera')
verbose_name_plural = _('Dnevni unosi mjesečnog izvještaja servisera')
ordering = ['entry_date']
constraints = [
models.UniqueConstraint(
fields=['user', 'entry_date', 'is_active'],
name='uniq_monthly_servicer_day_entry_user_date_active',
),
]
def __str__(self):
return f"{self.user_id} @ {self.entry_date} ({self.entry_type})"
def _calculate_regular_hours(self, start_time, end_time):
if start_time is None or end_time is None:
return Decimal('8.00')
start_minutes = (start_time.hour * 60) + start_time.minute + (start_time.second / 60)
end_minutes = (end_time.hour * 60) + end_time.minute + (end_time.second / 60)
if end_minutes <= start_minutes:
raise ValidationError({
'end_time': _('Kraj rada mora biti nakon početka rada.'),
})
hours = Decimal(str((end_minutes - start_minutes) / 60)).quantize(Decimal('0.01'))
return hours
def apply_defaults(self):
start_time = self.start_time
end_time = self.end_time
if self.entry_type == 'office':
if not self.description:
self.description = 'Rad u uredu'
if not self.location:
self.location = 'Zagreb (ured)'
if start_time is None:
start_time = time(8, 0)
if end_time is None:
end_time = time(16, 0)
elif self.entry_type == 'vacation':
if not self.description:
self.description = 'Godišnji odmor'
self.location = self.location or ''
elif self.entry_type == 'sick_leave':
if not self.description:
self.description = 'Bolovanje'
self.location = self.location or ''
else:
self.location = self.location or ''
self.start_time = start_time
self.end_time = end_time
self.regular_hours = self._calculate_regular_hours(start_time, end_time)
self.overtime_hours = Decimal('0.00')
def clean(self):
super().clean()
self.apply_defaults()
def save(self, *args, **kwargs):
self.full_clean()
return super().save(*args, **kwargs)

View File

@@ -10,9 +10,11 @@ from .models import (
Crane,
Vehicle,
ServiceContextNote,
MonthlyServicerDayEntry,
VehicleNotification,
WorkOrder,
WorkOrderAdditionalCostsTable,
WorkOrderTravelExpensesTable,
WorkOrderPhoto,
WorkOrderInvoice,
VehicleServiceRecord,
@@ -154,12 +156,12 @@ class ServiceContextNoteCreateSerializer(serializers.Serializer):
note_date = serializers.DateField(required=False, allow_null=True)
audience = serializers.ChoiceField(choices=AUDIENCE_CHOICES, default='self')
target_user = serializers.PrimaryKeyRelatedField(
queryset=User.objects.filter(is_active=True),
queryset=User.objects.filter(is_active=True, is_team_member=True),
required=False,
allow_null=True,
)
target_users = serializers.ListField(
child=serializers.PrimaryKeyRelatedField(queryset=User.objects.filter(is_active=True)),
child=serializers.PrimaryKeyRelatedField(queryset=User.objects.filter(is_active=True, is_team_member=True)),
required=False,
allow_empty=False,
)
@@ -202,12 +204,12 @@ class ServiceContextNoteCreateSerializer(serializers.Serializer):
if audience in {'self', 'team'} and target_user:
raise serializers.ValidationError({'target_user': 'Odabrani tip publike ne koristi ciljano polje člana tima.'})
if audience in {'self', 'team'} and len(target_users) > 0:
raise serializers.ValidationError({'target_users': 'Odabrani tip publike ne koristi listu servisera.'})
if target_user and not getattr(target_user, 'is_serviser', False):
raise serializers.ValidationError({'target_user': 'Bilješku je moguće poslati samo serviseru.'})
raise serializers.ValidationError({'target_users': 'Odabrani tip publike ne koristi listu članova tima.'})
if target_user and not getattr(target_user, 'is_team_member', False):
raise serializers.ValidationError({'target_user': 'Bilješku je moguće poslati samo članu tima.'})
for selected_user in target_users:
if not getattr(selected_user, 'is_serviser', False):
raise serializers.ValidationError({'target_users': 'Bilješku je moguće poslati samo serviserima.'})
if not getattr(selected_user, 'is_team_member', False):
raise serializers.ValidationError({'target_users': 'Bilješku je moguće poslati samo članovima tima.'})
if work_order:
if not work_order.is_active or str(work_order.status or '').lower() != 'open':
@@ -228,6 +230,33 @@ class ServiceContextNoteCreateSerializer(serializers.Serializer):
attrs['target_users'] = [target_user]
return attrs
class MonthlyServicerDayEntrySerializer(serializers.ModelSerializer):
class Meta:
model = MonthlyServicerDayEntry
fields = [
'id',
'entry_date',
'entry_type',
'description',
'location',
'start_time',
'end_time',
'regular_hours',
'overtime_hours',
'created_at',
'updated_at',
]
read_only_fields = [
'id',
'description',
'location',
'regular_hours',
'overtime_hours',
'created_at',
'updated_at',
]
class WorkOrderSerializer(serializers.ModelSerializer):
crane = serializers.SerializerMethodField()
crane_label = serializers.SerializerMethodField()
@@ -349,12 +378,14 @@ class WorkOrderSerializer(serializers.ModelSerializer):
class WorkOrderInvoiceSerializer(serializers.ModelSerializer):
image_url = serializers.SerializerMethodField()
image_content_type = serializers.SerializerMethodField()
work_order_display_code = serializers.SerializerMethodField()
class Meta:
model = WorkOrderInvoice
fields = [
'id',
'work_order',
'work_order_display_code',
'naziv_racuna',
'lokacija',
'datum',
@@ -397,6 +428,15 @@ class WorkOrderInvoiceSerializer(serializers.ModelSerializer):
return 'image/gif'
return 'application/octet-stream'
def get_work_order_display_code(self, obj):
work_order = getattr(obj, 'work_order', None)
if work_order is None:
return None
display_code = str(getattr(work_order, 'display_code', '') or '').strip()
if display_code:
return display_code
return str(getattr(work_order, 'id', '') or '').strip() or None
def validate_image(self, value):
if not value:
return value
@@ -462,6 +502,105 @@ class WorkOrderAdditionalCostsTableSerializer(serializers.ModelSerializer):
return super().update(instance, validated_data)
class WorkOrderTravelExpensesTableSerializer(serializers.ModelSerializer):
RATE_DEFAULTS = {
'HR': Decimal('30.00'),
'BIH': Decimal('50.00'),
'SI': Decimal('80.00'),
'CG': Decimal('50.00'),
}
class Meta:
model = WorkOrderTravelExpensesTable
fields = [
'id',
'work_order',
'broj_sati',
'kolicina_dnevnica',
'iznos_dnevnica',
'daily_rate_country',
'total_for_payout',
'created_at',
'updated_at',
]
read_only_fields = ['id', 'total_for_payout', 'created_at', 'updated_at']
@staticmethod
def _parse_decimal(value):
if value in (None, ''):
return None
try:
normalized = str(value).strip().replace('', '').replace(' ', '').replace(',', '.')
return Decimal(normalized)
except (InvalidOperation, ValueError, TypeError):
raise serializers.ValidationError('Neispravna decimalna vrijednost.')
def validate_daily_rate_country(self, value):
normalized = str(value or '').strip().upper()
if not normalized:
return WorkOrderTravelExpensesTable.DEFAULT_RATE_COUNTRY
valid_choices = {choice for choice, _label in WorkOrderTravelExpensesTable.RATE_CHOICES}
if normalized not in valid_choices:
raise serializers.ValidationError('Neispravna država dnevnice.')
return normalized
def validate(self, attrs):
instance = getattr(self, 'instance', None)
country = attrs.get(
'daily_rate_country',
getattr(instance, 'daily_rate_country', WorkOrderTravelExpensesTable.DEFAULT_RATE_COUNTRY),
)
if 'iznos_dnevnica' not in attrs or attrs.get('iznos_dnevnica') in (None, ''):
attrs['iznos_dnevnica'] = self.RATE_DEFAULTS.get(
country,
WorkOrderTravelExpensesTable.DEFAULT_RATE_AMOUNT,
)
if 'broj_sati' in attrs:
attrs['broj_sati'] = self._parse_decimal(attrs['broj_sati'])
if 'kolicina_dnevnica' in attrs:
attrs['kolicina_dnevnica'] = self._parse_decimal(attrs['kolicina_dnevnica'])
if 'iznos_dnevnica' in attrs:
attrs['iznos_dnevnica'] = self._parse_decimal(attrs['iznos_dnevnica'])
attrs['broj_sati'] = attrs.get(
'broj_sati',
getattr(instance, 'broj_sati', Decimal('0.00')),
) or Decimal('0.00')
attrs['kolicina_dnevnica'] = attrs.get(
'kolicina_dnevnica',
getattr(instance, 'kolicina_dnevnica', Decimal('0.00')),
) or Decimal('0.00')
attrs['iznos_dnevnica'] = attrs.get(
'iznos_dnevnica',
getattr(instance, 'iznos_dnevnica', self.RATE_DEFAULTS.get(country, WorkOrderTravelExpensesTable.DEFAULT_RATE_AMOUNT)),
) or self.RATE_DEFAULTS.get(country, WorkOrderTravelExpensesTable.DEFAULT_RATE_AMOUNT)
attrs['daily_rate_country'] = country
return attrs
def _calculate_total_for_payout(self, validated_data):
quantity = validated_data.get('kolicina_dnevnica', Decimal('0.00'))
rate = validated_data.get('iznos_dnevnica', Decimal('0.00'))
if not isinstance(quantity, Decimal):
quantity = self._parse_decimal(quantity) or Decimal('0.00')
if not isinstance(rate, Decimal):
rate = self._parse_decimal(rate) or Decimal('0.00')
return (quantity * rate).quantize(Decimal('0.01'))
def create(self, validated_data):
validated_data['total_for_payout'] = self._calculate_total_for_payout(validated_data)
return super().create(validated_data)
def update(self, instance, validated_data):
merged = {
'kolicina_dnevnica': validated_data.get('kolicina_dnevnica', instance.kolicina_dnevnica),
'iznos_dnevnica': validated_data.get('iznos_dnevnica', instance.iznos_dnevnica),
}
validated_data['total_for_payout'] = self._calculate_total_for_payout(merged)
return super().update(instance, validated_data)
class WorkOrderPhotoSerializer(serializers.ModelSerializer):
class Meta:
model = WorkOrderPhoto

View File

@@ -1,9 +1,11 @@
# backend/modules/fleet/tasks.py
from celery import shared_task
from core.celery import ResourceAwareTask
from django.core.mail import send_mail
from django.core.mail import EmailMessage
from django.conf import settings
from django.utils import timezone
from django.core.files import File
from django.core.files.base import ContentFile
import logging
from io import BytesIO
@@ -27,7 +29,7 @@ from pytesseract import TesseractNotFoundError
from reportlab.lib.pagesizes import A4
from reportlab.lib.utils import ImageReader
from reportlab.pdfgen import canvas
from .models import VehicleNotification, GeneratedWorkOrderPdf
from .models import VehicleNotification, GeneratedWorkOrderPdf, GeneratedFleetArchive
from .pdf_layout import register_unicode_fonts, draw_standard_header_footer
from .email_utils import append_user_signature
@@ -835,6 +837,107 @@ def process_work_order_invoice_ocr(invoice_id):
return {"status": "ok", "invoice_id": str(invoice.pk)}
@shared_task(base=ResourceAwareTask)
def build_monthly_archive_cached_task(generated_archive_id):
from .models import GeneratedFleetArchive
from .views import (
_build_monthly_service_tasks_archive_to_temp_file,
_build_monthly_work_orders_archive_to_temp_file,
_notify_monthly_archive_request,
)
generated = (
GeneratedFleetArchive.objects
.select_related('requested_by')
.filter(pk=generated_archive_id, is_active=True)
.first()
)
if generated is None:
return {"status": "failed", "error": "Generated archive record not found"}
tmp_archive_path = None
try:
if generated.archive_type == 'service_tasks':
tmp_archive_path = _build_monthly_service_tasks_archive_to_temp_file(
user=generated.requested_by,
year=generated.year,
month=generated.month,
)
else:
tmp_archive_path = _build_monthly_work_orders_archive_to_temp_file(
user=generated.requested_by,
year=generated.year,
month=generated.month,
)
if tmp_archive_path is None or tmp_archive_path.stat().st_size <= 0:
raise ValueError('ZIP arhiva je prazna.')
filename = generated.filename or f"{generated.archive_type}-{generated.year}-{generated.month}.zip"
with tmp_archive_path.open('rb') as temp_handle:
generated.file.save(filename, File(temp_handle), save=False)
generated.status = 'ready'
generated.error_message = ''
generated.save(update_fields=['file', 'status', 'error_message', 'updated_at'])
if generated.requested_by:
_notify_monthly_archive_request(
user=generated.requested_by,
archive_type=generated.archive_type,
stage='completed',
year=generated.year,
month=generated.month,
generated_archive=generated,
)
return {"status": "ready", "generated_archive_id": str(generated.pk)}
except Exception as exc:
generated.status = 'failed'
generated.error_message = str(exc)
generated.save(update_fields=['status', 'error_message', 'updated_at'])
if generated.requested_by:
_notify_monthly_archive_request(
user=generated.requested_by,
archive_type=generated.archive_type,
stage='failed',
year=generated.year,
month=generated.month,
generated_archive=generated,
)
logger.exception("Greška kod build_monthly_archive_cached_task: %s", exc)
return {"status": "failed", "error": str(exc)}
finally:
if tmp_archive_path is not None:
tmp_archive_path.unlink(missing_ok=True)
@shared_task
def cleanup_expired_generated_archives_task():
now = timezone.now()
expired = GeneratedFleetArchive.objects.filter(
is_active=True,
expires_at__isnull=False,
expires_at__lte=now,
)
deleted = 0
for item in expired:
if item.file:
item.file.delete(save=False)
item.is_active = False
item.status = 'failed'
item.error_message = 'ZIP arhiva je istekla.'
item.save(update_fields=['is_active', 'status', 'error_message', 'updated_at'])
deleted += 1
return {"deleted": deleted}
@shared_task
def cleanup_stale_tmp_archives_task():
from .views import _cleanup_stale_tmp_archives
deleted = _cleanup_stale_tmp_archives()
return {"deleted": deleted}
@shared_task
def cleanup_expired_generated_pdfs_task():
now = timezone.now()

View File

@@ -41,6 +41,13 @@ class ServiceContextNotesApiTests(TestCase):
is_team_member=True,
is_serviser=True,
)
self.team_member_non_servicer = user_model.objects.create_user(
username=f'team-{suffix}',
email=f'team-{suffix}@example.test',
password='test1234',
is_team_member=True,
is_serviser=False,
)
self.vehicle = Vehicle.objects.create(
asset_type='crane',
@@ -132,3 +139,31 @@ class ServiceContextNotesApiTests(TestCase):
)
self.assertEqual(response.status_code, 201, response.content)
self.assertEqual(int(response.data.get('created_count', 0)), 2)
def test_supervisor_can_create_member_note_for_non_servicer_team_member(self):
self.client.force_authenticate(user=self.supervisor)
response = self.client.post(
reverse('service-context-note-list'),
data={
'note': 'Bilješka za team člana koji nije serviser.',
'audience': 'member',
'target_users': [str(self.team_member_non_servicer.id)],
},
format='json',
)
self.assertEqual(response.status_code, 201, response.content)
self.assertEqual(int(response.data.get('created_count', 0)), 1)
def test_member_note_rejects_non_team_member_target(self):
self.client.force_authenticate(user=self.supervisor)
response = self.client.post(
reverse('service-context-note-list'),
data={
'note': 'Bilješka ne-smije ići van tima.',
'audience': 'member',
'target_users': [str(self.other.id)],
},
format='json',
)
self.assertEqual(response.status_code, 400, response.content)
self.assertIn('target_users', response.data)

View File

@@ -1,4 +1,5 @@
from django.contrib.auth import get_user_model
from django.core.files.base import ContentFile
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import TestCase
from django.urls import reverse
@@ -6,10 +7,31 @@ from django.utils import timezone
from rest_framework.test import APIClient
import uuid
from io import BytesIO
from datetime import timedelta
from datetime import date, timedelta
import zipfile
from unittest.mock import patch
from PIL import Image
from pypdf import PdfReader
from kombu.exceptions import OperationalError as KombuOperationalError
from modules.fleet.models import Vehicle, WorkOrder, WorkOrderInvoice, VehicleServiceRecord, VehicleServicePhoto, GeneratedWorkOrderPdf
from modules.fleet.models import (
Vehicle,
WorkOrder,
WorkOrderInvoice,
VehicleServiceRecord,
VehicleServicePhoto,
GeneratedWorkOrderPdf,
GeneratedFleetArchive,
VehicleNotification,
)
from modules.task_management.models import Task, TaskWorkHoursTable
from modules.fleet.tasks import build_monthly_archive_cached_task
from modules.fleet.views import (
_build_monthly_servicer_report_rows,
_calculate_daily_quantity_from_hours,
_compress_image_for_docx,
_work_order_related_tasks_queryset,
)
def create_test_image(filename='test.jpg', size=(40, 40), color='red'):
@@ -48,16 +70,32 @@ class WorkOrderImagesEndpointTests(TestCase):
self.vehicle = Vehicle.objects.create(
asset_type='crane',
registration_number='WO-IMG-001',
make='Liebherr',
model='LTM 1090',
crane_serial_number='123456',
superstructure_working_hours=111,
chassis_working_hours=222,
current_mileage=33344,
)
self.work_order = WorkOrder.objects.create(
vehicle=self.vehicle,
creator=self.user,
display_code='MT150726',
purpose='kontrola',
notes='Napomena iz putnog naloga',
)
self.task = Task.objects.create(
title='Test servisni zadatak',
assigned_to=self.user,
vehicle=self.vehicle,
work_order=self.work_order,
scheduled_date=date(2033, 12, 24),
service_report_note='Napomena iz taska',
)
self.service_record = VehicleServiceRecord.objects.create(
vehicle=self.vehicle,
task=self.task,
performed_by=self.user,
description='Servisni zapis za test izolacije fotografija.',
service_title='Test servis',
@@ -203,6 +241,58 @@ class WorkOrderImagesEndpointTests(TestCase):
self.assertIn('additional_costs_table', payload)
self.assertEqual(payload['additional_costs_table']['total_for_payout'], '5.00')
def test_compress_image_for_docx_keeps_standard_phone_photos(self):
file = BytesIO()
Image.new('RGB', (4000, 3000), color='blue').save(file, format='JPEG', quality=85)
file.seek(0)
uploaded = SimpleUploadedFile('phone-12mp.jpg', file.getvalue(), content_type='image/jpeg')
result = _compress_image_for_docx(uploaded)
self.assertIsNotNone(result)
self.assertGreater(len(result.getvalue()), 0)
def test_compress_image_for_docx_handles_exif_orientation(self):
exif = Image.Exif()
exif[0x0112] = 6
file = BytesIO()
Image.new('RGB', (3000, 2000), color='green').save(file, format='JPEG', quality=85, exif=exif.tobytes())
file.seek(0)
uploaded = SimpleUploadedFile('rotated-phone.jpg', file.getvalue(), content_type='image/jpeg')
result = _compress_image_for_docx(uploaded)
self.assertIsNotNone(result)
self.assertGreater(len(result.getvalue()), 0)
def test_work_order_related_tasks_queryset_excludes_other_work_orders_for_same_vehicle(self):
other_work_order = WorkOrder.objects.create(
vehicle=self.vehicle,
creator=self.user,
display_code='MT160726',
purpose='kontrola',
)
other_task = Task.objects.create(
title='Drugi nalog isti stroj',
assigned_to=self.user,
vehicle=self.vehicle,
work_order=other_work_order,
scheduled_date=date(2033, 12, 25),
)
VehicleServiceRecord.objects.create(
vehicle=self.vehicle,
task=other_task,
performed_by=self.user,
description='Ne smije se pojaviti u prvom nalogu.',
service_title='Krivi nalog',
)
resolved_ids = list(_work_order_related_tasks_queryset(self.work_order).values_list('id', flat=True))
self.assertIn(self.task.pk, resolved_ids)
self.assertNotIn(other_task.pk, resolved_ids)
def test_invoice_upload_immediately_appears_in_additional_costs_table(self):
create_response = self.client.post(
reverse('work-order-invoice-list'),
@@ -240,3 +330,661 @@ class WorkOrderImagesEndpointTests(TestCase):
cached.refresh_from_db()
self.assertFalse(cached.is_active)
self.assertEqual(cached.status, 'failed')
def test_travel_expenses_update_invalidates_work_order_pdf_cache(self):
cached = GeneratedWorkOrderPdf.objects.create(
work_order=self.work_order,
requested_by=self.user,
pdf_type='work_order',
status='ready',
filename='MT150726.work-order.pdf',
expires_at=timezone.now() + timedelta(hours=1),
)
response = self.client.put(
f"/api/fleet/work-orders/{self.work_order.pk}/travel-expenses-table/",
data={
'broj_sati': '9.5',
'kolicina_dnevnica': '0.5',
'iznos_dnevnica': '30',
'daily_rate_country': 'HR',
},
format='json',
)
self.assertEqual(response.status_code, 200, response.content)
cached.refresh_from_db()
self.assertFalse(cached.is_active)
self.assertEqual(cached.status, 'failed')
def test_service_record_delete_invalidates_service_records_pdf_cache(self):
cached = GeneratedWorkOrderPdf.objects.create(
work_order=self.work_order,
requested_by=self.user,
pdf_type='service_records',
status='ready',
filename='MT150726.work-order-service-records.pdf',
expires_at=timezone.now() + timedelta(hours=1),
)
cached.file.save(
'MT150726.work-order-service-records.pdf',
ContentFile(b'%PDF-1.4 cached service records'),
save=True,
)
VehicleServiceRecord.objects.filter(pk=self.service_record.pk).delete()
cached.refresh_from_db()
self.assertFalse(cached.is_active)
self.assertEqual(cached.status, 'failed')
self.assertFalse(cached.file.name)
def test_service_records_pdf_rebuilds_when_cached_file_is_missing(self):
cached = GeneratedWorkOrderPdf.objects.create(
work_order=self.work_order,
requested_by=self.user,
pdf_type='service_records',
status='ready',
filename='MT150726.work-order-service-records.pdf',
expires_at=timezone.now() + timedelta(hours=1),
)
cached.file.save(
'MT150726.work-order-service-records.pdf',
ContentFile(b'%PDF-1.4 cached service records'),
save=True,
)
cached.file.storage.delete(cached.file.name)
response = self.client.get(f"/api/fleet/work-orders/{self.work_order.pk}/service-records-pdf/")
self.assertEqual(response.status_code, 200, response.content)
self.assertEqual(response['Content-Type'], 'application/pdf')
cached.refresh_from_db()
self.assertFalse(cached.is_active)
self.assertEqual(cached.status, 'failed')
def test_service_records_docx_contains_embedded_service_photos(self):
VehicleServicePhoto.objects.create(
service_record=self.service_record,
image=create_test_image('service-record-docx.jpg', size=(320, 200), color='blue'),
description='DOCX photo',
uploaded_by=self.user,
)
response = self.client.get(f"/api/fleet/work-orders/{self.work_order.pk}/service-records-docx/")
self.assertEqual(response.status_code, 200, response.content)
self.assertEqual(
response['Content-Type'],
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
)
archive = zipfile.ZipFile(BytesIO(response.content))
media_files = [name for name in archive.namelist() if name.startswith('word/media/')]
self.assertTrue(media_files, archive.namelist())
settings_xml = archive.read('word/settings.xml').decode('utf-8')
self.assertNotIn('documentProtection', settings_xml)
self.assertNotIn('writeProtection', settings_xml)
def test_task_specific_service_records_docx_uses_task_note_and_scheduled_date(self):
response = self.client.get(
f"/api/fleet/work-orders/{self.work_order.pk}/service-records-docx/?task_id={self.task.pk}"
)
self.assertEqual(response.status_code, 200, response.content)
content_disposition = response.get('Content-Disposition', '')
self.assertIn('MT150726.SN-Test_servisni_zadatak.docx', content_disposition)
archive = zipfile.ZipFile(BytesIO(response.content))
document_xml = archive.read('word/document.xml').decode('utf-8')
self.assertIn('Napomena iz taska', document_xml)
self.assertNotIn('Napomena iz putnog naloga', document_xml)
self.assertIn('24.12.2033', document_xml)
self.assertEqual(document_xml.count('w:type="page"'), 1)
def test_task_specific_service_records_docx_uses_dynamic_docx_header_values(self):
response = self.client.get(
f"/api/fleet/work-orders/{self.work_order.pk}/service-records-docx/?task_id={self.task.pk}"
)
self.assertEqual(response.status_code, 200, response.content)
archive = zipfile.ZipFile(BytesIO(response.content))
header_xml = archive.read('word/header1.xml').decode('utf-8')
self.assertIn('Liebherr LTM 1090', header_xml)
self.assertIn('123456', header_xml)
self.assertIn('111', header_xml)
self.assertIn('222', header_xml)
self.assertIn('33344', header_xml)
self.assertIn('MT150726', header_xml)
self.assertNotIn('MT250226', header_xml)
self.assertNotIn('LTM-1350', header_xml)
def test_task_specific_service_records_pdf_uses_task_header_values(self):
vehicle_two = Vehicle.objects.create(
asset_type='crane',
registration_number='WO-IMG-003',
make='Tadano',
model='ATF 120',
crane_serial_number='SN-456',
superstructure_working_hours=777,
chassis_working_hours=888,
current_mileage=99911,
)
work_order_two = WorkOrder.objects.create(
vehicle=vehicle_two,
creator=self.user,
display_code='MT170726',
purpose='kontrola',
)
task_two = Task.objects.create(
title='PDF kontekst task',
assigned_to=self.user,
vehicle=vehicle_two,
work_order=work_order_two,
scheduled_date=date(2033, 12, 27),
)
response = self.client.get(
f"/api/fleet/work-orders/{work_order_two.pk}/service-records-pdf/?task_id={task_two.pk}"
)
self.assertEqual(response.status_code, 200, response.content)
self.assertEqual(response['Content-Type'], 'application/pdf')
reader = PdfReader(BytesIO(response.content))
text = "\n".join((page.extract_text() or '') for page in reader.pages)
self.assertIn('Tadano', text)
self.assertIn('ATF 120', text)
self.assertIn('SN-456', text)
self.assertIn('777', text)
self.assertIn('888', text)
self.assertIn('99911', text)
self.assertIn('MT170726', text)
self.assertNotIn('MT150726', text)
def test_service_records_docx_includes_work_and_travel_total_row(self):
TaskWorkHoursTable.objects.create(
task=self.task,
data={
'rows': [{
'day': 'PON',
'date': '01.12.2033',
'work_time_from': '08:00',
'work_time_to': '12:30',
'travel_time_from': '07:00',
'travel_time_to': '08:00',
'break_hours': '0,5',
'work_hours': '4,0',
'travel_hours': '1,0',
'departure_place': 'Zagreb',
'arrival_place': 'Split',
'vehicle_km': '120',
}]
},
)
response = self.client.get(f"/api/fleet/work-orders/{self.work_order.pk}/service-records-docx/?task_id={self.task.pk}")
self.assertEqual(response.status_code, 200, response.content)
archive = zipfile.ZipFile(BytesIO(response.content))
document_xml = archive.read('word/document.xml').decode('utf-8', errors='ignore')
self.assertIn('Ukupno', document_xml)
self.assertIn('4,0', document_xml)
self.assertIn('1,0', document_xml)
def test_service_records_pdf_includes_work_and_travel_total_row(self):
TaskWorkHoursTable.objects.create(
task=self.task,
data={
'rows': [{
'day': 'PON',
'date': '01.12.2033',
'work_time_from': '08:00',
'work_time_to': '12:30',
'travel_time_from': '07:00',
'travel_time_to': '08:00',
'break_hours': '0,5',
'work_hours': '4,0',
'travel_hours': '1,0',
'departure_place': 'Zagreb',
'arrival_place': 'Split',
'vehicle_km': '120',
}]
},
)
response = self.client.get(f"/api/fleet/work-orders/{self.work_order.pk}/service-records-pdf/?task_id={self.task.pk}")
self.assertEqual(response.status_code, 200, response.content)
self.assertEqual(response['Content-Type'], 'application/pdf')
reader = PdfReader(BytesIO(response.content))
text = "\n".join((page.extract_text() or '') for page in reader.pages)
self.assertIn('UKUPNO', text)
self.assertIn('4,0', text)
self.assertIn('1,0', text)
def test_monthly_service_tasks_archive_returns_zip_with_task_docx(self):
response = self.client.get('/api/fleet/reports/monthly-service-tasks-archive/?year=2033&month=12')
self.assertEqual(response.status_code, 200, response.content)
self.assertEqual(response['Content-Type'], 'application/zip')
archive = zipfile.ZipFile(BytesIO(response.content))
names = archive.namelist()
self.assertIn('MT150726.SN-Test_servisni_zadatak.docx', names)
def test_monthly_service_tasks_archive_docx_uses_dynamic_header_values(self):
response = self.client.get('/api/fleet/reports/monthly-service-tasks-archive/?year=2033&month=12')
self.assertEqual(response.status_code, 200, response.content)
archive = zipfile.ZipFile(BytesIO(response.content))
docx_name = 'MT150726.SN-Test_servisni_zadatak.docx'
self.assertIn(docx_name, archive.namelist())
docx_bytes = archive.read(docx_name)
docx_archive = zipfile.ZipFile(BytesIO(docx_bytes))
header_xml = docx_archive.read('word/header1.xml').decode('utf-8', errors='ignore')
self.assertIn('Liebherr LTM 1090', header_xml)
self.assertIn('123456', header_xml)
self.assertIn('111', header_xml)
self.assertIn('222', header_xml)
self.assertIn('33344', header_xml)
self.assertIn('MT150726', header_xml)
self.assertNotIn('MT250226', header_xml)
self.assertNotIn('LTM-1350', header_xml)
def test_monthly_service_tasks_archive_uses_task_specific_headers_for_each_docx(self):
vehicle_two = Vehicle.objects.create(
asset_type='crane',
registration_number='WO-IMG-002',
make='Tadano',
model='ATF 90',
crane_serial_number='SN-999',
superstructure_working_hours=444,
chassis_working_hours=555,
current_mileage=66677,
)
work_order_two = WorkOrder.objects.create(
vehicle=vehicle_two,
creator=self.user,
display_code='MT160726',
purpose='kontrola',
)
Task.objects.create(
title='Drugi zadatak',
assigned_to=self.user,
vehicle=vehicle_two,
work_order=work_order_two,
scheduled_date=date(2033, 12, 26),
service_report_note='Napomena drugog taska',
)
response = self.client.get('/api/fleet/reports/monthly-service-tasks-archive/?year=2033&month=12')
self.assertEqual(response.status_code, 200, response.content)
archive = zipfile.ZipFile(BytesIO(response.content))
first_docx = 'MT150726.SN-Test_servisni_zadatak.docx'
second_docx = 'MT160726.SN-Drugi_zadatak.docx'
self.assertIn(first_docx, archive.namelist())
self.assertIn(second_docx, archive.namelist())
first_header = zipfile.ZipFile(BytesIO(archive.read(first_docx))).read('word/header1.xml').decode('utf-8', errors='ignore')
second_header = zipfile.ZipFile(BytesIO(archive.read(second_docx))).read('word/header1.xml').decode('utf-8', errors='ignore')
self.assertIn('Liebherr LTM 1090', first_header)
self.assertIn('MT150726', first_header)
self.assertNotIn('Tadano ATF 90', first_header)
self.assertNotIn('MT160726', first_header)
self.assertIn('Tadano ATF 90', second_header)
self.assertIn('SN-999', second_header)
self.assertIn('444', second_header)
self.assertIn('555', second_header)
self.assertIn('66677', second_header)
self.assertIn('MT160726', second_header)
self.assertNotIn('Liebherr LTM 1090', second_header)
self.assertNotIn('MT150726', second_header)
def test_monthly_work_orders_archive_includes_work_orders_for_user_even_without_assigned_task(self):
other_user = get_user_model().objects.create_user(
username=f'wo-other-{uuid.uuid4().hex[:8]}',
email=f'wo-other-{uuid.uuid4().hex[:8]}@example.test',
password='test1234',
)
linked_work_order = WorkOrder.objects.create(
vehicle=self.vehicle,
creator=self.user,
display_code='MT170726',
purpose='kontrola',
)
Task.objects.create(
title='Zadatak drugog servisera',
assigned_to=other_user,
vehicle=self.vehicle,
work_order=linked_work_order,
scheduled_date=date(2033, 12, 20),
)
WorkOrderInvoice.objects.create(
work_order=linked_work_order,
naziv_racuna='Prosinac račun osoba',
datum='2033-12-12',
image=create_test_pdf('racun-prosinac-osoba.pdf'),
created_by=self.user,
)
response = self.client.get('/api/fleet/reports/monthly-work-orders-archive/?year=2033&month=12')
self.assertEqual(response.status_code, 200, response.content)
archive = zipfile.ZipFile(BytesIO(response.content))
self.assertIn('MT150726.work-order.pdf', archive.namelist())
self.assertIn('MT170726.work-order.pdf', archive.namelist())
def test_monthly_work_orders_archive_contains_work_orders_and_invoices_folder(self):
WorkOrderInvoice.objects.create(
work_order=self.work_order,
naziv_racuna='Prosinac račun',
datum='2033-12-10',
image=create_test_pdf('racun-prosinac.pdf'),
created_by=self.user,
)
response = self.client.get('/api/fleet/reports/monthly-work-orders-archive/?year=2033&month=12')
self.assertEqual(response.status_code, 200, response.content)
self.assertEqual(response['Content-Type'], 'application/zip')
archive = zipfile.ZipFile(BytesIO(response.content))
names = archive.namelist()
self.assertIn('MT150726.work-order.pdf', names)
self.assertTrue(any(name.startswith('Racuni/MT150726/') for name in names), names)
def test_work_order_pdf_travel_expense_uses_task_work_hours_table(self):
TaskWorkHoursTable.objects.create(
task=self.task,
data={
'rows': [
{
'date': '24.12.2033',
'work_time_from': '08:00',
'work_time_to': '16:00',
'travel_hours': '1',
'work_hours': '6',
},
{
'date': '25.12.2033',
'work_time_from': '09:00',
'work_time_to': '15:00',
'travel_hours': '0,5',
'work_hours': '2',
},
]
},
)
save_response = self.client.put(
f'/api/fleet/work-orders/{self.work_order.pk}/travel-expenses-table/',
data={
'broj_sati': '9.5',
'kolicina_dnevnica': '0.5',
'iznos_dnevnica': '80',
'daily_rate_country': 'SI',
},
format='json',
)
self.assertEqual(save_response.status_code, 200, save_response.content)
self.assertEqual(save_response.data['total_for_payout'], '40.00')
response = self.client.get(f'/api/fleet/work-orders/{self.work_order.pk}/pdf/')
self.assertEqual(response.status_code, 200, response.content)
self.assertEqual(response['Content-Type'], 'application/pdf')
reader = PdfReader(BytesIO(response.content))
text = '\n'.join((page.extract_text() or '') for page in reader.pages)
self.assertIn('24.12.2033.', text)
self.assertIn('25.12.2033.', text)
self.assertIn('9,5', text)
self.assertIn('0,5', text)
self.assertIn('80.00 €', text)
self.assertIn('40.00 €', text)
def test_work_order_travel_expenses_table_defaults_to_hr_rate_and_calculates_total(self):
TaskWorkHoursTable.objects.create(
task=self.task,
data={
'rows': [
{
'date': '24.12.2033',
'work_time_from': '08:00',
'work_time_to': '16:00',
'travel_hours': '1',
'work_hours': '6',
},
{
'date': '25.12.2033',
'work_time_from': '09:00',
'work_time_to': '15:00',
'travel_hours': '0,5',
'work_hours': '2',
},
]
},
)
response = self.client.get(f'/api/fleet/work-orders/{self.work_order.pk}/travel-expenses-table/')
self.assertEqual(response.status_code, 200, response.content)
self.assertEqual(response.data['broj_sati'], '9.50')
self.assertEqual(response.data['kolicina_dnevnica'], '0.50')
self.assertEqual(response.data['daily_rate_country'], 'HR')
self.assertEqual(response.data['iznos_dnevnica'], '30.00')
self.assertEqual(response.data['total_for_payout'], '15.00')
def test_calculate_daily_quantity_from_hours_follows_business_rules(self):
self.assertEqual(_calculate_daily_quantity_from_hours(0), 0.0)
self.assertEqual(_calculate_daily_quantity_from_hours(7), 0.0)
self.assertEqual(_calculate_daily_quantity_from_hours(8), 0.5)
self.assertEqual(_calculate_daily_quantity_from_hours(10.5), 0.5)
self.assertEqual(_calculate_daily_quantity_from_hours(12), 1.0)
self.assertEqual(_calculate_daily_quantity_from_hours(13.5), 1.0)
def test_monthly_servicer_report_rows_use_task_work_hours_table_hours(self):
TaskWorkHoursTable.objects.create(
task=self.task,
data={
'rows': [
{
'date': '24.12.2033',
'work_time_from': '08:00',
'work_time_to': '16:00',
'travel_hours': '2',
'work_hours': '6',
},
{
'date': '25.12.2033',
'work_time_from': '09:00',
'work_time_to': '15:00',
'travel_hours': '1,5',
'work_hours': '4',
},
]
},
)
rows = _build_monthly_servicer_report_rows(self.user, 2033, 12)
row_by_date = {row['date']: row for row in rows if row['source'] == 'task'}
self.assertIn(date(2033, 12, 24), row_by_date)
self.assertIn(date(2033, 12, 25), row_by_date)
self.assertEqual(row_by_date[date(2033, 12, 24)]['redovan_rad'], '8')
self.assertEqual(row_by_date[date(2033, 12, 24)]['prekovremeni'], '8')
self.assertEqual(row_by_date[date(2033, 12, 25)]['redovan_rad'], '8')
self.assertEqual(row_by_date[date(2033, 12, 25)]['prekovremeni'], '5.5')
def test_monthly_service_tasks_archive_request_creates_ready_download_with_notification(self):
response = self.client.post(
'/api/fleet/reports/monthly-service-tasks-archive-request/',
data={'year': 2033, 'month': 12},
format='json',
)
self.assertIn(response.status_code, [200, 202], response.content)
payload = response.json()
self.assertIn('generated_archive_id', payload)
generated = GeneratedFleetArchive.objects.filter(
pk=payload['generated_archive_id'],
requested_by=self.user,
archive_type='service_tasks',
).first()
self.assertIsNotNone(generated)
if generated.status != 'ready':
build_monthly_archive_cached_task.apply(args=[str(generated.pk)]).get()
generated.refresh_from_db()
self.assertEqual(generated.status, 'ready')
download_response = self.client.get(f"/api/fleet/reports/generated-archives/{generated.pk}/download/")
self.assertEqual(download_response.status_code, 200)
self.assertEqual(download_response['Content-Type'], 'application/zip')
archive_bytes = download_response.content if hasattr(download_response, 'content') else b''.join(download_response.streaming_content)
archive = zipfile.ZipFile(BytesIO(archive_bytes))
self.assertIn('MT150726.SN-Test_servisni_zadatak.docx', archive.namelist())
notifications = VehicleNotification.objects.filter(recipient=self.user).order_by('created_at')
self.assertTrue(notifications.filter(metadata__entity_type='fleet_archive', metadata__stage='requested').exists())
self.assertTrue(notifications.filter(metadata__entity_type='fleet_archive', metadata__stage='completed').exists())
requested_notification = notifications.filter(metadata__entity_type='fleet_archive', metadata__stage='requested').last()
completed_notification = notifications.filter(metadata__entity_type='fleet_archive', metadata__stage='completed').last()
self.assertEqual(str(requested_notification.metadata.get('generated_archive_id')), str(generated.pk))
self.assertEqual(str(completed_notification.metadata.get('generated_archive_id')), str(generated.pk))
def test_monthly_service_tasks_archive_request_ignores_stale_cached_archive(self):
stale = GeneratedFleetArchive.objects.create(
requested_by=self.user,
archive_type='service_tasks',
year=2033,
month=12,
status='ready',
filename='stale-archive.zip',
expires_at=timezone.now() + timedelta(days=2),
)
stale.file.save('stale-archive.zip', ContentFile(b'OLD-ARCHIVE'), save=True)
stale_updated_at = timezone.now() - timedelta(days=1)
GeneratedFleetArchive.objects.filter(pk=stale.pk).update(updated_at=stale_updated_at)
self.task.service_report_note = 'Nova napomena nakon cache-a'
self.task.save()
response = self.client.post(
'/api/fleet/reports/monthly-service-tasks-archive-request/',
data={'year': 2033, 'month': 12},
format='json',
)
self.assertIn(response.status_code, [200, 202], response.content)
payload = response.json()
self.assertIn('generated_archive_id', payload)
self.assertNotEqual(str(stale.pk), str(payload['generated_archive_id']))
stale.refresh_from_db()
self.assertFalse(stale.is_active)
self.assertEqual(stale.status, 'failed')
def test_monthly_service_tasks_archive_request_uses_async_local_fallback_when_broker_unavailable(self):
with (
patch('modules.fleet.views.build_monthly_archive_cached_task.delay', side_effect=KombuOperationalError('broker-down')),
patch('modules.fleet.views._schedule_monthly_archive_local_fallback') as fallback_mock,
):
response = self.client.post(
'/api/fleet/reports/monthly-service-tasks-archive-request/',
data={'year': 2033, 'month': 12},
format='json',
)
self.assertEqual(response.status_code, 202, response.content)
fallback_mock.assert_called_once()
generated = (
GeneratedFleetArchive.objects
.filter(
requested_by=self.user,
archive_type='service_tasks',
year=2033,
month=12,
)
.order_by('-created_at')
.first()
)
self.assertIsNotNone(generated)
self.assertEqual(generated.status, 'pending')
def test_monthly_service_tasks_archive_request_replaces_stale_pending_archive(self):
stale_pending = GeneratedFleetArchive.objects.create(
requested_by=self.user,
archive_type='service_tasks',
year=2033,
month=12,
status='pending',
filename='pending-archive.zip',
expires_at=timezone.now() + timedelta(days=2),
)
stale_created_at = timezone.now() - timedelta(minutes=10)
GeneratedFleetArchive.objects.filter(pk=stale_pending.pk).update(created_at=stale_created_at)
response = self.client.post(
'/api/fleet/reports/monthly-service-tasks-archive-request/',
data={'year': 2033, 'month': 12},
format='json',
)
self.assertIn(response.status_code, [200, 202], response.content)
payload = response.json()
self.assertIn('generated_archive_id', payload)
self.assertNotEqual(str(stale_pending.pk), str(payload['generated_archive_id']))
stale_pending.refresh_from_db()
self.assertFalse(stale_pending.is_active)
self.assertEqual(stale_pending.status, 'failed')
self.assertIn('pending statusu', stale_pending.error_message)
def test_monthly_work_orders_archive_request_creates_ready_download_with_notification(self):
WorkOrderInvoice.objects.create(
work_order=self.work_order,
naziv_racuna='Prosinac račun',
datum='2033-12-10',
image=create_test_pdf('racun-prosinac.pdf'),
created_by=self.user,
)
response = self.client.post(
'/api/fleet/reports/monthly-work-orders-archive-request/',
data={'year': 2033, 'month': 12},
format='json',
)
self.assertIn(response.status_code, [200, 202], response.content)
payload = response.json()
self.assertIn('generated_archive_id', payload)
generated = GeneratedFleetArchive.objects.filter(
pk=payload['generated_archive_id'],
requested_by=self.user,
archive_type='work_orders',
).first()
self.assertIsNotNone(generated)
if generated.status != 'ready':
build_monthly_archive_cached_task.apply(args=[str(generated.pk)]).get()
generated.refresh_from_db()
self.assertEqual(generated.status, 'ready')
download_response = self.client.get(f"/api/fleet/reports/generated-archives/{generated.pk}/download/")
self.assertEqual(download_response.status_code, 200)
self.assertEqual(download_response['Content-Type'], 'application/zip')
archive_bytes = download_response.content if hasattr(download_response, 'content') else b''.join(download_response.streaming_content)
archive = zipfile.ZipFile(BytesIO(archive_bytes))
names = archive.namelist()
self.assertIn('MT150726.work-order.pdf', names)
self.assertTrue(any(name.startswith('Racuni/MT150726/') for name in names), names)
self.assertTrue(
VehicleNotification.objects.filter(
recipient=self.user,
metadata__entity_type='fleet_archive',
metadata__archive_type='work_orders',
metadata__stage='completed',
).exists()
)
requested_notification = VehicleNotification.objects.filter(
recipient=self.user,
metadata__entity_type='fleet_archive',
metadata__archive_type='work_orders',
metadata__stage='requested',
).order_by('created_at').last()
self.assertEqual(str(requested_notification.metadata.get('generated_archive_id')), str(generated.pk))

View File

@@ -3,7 +3,10 @@ from django.urls import path
from .views import (
CraneViewSet, VehicleViewSet, WorkOrderViewSet, VehicleServiceRecordViewSet,
WorkOrderInvoiceViewSet, ServiceContextNoteViewSet, VehicleNotificationViewSet, VehicleServicePhotoViewSet, VehicleServiceAttachmentViewSet, pusher_auth,
monthly_servicer_report_docx, monthly_costs_report_docx,
monthly_servicer_report_docx, monthly_costs_report_docx, MonthlyServicerDayEntryViewSet,
monthly_service_tasks_archive, monthly_service_tasks_archive_request,
monthly_work_orders_archive, monthly_work_orders_archive_request,
generated_archive_download,
)
router = DefaultRouter()
@@ -16,6 +19,7 @@ router.register(r'service-records', VehicleServiceRecordViewSet, basename='servi
router.register(r'notifications', VehicleNotificationViewSet, basename='vehicle-notification')
router.register(r'service-photos', VehicleServicePhotoViewSet, basename='service-photo')
router.register(r'service-attachments', VehicleServiceAttachmentViewSet, basename='service-attachment')
router.register(r'monthly-servicer-day-entries', MonthlyServicerDayEntryViewSet, basename='monthly-servicer-day-entry')
urlpatterns = router.urls
@@ -23,4 +27,9 @@ urlpatterns += [
path('pusher-auth/', pusher_auth, name='pusher-auth'),
path('reports/monthly-servicer/', monthly_servicer_report_docx, name='monthly-servicer-report'),
path('reports/monthly-costs/', monthly_costs_report_docx, name='monthly-costs-report'),
path('reports/monthly-service-tasks-archive/', monthly_service_tasks_archive, name='monthly-service-tasks-archive'),
path('reports/monthly-service-tasks-archive-request/', monthly_service_tasks_archive_request, name='monthly-service-tasks-archive-request'),
path('reports/monthly-work-orders-archive/', monthly_work_orders_archive, name='monthly-work-orders-archive'),
path('reports/monthly-work-orders-archive-request/', monthly_work_orders_archive_request, name='monthly-work-orders-archive-request'),
path('reports/generated-archives/<str:archive_id>/download/', generated_archive_download, name='generated-archive-download'),
]

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,21 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('task_management', '0011_taskcalendar'),
]
operations = [
migrations.AddField(
model_name='task',
name='service_report_note',
field=models.TextField(
blank=True,
default='',
verbose_name='Napomena izvještaja servisera',
help_text='Tekst napomene za PDF izvještaj servisera za ovaj zadatak.',
),
),
]

View File

@@ -54,6 +54,12 @@ class Task(BaseModel):
verbose_name=_("Planirani datum izvršenja"),
help_text=_("Datum kada je planirano izvršenje radnog zadatka."),
)
service_report_note = models.TextField(
blank=True,
default='',
verbose_name=_("Napomena izvještaja servisera"),
help_text=_("Tekst napomene za PDF izvještaj servisera za ovaj zadatak."),
)
class Meta:
ordering = ['-created_at']

View File

@@ -91,6 +91,7 @@ class TaskSerializer(serializers.ModelSerializer):
vehicle_owner_name = serializers.SerializerMethodField()
vehicle_asset_type = serializers.SerializerMethodField()
crane_serial_number = serializers.SerializerMethodField()
work_hours_table = serializers.SerializerMethodField()
template_id = serializers.UUIDField(write_only=True, required=False, allow_null=True)
auto_close_work_order = serializers.BooleanField(write_only=True, required=False)
@@ -100,9 +101,10 @@ class TaskSerializer(serializers.ModelSerializer):
'id', 'title', 'description', 'status',
'assigned_to', 'assigned_to_name',
'vehicle', 'vehicle_registration', 'vehicle_make', 'vehicle_model',
'vehicle_owner_name', 'vehicle_asset_type', 'crane_serial_number',
'vehicle_owner_name', 'vehicle_asset_type', 'crane_serial_number', 'work_hours_table',
'work_order', 'work_order_label',
'scheduled_date',
'service_report_note',
'template_id', 'auto_close_work_order',
'created_at',
]
@@ -114,6 +116,7 @@ class TaskSerializer(serializers.ModelSerializer):
'work_order': {'required': False, 'allow_null': True},
'vehicle': {'required': False, 'allow_null': True},
'scheduled_date': {'required': False, 'allow_null': True},
'service_report_note': {'required': False, 'allow_blank': True},
}
def validate_title(self, value):
@@ -146,13 +149,12 @@ class TaskSerializer(serializers.ModelSerializer):
'title': "Naslov zadatka je obavezan ako se task ne kreira iz templatea."
})
# If task has no crane yet, auto-derive it from the work order's crane (backward compat).
# Cross-crane assignment is intentionally allowed: a servicer can share one work order
# across tasks for multiple cranes on the same day.
if work_order and not vehicle:
vehicle = work_order.vehicle
attrs['vehicle'] = vehicle
if work_order and vehicle and work_order.vehicle_id != vehicle.id:
raise serializers.ValidationError({
'work_order': "Putni nalog mora pripadati istoj dizalici kao task."
})
if status_value in {'neaktivan', 'zavrsen'} and not work_order:
raise serializers.ValidationError({
@@ -211,3 +213,9 @@ class TaskSerializer(serializers.ModelSerializer):
if not obj.vehicle_id:
return None
return obj.vehicle.crane_serial_number or None
def get_work_hours_table(self, obj):
table = getattr(obj, 'work_hours_table', None)
if not table:
return None
return TaskWorkHoursTableSerializer(table).data

View File

@@ -2,7 +2,7 @@ from django.test import TestCase
from modules.task_management.serializers import TaskSerializer
from rest_framework.exceptions import ValidationError
from django.contrib.auth import get_user_model
from modules.task_management.models import Task
from modules.task_management.models import Task, TaskWorkHoursTable
from modules.fleet.models import Vehicle, WorkOrder
from modules.crm.models import Client
@@ -86,6 +86,22 @@ class TaskSerializerTests(TestCase):
assert serializer.data['vehicle_owner_name'] == 'Klijent zadatka'
assert serializer.data['vehicle_asset_type'] == 'crane'
def test_work_hours_table_is_included_in_serialized_output(self):
task = Task.objects.create(title="Task work hours", assigned_to=self.user, vehicle=self.vehicle)
TaskWorkHoursTable.objects.create(
task=task,
data={
'rows': [
{'date': '12.07.2026', 'work_hours': '8', 'travel_hours': '2'},
]
},
)
serializer = TaskSerializer(instance=task)
assert serializer.data['work_hours_table']['data']['rows'][0]['date'] == '12.07.2026'
assert serializer.data['work_hours_table']['data']['rows'][0]['work_hours'] == '8'
assert serializer.data['work_hours_table']['data']['rows'][0]['travel_hours'] == '2'
def test_scheduled_date_accepts_valid_date(self):
serializer = TaskSerializer(
data={"title": "Task mit datum", "status": "aktivan",
@@ -99,4 +115,58 @@ class TaskSerializerTests(TestCase):
"vehicle": str(self.vehicle.id), "scheduled_date": "not-a-date"}
)
assert not serializer.is_valid()
assert 'scheduled_date' in serializer.errors
assert 'scheduled_date' in serializer.errors
def test_can_assign_work_order_from_different_crane(self):
"""Task može dobiti putni nalog koji pripada drugoj dizalici (cross-crane dijeljenje naloga)."""
other_vehicle = Vehicle.objects.create(
registration_number="ZG-TSK-02",
asset_type='crane',
make='Manitowoc',
model='Grove',
client=self.client,
is_company_vehicle=False,
)
work_order = WorkOrder.objects.create(vehicle=other_vehicle, creator=self.user, purpose='kontrola')
task = Task.objects.create(title="Cross-crane task", status="servis", assigned_to=self.user, vehicle=self.vehicle)
serializer = TaskSerializer(instance=task, data={"work_order": str(work_order.id)}, partial=True)
assert serializer.is_valid(), serializer.errors
updated_task = serializer.save()
assert updated_task.work_order_id == work_order.id
assert updated_task.vehicle_id == self.vehicle.id # task zadržava svoju dizalicu
def test_can_close_task_with_cross_crane_work_order(self):
"""Task se može zatvoriti putnim nalogom koji pripada drugoj dizalici."""
other_vehicle = Vehicle.objects.create(
registration_number="ZG-TSK-03",
asset_type='crane',
make='Tadano',
model='GR',
client=self.client,
is_company_vehicle=False,
)
work_order = WorkOrder.objects.create(vehicle=other_vehicle, creator=self.user, purpose='popravak')
task = Task.objects.create(title="Task for close", status="servis", assigned_to=self.user, vehicle=self.vehicle)
serializer = TaskSerializer(
instance=task,
data={"status": "zavrsen", "work_order": str(work_order.id)},
partial=True
)
assert serializer.is_valid(), serializer.errors
updated_task = serializer.save()
assert updated_task.status == 'zavrsen'
assert updated_task.work_order_id == work_order.id
def test_work_order_auto_derives_vehicle_when_task_has_none(self):
"""Ako task nema dizalicu a dobiva WO, dizalica se preuzima iz WO (backward compat)."""
work_order = WorkOrder.objects.create(vehicle=self.vehicle, creator=self.user, purpose='defektaza')
serializer = TaskSerializer(
data={"title": "Auto-vehicle task", "status": "aktivan", "work_order": str(work_order.id)},
)
# vehicle je obavezan pri kreiranju, ali ako task nije instanca ovo bi trebalo failati
# Provjeri da vehicle derivation iz WO radi samo ako task već postoji (partial update)
task = Task.objects.create(title="Temp task", status="aktivan", assigned_to=self.user, vehicle=self.vehicle)
serializer2 = TaskSerializer(instance=task, data={"work_order": str(work_order.id)}, partial=True)
assert serializer2.is_valid(), serializer2.errors

View File

@@ -0,0 +1,12 @@
#!/bin/sh
set -eu
TARGET_DIR="${TMP_ARCHIVE_DIR:-/tmp/erp-generated-archives}"
RETENTION_DAYS="${TMP_RETENTION_DAYS:-1}"
if [ ! -d "$TARGET_DIR" ]; then
exit 0
fi
find "$TARGET_DIR" -type f -name '*.zip' -mtime +"$RETENTION_DAYS" -delete
find "$TARGET_DIR" -type d -empty -delete

View File

@@ -25,6 +25,8 @@ services:
condition: service_healthy
redis:
condition: service_started
media-permissions:
condition: service_completed_successfully
worker:
build:
@@ -37,7 +39,7 @@ services:
environment:
DEBUG: "False"
DJANGO_SETTINGS_MODULE: core.settings.production
command: celery -A core worker --loglevel=info
command: celery -A core worker --loglevel=info --concurrency=2 --prefetch-multiplier=1 --max-tasks-per-child=20 --max-memory-per-child=300000
volumes:
- media_volume:/app/media
depends_on:
@@ -45,6 +47,8 @@ services:
condition: service_healthy
redis:
condition: service_started
media-permissions:
condition: service_completed_successfully
beat:
build:
@@ -65,6 +69,8 @@ services:
condition: service_started
worker:
condition: service_started
media-permissions:
condition: service_completed_successfully
flower:
build:

View File

@@ -21,6 +21,17 @@ services:
# i aktiviramo LRU algoritam koji automatski briše najstarije ključeve ako se limit prijeđe.
command: redis-server --appendonly yes --maxmemory 100mb --maxmemory-policy allkeys-lru
media-permissions:
image: alpine:3.22
container_name: 004erpmediafix
command: >
sh -c "mkdir -p /app/media/fleet/generated_archives /app/media/fleet/generated_pdfs
&& chown -R 1001:1001 /app/media
&& chmod -R u+rwX,g+rwX /app/media"
volumes:
- media_volume:/app/media
restart: "no"
worker:
container_name: 004erpworker
build:
@@ -36,6 +47,7 @@ services:
depends_on:
db: { condition: service_healthy }
redis: { condition: service_started }
media-permissions: { condition: service_completed_successfully }
deploy:
resources:
limits:

View File

@@ -0,0 +1,237 @@
# Preuzimanje produkcijske baze podataka na lokalni PC
Vodič za lokalni razvoj s kopijom produkcijskog PostgreSQL-a bez mijenjanja produkcijske baze.
---
## Preduvjeti
- SSH pristup produkcijskom serveru
- Docker Desktop instaliran lokalno
- `scp` dostupan u PowerShell / terminalu
- Lokalni `.env` s `DEBUG=True` i **drugačijim** `SECRET_KEY` od produkcije
---
## Korak 1 — Na produkcijskom serveru: napravi dump
SSH-aj na produkcijski server, zatim pokreni:
```bash
docker exec 004erpdb pg_dump \
-U $POSTGRES_USER \
-d $POSTGRES_DB \
--no-owner \
--no-acl \
-Fc \
-f /tmp/prod_dump.dump
```
> **`-Fc`** — custom format (komprimiran, pogodniji za `pg_restore`)
> **`--no-owner --no-acl`** — izostavlja produkcijske usere i privilegije
Ako env varijable nisu dostupne u shell sesiji, napiši vrijednosti direktno:
```bash
docker exec 004erpdb sh -c \
"pg_dump -U TVOJ_USER TVOJA_BAZA --no-owner --no-acl -Fc -f /tmp/prod_dump.dump"
```
Ako baza ima desetke gigabajta, pg_dump može opteretiti disk produkcije. U tom slučaju, dumpaj u komprimirani format **--exclude-table-data='log_*'**:
```bash
docker exec 004erpdb pg_dump \
-U $POSTGRES_USER \
-d $POSTGRES_DB \
--no-owner \
--no-acl \
--exclude-table-data='log_*' \
-Fc \
-f /tmp/prod_dump.dump
```
```bash
docker exec 004erpdb sh -c 'pg_dump
-U "$TVOJ_USER"
-d "$TVOJA_BAZA"
--no-owner
--no-acl
--exclude-table-data='log_*'
-Fc
-f /tmp/prod_dump.dump'
```
---
## Korak 2 — Kopiraj dump na lokalni PC
Iz docker servera na host server:
```bash
docker cp 004erpdb:/tmp/prod_dump.dump /tmp/prod_dump.dump
```
```bash
Iz lokalnog PowerShell terminala:
```powershell
scp tvoj_user@IP_SERVERA:/tmp/prod_dump.dump C:\Users\mario.tkalac\Downloads\prod_dump.dump
```
S SSH ključem:
```powershell
scp -i C:\Users\mario.tkalac\.ssh\id_rsa `
tvoj_user@IP_SERVERA:/tmp/prod_dump.dump `
C:\Users\mario.tkalac\Downloads\prod_dump.dump
```
Brisanje dump-a s produkcijskog servera (opcionalno):
```powershell
ssh tvoj_user@IP_SERVERA "rm /tmp/prod_dump.dump"
```
---
## Korak 3 — Pokreni lokalnu bazu
```powershell
cd C:\Users\mario.tkalac\workspace\aplikacija\004.erp.generic
docker compose -f docker-compose.yml up -d db
```
Pričekaj dok container postane `healthy`:
```powershell
docker ps --filter "name=004erpdb"
```
---
## Korak 4 — Restoriraj dump u lokalnu bazu
```powershell
# Kopiraj dump file u container
docker cp C:\Users\mario.tkalac\Downloads\prod_dump.dump 004erpdb:/tmp/prod_dump.dump
# Dropaj lokalnu bazu i kreiraj novu praznu (BRIŠE lokalne podatke!)
docker exec 004erpdb dropdb -U TVOJ_USER TVOJA_BAZA --if-exists
docker exec 004erpdb createdb -U TVOJ_USER TVOJA_BAZA
# Restoriraj
docker exec 004erpdb sh -c 'pg_restore
-U "$TVOJ_USER"
-d "$TVOJA_BAZA"
--no-owner
--no-acl
-Fc
-f /tmp/prod_dump.dump'
```
> ⚠️ Zamijeni `TVOJ_USER` i `TVOJA_BAZA` s vrijednostima iz lokalnog `.env` fajla:
> `POSTGRES_USER` i `POSTGRES_DB`.
---
## Korak 5 — Primijeni nove migracije
Pokrenuti odmah nakon restore-a kako bi lokalna shema bila u sinkronizaciji s kodom:
```powershell
docker compose -f docker-compose.yml -f docker-compose.dev.yml `
run --rm backend python manage.py migrate
```
Migracije na postojećim podacima su sigurne — dodaju tablice/stupce, ne brišu podatke.
---
## Korak 6 — Postavi lokalnog admin korisnika
Produkcijske lozinke su hashirane i ne mogu se koristiti direktno. Kreiraj novog superusera:
```powershell
docker compose -f docker-compose.yml -f docker-compose.dev.yml `
run --rm backend python manage.py createsuperuser
```
Ili resetiraj lozinku postojećem accountu:
```powershell
docker compose -f docker-compose.yml -f docker-compose.dev.yml `
run --rm backend python manage.py changepassword tvoj@email.com
```
---
## Korak 7 — Pokreni cijeli dev stack
```powershell
docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d backend frontend
```
Provjeri:
- Backend: `http://localhost:8001/api/`
- Frontend: `http://localhost:4321/`
---
## Napomene o sigurnosti
| Tema | Akcija |
|------|--------|
| **Produkcijska baza** | `pg_dump` je read-only operacija — produkcijska baza nije dirnuta |
| **SECRET_KEY** | Lokalni `.env` **mora** imati drugačiji `SECRET_KEY` od produkcije |
| **DEBUG** | Lokalni `.env` mora imati `DEBUG=True` |
| **Media fajlovi** | Dump ne uključuje fajlove iz `media_volume`; kopirati ručno ako su potrebni |
| **Osobni podaci** | Baza može sadržavati osobne podatke klijenata — koristiti samo na zaštićenom lokalnom stroju |
---
## Media fajlovi (opcionalno)
Ako trebaju priloženi fajlovi (slike, PDF-ovi):
```bash
# Na produkcijskom serveru — arhiviraj media
docker run --rm \
-v media_volume:/data \
alpine tar czf /tmp/media.tar.gz -C /data .
# Kopiraj lokalno
scp tvoj_user@IP_SERVERA:/tmp/media.tar.gz C:\Users\mario.tkalac\Downloads\media.tar.gz
```
Raspakiraj u lokalni Docker volume:
```powershell
# Kopiraj arhivu u privremeni container
docker run --rm `
-v 004erpgeneric_media_volume:/data `
-v C:\Users\mario.tkalac\Downloads:/backup `
alpine sh -c "tar xzf /backup/media.tar.gz -C /data"
```
---
## Brzi pregled naredbi (cheat sheet)
```bash
# 1. Dump na serveru
docker exec 004erpdb pg_dump -U USER DB --no-owner --no-acl -Fc -f /tmp/prod_dump.dump
# 2. Kopiraj lokalno
scp user@IP:/tmp/prod_dump.dump ~/Downloads/prod_dump.dump
# 3. Restore lokalno
docker cp ~/Downloads/prod_dump.dump 004erpdb:/tmp/prod_dump.dump
docker exec 004erpdb dropdb -U USER DB --if-exists
docker exec 004erpdb createdb -U USER DB
docker exec 004erpdb pg_restore -U USER -d DB --no-owner --no-acl -Fc /tmp/prod_dump.dump
# 4. Migracije
docker compose -f docker-compose.yml -f docker-compose.dev.yml run --rm backend python manage.py migrate
```

View File

@@ -1,10 +1,10 @@
const CACHE_NAME = 'erp-shell-v2';
const CACHE_NAME = 'erp-shell-v3';
const API_CACHE_NAME = 'erp-api-v1';
const WRITE_QUEUE_DB_NAME = 'erp-write-queue-db';
const WRITE_QUEUE_STORE = 'requests';
const WRITE_QUEUE_SYNC_TAG = 'erp-write-queue-sync';
const OFFLINE_URL = '/offline.html';
const PRECACHE_URLS = ['/', '/index.html', '/manifest.webmanifest', '/pwa-icon.svg', OFFLINE_URL];
const PRECACHE_URLS = ['/', '/manifest.webmanifest', '/pwa-icon.svg', OFFLINE_URL];
const API_CACHE_BLOCKLIST = [
'/api/token/',
@@ -142,10 +142,26 @@ async function replayQueuedRequests() {
}
}
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => cache.addAll(PRECACHE_URLS))
async function precacheShell() {
const cache = await caches.open(CACHE_NAME);
await Promise.all(
PRECACHE_URLS.map(async (url) => {
try {
const response = await fetch(url, { cache: 'reload' });
if (!response || !response.ok) {
console.warn('Skipping precache for non-OK response:', url, response && response.status);
return;
}
await cache.put(url, response);
} catch (error) {
console.warn('Skipping precache for failed request:', url, error);
}
})
);
}
self.addEventListener('install', (event) => {
event.waitUntil(precacheShell());
self.skipWaiting();
});

View File

@@ -4,7 +4,7 @@ import AuthWidget from './AuthWidget';
import UserDisplay from './ui/UserDisplay';
import { useEffect, useRef, useState } from 'preact/hooks';
import { useSpring, animated } from '@react-spring/web';
import { hydrateAuthFromStorage } from '../stores/authStore';
import { hydrateAuthFromStorage, loadCurrentUser, $user, $accessToken } from '../stores/authStore';
const ITEMS = [
{ id: 'dashboard', label: 'Dashboard', href: '/' },
@@ -21,8 +21,12 @@ function normalizePath(p) {
}
function getActiveIndex(path) {
const idx = ITEMS.findIndex((item) => item.href === path);
return idx >= 0 ? idx : 0;
// Exact match (e.g. '/' → Dashboard)
const exact = ITEMS.findIndex((item) => item.href === path);
if (exact >= 0) return exact;
// Prefix match for sub-paths (e.g. '/putni-nalozi/racuni' → Putni nalozi)
const prefix = ITEMS.findIndex((item) => item.href !== '/' && path.startsWith(item.href));
return prefix >= 0 ? prefix : 0;
}
export default function Navbar({ minimal = false }) {
@@ -55,6 +59,10 @@ export default function Navbar({ minimal = false }) {
// Mount: izmjeri početnu poziciju bez animacije, pa uključi animaciju za buduće navigacije
useEffect(() => {
hydrateAuthFromStorage();
// Učitaj korisnika ako token postoji ali $user još nije popunjen
if (!$user.get() && $accessToken.get()) {
loadCurrentUser();
}
const timer = setTimeout(() => {
measureIndicator();
@@ -92,7 +100,7 @@ export default function Navbar({ minimal = false }) {
const currentItem = ITEMS[activeIndex];
return (
<nav className="sticky top-0 z-30 mb-4 rounded-lg border border-border-hairline bg-canvas-elevated/95 px-4 py-3 backdrop-blur">
<nav className="sticky top-0 z-30 mb-4 rounded-lg border border-border-hairline bg-canvas-elevated/95 px-4 py-3 backdrop-blur overflow-visible">
<div className="flex flex-wrap items-center justify-between gap-2">
{/* Logo + korisnik */}
<a href="/" className="flex flex-col hover:opacity-80">
@@ -135,10 +143,11 @@ export default function Navbar({ minimal = false }) {
<div className="flex items-center gap-2">
{/* Mobilni dropdown */}
<details className="relative sm:hidden">
<summary className="list-none cursor-pointer rounded-lg border border-border-hairline px-2 py-1 text-xs text-text-main hover:bg-canvas-deep">
{currentItem?.label || 'Izbornik'}
<summary className="list-none cursor-pointer rounded-lg border border-border-hairline bg-canvas-base px-3 py-1.5 text-xs font-medium text-text-main hover:bg-canvas-deep select-none">
{currentItem?.label || 'Izbornik'}
</summary>
<div className="absolute right-0 mt-2 min-w-44 rounded-lg border border-border-hairline bg-canvas-elevated shadow-lg">
{/* Dropdown je positioniran fixed-like kroz visoki z-index kako ne bi bio odsječen */}
<div className="absolute left-0 top-full mt-1 z-50 min-w-52 rounded-lg border border-border-hairline bg-canvas-elevated shadow-xl">
<div className="border-b border-border-hairline px-3 py-2 text-[11px] uppercase tracking-wide text-text-muted">
Navigacija
</div>
@@ -149,10 +158,11 @@ export default function Navbar({ minimal = false }) {
href={item.href}
className={
pathname === item.href
? 'block bg-indigo-50 px-3 py-2 text-sm font-medium text-indigo-700'
: 'block px-3 py-2 text-sm text-text-main hover:bg-canvas-deep'
? 'flex items-center gap-2 bg-indigo-50 px-3 py-2.5 text-sm font-medium text-indigo-700'
: 'flex items-center gap-2 px-3 py-2.5 text-sm text-text-main hover:bg-canvas-deep'
}
>
{pathname === item.href && <span className="h-1.5 w-1.5 rounded-full bg-indigo-500" />}
{item.label}
</a>
</li>

View File

@@ -1,13 +0,0 @@
import { showToast } from '../stores/toastStore';
export default function ToastTrigger() {
return (
<button
type="button"
onClick={() => showToast('Sustav je spreman za rad.', 'info', 2500)}
className="fixed bottom-4 right-4 z-50 rounded-full border border-border-hairline bg-canvas-elevated px-4 py-2 text-xs font-semibold text-text-main shadow-sm hover:bg-canvas-deep"
>
Test Toast
</button>
);
}

View File

@@ -1,32 +1,18 @@
import { showToast } from '../../stores/toastStore';
import ServiceContextSelector from './ServiceContextSelector';
const NAV_ITEMS = [
{ key: 'dashboard', label: 'Dashboard' },
{ key: 'work-orders', label: 'Putni nalozi' },
{ key: 'service-records', label: 'Servisni zapisi' },
{ key: 'cranes', label: 'Dizalice' },
{ key: 'clients', label: 'Klijenti' },
];
export default function DashboardTopbar({
activeSection,
onSectionChange,
onOpenWorkOrderModal,
onOpenTaskModal,
pendingSyncCount,
onNewServiceRecord,
showSectionNav = true,
}) {
const sectionTitle = NAV_ITEMS.find((item) => item.key === activeSection)?.label || 'Dashboard';
return (
<header className="sticky top-0 z-20 border-b border-border-hairline bg-canvas-elevated/95 backdrop-blur">
{/* ── Gornji red ───────────────────────────────────────── */}
<div className="flex flex-wrap items-center justify-between gap-3 px-4 py-3 sm:px-6">
<div>
<p className="text-xs uppercase tracking-wide text-text-muted">Pregled sustava</p>
<h2 className="text-xl font-semibold text-text-main">{sectionTitle}</h2>
<h2 className="text-xl font-semibold text-text-main">ERP</h2>
</div>
<div className="flex items-center gap-3">
{pendingSyncCount > 0 && (
@@ -53,31 +39,6 @@ export default function DashboardTopbar({
{/* ── Selector bar: Kupac + Dizalica ───────────────────── */}
<ServiceContextSelector onNewServiceRecord={onNewServiceRecord} />
{/* ── Navigacijski tabovi ──────────────────────────────── */}
{showSectionNav && (
<nav className="overflow-x-auto border-t border-border-hairline px-4 sm:px-6">
<ul className="flex min-w-max items-center gap-1 py-2 text-sm">
{NAV_ITEMS.map((item) => (
<li key={item.key}>
<button
type="button"
onClick={() => {
onSectionChange(item.key);
}}
className={
activeSection === item.key
? 'rounded-lg bg-indigo-50 px-3 py-2 font-medium text-indigo-700'
: 'rounded-lg px-3 py-2 text-text-main hover:bg-canvas-deep'
}
>
{item.label}
</button>
</li>
))}
</ul>
</nav>
)}
</header>
);
}

View File

@@ -1,4 +1,4 @@
import { useStore } from '@nanostores/preact';
import { useStore } from '@nanostores/preact';
import { useEffect, useMemo, useState } from 'preact/hooks';
import { animated, useSpring, useTransition } from '@react-spring/web';
import DashboardTopbar from './DashboardTopbar';
@@ -136,17 +136,7 @@ function SortableCodeLabel({ title, direction, onToggle }) {
);
}
const PAGE_MODE_TO_SECTION = {
dashboard: 'dashboard',
'work-orders': 'work-orders',
'service-records': 'service-records',
vehicles: 'cranes',
clients: 'clients',
};
export default function FleetDashboardShell({ initialSection = 'dashboard', pageMode = 'dashboard' }) {
const resolvedInitialSection = initialSection || PAGE_MODE_TO_SECTION[pageMode] || 'dashboard';
const [activeSection, setActiveSection] = useState(resolvedInitialSection);
export default function FleetDashboardShell({ pageMode = 'dashboard' }) {
const [isWorkOrderModalOpen, setIsWorkOrderModalOpen] = useState(false);
const [isTaskModalOpen, setIsTaskModalOpen] = useState(false);
const [selectedWorkOrder, setSelectedWorkOrder] = useState(null);
@@ -175,6 +165,7 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
const selectedClientId = useStore($selectedClientId);
const selectedVehicleId = useStore($selectedVehicleId);
const [currentPage, setCurrentPage] = useState(1);
const [serviceRecordsPage, setServiceRecordsPage] = useState(1);
const [taskFilter, setTaskFilter] = useState('active');
const [taskScope, setTaskScope] = useState('all');
const [supervisorServicers, setSupervisorServicers] = useState([]);
@@ -185,6 +176,7 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
const [selectedCraneRowId, setSelectedCraneRowId] = useState(null);
const [expandedServiceTaskId, setExpandedServiceTaskId] = useState(null);
const [hasMounted, setHasMounted] = useState(false);
const [taskCraneFilter, setTaskCraneFilter] = useState('all');
useEffect(() => {
setHasMounted(true);
@@ -270,26 +262,9 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
};
}, [authReady]);
useEffect(() => {
function handleNavbarNavigate(event) {
const section = event?.detail;
if (typeof section === 'string' && section.length > 0) {
setActiveSection(section);
}
}
window.addEventListener('navbar:navigate', handleNavbarNavigate);
return () => window.removeEventListener('navbar:navigate', handleNavbarNavigate);
}, []);
useEffect(() => {
function handleNotificationOpenEntity(event) {
const detail = event?.detail || {};
const section = detail.section;
if (typeof section === 'string' && section.length > 0) {
setActiveSection(section);
}
if (detail.entityType === 'work_order' && detail.entityId) {
if (detail.vehicleId) {
const targetCrane = cranes.find((item) => String(item.id) === String(detail.craneId || detail.vehicleId));
@@ -352,6 +327,51 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
if (serial) return `SN ${serial}`;
return hydratedContextCrane.registration_number || '-';
}, [hydratedContextCrane]);
// KPI: context-aware counts (null kad kontekst nije postavljen)
const contextStats = useMemo(() => {
if (!hydratedSelectedVehicleId) return null;
const ctxWoIds = new Set(
tasks
.filter((t) => String(t.vehicle || '') === String(hydratedSelectedVehicleId) && t.work_order)
.map((t) => String(t.work_order))
);
const ctxWorkOrders = workOrders.filter(
(o) => String(o.vehicle) === String(hydratedSelectedVehicleId) || ctxWoIds.has(String(o.id))
);
const today = new Date().toISOString().slice(0, 10);
return {
openWorkOrders: ctxWorkOrders.filter((o) => o.status !== 'closed').length,
servicesToday: tasks.filter(
(t) => String(t.vehicle || '') === String(hydratedSelectedVehicleId)
&& t.scheduled_date === today
&& (t.status === 'aktivan' || t.status === 'servis')
).length,
};
}, [tasks, workOrders, hydratedSelectedVehicleId]);
// KPI: moji zadaci planirani danas
const myTasksToday = useMemo(() => {
if (!user?.id || !hasMounted) return 0;
const today = new Date().toISOString().slice(0, 10);
return tasks.filter(
(t) => String(t.assigned_to || '') === String(user.id)
&& t.scheduled_date === today
&& isTaskActive(t)
).length;
}, [tasks, user?.id, hasMounted]);
// Zadnji servisni zapisi za fallback prikaz (kad kontekst nije postavljen)
const recentServiceRecords = useMemo(() => (
[...serviceRecords]
.sort((a, b) => String(b.service_date || '').localeCompare(String(a.service_date || '')))
), [serviceRecords]);
const serviceRecordsPageSize = 10;
const serviceRecordsTotalPages = Math.max(1, Math.ceil(recentServiceRecords.length / serviceRecordsPageSize));
const paginatedRecentServiceRecords = useMemo(
() => recentServiceRecords.slice(
(serviceRecordsPage - 1) * serviceRecordsPageSize,
serviceRecordsPage * serviceRecordsPageSize
),
[recentServiceRecords, serviceRecordsPage]
);
const serviceRecordsForContext = useMemo(() => {
if (!selectedVehicleId) return [];
return [...serviceRecords]
@@ -423,19 +443,44 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
if (!hydratedSelectedVehicleId) {
return [];
}
return codeSorted.filter((order) => String(order.vehicle) === String(hydratedSelectedVehicleId));
}, [workOrders, workOrderScope, hydratedSelectedVehicleId, user?.id, user?.is_serviser, user?.is_team_member, workOrderCodeSortDirection]);
// Collect work order IDs that are directly assigned to tasks of the context crane.
// This covers cross-crane shared work orders: a WO created for crane A can be
// assigned to a task of crane B — it should still appear in crane B's context view.
const contextTaskWorkOrderIds = new Set(
tasks
.filter((t) => String(t.vehicle || '') === String(hydratedSelectedVehicleId) && t.work_order)
.map((t) => String(t.work_order))
);
return codeSorted.filter(
(order) => String(order.vehicle) === String(hydratedSelectedVehicleId)
|| contextTaskWorkOrderIds.has(String(order.id))
);
}, [workOrders, tasks, workOrderScope, hydratedSelectedVehicleId, user?.id, user?.is_serviser, user?.is_team_member, workOrderCodeSortDirection]);
const pageSize = 10;
const totalPages = Math.max(1, Math.ceil(scopedWorkOrders.length / pageSize));
const latestWorkOrders = useMemo(
() => scopedWorkOrders.slice((currentPage - 1) * pageSize, currentPage * pageSize),
[scopedWorkOrders, currentPage]
);
// Map: workOrderId → array of unique crane objects (own + cross-crane task links)
const workOrderSharedCranes = useMemo(() => {
const craneById = new Map(cranes.map((c) => [String(c.id), c]));
const map = new Map();
tasks.forEach((task) => {
if (!task.work_order || !task.vehicle) return;
const woId = String(task.work_order);
if (!map.has(woId)) map.set(woId, new Map());
const craneObj = craneById.get(String(task.vehicle));
if (craneObj) map.get(woId).set(String(task.vehicle), craneObj);
});
return map;
}, [tasks, cranes]);
const serviceRecordWorkOrders = useMemo(() => {
const craneId = serviceRecordCrane?.id || contextCrane?.id;
if (!craneId) return [];
return workOrders
.filter((order) => String(order.vehicle) === String(craneId))
// Show ALL open work orders so a task can be linked to any work order regardless of crane.
// Cross-crane sharing is intentional: a servicer may use one travel order for multiple cranes.
return [...workOrders]
.filter((order) => String(order.status || '').toLowerCase() !== 'closed')
.sort((a, b) => String(b.date || '').localeCompare(String(a.date || '')));
}, [workOrders, serviceRecordCrane, contextCrane]);
const serviceRecordTasks = useMemo(() => {
@@ -525,14 +570,23 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
return filteredByServicer;
})();
return [...filteredByStatus].sort((left, right) => (
const filteredByCrane = (() => {
if (taskCraneFilter !== 'context' || !hydratedSelectedVehicleId) {
return filteredByStatus;
}
return filteredByStatus.filter(
(task) => String(task.vehicle || '') === String(hydratedSelectedVehicleId)
);
})();
return [...filteredByCrane].sort((left, right) => (
compareWorkOrderCodesByDate(
left?.work_order_label || '',
right?.work_order_label || '',
taskWorkOrderSortDirection
)
));
}, [tasks, taskFilter, canViewAllTasks, canToggleTaskScope, taskScope, user?.id, isSupervisorNotServiser, selectedServicerId, taskWorkOrderSortDirection]);
}, [tasks, taskFilter, canViewAllTasks, canToggleTaskScope, taskScope, user?.id, isSupervisorNotServiser, selectedServicerId, taskWorkOrderSortDirection, taskCraneFilter, hydratedSelectedVehicleId]);
const selectedTaskServiceRecords = useMemo(() => {
if (!selectedTask?.id) return [];
return [...serviceRecords]
@@ -541,10 +595,18 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
}, [serviceRecords, selectedTask?.id]);
const selectedTaskWorkOrders = useMemo(() => {
if (!selectedTask) return [];
const byVehicle = selectedTask.vehicle
? workOrders.filter((order) => String(order.vehicle) === String(selectedTask.vehicle))
: workOrders;
return [...byVehicle].sort((a, b) => String(b.date || '').localeCompare(String(a.date || '')));
// Keep dropdown focused on open work orders, but preserve the task's already assigned
// work order even when it is closed so the reference remains visible/editable in task details.
// Cross-crane sharing allowed: no vehicle filter.
const selectedWorkOrderId = String(selectedTask.work_order || '');
return [...workOrders]
.filter((order) => {
const orderId = String(order.id || '');
const isClosed = String(order.status || '').toLowerCase() === 'closed';
if (!isClosed) return true;
return selectedWorkOrderId && orderId === selectedWorkOrderId;
})
.sort((a, b) => String(b.date || '').localeCompare(String(a.date || '')));
}, [selectedTask, workOrders]);
useEffect(() => {
@@ -553,10 +615,20 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
}
}, [currentPage, totalPages]);
useEffect(() => {
if (serviceRecordsPage > serviceRecordsTotalPages) {
setServiceRecordsPage(serviceRecordsTotalPages);
}
}, [serviceRecordsPage, serviceRecordsTotalPages]);
useEffect(() => {
setCurrentPage(1);
}, [workOrderScope, hydratedSelectedVehicleId]);
useEffect(() => {
setServiceRecordsPage(1);
}, [hydratedSelectedVehicleId]);
useEffect(() => {
if (!expandedServiceTaskId) return;
const exists = serviceTaskGroups.some((group) => String(group.id) === String(expandedServiceTaskId));
@@ -588,7 +660,7 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
await updateWorkOrder(workOrder.id, changes);
showToast('Nalog je uspješno ažuriran.', 'success');
await fetchFleetDashboardData({ silent: true });
await fetchTasks();
await fetchTasks({ force: true });
} catch (error) {
showToast(error?.message || 'Ažuriranje naloga nije uspjelo.', 'error');
}
@@ -701,9 +773,6 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
<AnimatedPage className="min-h-screen">
<main className="min-w-0">
<DashboardTopbar
activeSection={activeSection}
onSectionChange={setActiveSection}
showSectionNav={false}
onOpenWorkOrderModal={async () => {
try {
const availableCranes = await ensureVehiclesCatalog();
@@ -736,30 +805,92 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
)}
{isDashboardPage && (
<div id="section-dashboard" className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<article className="rounded-xl border border-border-hairline bg-canvas-elevated p-4 shadow-sm">
<p className="text-sm text-text-muted">Otvoreni nalozi</p>
<p className="mt-2 text-2xl font-semibold text-text-main">{stats.openWorkOrders}</p>
<div id="section-dashboard" className="grid grid-cols-2 gap-3 sm:grid-cols-3 xl:grid-cols-5">
{/* Otvoreni nalozi */}
<a
href="/putni-nalozi"
className="group flex flex-col gap-0.5 rounded-xl border border-border-hairline bg-canvas-elevated px-3 py-2.5 shadow-sm transition-all hover:border-indigo-300 hover:bg-indigo-50/60 hover:shadow-md"
>
<p className="text-xs text-text-muted group-hover:text-indigo-700">Otvoreni nalozi</p>
<p className="text-xl font-semibold text-text-main">{stats.openWorkOrders}</p>
{hydratedSelectedVehicleId && contextStats !== null && (
<p className="text-[10px] text-indigo-600">{contextStats.openWorkOrders} u kontekstu</p>
)}
</a>
{/* Servisi danas */}
<a
href="/servisni-zapisi"
className="group flex flex-col gap-0.5 rounded-xl border border-border-hairline bg-canvas-elevated px-3 py-2.5 shadow-sm transition-all hover:border-indigo-300 hover:bg-indigo-50/60 hover:shadow-md"
>
<p className="text-xs text-text-muted group-hover:text-indigo-700">Servisi danas</p>
<p className="text-xl font-semibold text-text-main">{stats.servicesToday}</p>
{hydratedSelectedVehicleId && contextStats !== null && (
<p className="text-[10px] text-indigo-600">{contextStats.servicesToday} u kontekstu</p>
)}
</a>
{/* Bez putnog naloga */}
<article className="flex flex-col gap-0.5 rounded-xl border border-border-hairline bg-canvas-elevated px-3 py-2.5 shadow-sm">
<p className="text-xs text-text-muted">Bez put. naloga</p>
<p className={`text-xl font-semibold ${stats.tasksWithoutWorkOrder > 0 ? 'text-amber-600' : 'text-text-main'}`}>
{stats.tasksWithoutWorkOrder}
</p>
<p className="text-[10px] text-text-muted">aktivnih zadataka</p>
</article>
<article className="rounded-xl border border-border-hairline bg-canvas-elevated p-4 shadow-sm">
<p className="text-sm text-text-muted">Servisi danas</p>
<p className="mt-2 text-2xl font-semibold text-text-main">{stats.servicesToday}</p>
{/* Dovršeni nalozi */}
<article className="flex flex-col gap-0.5 rounded-xl border border-border-hairline bg-canvas-elevated px-3 py-2.5 shadow-sm">
<p className="text-xs text-text-muted">Dovršeni nalozi</p>
<p className="text-xl font-semibold text-emerald-600">{stats.closedWorkOrders}</p>
{stats.totalWorkOrders > 0 && (
<p className="text-[10px] text-text-muted">
{Math.round((stats.closedWorkOrders / stats.totalWorkOrders) * 100)}% / {stats.totalWorkOrders} ukupno
</p>
)}
</article>
<article className="rounded-xl border border-border-hairline bg-canvas-elevated p-4 shadow-sm">
<p className="text-sm text-text-muted">Upozorenja</p>
<p className="mt-2 text-2xl font-semibold text-amber-600">{stats.warnings}</p>
</article>
<article className="rounded-xl border border-border-hairline bg-canvas-elevated p-4 shadow-sm">
<p className="text-sm text-text-muted">Dovršeni nalozi</p>
<p className="mt-2 text-2xl font-semibold text-emerald-600">{stats.closedWorkOrders}</p>
{/* Moji zadaci danas */}
<article className="flex flex-col gap-0.5 rounded-xl border border-border-hairline bg-canvas-elevated px-3 py-2.5 shadow-sm">
<p className="text-xs text-text-muted">Moji danas</p>
<p className={`text-xl font-semibold ${myTasksToday > 0 ? 'text-indigo-600' : 'text-text-main'}`}>
{hasMounted ? myTasksToday : '—'}
</p>
<p className="text-[10px] text-text-muted">planirani na mene</p>
</article>
</div>
)}
{isDashboardPage && (
<article className="overflow-hidden rounded-xl border border-border-hairline bg-canvas-elevated shadow-sm">
<div className="flex items-center justify-between border-b border-border-hairline px-4 py-3">
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-border-hairline px-4 py-3">
<h3 className="font-semibold text-text-main">Radni zadaci</h3>
<div className="flex items-center gap-2 text-xs">
<div className="flex flex-wrap items-center gap-2 text-xs">
{/* Crane context filter */}
{hasMounted && hydratedSelectedVehicleId && (
<>
<span className="text-text-muted">|</span>
<button
type="button"
onClick={() => setTaskCraneFilter('all')}
className={taskCraneFilter === 'all'
? 'rounded-md bg-emerald-50 px-2.5 py-1 font-semibold text-emerald-700'
: 'rounded-md px-2.5 py-1 text-text-main hover:bg-canvas-deep'}
>
Sve dizalice
</button>
<button
type="button"
onClick={() => setTaskCraneFilter('context')}
className={taskCraneFilter === 'context'
? 'rounded-md bg-emerald-50 px-2.5 py-1 font-semibold text-emerald-700'
: 'rounded-md px-2.5 py-1 text-text-main hover:bg-canvas-deep'}
title={contextCraneLabel ?? undefined}
>
Odabrani kontekst
</button>
<span className="text-text-muted">|</span>
</>
)}
{hasMounted && isSupervisorNotServiser && (
<label className="flex items-center gap-2 text-text-main">
<span>Serviser</span>
@@ -884,9 +1015,15 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
</article>
)}
{isDashboardPage && (
<div>
<SyncLogPanel />
</div>
<details className="overflow-hidden rounded-xl border border-border-hairline bg-canvas-elevated shadow-sm">
<summary className="flex cursor-pointer select-none items-center justify-between px-4 py-3 text-sm font-medium text-text-muted hover:bg-canvas-deep">
<span>Sync log</span>
<span className="text-xs opacity-60"></span>
</summary>
<div className="border-t border-border-hairline">
<SyncLogPanel />
</div>
</details>
)}
{showWorkOrders && (
@@ -947,7 +1084,7 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
workOrderScope === 'context'
? (!hydratedSelectedVehicleId
? 'Odaberite dizalicu za prikaz putnih naloga iz servisnog konteksta.'
: 'Nema dostupnih putnih naloga za odabrani servisni kontekst.')
: 'Nema dostupnih putnih naloga za odabrani servisni kontekst. Možete prebaciti na prikaz svih naloga.')
: 'Nema dostupnih putnih naloga.'
}
renderRow={(workOrder) => (
@@ -961,10 +1098,51 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
<td
className="px-4 py-3"
>
{[workOrder.craneInfo?.make, workOrder.craneInfo?.model].filter(Boolean).join(' ') || workOrder.craneLabel || workOrder.craneInfo?.registration_number || `#${workOrder.crane || workOrder.vehicle}`}
{(() => {
const sharedCranes = workOrderSharedCranes.get(String(workOrder.id));
if (sharedCranes && sharedCranes.size > 0) {
return (
<div className="flex flex-col gap-0.5">
{[...sharedCranes.values()].map((c) => {
const craneName = [c.make, c.model].filter(Boolean).join(' ') || c.registration_number || `#${c.id}`;
return (
<span key={c.id}>
{craneName}
{c.client_name && (
<span className="text-text-muted text-xs ml-1">({c.client_name})</span>
)}
</span>
);
})}
</div>
);
}
return (
<span>
{[workOrder.craneInfo?.make, workOrder.craneInfo?.model].filter(Boolean).join(' ')
|| workOrder.craneLabel
|| workOrder.craneInfo?.registration_number
|| `#${workOrder.crane || workOrder.vehicle}`}
{workOrder.craneInfo?.client_name && (
<span className="text-text-muted text-xs ml-1">({workOrder.craneInfo.client_name})</span>
)}
</span>
);
})()}
</td>
<td className="px-4 py-3 font-mono text-xs text-text-muted">
{workOrder.craneInfo?.crane_serial_number || '-'}
{(() => {
const sharedCranes = workOrderSharedCranes.get(String(workOrder.id));
if (sharedCranes && sharedCranes.size > 0) {
const sns = [...sharedCranes.values()].map((c) => c.crane_serial_number || '-');
return (
<div className="flex flex-col gap-0.5">
{sns.map((sn, i) => <span key={i}>{sn}</span>)}
</div>
);
}
return workOrder.craneInfo?.crane_serial_number || '-';
})()}
</td>
<td className="px-4 py-3">
{formatDate(workOrder.date)}
@@ -988,7 +1166,7 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
className="rounded px-2 py-1 text-xs font-medium text-text-muted hover:bg-canvas-deep hover:text-text-main"
title="Upload fotografija putnog naloga"
>
📷
Foto
</button>
</td>
</>
@@ -1057,9 +1235,80 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
</div>
</div>
{!hydratedSelectedVehicleId && (
<p className="px-4 py-6 text-center text-sm text-text-muted">
Odaberite dizalicu za prikaz servisnih zapisa.
</p>
<div>
<p className="border-b border-border-hairline px-4 py-3 text-xs text-text-muted">
Odaberite dizalicu u servisnom kontekstu za prikaz zapisa po radnim zadacima.
Ispod su prikazani svi servisni zapisi.
</p>
{recentServiceRecords.length === 0 ? (
<p className="px-4 py-6 text-center text-sm text-text-muted">Nema servisnih zapisa.</p>
) : (
<>
<AnimatedDataTable
columns={[
{ key: 'date', label: 'Datum', className: 'px-4 py-3' },
{ key: 'crane', label: 'Dizalica', className: 'px-4 py-3' },
{ key: 'description', label: 'Opis', className: 'px-4 py-3' },
{ key: 'cost', label: 'Trošak', className: 'px-4 py-3' },
{ key: 'actions', label: 'Akcije', className: 'px-4 py-3' },
]}
rows={paginatedRecentServiceRecords}
rowKey={(r) => r.id}
rowClassName="hover:bg-canvas-deep"
renderRow={(record) => (
<>
<td className="px-4 py-3">{formatDate(record.service_date)}</td>
<td className="px-4 py-3">{formatServiceRecordCraneLabel(record, cranes)}</td>
<td className="max-w-xs truncate px-4 py-3" title={record.description || ''}>
{record.description || '-'}
</td>
<td className="px-4 py-3">{formatCost(record.cost)}</td>
<td className="px-4 py-3">
<button
type="button"
onClick={() => {
setServiceRecordBackTask(null);
setSelectedServiceRecord(record);
}}
className="rounded border border-border-hairline px-2 py-1 text-xs text-text-main hover:bg-canvas-base"
>
Detalji
</button>
</td>
</>
)}
/>
<div className="flex items-center justify-between border-t border-border-hairline px-4 py-3 text-sm">
<span className="text-text-muted">
Prikaz {recentServiceRecords.length ? (serviceRecordsPage - 1) * serviceRecordsPageSize + 1 : 0}
-
{recentServiceRecords.length ? Math.min(serviceRecordsPage * serviceRecordsPageSize, recentServiceRecords.length) : 0} od {recentServiceRecords.length}
</span>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setServiceRecordsPage((page) => Math.max(1, page - 1))}
disabled={serviceRecordsPage <= 1}
className="rounded border border-border-hairline px-2 py-1 text-text-main hover:bg-canvas-deep disabled:opacity-50"
>
Prethodna
</button>
<span className="text-text-muted">
{serviceRecordsPage}/{serviceRecordsTotalPages}
</span>
<button
type="button"
onClick={() => setServiceRecordsPage((page) => Math.min(serviceRecordsTotalPages, page + 1))}
disabled={serviceRecordsPage >= serviceRecordsTotalPages}
className="rounded border border-border-hairline px-2 py-1 text-text-main hover:bg-canvas-deep disabled:opacity-50"
>
Sljedeća
</button>
</div>
</div>
</>
)}
</div>
)}
{hydratedSelectedVehicleId && loading && (
<p className="px-4 py-6 text-center text-sm text-text-muted">
@@ -1077,22 +1326,33 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
const isOpen = String(expandedServiceTaskId || '') === String(group.id);
return (
<animated.div key={group.id} style={style}>
<button
type="button"
onClick={() => setExpandedServiceTaskId(isOpen ? null : group.id)}
className="flex w-full items-center justify-between px-4 py-3 text-left hover:bg-canvas-deep"
>
<div>
<span className="font-medium text-text-main">{group.title}</span>
<span className="ml-2 text-xs text-text-muted">{group.status}</span>
</div>
<div className="flex items-center gap-2">
<span className="rounded-full bg-indigo-50 px-2 py-0.5 text-xs text-indigo-700">
{group.records.length} servisnih zapisa
</span>
<span className="text-text-muted">{isOpen ? '▲' : '▼'}</span>
</div>
</button>
<div className="flex items-center gap-3 px-4 py-3 hover:bg-canvas-deep">
<button
type="button"
onClick={() => setExpandedServiceTaskId(isOpen ? null : group.id)}
className="flex min-w-0 flex-1 items-center justify-between gap-3 text-left"
>
<div className="min-w-0">
<span className="font-medium text-text-main">{group.title}</span>
<span className="ml-2 text-xs text-text-muted">{group.status}</span>
</div>
<div className="flex items-center gap-2">
<span className="rounded-full bg-indigo-50 px-2 py-0.5 text-xs text-indigo-700">
{group.records.length} servisnih zapisa
</span>
<span className="text-text-muted">{isOpen ? "^" : "v"}</span>
</div>
</button>
{group.task && (
<button
type="button"
onClick={() => setSelectedTask(group.task)}
className="shrink-0 rounded border border-border-hairline px-2 py-1 text-xs text-text-main hover:bg-canvas-base"
>
Detalji taska
</button>
)}
</div>
{isOpen && (
<div className="border-t border-border-hairline bg-canvas-base/50 px-4 py-3">
{group.records.length === 0 ? (
@@ -1140,7 +1400,7 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
onClick={() => setPhotoUpload({ open: true, serviceRecordId: record.id })}
className="rounded border border-border-hairline px-2 py-1 text-xs text-text-main hover:bg-canvas-base"
>
📎 Dodaj datoteke
Dodaj datoteke
</button>
</td>
</>
@@ -1159,7 +1419,7 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
{showClients && (
<>
{/* ── Tablica klijenata i njihovih dizalica ───────── */}
{/* Tablica klijenata i njihovih dizalica */}
<div id="section-clients">
<ClientsSection
onSetServiceContext={handleSetServiceContextForCraneId}

File diff suppressed because it is too large Load Diff

View File

@@ -2,6 +2,7 @@
import { createTask, fetchTaskTemplates, getStatusLabel } from '../../stores/taskStore';
import { formatPurposeLabel, formatWorkOrderDisplayCode } from '../../lib/displayIds';
import ModalShell from '../ui/ModalShell';
import { updateVehicle } from '../../stores/fleetDashboardStore';
const STATUS_OPTIONS = ['aktivan', 'servis', 'zavrsen', 'neaktivan'];
@@ -13,6 +14,17 @@ const INITIAL_FORM = {
scheduled_date: '',
};
function todayIso() {
return new Date().toISOString().slice(0, 10);
}
function formatWODate(dateStr) {
if (!dateStr) return '';
const d = new Date(dateStr);
if (Number.isNaN(d.getTime())) return '';
return d.toLocaleDateString('hr-HR');
}
export default function TaskCreateModal({ open, workOrders = [], contextCrane = null, onClose, onSuccess }) {
const [form, setForm] = useState(INITIAL_FORM);
const [submitting, setSubmitting] = useState(false);
@@ -21,6 +33,10 @@ export default function TaskCreateModal({ open, workOrders = [], contextCrane =
const [templateLoading, setTemplateLoading] = useState(false);
const [templateId, setTemplateId] = useState('');
const [templates, setTemplates] = useState([]);
const [craneDataModalOpen, setCraneDataModalOpen] = useState(false);
const [craneDataForm, setCraneDataForm] = useState({ superstructure_working_hours: '', chassis_working_hours: '', current_mileage: '' });
const [savingCraneData, setSavingCraneData] = useState(false);
const [craneDataError, setCraneDataError] = useState('');
const selectedTemplate = useMemo(
() => templates.find((template) => String(template.id) === String(templateId)) || null,
@@ -40,8 +56,22 @@ export default function TaskCreateModal({ open, workOrders = [], contextCrane =
setTemplateId('');
setTemplates([]);
setTemplateLoading(false);
setCraneDataModalOpen(false);
setCraneDataForm({ superstructure_working_hours: '', chassis_working_hours: '', current_mileage: '' });
setSavingCraneData(false);
setCraneDataError('');
return;
}
}, [open]);
// Auto-pre-select today's open work order if exactly one exists for this crane
const today = todayIso();
const todayOpen = workOrders.filter(
(wo) => String(wo?.status || '').toLowerCase() !== 'closed'
&& String(wo?.date || '').slice(0, 10) === today
);
if (todayOpen.length === 1) {
setForm((prev) => ({ ...prev, work_order: String(todayOpen[0].id) }));
}
}, [open]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (!selectedTemplate) return;
@@ -55,6 +85,60 @@ export default function TaskCreateModal({ open, workOrders = [], contextCrane =
}));
}, [selectedTemplate]);
function openCraneDataModal() {
if (!contextCrane?.id) {
setCraneDataError('Dizalica nije odabrana u servisnom kontekstu.');
return;
}
setCraneDataForm({
superstructure_working_hours: String(contextCrane?.superstructure_working_hours ?? ''),
chassis_working_hours: String(contextCrane?.chassis_working_hours ?? ''),
current_mileage: String(contextCrane?.current_mileage ?? ''),
});
setCraneDataError('');
setCraneDataModalOpen(true);
}
async function handleSaveCraneData() {
if (!contextCrane?.id) {
setCraneDataError('Dizalica nije odabrana u servisnom kontekstu.');
return;
}
const superstructure = craneDataForm.superstructure_working_hours === '' ? undefined : Number(craneDataForm.superstructure_working_hours);
const chassis = craneDataForm.chassis_working_hours === '' ? undefined : Number(craneDataForm.chassis_working_hours);
const mileage = craneDataForm.current_mileage === '' ? undefined : Number(craneDataForm.current_mileage);
if (superstructure !== undefined && (Number.isNaN(superstructure) || superstructure < 0)) {
setCraneDataError('Radni sati nadogradnje moraju biti pozitivan broj.');
return;
}
if (chassis !== undefined && (Number.isNaN(chassis) || chassis < 0)) {
setCraneDataError('Radni sati podvozja moraju biti pozitivan broj.');
return;
}
if (mileage !== undefined && (Number.isNaN(mileage) || mileage < 0)) {
setCraneDataError('Kilometraza mora biti pozitivan broj.');
return;
}
const payload = {};
if (superstructure !== undefined) payload.superstructure_working_hours = superstructure;
if (chassis !== undefined) payload.chassis_working_hours = chassis;
if (mileage !== undefined) payload.current_mileage = mileage;
if (Object.keys(payload).length === 0) {
setCraneDataError('Unesite barem jednu vrijednost za azuriranje.');
return;
}
setSavingCraneData(true);
setCraneDataError('');
try {
await updateVehicle(contextCrane.id, payload);
setCraneDataModalOpen(false);
} catch (err) {
setCraneDataError(err?.message || 'Azuriranje podataka dizalice nije uspjelo.');
} finally {
setSavingCraneData(false);
}
}
if (!open) return null;
function setField(name, value) {
@@ -135,6 +219,7 @@ export default function TaskCreateModal({ open, workOrders = [], contextCrane =
}
return (
<>
<ModalShell
onClose={onClose}
overlayClassName="z-50 overflow-y-auto p-4 pt-20"
@@ -194,6 +279,39 @@ export default function TaskCreateModal({ open, workOrders = [], contextCrane =
</label>
)}
{contextCrane && (
<div className="rounded-lg border border-border-hairline bg-canvas-base p-3">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-sm font-semibold text-text-main">Podaci odabrane dizalice</p>
<p className="text-xs text-text-muted">{contextCrane.registration_number || '-'} / {contextCrane.make || '-'} {contextCrane.model || ''}</p>
</div>
<button
type="button"
onClick={openCraneDataModal}
disabled={submitting}
className="rounded border border-border-hairline px-3 py-1 text-xs font-semibold text-text-main hover:bg-canvas-deep disabled:opacity-60"
>
Uredi podatke dizalice
</button>
</div>
<dl className="mt-3 grid gap-2 text-sm sm:grid-cols-3">
<div>
<dt className="text-xs text-text-muted">Radni sati nadogradnje</dt>
<dd className="text-text-main">{contextCrane.superstructure_working_hours ?? '-'}</dd>
</div>
<div>
<dt className="text-xs text-text-muted">Radni sati podvozja</dt>
<dd className="text-text-main">{contextCrane.chassis_working_hours ?? '-'}</dd>
</div>
<div>
<dt className="text-xs text-text-muted">Kilometraza</dt>
<dd className="text-text-main">{contextCrane.current_mileage ?? '-'}</dd>
</div>
</dl>
</div>
)}
<label className="flex flex-col gap-1 text-sm">
<span className="font-medium text-text-main">Naslov zadatka *</span>
<input
@@ -254,12 +372,18 @@ export default function TaskCreateModal({ open, workOrders = [], contextCrane =
className="rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-text-main disabled:opacity-50"
>
<option value=""> Bez povezanog naloga </option>
{availableWorkOrders.map((workOrder) => (
<option key={workOrder.id} value={String(workOrder.id)}>
{formatWorkOrderDisplayCode(workOrder)} {workOrder.craneLabel || workOrder.craneInfo?.registration_number || '-'} {formatPurposeLabel(workOrder.purpose) || '-'}
</option>
))}
{availableWorkOrders.map((workOrder) => {
const isToday = String(workOrder.date || '').slice(0, 10) === todayIso();
return (
<option key={workOrder.id} value={String(workOrder.id)}>
{isToday ? '★ ' : ''}{formatWorkOrderDisplayCode(workOrder)} {formatWODate(workOrder.date)} {workOrder.craneLabel || workOrder.craneInfo?.registration_number || '-'} {formatPurposeLabel(workOrder.purpose) || '-'}
</option>
);
})}
</select>
<span className="text-[11px] text-text-muted">
Više zadataka može dijeliti isti putni nalog. Danas kreiran nalog označen je s .
</span>
{templateMode && (
<span className="text-[11px] text-text-muted">
Kod template unosa nalog nije obavezan i backend ga neće automatski povezati.
@@ -299,5 +423,84 @@ export default function TaskCreateModal({ open, workOrders = [], contextCrane =
</form>
</div>
</ModalShell>
{craneDataModalOpen && (
<ModalShell
onClose={() => setCraneDataModalOpen(false)}
overlayClassName="z-[60] bg-black/40 p-4"
contentClassName="flex min-h-full items-center justify-center"
panelClassName="w-full max-w-lg rounded-xl border border-border-hairline bg-canvas-elevated shadow-2xl"
>
<div className="space-y-4 p-4">
<div>
<h4 className="text-base font-semibold text-text-main">Izmijeni podatke dizalice</h4>
<p className="mt-1 text-sm text-text-muted">
Azurirajte radne sate i kilometrazu za dizalicu: <strong>{contextCrane?.registration_number || "-"}</strong>
</p>
</div>
<label className="flex flex-col gap-1 text-sm">
<span className="text-xs text-text-muted">Radni sati nadogradnje</span>
<input
type="number"
min="0"
step="0.01"
value={craneDataForm.superstructure_working_hours}
onInput={(event) => setCraneDataForm((prev) => ({ ...prev, superstructure_working_hours: event.currentTarget.value }))}
disabled={savingCraneData}
className="rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-text-main disabled:opacity-60"
/>
</label>
<label className="flex flex-col gap-1 text-sm">
<span className="text-xs text-text-muted">Radni sati podvozja</span>
<input
type="number"
min="0"
step="0.01"
value={craneDataForm.chassis_working_hours}
onInput={(event) => setCraneDataForm((prev) => ({ ...prev, chassis_working_hours: event.currentTarget.value }))}
disabled={savingCraneData}
className="rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-text-main disabled:opacity-60"
/>
</label>
<label className="flex flex-col gap-1 text-sm">
<span className="text-xs text-text-muted">Kilometraza (km)</span>
<input
type="number"
min="0"
step="1"
value={craneDataForm.current_mileage}
onInput={(event) => setCraneDataForm((prev) => ({ ...prev, current_mileage: event.currentTarget.value }))}
disabled={savingCraneData}
className="rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-text-main disabled:opacity-60"
/>
</label>
{craneDataError && (
<div className="rounded-lg border border-red-300 bg-red-50 px-3 py-2 text-sm text-red-700">
{craneDataError}
</div>
)}
<div className="flex justify-end gap-2">
<button
type="button"
onClick={() => setCraneDataModalOpen(false)}
disabled={savingCraneData}
className="rounded-lg border border-border-hairline px-4 py-2 text-sm font-medium text-text-main hover:bg-canvas-deep disabled:opacity-60"
>
Odustani
</button>
<button
type="button"
onClick={handleSaveCraneData}
disabled={savingCraneData}
className="rounded-lg bg-emerald-600 px-4 py-2 text-sm font-semibold text-white hover:bg-emerald-700 disabled:opacity-60"
>
{savingCraneData ? "Spremanje..." : "Spremi podatke"}
</button>
</div>
</div>
</ModalShell>
)}
</>
);
}

View File

@@ -1,11 +1,16 @@
import { useEffect, useMemo, useState } from 'preact/hooks';
import { useEffect, useMemo, useState } from 'preact/hooks';
import ModalShell from '../ui/ModalShell';
import { formatWorkOrderDisplayCode } from '../../lib/displayIds';
import { getStatusLabel } from '../../stores/taskStore';
import { getTaskCraneDisplay, getTaskCraneOwner } from '../../lib/taskCraneDisplay';
import { updateVehicle, $vehicles } from '../../stores/fleetDashboardStore';
const STATUS_OPTIONS = ['aktivan', 'servis', 'spreman_za_zavrsetak', 'zavrsen', 'neaktivan'];
function todayIso() {
return new Date().toISOString().slice(0, 10);
}
function formatDate(value) {
if (!value) return '-';
const parsed = new Date(value);
@@ -51,6 +56,10 @@ export default function TaskServiceRecordsModal({
status: 'aktivan',
scheduled_date: '',
});
const [craneDataModalOpen, setCraneDataModalOpen] = useState(false);
const [craneDataForm, setCraneDataForm] = useState({ superstructure_working_hours: '', chassis_working_hours: '', current_mileage: '' });
const [savingCraneData, setSavingCraneData] = useState(false);
const [craneDataError, setCraneDataError] = useState('');
useEffect(() => {
if (!open || typeof window === 'undefined') return;
@@ -72,17 +81,79 @@ export default function TaskServiceRecordsModal({
status: String(task.status || 'aktivan'),
scheduled_date: String(task.scheduled_date || ''),
});
setCraneDataModalOpen(false);
setCraneDataError('');
setSavingCraneData(false);
}, [task?.id, task?.work_order]);
const openCraneDataModal = () => {
const craneVehicleId = task?.vehicle;
if (!craneVehicleId) return;
const craneVehicle = ($vehicles?.get?.() || []).find((v) => String(v.id) === String(craneVehicleId)) || null;
setCraneDataForm({
superstructure_working_hours: String(craneVehicle?.superstructure_working_hours ?? ''),
chassis_working_hours: String(craneVehicle?.chassis_working_hours ?? ''),
current_mileage: String(craneVehicle?.current_mileage ?? ''),
});
setCraneDataError('');
setCraneDataModalOpen(true);
};
const handleSaveCraneData = async () => {
const craneVehicleId = task?.vehicle;
if (!craneVehicleId) {
setCraneDataError('Dizalica nije pronađena.');
return;
}
const superstructure = craneDataForm.superstructure_working_hours === '' ? undefined : Number(craneDataForm.superstructure_working_hours);
const chassis = craneDataForm.chassis_working_hours === '' ? undefined : Number(craneDataForm.chassis_working_hours);
const mileage = craneDataForm.current_mileage === '' ? undefined : Number(craneDataForm.current_mileage);
if (superstructure !== undefined && (Number.isNaN(superstructure) || superstructure < 0)) {
setCraneDataError('Radni sati superstrukture moraju biti pozitivan broj.');
return;
}
if (chassis !== undefined && (Number.isNaN(chassis) || chassis < 0)) {
setCraneDataError('Radni sati podvozja moraju biti pozitivan broj.');
return;
}
if (mileage !== undefined && (Number.isNaN(mileage) || mileage < 0)) {
setCraneDataError('Kilometraža mora biti pozitivan broj.');
return;
}
const payload = {};
if (superstructure !== undefined) payload.superstructure_working_hours = superstructure;
if (chassis !== undefined) payload.chassis_working_hours = chassis;
if (mileage !== undefined) payload.current_mileage = mileage;
if (Object.keys(payload).length === 0) {
setCraneDataError('Nije unesena niti jedna vrijednost za ažuriranje.');
return;
}
setSavingCraneData(true);
setCraneDataError('');
try {
await updateVehicle(craneVehicleId, payload);
setCraneDataModalOpen(false);
} catch (err) {
setCraneDataError(err?.message || 'Ažuriranje podataka dizalice nije uspjelo.');
} finally {
setSavingCraneData(false);
}
};
const currentWorkOrder = useMemo(
() => workOrders.find((item) => String(item.id) === String(task?.work_order || selectedWorkOrderId)) || null,
[workOrders, task?.work_order, selectedWorkOrderId]
);
const sortedWorkOrders = useMemo(
() => [...workOrders].sort((a, b) => String(b.date || '').localeCompare(String(a.date || ''))),
[workOrders]
);
const sortedWorkOrders = useMemo(() => {
const today = todayIso();
return [...workOrders].sort((a, b) => {
const aToday = String(a.date || '').slice(0, 10) === today ? 1 : 0;
const bToday = String(b.date || '').slice(0, 10) === today ? 1 : 0;
if (bToday !== aToday) return bToday - aToday;
return String(b.date || '').localeCompare(String(a.date || ''));
});
}, [workOrders]);
const effectiveWorkOrderId = task?.work_order ? String(task.work_order) : String(selectedWorkOrderId || '');
@@ -157,6 +228,7 @@ export default function TaskServiceRecordsModal({
if (!open || !task) return null;
return (
<>
<ModalShell
onClose={onClose}
overlayClassName="z-50 overflow-y-auto p-4 pt-2"
@@ -179,6 +251,15 @@ export default function TaskServiceRecordsModal({
>
Postavi servisni kontekst
</button>
{task?.vehicle && (
<button
type="button"
onClick={openCraneDataModal}
className="rounded-md border border-border-hairline px-3 py-1 text-xs font-semibold text-text-main hover:bg-canvas-base"
>
Uredi podatke dizalice
</button>
)}
<button
type="button"
onClick={onClose}
@@ -228,7 +309,19 @@ export default function TaskServiceRecordsModal({
</div>
<div>
<dt className="text-xs text-text-muted">Dizalica</dt>
<dd className="text-text-main">{getTaskCraneDisplay(task)}</dd>
<dd className="flex items-center gap-2 text-text-main">
<span>{getTaskCraneDisplay(task)}</span>
{task?.vehicle && (
<button
type="button"
onClick={openCraneDataModal}
className="rounded border border-border-hairline px-2 py-1 text-xs text-text-main hover:bg-canvas-base"
title="Izmijeni radne sate i kilometrazu dizalice"
>
Uredi podatke
</button>
)}
</dd>
</div>
<div>
<dt className="text-xs text-text-muted">Putni nalog</dt>
@@ -355,7 +448,7 @@ export default function TaskServiceRecordsModal({
{currentWorkOrder && !showWorkOrderEditor ? (
<div className="flex flex-wrap items-center justify-between gap-2 rounded border border-border-hairline bg-canvas-elevated px-3 py-2">
<p className="text-sm text-text-main">
{formatWorkOrderDisplayCode(currentWorkOrder)} {currentWorkOrder.craneLabel || currentWorkOrder.craneInfo?.registration_number || '-'} {currentWorkOrder.status || '-'}
{formatWorkOrderDisplayCode(currentWorkOrder)} {formatDate(currentWorkOrder.date)} {currentWorkOrder.craneLabel || currentWorkOrder.craneInfo?.registration_number || '-'} {currentWorkOrder.status || '-'}
</p>
<button
type="button"
@@ -374,14 +467,19 @@ export default function TaskServiceRecordsModal({
className="w-full rounded-lg border border-border-hairline bg-canvas-elevated px-3 py-2 text-sm text-text-main disabled:opacity-60"
>
<option value=""> Odaberi putni nalog </option>
{sortedWorkOrders.map((workOrder) => (
<option key={workOrder.id} value={String(workOrder.id)}>
{formatWorkOrderDisplayCode(workOrder)} {workOrder.craneLabel || workOrder.craneInfo?.registration_number || '-'} {workOrder.status || '-'}
</option>
))}
{sortedWorkOrders.map((workOrder) => {
const isToday = String(workOrder.date || '').slice(0, 10) === todayIso();
return (
<option key={workOrder.id} value={String(workOrder.id)}>
{isToday ? '★ ' : ''}{formatWorkOrderDisplayCode(workOrder)} {formatDate(workOrder.date)} {workOrder.craneLabel || workOrder.craneInfo?.registration_number || '-'} {workOrder.status || '-'}
</option>
);
})}
</select>
<p className="text-xs text-text-muted">
{assigningWorkOrder ? 'Spremanje odabira putnog naloga...' : 'Odabir se automatski sprema u bazu.'}
{assigningWorkOrder
? 'Spremanje odabira putnog naloga...'
: 'Isti putni nalog možete dodijeliti više zadataka. Danas (★) su prikazani prvo.'}
</p>
</div>
)}
@@ -448,5 +546,81 @@ export default function TaskServiceRecordsModal({
</div>
</div>
</ModalShell>
{craneDataModalOpen && (
<ModalShell
onClose={() => setCraneDataModalOpen(false)}
overlayClassName="z-[60] bg-black/40 p-4"
contentClassName="flex min-h-full items-center justify-center"
panelClassName="w-full max-w-lg rounded-xl border border-border-hairline bg-canvas-elevated shadow-2xl"
>
<div className="p-4 space-y-4">
<p className="text-sm text-text-muted">
Ažurirajte radne sate i kilometražu za dizalicu: <strong>{getTaskCraneDisplay(task)}</strong>
</p>
<label className="flex flex-col gap-1 text-sm">
<span className="text-xs text-text-muted">Radni sati superstrukture</span>
<input
type="number"
min="0"
step="0.01"
value={craneDataForm.superstructure_working_hours}
onInput={(e) => setCraneDataForm((p) => ({ ...p, superstructure_working_hours: e.currentTarget.value }))}
disabled={savingCraneData}
className="rounded-lg border border-border-hairline bg-canvas-elevated px-3 py-2 text-text-main disabled:opacity-60"
placeholder="npr. 1250.5"
/>
</label>
<label className="flex flex-col gap-1 text-sm">
<span className="text-xs text-text-muted">Radni sati podvozja</span>
<input
type="number"
min="0"
step="0.01"
value={craneDataForm.chassis_working_hours}
onInput={(e) => setCraneDataForm((p) => ({ ...p, chassis_working_hours: e.currentTarget.value }))}
disabled={savingCraneData}
className="rounded-lg border border-border-hairline bg-canvas-elevated px-3 py-2 text-text-main disabled:opacity-60"
placeholder="npr. 980.0"
/>
</label>
<label className="flex flex-col gap-1 text-sm">
<span className="text-xs text-text-muted">Trenutna kilometraža (km)</span>
<input
type="number"
min="0"
step="1"
value={craneDataForm.current_mileage}
onInput={(e) => setCraneDataForm((p) => ({ ...p, current_mileage: e.currentTarget.value }))}
disabled={savingCraneData}
className="rounded-lg border border-border-hairline bg-canvas-elevated px-3 py-2 text-text-main disabled:opacity-60"
placeholder="npr. 45000"
/>
</label>
{craneDataError && (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">{craneDataError}</p>
)}
<div className="flex justify-end gap-2">
<button
type="button"
onClick={() => setCraneDataModalOpen(false)}
disabled={savingCraneData}
className="rounded-lg border border-border-hairline px-4 py-2 text-sm text-text-main hover:bg-canvas-base disabled:opacity-50"
>
Odustani
</button>
<button
type="button"
onClick={handleSaveCraneData}
disabled={savingCraneData}
className="rounded-lg bg-emerald-600 px-4 py-2 text-sm font-semibold text-white hover:bg-emerald-700 disabled:opacity-50"
>
{savingCraneData ? 'Spremam…' : 'Spremi podatke'}
</button>
</div>
</div>
</ModalShell>
)}
</>
);
}

View File

@@ -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',
@@ -137,6 +138,7 @@ const FB_MUTED = [
export default function TaskWorkHoursTableModal({
open,
task,
workOrder = null,
defaultLocation = '',
saving = false,
onClose,
@@ -144,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;
@@ -251,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' },
]);
};
@@ -337,6 +364,39 @@ export default function TaskWorkHoursTableModal({
<div className="space-y-4 overflow-y-auto px-5 py-4">
{/* ── read-only work order travel data ── */}
{workOrder && (
<div className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2.5 text-xs dark:border-amber-800 dark:bg-amber-900/20">
<p className="mb-1.5 font-semibold text-amber-800 dark:text-amber-300">Podaci iz putnog naloga (samo za prikaz)</p>
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-amber-900 dark:text-amber-200 sm:grid-cols-3">
{workOrder.origin_location && (
<div><span className="font-medium">Ishodište:</span> {workOrder.origin_location}</div>
)}
{workOrder.location && (
<div><span className="font-medium">Odredište:</span> {workOrder.location}</div>
)}
{workOrder.travel_start_at && (
<div><span className="font-medium">Polazak:</span> {new Date(workOrder.travel_start_at).toLocaleString('hr-HR', { dateStyle: 'short', timeStyle: 'short' })}</div>
)}
{workOrder.travel_end_at && (
<div><span className="font-medium">Povratak:</span> {new Date(workOrder.travel_end_at).toLocaleString('hr-HR', { dateStyle: 'short', timeStyle: 'short' })}</div>
)}
{workOrder.start_mileage != null && (
<div><span className="font-medium">KM početak:</span> {workOrder.start_mileage}</div>
)}
{workOrder.end_mileage != null && (
<div><span className="font-medium">KM kraj:</span> {workOrder.end_mileage}</div>
)}
{workOrder.distance != null && (
<div><span className="font-medium">Prijeđeno km:</span> {workOrder.distance}</div>
)}
{workOrder.servicer_vehicle_registration && (
<div><span className="font-medium">Vozilo servisera:</span> {workOrder.servicer_vehicle_registration} {workOrder.servicer_vehicle_make_model || ''}</div>
)}
</div>
</div>
)}
{/* ── info banner ── */}
<div className="flex flex-wrap gap-x-4 gap-y-1 rounded-lg border border-blue-200 bg-blue-50 px-3 py-2 text-xs text-blue-800 dark:border-blue-800 dark:bg-blue-900/20 dark:text-blue-300">
<span><span className="font-semibold">Put (oddo):</span> unesi Put od/do Sati puta se računaju automatski; Km vozila se popunjava ručno; polja Vr. rada su blokirana.</span>

View File

@@ -609,13 +609,6 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi
>
Dodaj račun
</button>
<button
type="button"
onClick={openCraneDataModal}
className="rounded-md border border-border-hairline px-3 py-2 text-xs font-medium text-text-main hover:bg-canvas-deep"
>
🔧 Izmijeni podatke dizalice
</button>
<button
type="button"
onClick={() => {

View File

@@ -10,18 +10,20 @@ import {
fetchWorkOrderAdditionalCostsTable,
fetchWorkOrderById,
fetchWorkOrderInvoices,
fetchWorkOrderTravelExpensesTable,
fetchWorkOrderTaskServiceContext,
updateTaskWorkHoursTable,
updateWorkOrder,
updateTaskServiceReportNote,
updateWorkOrderAdditionalCostsTable,
updateWorkOrderTravelExpensesTable,
} from '../../stores/fleetDashboardStore';
import { $accessToken, $authReady, hydrateAuthFromStorage } from '../../stores/authStore';
import { fetchNotifications, connectNotifications } from '../../stores/notificationStore';
import { formatWorkOrderDisplayCode } from '../../lib/displayIds';
import { showToast } from '../../stores/toastStore';
import { useAuthenticatedMediaSources } from './WorkOrderImageCarousel';
import TaskWorkHoursTableModal from './TaskWorkHoursTableModal';
import WorkOrderAdditionalCostsModal from './WorkOrderAdditionalCostsModal';
import WorkOrderTravelExpensesModal from './WorkOrderTravelExpensesModal';
import WorkOrderServiceNotesModal from './WorkOrderServiceNotesModal';
function readWorkOrderIdFromQuery() {
@@ -148,6 +150,7 @@ export default function WorkOrderInvoicesPdfPage() {
const [workOrder, setWorkOrder] = useState(null);
const [invoices, setInvoices] = useState([]);
const [taskContext, setTaskContext] = useState({ tasks: [] });
const [travelExpensesTable, setTravelExpensesTable] = useState({ broj_sati: '0.00', kolicina_dnevnica: '0.00', iznos_dnevnica: '30.00', daily_rate_country: 'HR', total_for_payout: '0.00' });
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [editingTask, setEditingTask] = useState(null);
@@ -155,9 +158,11 @@ export default function WorkOrderInvoicesPdfPage() {
const [additionalCostsTable, setAdditionalCostsTable] = useState({ data: { rows: [] }, total_for_payout: '0.00' });
const [editingAdditionalCosts, setEditingAdditionalCosts] = useState(false);
const [savingAdditionalCosts, setSavingAdditionalCosts] = useState(false);
const [editingServiceReportNote, setEditingServiceReportNote] = useState(false);
const [savingServiceReportNote, setSavingServiceReportNote] = useState(false);
const [serviceReportNoteError, setServiceReportNoteError] = useState('');
const [editingTravelExpenses, setEditingTravelExpenses] = useState(false);
const [savingTravelExpenses, setSavingTravelExpenses] = useState(false);
const [editingTaskNote, setEditingTaskNote] = useState(null);
const [savingTaskNote, setSavingTaskNote] = useState(false);
const [taskNoteError, setTaskNoteError] = useState('');
const [invoiceDetailModal, setInvoiceDetailModal] = useState(null);
useEffect(() => {
@@ -193,11 +198,12 @@ export default function WorkOrderInvoicesPdfPage() {
(async () => {
try {
const [contextPayload, order, invoiceItems, additionalCostsPayload] = await Promise.all([
const [contextPayload, order, invoiceItems, additionalCostsPayload, travelExpensesPayload] = await Promise.all([
fetchWorkOrderTaskServiceContext(workOrderId),
fetchWorkOrderById(workOrderId),
fetchWorkOrderInvoices(workOrderId),
fetchWorkOrderAdditionalCostsTable(workOrderId),
fetchWorkOrderTravelExpensesTable(workOrderId),
]);
if (cancelled) {
return;
@@ -206,6 +212,13 @@ export default function WorkOrderInvoicesPdfPage() {
setWorkOrder(order);
setInvoices(invoiceItems);
setAdditionalCostsTable(additionalCostsPayload || contextPayload?.additional_costs_table || { data: { rows: [] }, total_for_payout: '0.00' });
setTravelExpensesTable(travelExpensesPayload || contextPayload?.travel_expenses_table || {
broj_sati: '0.00',
kolicina_dnevnica: '0.00',
iznos_dnevnica: '30.00',
daily_rate_country: 'HR',
total_for_payout: '0.00',
});
} catch (err) {
if (!cancelled) {
setError(err?.message || 'Neuspješno dohvaćanje podataka o računima.');
@@ -277,6 +290,29 @@ export default function WorkOrderInvoicesPdfPage() {
}
};
const handleSaveTaskServiceReportNote = async (note) => {
if (!editingTaskNote?.id) return;
setSavingTaskNote(true);
setTaskNoteError('');
try {
const updated = await updateTaskServiceReportNote(editingTaskNote.id, note);
setTaskContext((prev) => ({
...prev,
tasks: (Array.isArray(prev.tasks) ? prev.tasks : []).map((task) => (
String(task.id) === String(editingTaskNote.id)
? { ...task, service_report_note: updated?.service_report_note ?? note }
: task
)),
}));
setEditingTaskNote(null);
} catch (err) {
const message = err?.message || 'Spremanje napomene nije uspjelo.';
setTaskNoteError(message);
} finally {
setSavingTaskNote(false);
}
};
const handleSaveAdditionalCostsTable = async (tableData) => {
if (!workOrderId) return;
setSavingAdditionalCosts(true);
@@ -289,21 +325,21 @@ export default function WorkOrderInvoicesPdfPage() {
}
};
const handleSaveServiceReportNote = async (notes) => {
const handleSaveTravelExpensesTable = async (tableData) => {
if (!workOrderId) return;
setSavingServiceReportNote(true);
setServiceReportNoteError('');
setSavingTravelExpenses(true);
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');
const response = await updateWorkOrderTravelExpensesTable(workOrderId, tableData);
setTravelExpensesTable(response || {
broj_sati: '0.00',
kolicina_dnevnica: '0.00',
iznos_dnevnica: '30.00',
daily_rate_country: 'HR',
total_for_payout: '0.00',
});
setEditingTravelExpenses(false);
} finally {
setSavingServiceReportNote(false);
setSavingTravelExpenses(false);
}
};
@@ -317,22 +353,32 @@ export default function WorkOrderInvoicesPdfPage() {
{workOrder ? `Dizalica: ${workOrder.crane_label || workOrder.crane || '-'}` : 'Pregled računa prije kreiranja PDF-a.'}
</p>
</div>
<button
type="button"
onClick={() => downloadWorkOrderPdf(workOrderId)}
disabled={!workOrderId}
className="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-700 disabled:opacity-60"
>
Preuzmi PDF putnog naloga
</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 className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={() => downloadWorkOrderPdf(workOrderId)}
disabled={!workOrderId}
className="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-700 disabled:opacity-60"
>
Preuzmi PDF putnog naloga
</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>
<button
type="button"
onClick={() => setEditingTravelExpenses(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 obračun putnih troškova
</button>
</div>
</div>
</div>
@@ -340,33 +386,13 @@ export default function WorkOrderInvoicesPdfPage() {
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<h2 className="text-lg font-semibold text-text-main">Servisni zapisi</h2>
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={() => {
const firstTask = Array.isArray(taskContext.tasks) ? taskContext.tasks[0] : null;
if (!firstTask) return;
setEditingTask(firstTask);
}}
disabled={!Array.isArray(taskContext.tasks) || taskContext.tasks.length === 0}
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 tablicu radnih sati
</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
type="button"
onClick={() => downloadWorkOrderServiceRecordsPdf(workOrderId)}
disabled={!workOrderId}
className="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-700 disabled:opacity-60"
>
Preuzmi PDF servisnih zapisa
Preuzmi PDF svih servisnih zapisa
</button>
<button
type="button"
@@ -374,7 +400,7 @@ export default function WorkOrderInvoicesPdfPage() {
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
Preuzmi DOCX svih servisnih zapisa
</button>
</div>
</div>
@@ -386,13 +412,66 @@ export default function WorkOrderInvoicesPdfPage() {
<div className="space-y-4">
{taskContext.tasks.map((task) => (
<article key={task.id} className="rounded-lg border border-border-hairline bg-canvas-base p-3">
<div className="space-y-1 text-sm text-text-main">
<div><span className="font-semibold">Radni zadatak:</span> {task.title || '-'}</div>
<div><span className="font-semibold">Status:</span> {task.status || '-'}</div>
<div><span className="font-semibold">Dodijeljeno:</span> {task.assigned_to_name || '-'}</div>
<div><span className="font-semibold">Dizalica:</span> {task.vehicle_registration || '-'}</div>
<div className="mb-3 flex flex-wrap items-start justify-between gap-2">
<div className="space-y-1 text-sm text-text-main">
<div><span className="font-semibold">Radni zadatak:</span> {task.title || '-'}</div>
<div><span className="font-semibold">Status:</span> {task.status || '-'}</div>
<div><span className="font-semibold">Dodijeljeno:</span> {task.assigned_to_name || '-'}</div>
<div><span className="font-semibold">Dizalica:</span> {task.vehicle_registration || '-'}</div>
{task.service_report_note && (
<div className="mt-1 rounded bg-canvas-deep px-2 py-1 text-xs text-text-muted">
<span className="font-semibold">Napomena:</span> {task.service_report_note}
</div>
)}
</div>
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => setEditingTask(task)}
className="rounded-lg border border-border-hairline px-3 py-1.5 text-xs font-semibold text-text-main hover:bg-canvas-deep"
>
Uredi tablicu radnih sati
</button>
<button
type="button"
onClick={() => { setTaskNoteError(''); setEditingTaskNote(task); }}
className="rounded-lg border border-border-hairline px-3 py-1.5 text-xs font-semibold text-text-main hover:bg-canvas-deep"
>
Uredi tekst napomene
</button>
<button
type="button"
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"
>
PDF za ovaj task
</button>
<button
type="button"
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"
>
DOCX za ovaj task
</button>
</div>
</div>
<div className="mt-3 space-y-3">
<div className="space-y-3">
{!Array.isArray(task.service_records) || task.service_records.length === 0 ? (
<p className="text-xs text-text-muted">Nema povezanih servisnih zapisa.</p>
) : (
@@ -517,7 +596,8 @@ export default function WorkOrderInvoicesPdfPage() {
<TaskWorkHoursTableModal
open={!!editingTask}
task={editingTask}
defaultLocation={workOrder?.location || ''}
workOrder={editingTask?.task_work_order ?? null}
defaultLocation={editingTask?.task_work_order?.location ?? ''}
saving={savingWorkHours}
onClose={() => setEditingTask(null)}
onSave={handleSaveWorkHoursTable}
@@ -530,16 +610,24 @@ export default function WorkOrderInvoicesPdfPage() {
onClose={() => setEditingAdditionalCosts(false)}
onSave={handleSaveAdditionalCostsTable}
/>
<WorkOrderServiceNotesModal
open={editingServiceReportNote}
<WorkOrderTravelExpensesModal
open={editingTravelExpenses}
workOrder={workOrder}
saving={savingServiceReportNote}
error={serviceReportNoteError}
tableData={travelExpensesTable}
saving={savingTravelExpenses}
onClose={() => setEditingTravelExpenses(false)}
onSave={handleSaveTravelExpensesTable}
/>
<WorkOrderServiceNotesModal
open={!!editingTaskNote}
task={editingTaskNote}
saving={savingTaskNote}
error={taskNoteError}
onClose={() => {
setEditingServiceReportNote(false);
setServiceReportNoteError('');
setEditingTaskNote(null);
setTaskNoteError('');
}}
onSave={handleSaveServiceReportNote}
onSave={handleSaveTaskServiceReportNote}
/>
{invoiceDetailModal && (
<InvoiceDetailModal

View File

@@ -3,7 +3,7 @@ import ModalShell from '../ui/ModalShell';
export default function WorkOrderServiceNotesModal({
open,
workOrder,
task,
saving = false,
error = '',
onClose,
@@ -16,8 +16,8 @@ export default function WorkOrderServiceNotesModal({
setNotes('');
return;
}
setNotes(String(workOrder?.notes || ''));
}, [open, workOrder?.id, workOrder?.notes]);
setNotes(String(task?.service_report_note || ''));
}, [open, task?.id, task?.service_report_note]);
if (!open) return null;
@@ -37,7 +37,9 @@ export default function WorkOrderServiceNotesModal({
<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.
{task?.title
? `Zadatak: ${task.title}`
: 'Tekst se prikazuje u PDF-u "Izvještaj servisera".'}
</p>
</div>
<button
@@ -56,11 +58,11 @@ export default function WorkOrderServiceNotesModal({
{error}
</div>
) : null}
<label htmlFor="work-order-notes" className="text-sm font-medium text-text-main">
<label htmlFor="task-service-report-note" className="text-sm font-medium text-text-main">
Napomena
</label>
<textarea
id="work-order-notes"
id="task-service-report-note"
value={notes}
onInput={(event) => setNotes(event.currentTarget.value)}
rows={8}

View File

@@ -0,0 +1,203 @@
import { useEffect, useMemo, useState } from 'preact/hooks';
import ModalShell from '../ui/ModalShell';
const RATE_OPTIONS = [
{ value: 'HR', label: 'Hrvatska', amount: '30.00' },
{ value: 'BIH', label: 'BiH', amount: '50.00' },
{ value: 'SI', label: 'Slovenija', amount: '80.00' },
{ value: 'CG', label: 'Crna Gora', amount: '50.00' },
];
const DEFAULT_ROW = {
broj_sati: '0.00',
kolicina_dnevnica: '0.00',
iznos_dnevnica: '30.00',
daily_rate_country: 'HR',
};
function parseAmount(value) {
const normalized = String(value || '').trim().replace('€', '').replace(/\s+/g, '').replace(',', '.');
const parsed = Number(normalized);
return Number.isFinite(parsed) ? parsed : 0;
}
function toDisplayValue(value) {
if (value == null || value === '') return '0.00';
const numeric = Number(String(value).replace(',', '.'));
return Number.isFinite(numeric) ? numeric.toFixed(2) : String(value);
}
function getRateAmount(country) {
const option = RATE_OPTIONS.find((item) => item.value === country);
return option ? option.amount : DEFAULT_ROW.iznos_dnevnica;
}
export default function WorkOrderTravelExpensesModal({
open,
workOrder,
tableData,
saving = false,
onClose,
onSave,
}) {
const [row, setRow] = useState(DEFAULT_ROW);
useEffect(() => {
if (!open) {
setRow(DEFAULT_ROW);
return;
}
const incoming = tableData && typeof tableData === 'object' ? tableData : {};
const country = String(incoming.daily_rate_country || DEFAULT_ROW.daily_rate_country).toUpperCase();
const amount = incoming.iznos_dnevnica != null && incoming.iznos_dnevnica !== ''
? incoming.iznos_dnevnica
: getRateAmount(country);
setRow({
broj_sati: toDisplayValue(incoming.broj_sati),
kolicina_dnevnica: toDisplayValue(incoming.kolicina_dnevnica),
iznos_dnevnica: toDisplayValue(amount),
daily_rate_country: country,
});
}, [open, tableData]);
const total = useMemo(() => (
(parseAmount(row.kolicina_dnevnica) * parseAmount(row.iznos_dnevnica)).toFixed(2)
), [row]);
if (!open) return null;
const updateField = (field, value) => {
setRow((prev) => ({ ...prev, [field]: value }));
};
const updateCountry = (country) => {
setRow((prev) => ({
...prev,
daily_rate_country: country,
iznos_dnevnica: getRateAmount(country),
}));
};
const handleSave = async () => {
await onSave?.({
broj_sati: String(row.broj_sati || '').trim(),
kolicina_dnevnica: String(row.kolicina_dnevnica || '').trim(),
iznos_dnevnica: String(row.iznos_dnevnica || '').trim(),
daily_rate_country: String(row.daily_rate_country || '').trim(),
});
};
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-4xl 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">OBRAČUN PUTNIH TROŠKOVA</h3>
<p className="text-xs text-text-muted">{workOrder?.display_code || workOrder?.id || '-'}</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-4 overflow-y-auto px-5 py-4">
<div className="rounded-lg border border-gray-200 bg-white p-4 text-xs text-gray-600 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300">
<p className="font-semibold text-gray-800 dark:text-gray-100">Brzi odabir dnevnice</p>
<p>Odabir države automatski postavlja iznos dnevnice. Po potrebi iznos možete ručno izmijeniti.</p>
</div>
<div className="overflow-auto rounded-xl border border-gray-200 shadow-sm dark:border-gray-700">
<table className="min-w-[900px] w-full bg-white text-xs dark:bg-gray-900">
<thead>
<tr className="border-b border-gray-200 bg-gray-50 text-left dark:border-gray-700 dark:bg-gray-800">
<th className="w-8 px-2 py-2.5 text-center text-gray-400">#</th>
<th className="px-2 py-2.5 font-semibold text-gray-700 dark:text-gray-200">Broj sati</th>
<th className="px-2 py-2.5 font-semibold text-gray-700 dark:text-gray-200">Količina dnevnica</th>
<th className="px-2 py-2.5 font-semibold text-gray-700 dark:text-gray-200">Država</th>
<th className="px-2 py-2.5 font-semibold text-gray-700 dark:text-gray-200">Iznos dnevnice</th>
<th className="px-2 py-2.5 font-semibold text-gray-700 dark:text-gray-200">Ukupan iznos</th>
</tr>
</thead>
<tbody>
<tr className="border-t border-gray-100 dark:border-gray-700">
<td className="px-2 py-1.5 text-center text-[11px] text-gray-400">1</td>
<td className="px-1 py-1">
<input
type="text"
value={row.broj_sati}
onInput={(event) => updateField('broj_sati', event.currentTarget.value)}
className="block w-full rounded-lg border border-gray-300 bg-gray-50 px-2.5 py-2 text-xs text-gray-900"
/>
</td>
<td className="px-1 py-1">
<input
type="text"
value={row.kolicina_dnevnica}
onInput={(event) => updateField('kolicina_dnevnica', event.currentTarget.value)}
className="block w-full rounded-lg border border-gray-300 bg-gray-50 px-2.5 py-2 text-xs text-gray-900"
/>
</td>
<td className="px-1 py-1">
<select
value={row.daily_rate_country}
onChange={(event) => updateCountry(event.currentTarget.value)}
className="block w-full rounded-lg border border-gray-300 bg-gray-50 px-2.5 py-2 text-xs text-gray-900"
>
{RATE_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</td>
<td className="px-1 py-1">
<input
type="text"
value={row.iznos_dnevnica}
onInput={(event) => updateField('iznos_dnevnica', event.currentTarget.value)}
className="block w-full rounded-lg border border-gray-300 bg-gray-50 px-2.5 py-2 text-xs text-gray-900"
/>
</td>
<td className="px-1 py-1">
<div className="rounded-lg border border-gray-300 bg-gray-100 px-2.5 py-2 text-right text-xs font-semibold text-gray-800">
{total}
</div>
</td>
</tr>
</tbody>
</table>
</div>
</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 obračun'}
</button>
</div>
</div>
</ModalShell>
);
}

View File

@@ -24,7 +24,7 @@ export default function ServiceNotesWidget({
canManageRecipients = false,
activeTasks = [],
activeWorkOrders = [],
servicers = [],
teamMembers = [],
onCreate,
onClose,
}) {
@@ -156,7 +156,7 @@ export default function ServiceNotesWidget({
className="w-full rounded-md border border-border-hairline bg-canvas-base px-2 py-1.5 text-xs text-text-main"
>
<option value="self">Notifikacija samo meni</option>
{canManageRecipients && <option value="member">Notifikacija serviserima</option>}
{canManageRecipients && <option value="member">Notifikacija odabranim članovima tima</option>}
{canManageRecipients && <option value="team">Notifikacija svim članovima tima</option>}
</select>
{canManageRecipients && audience === 'member' && (
@@ -167,10 +167,10 @@ export default function ServiceNotesWidget({
const selected = Array.from(event.currentTarget.selectedOptions).map((option) => option.value);
setTargetUsers(selected);
}}
size={Math.min(6, Math.max(3, servicers.length || 3))}
size={Math.min(6, Math.max(3, teamMembers.length || 3))}
className="w-full rounded-md border border-border-hairline bg-canvas-base px-2 py-1.5 text-xs text-text-main"
>
{servicers.map((member) => (
{teamMembers.map((member) => (
<option key={member.id} value={member.id}>
{member.full_name || member.email}
</option>
@@ -179,7 +179,7 @@ export default function ServiceNotesWidget({
)}
{canManageRecipients && audience === 'member' && (
<p className="text-[10px] text-text-muted">
Držite Ctrl (ili Cmd) za odabir više servisera.
Držite Ctrl (ili Cmd) za odabir više članova tima.
</p>
)}
<button

View File

@@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from 'preact/hooks';
import { useStore } from '@nanostores/preact';
import { $tasks } from '../../stores/taskStore';
import { $user } from '../../stores/authStore';
import { $workOrdersWithVehicle, fetchServicers } from '../../stores/fleetDashboardStore';
import { $workOrdersWithVehicle, fetchTeamMembers } from '../../stores/fleetDashboardStore';
import {
$serviceNotes,
$serviceNotesLoading,
@@ -30,7 +30,7 @@ export default function TaskCalendarPortal() {
const notes = useStore($serviceNotes);
const notesLoading = useStore($serviceNotesLoading);
const user = useStore($user);
const [servicers, setServicers] = useState([]);
const [teamMembers, setTeamMembers] = useState([]);
const isServiser = Boolean(user?.is_serviser);
const isTeamMember = Boolean(user?.is_team_member);
@@ -64,6 +64,17 @@ export default function TaskCalendarPortal() {
));
}, [workOrders, isSupervisorNotServiser, user?.id]);
const calendarWorkOrders = useMemo(() => {
const base = Array.isArray(workOrders) ? workOrders : [];
if (isSupervisorNotServiser) {
return base;
}
return base.filter((item) => (
String(item.creator || '') === String(user?.id || '')
|| String(item.craneInfo?.assigned_servicer || '') === String(user?.id || '')
));
}, [workOrders, isSupervisorNotServiser, user?.id]);
useEffect(() => {
if (!user?.id) return;
fetchServiceNotes().catch(() => {});
@@ -71,18 +82,18 @@ export default function TaskCalendarPortal() {
useEffect(() => {
if (!canManageRecipients) {
setServicers([]);
setTeamMembers([]);
return;
}
let active = true;
fetchServicers()
fetchTeamMembers()
.then((rows) => {
if (!active) return;
setServicers(Array.isArray(rows) ? rows : []);
setTeamMembers(Array.isArray(rows) ? rows : []);
})
.catch(() => {
if (!active) return;
setServicers([]);
setTeamMembers([]);
});
return () => {
active = false;
@@ -103,7 +114,7 @@ export default function TaskCalendarPortal() {
<TaskCalendarWidget
tasks={tasks}
notes={notes}
workOrders={activeWorkOrders}
workOrders={calendarWorkOrders}
onTaskClick={handleTaskClick}
/>
<ServiceNotesWidget
@@ -112,7 +123,7 @@ export default function TaskCalendarPortal() {
canManageRecipients={canManageRecipients}
activeTasks={activeTasks}
activeWorkOrders={activeWorkOrders}
servicers={servicers}
teamMembers={teamMembers}
onCreate={createServiceNote}
onClose={closeServiceNote}
/>

View File

@@ -1,6 +1,7 @@
import { useMemo } from 'preact/hooks';
import ModalShell from '../ui/ModalShell';
import {
downloadGeneratedArchiveByUrl,
downloadGeneratedPdfByUrl,
downloadWorkOrderInvoicesPdf,
downloadWorkOrderPdf,
@@ -21,6 +22,7 @@ function formatTimestamp(value) {
}
function detectNotificationKind(notification) {
if (notification?.metadata?.entity_type === 'fleet_archive') return 'fleet_archive';
const title = String(notification?.title || '').toLowerCase();
if (title.includes('servisni kontekst')) return 'service_context';
if (title.includes('pdf')) return 'work_order_pdf';
@@ -79,6 +81,28 @@ function getEntityAction(notification, meta) {
},
};
}
if (metadata.entity_type === 'fleet_archive') {
const archiveType = metadata.archive_type === 'work_orders' ? 'work_order' : 'service_tasks';
if (metadata.stage === 'completed' && metadata.download_url) {
return {
label: archiveType === 'work_order'
? 'Preuzmi ZIP putnih naloga + računa'
: 'Preuzmi ZIP servisnih taskova',
run() {
return downloadGeneratedArchiveByUrl(
metadata.download_url,
metadata.filename || `MT-${String(metadata.month || '').padStart(2, '0')}-${metadata.year || ''}-${archiveType === 'work_order' ? 'PN' : 'SN'}.zip`,
);
},
};
}
return {
label: 'Otvori servisne zapise',
run() {
window.dispatchEvent(new CustomEvent('navbar:navigate', { detail: 'service-records' }));
},
};
}
if (metadata.entity_type === 'work_order' && metadata.work_order_id) {
return {
label: 'Otvori detalje naloga',
@@ -125,6 +149,13 @@ function getNotificationTypeMeta(kind) {
recommendation: 'Možete odmah preuzeti generirani PDF dokument.',
};
}
if (kind === 'fleet_archive') {
return {
category: 'ZIP arhive',
section: 'service-records',
recommendation: 'Pričekajte završetak generiranja, zatim preuzmite ZIP iz notifikacije.',
};
}
if (kind.startsWith('work_order')) {
return {
category: 'Putni nalozi',
@@ -168,6 +199,11 @@ export default function NotificationDetailModal({ open, notification, onClose })
notification?.metadata?.stage === 'completed' &&
notification?.metadata?.work_order_id
);
const showArchiveLink = Boolean(
notification?.metadata?.entity_type === 'fleet_archive' &&
notification?.metadata?.stage === 'completed' &&
notification?.metadata?.download_url
);
const failedEmailCachedPdfs = Array.isArray(notification?.metadata?.cached_pdfs)
? notification.metadata.cached_pdfs
: [];
@@ -212,7 +248,7 @@ export default function NotificationDetailModal({ open, notification, onClose })
<div className="rounded-lg border border-border-hairline bg-canvas-base p-3">
<p className="text-sm font-semibold text-text-main">{notification.title || 'Obavijest'}</p>
<p className="mt-2 text-sm text-text-muted">{notification.message || '-'}</p>
{showPdfLink && (
{(showPdfLink || showArchiveLink) && (
<button
type="button"
onClick={() => action.run()}

View File

@@ -1,5 +1,17 @@
import { animated, useTransition } from '@react-spring/web';
import { useEffect, useState } from 'preact/hooks';
import { useEffect, useRef, useState } from 'preact/hooks';
function SkeletonRows({ columns, rows = 4 }) {
return Array.from({ length: rows }).map((_, rowIdx) => (
<tr key={rowIdx} className="animate-pulse">
{columns.map((col) => (
<td key={col.key} className={col.className}>
<div className="h-3 rounded bg-canvas-deep" style={{ width: `${55 + ((rowIdx * 17 + col.key.length * 7) % 30)}%` }} />
</td>
))}
</tr>
));
}
export default function AnimatedDataTable({
columns = [],
@@ -14,17 +26,43 @@ export default function AnimatedDataTable({
bodyClassName = 'divide-y divide-border-hairline',
rowClassName = 'hover:bg-canvas-deep',
isRowSelected = () => false,
wrapperClassName = 'overflow-x-auto',
wrapperClassName = '',
trail = 35,
ariaLabel,
}) {
const [mounted, setMounted] = useState(false);
const scrollRef = useRef(null);
const [scrollState, setScrollState] = useState({ left: false, right: false });
useEffect(() => {
setMounted(true);
}, []);
// Prati scroll poziciju da bi se prikazali fade indikatori s lijeva/desna
useEffect(() => {
const el = scrollRef.current;
if (!el) return;
function updateScrollState() {
setScrollState({
left: el.scrollLeft > 4,
right: el.scrollLeft + el.clientWidth < el.scrollWidth - 4,
});
}
updateScrollState();
el.addEventListener('scroll', updateScrollState, { passive: true });
const ro = new ResizeObserver(updateScrollState);
ro.observe(el);
return () => {
el.removeEventListener('scroll', updateScrollState);
ro.disconnect();
};
}, [mounted, rows.length]);
const transitions = useTransition(rows, {
keys: (row) => rowKey(row),
from: { opacity: 0, transform: 'translate3d(0,8px,0)', boxShadow: 'inset 0 0 0 0px rgba(16,185,129,0)' },
from: { opacity: 0, transform: 'translate3d(0,6px,0)', boxShadow: 'inset 0 0 0 0px rgba(16,185,129,0)' },
enter: (row) => ({
opacity: 1,
transform: 'translate3d(0,0,0)',
@@ -37,58 +75,71 @@ export default function AnimatedDataTable({
? 'inset 0 0 0 2px rgba(16,185,129,0.75)'
: 'inset 0 0 0 0px rgba(16,185,129,0)',
}),
leave: { opacity: 0, transform: 'translate3d(0,-8px,0)', boxShadow: 'inset 0 0 0 0px rgba(16,185,129,0)' },
leave: { opacity: 0, transform: 'translate3d(0,-4px,0)', boxShadow: 'inset 0 0 0 0px rgba(16,185,129,0)' },
trail,
config: { tension: 230, friction: 26 },
config: { tension: 260, friction: 28 },
});
const colSpan = Math.max(1, columns.length);
return (
<div className={wrapperClassName}>
<table className={tableClassName}>
<thead className={headClassName}>
<tr>
{columns.map((column) => (
<th key={column.key} className={column.className}>
{column.label}
</th>
<div className={`relative ${wrapperClassName}`}>
{/* Fade gradijent — lijeva strana (kad se može scrollati ulijevo) */}
{scrollState.left && (
<div
aria-hidden="true"
className="pointer-events-none absolute inset-y-0 left-0 z-10 w-8 bg-gradient-to-r from-canvas-elevated to-transparent"
/>
)}
{/* Fade gradijent — desna strana (kad postoji sadržaj desno) */}
{scrollState.right && (
<div
aria-hidden="true"
className="pointer-events-none absolute inset-y-0 right-0 z-10 w-8 bg-gradient-to-l from-canvas-elevated to-transparent"
/>
)}
<div
ref={scrollRef}
className="overflow-x-auto"
style={{ WebkitOverflowScrolling: 'touch', scrollbarWidth: 'thin' }}
>
<table
className={tableClassName}
aria-label={ariaLabel}
aria-busy={loading ? 'true' : undefined}
>
<thead className={headClassName}>
<tr>
{columns.map((column) => (
<th key={column.key} scope="col" className={column.className}>
{column.label}
</th>
))}
</tr>
</thead>
<tbody className={bodyClassName}>
{!mounted && <SkeletonRows columns={columns} rows={3} />}
{mounted && loading && <SkeletonRows columns={columns} rows={3} />}
{mounted && !loading && rows.length === 0 && (
<tr>
<td colSpan={colSpan} className="px-4 py-8 text-center text-sm text-text-muted">
{emptyMessage}
</td>
</tr>
)}
{mounted && !loading && transitions((style, row) => (
<animated.tr
key={rowKey(row)}
style={style}
className={typeof rowClassName === 'function' ? rowClassName(row) : rowClassName}
>
{renderRow(row)}
</animated.tr>
))}
</tr>
</thead>
<tbody className={bodyClassName}>
{!mounted && (
<tr>
<td colSpan={colSpan} className="px-4 py-6 text-center text-sm text-text-muted">
{loadingMessage}
</td>
</tr>
)}
{mounted && loading && (
<tr>
<td colSpan={colSpan} className="px-4 py-6 text-center text-sm text-text-muted">
{loadingMessage}
</td>
</tr>
)}
{mounted && !loading && rows.length === 0 && (
<tr>
<td colSpan={colSpan} className="px-4 py-6 text-center text-sm text-text-muted">
{emptyMessage}
</td>
</tr>
)}
{mounted && !loading && transitions((style, row) => (
<animated.tr
key={rowKey(row)}
style={style}
className={typeof rowClassName === 'function' ? rowClassName(row) : rowClassName}
>
{renderRow(row)}
</animated.tr>
))}
</tbody>
</table>
</tbody>
</table>
</div>
</div>
);
}

View File

@@ -5,7 +5,6 @@ import '../styles/global.css';
import ThemeToggle from '../components/ui/ThemeToggle.jsx';
import ButtonDisplayCounter from '../components/atom/ButtonDisplayCounter.jsx';
import Toast from '../components/ui/Toast.jsx';
import ToastTrigger from '../components/ToastTrigger.jsx';
import NetworkGuard from '../components/ui/NetworkGuard.jsx';
import Navbar from '../components/Navbar.jsx';
import TaskCalendarPortal from '../components/layout/TaskCalendarPortal.jsx';
@@ -18,7 +17,6 @@ interface Props {
const { title, isAuthPage = false, minimalNav = false } = Astro.props as Props;
const isProd = import.meta.env.PROD;
const isDev = import.meta.env.DEV;
---
<!DOCTYPE html>
@@ -57,7 +55,6 @@ const isDev = import.meta.env.DEV;
{!isAuthPage && <NetworkGuard client:only="preact" transition:persist="network-guard" />}
<Toast client:only="preact" transition:persist="toast" />
{!isAuthPage && isDev && <ToastTrigger client:load />}
{!isAuthPage && <Navbar client:only="preact" transition:persist="navbar" minimal={minimalNav} />}
{!isAuthPage && <TaskCalendarPortal client:only="preact" transition:persist="calendar-portal" />}

View File

@@ -28,6 +28,9 @@ const DASHBOARD_FETCH_TTL_MS = 30_000; // 30 sekundi
let dbPromise = null;
let syncListenerStarted = false;
let isSyncInProgress = false;
const archiveNotificationPollers = new Map();
const ARCHIVE_NOTIFICATION_POLL_INTERVAL_MS = 10_000;
const ARCHIVE_NOTIFICATION_POLL_TIMEOUT_MS = 15 * 60 * 1000;
function isBrowser() {
return typeof window !== 'undefined';
@@ -731,10 +734,12 @@ export async function fetchTasksByWorkOrder(workOrderId) {
export async function fetchWorkOrderTaskServiceContext(workOrderId) {
if (!workOrderId) {
return { tasks: [], additional_costs_table: null };
return { tasks: [], additional_costs_table: null, travel_expenses_table: null };
}
const payload = await api.get(`fleet/work-orders/${encodeURIComponent(workOrderId)}/task-service-context/`);
return payload && typeof payload === 'object' ? payload : { tasks: [], additional_costs_table: null };
return payload && typeof payload === 'object'
? payload
: { tasks: [], additional_costs_table: null, travel_expenses_table: null };
}
export async function fetchWorkOrderAdditionalCostsTable(workOrderId) {
@@ -759,6 +764,42 @@ export async function updateWorkOrderAdditionalCostsTable(workOrderId, data) {
return payload;
}
export async function fetchWorkOrderTravelExpensesTable(workOrderId) {
if (!workOrderId) {
return {
work_order: null,
broj_sati: '0.00',
kolicina_dnevnica: '0.00',
iznos_dnevnica: '30.00',
daily_rate_country: 'HR',
total_for_payout: '0.00',
};
}
const payload = await api.get(`fleet/work-orders/${encodeURIComponent(workOrderId)}/travel-expenses-table/`);
return payload && typeof payload === 'object'
? payload
: {
work_order: null,
broj_sati: '0.00',
kolicina_dnevnica: '0.00',
iznos_dnevnica: '30.00',
daily_rate_country: 'HR',
total_for_payout: '0.00',
};
}
export async function updateWorkOrderTravelExpensesTable(workOrderId, data) {
if (!workOrderId) {
throw new Error('Work order ID je obavezan.');
}
const payload = await api.put(
`fleet/work-orders/${encodeURIComponent(workOrderId)}/travel-expenses-table/`,
data ?? {},
);
showToast('Obračun putnih troškova je uspješno spremljen.', 'success');
return payload;
}
export async function updateTaskWorkHoursTable(taskId, data) {
if (!taskId) {
throw new Error('Task ID je obavezan.');
@@ -768,6 +809,17 @@ export async function updateTaskWorkHoursTable(taskId, data) {
return payload;
}
export async function updateTaskServiceReportNote(taskId, note) {
if (!taskId) {
throw new Error('Task ID je obavezan.');
}
const payload = await api.patch(`tasks/tasks/${encodeURIComponent(taskId)}/`, {
service_report_note: String(note ?? ''),
});
showToast('Napomena servisnog izvještaja je uspješno spremljena.', 'success');
return payload;
}
export async function createWorkOrderInvoice(workOrderId, data = {}, signal = null) {
if (!workOrderId) {
throw new Error('Work order ID je obavezan.');
@@ -817,6 +869,70 @@ function _schedulePdfNotificationPolling() {
});
}
function _findCompletedArchiveNotification(notifications, generatedArchiveId) {
if (!generatedArchiveId || !Array.isArray(notifications)) {
return null;
}
return notifications.find((notification) => {
const metadata = notification?.metadata || {};
return metadata.entity_type === 'fleet_archive'
&& String(metadata.generated_archive_id || '') === String(generatedArchiveId)
&& ['completed', 'failed'].includes(String(metadata.stage || ''));
}) || null;
}
function _clearArchiveNotificationPoller(generatedArchiveId) {
const key = String(generatedArchiveId || '');
const handles = archiveNotificationPollers.get(key);
if (!handles) {
return;
}
window.clearInterval(handles.intervalId);
window.clearTimeout(handles.timeoutId);
archiveNotificationPollers.delete(key);
}
function _scheduleArchiveNotificationPolling(generatedArchiveId = null) {
if (!isBrowser()) return;
if (!generatedArchiveId) {
[5000, 20000, 60000].forEach((delay) => {
setTimeout(() => _refreshNotificationsAsync(), delay);
});
return;
}
const key = String(generatedArchiveId);
if (archiveNotificationPollers.has(key)) {
return;
}
const pollOnce = async () => {
try {
const notificationModule = await import('./notificationStore.js');
await notificationModule.fetchNotifications();
const resolvedNotification = _findCompletedArchiveNotification(
notificationModule.$notifications.get(),
key
);
if (resolvedNotification) {
_clearArchiveNotificationPoller(key);
}
} catch (_) {
// silent — korisnik će i dalje vidjeti toast ili ručno osvježiti notifikacije
}
};
const intervalId = window.setInterval(() => {
void pollOnce();
}, ARCHIVE_NOTIFICATION_POLL_INTERVAL_MS);
const timeoutId = window.setTimeout(() => {
_clearArchiveNotificationPoller(key);
}, ARCHIVE_NOTIFICATION_POLL_TIMEOUT_MS);
archiveNotificationPollers.set(key, { intervalId, timeoutId });
void pollOnce();
}
export async function downloadWorkOrderPdf(workOrderId) {
if (!workOrderId) {
throw new Error('Work order ID je obavezan.');
@@ -913,6 +1029,37 @@ export async function downloadWorkOrderInvoicesDocx(workOrderId) {
}
}
export async function fetchMonthlyServicerEntries(year, month) {
const payload = await api.get(
`fleet/monthly-servicer-day-entries/?year=${year}&month=${month}`
);
return Array.isArray(payload) ? payload : (payload?.results ?? []);
}
export async function fetchMonthlyCostInvoices(year, month) {
const payload = await api.get(
`fleet/work-order-invoices/?year=${year}&month=${month}`
);
return Array.isArray(payload) ? payload : (payload?.results ?? []);
}
export async function upsertMonthlyServicerEntry(data = {}) {
if (!data.entry_date) {
throw new Error('Datum dnevnog unosa je obavezan.');
}
if (!data.entry_type) {
throw new Error('Vrsta dnevnog unosa je obavezna.');
}
try {
const response = await api.post('fleet/monthly-servicer-day-entries/', data);
showToast('Dnevni unos je uspjesno spremljen.', 'success');
return response;
} catch (err) {
showToast(err?.message || 'Spremanje dnevnog unosa nije uspjelo.', 'error');
throw err;
}
}
export async function downloadMonthlyServiserReport(year, month) {
try {
const blob = await api.get(
@@ -941,6 +1088,40 @@ export async function downloadMonthlyCostsReport(year, month) {
}
}
export async function downloadMonthlyServiceTasksArchive(year, month) {
try {
const payload = await api.post('fleet/reports/monthly-service-tasks-archive-request/', { year, month });
await _refreshNotificationsAsync();
if (payload?.status === 'ready' && payload?.download_url) {
showToast('ZIP arhiva servisnih taskova je spremna. Preuzmite je kroz notifikaciju.', 'success');
return payload;
}
showToast('ZIP arhiva servisnih taskova se generira u pozadini. Preuzimanje je dostupno kroz notifikaciju.', 'info');
_scheduleArchiveNotificationPolling(payload?.generated_archive_id || null);
return payload;
} catch (err) {
showToast(err?.message || 'Pokretanje generiranja ZIP arhive servisnih taskova nije uspjelo.', 'error');
throw err;
}
}
export async function downloadMonthlyWorkOrdersArchive(year, month) {
try {
const payload = await api.post('fleet/reports/monthly-work-orders-archive-request/', { year, month });
await _refreshNotificationsAsync();
if (payload?.status === 'ready' && payload?.download_url) {
showToast('ZIP arhiva putnih naloga je spremna. Preuzmite je kroz notifikaciju.', 'success');
return payload;
}
showToast('ZIP arhiva putnih naloga se generira u pozadini. Preuzimanje je dostupno kroz notifikaciju.', 'info');
_scheduleArchiveNotificationPolling(payload?.generated_archive_id || null);
return payload;
} catch (err) {
showToast(err?.message || 'Pokretanje generiranja ZIP arhive 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');
@@ -957,6 +1138,22 @@ export async function downloadGeneratedPdfByUrl(downloadUrl, filename = 'documen
}
}
export async function downloadGeneratedArchiveByUrl(downloadUrl, filename = 'archive.zip') {
if (!downloadUrl) {
showToast('Nedostaje URL za preuzimanje ZIP arhive.', 'error');
throw new Error('Nedostaje URL za preuzimanje ZIP arhive.');
}
try {
const blob = await api.get(downloadUrl, { responseType: 'blob' });
saveBlobToFile(blob, filename);
return true;
} catch (err) {
const msg = err?.message || 'Preuzimanje ZIP arhive nije uspjelo.';
showToast(msg, 'error');
throw err;
}
}
export function openWorkOrderInvoicesPdfPage(workOrderId) {
if (!isBrowser() || !workOrderId) {
return;

View File

@@ -3,6 +3,7 @@ import Pusher from 'pusher-js';
import { api } from '../services/apiClient';
import { $accessToken, $user } from './authStore';
import { showToast } from './toastStore';
import { fetchServiceNotes } from './serviceNotesStore';
export const $notifications = atom([]);
export const $isRealtimeConnected = atom(false);
@@ -84,6 +85,9 @@ function attachChannelHandlers(channel) {
}
$notifications.set([next, ...existing]);
if (next?.metadata?.entity_type === 'service_note') {
fetchServiceNotes().catch(() => {});
}
showToast(next.title, next.level === 'warning' || next.level === 'critical' ? 'error' : 'success');
});
}

View File

@@ -23,7 +23,7 @@ export async function fetchServiceNotes() {
export async function createServiceNote(data) {
const payload = await api.post('fleet/service-notes/', data ?? {});
const nextNotes = Array.isArray(payload?.notes) ? payload.notes : null;
if (nextNotes) {
if (nextNotes && nextNotes.length > 0) {
$serviceNotes.set(nextNotes);
} else {
await fetchServiceNotes();