74 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
mariomitte
fe739b14c0 feat: dodaj mjesecni izvjestaj servisera i troskova u kalendar widget
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
- Dva nova Django API endpointa (GET /api/fleet/reports/monthly-servicer/ i
  /api/fleet/reports/monthly-costs/) koji generiraju DOCX u A4 landscape formatu
- TaskCalendarWidget prosiren s report panelom: dva gumba iznad kalendara
  (Izvjestaj servisera / Izvjestaj troskova) sirenjem drawer-a na 680 px
- Preview tablice s podacima iz postojeceg tasks/workOrders store-a
- downloadMonthlyServiserReport i downloadMonthlyCostsReport u store-u
- TaskCalendarPortal prosljeduje activeWorkOrders prop widgetu

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-31 13:31:58 +02:00
mariomitte
8b82481bb4 feat: vise taskova po putnom nalogu i izmjena podataka dizalice
- task_management/views.py: dodaj ?work_order= filter u get_queryset
  (jedini razlog zasto putni nalog nije mogao imati vise taskova bio je
  nedostatak backend filtera; model uvijek podrzavao vise taskova)
- fleet/serializers.py: ukloni current_mileage iz read_only_fields
  kako bi se moglo azurirati putem PATCH /fleet/cranes/{id}/
- fleetDashboardStore.js: dodaj fetchTasksByWorkOrder(workOrderId)
  koji dohvaca GET tasks/tasks/?work_order={id}
- WorkOrderDetailModal.jsx:
  * import TaskCreateModal, fetchTasksByWorkOrder, updateVehicle, getStatusLabel
  * nova sekcija 'Zadaci putnog naloga' s popisom i gumbom '+ Dodaj zadatak'
  * gumb '🔧 Izmijeni podatke dizalice' otvara CraneDataModal
  * CraneDataModal: unos superstructure_working_hours, chassis_working_hours,
    current_mileage; poziva updateVehicle; prikazuje trenutne vrijednosti
    kao placeholder

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-31 12:28:48 +02:00
mariomitte
525152b817 fix: ispravi prikaz računa u admin/frontendu i smanji PDF slike
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
- admin.py: thumbnail_preview u WorkOrderInvoiceInline i WorkOrderInvoiceAdmin
  prikazuje slike/PDF veze putem optimiziranog image endpointa
- serializers.py: image_url sada uključuje ?w=1280&q=80&fmt=jpeg za ne-PDF slike
- views.py: doda _compress_image_for_pdf() helper, zamijeni ImageReader(photo.image)
  s kompresiranom JPEG verzijom na oba mjesta u PDF generiranju
- tasks.py: kompresija invoice slike u JPEG u memoriji prije ImageReader poziva
- WorkOrderInvoicesPdfPage.jsx:
  * InvoiceImagePreview prikazuje 'Učitavanje...' dok se blob dohvaća (ne 401 fallback)
  * uklonjen resolveMediaUrl fallback koji uzrokuje 401 greške
  * svaka invoice kartica je klikabilna i otvara InvoiceDetailModal
  * InvoiceDetailModal prikazuje detalje i uvećanu sliku/PDF vezu

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-31 11:54:34 +02:00
mariomitte
ae6ae08fb1 feat: dodaj DOCX akcije i napomene na racunima
Some checks failed
ERP CI/CD Pipeline / test (push) Has been cancelled
ERP CI/CD Pipeline / Deploy (server git pull + compose) (push) Has been cancelled
- dodaje DOCX preuzimanje za putni nalog, servisne zapise i racune na /putni-nalozi/racuni\n- uvodi modal za uredjivanje teksta napomene izvjestaja servisera\n- poboljsava update work order toka i upsert lokalnog stanja\n- ukljucuje TaskCalendar migraciju, dev watch stabilizaciju i deploy upute

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-23 13:34:53 +02:00
mariomitte
1055820741 fix: uskladi servisni DOCX template i deduplikaciju dijelova
- koristi novi Servisni N-R template za service-records DOCX\n- premjesta tablicu radnih sati odmah ispod naslova na prvoj stranici\n- deduplicira ponovljene unose koristenih dijelova u tablici artikala\n- uklanja fallback neautorizirani media URL za zasticene fleet resurse

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-23 13:27:13 +02:00
mariomitte
908025abc6 fix: prikazi napomenu iz DB u servisnom PDF-u
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
Servisni PDF sada koristi work_order.notes za polje Napomene umjesto izvedenog teksta iz task naslova. Dodana je i invalidacija generated PDF cachea pri updateu putnog naloga kako bi se nakon spremanja napomene uvijek generirao svjezi PDF.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-22 23:18:48 +02:00
mariomitte
666ddb56c9 feat: dodaj prefill email potpisa i body logging
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
Uvodi signature polje i default predlozak potpisa za nove korisnike, te data migraciju koja popunjava postojece prazne potpise. Email flow sada dosljedno dodaje korisnicki potpis i sprema finalni body poruke u dispatch log radi audita.\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-21 15:58:50 +02:00
mariomitte
ebb642e572 fix: popravi vidljivost checkboxa u dark modu
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
Dodaj prilagođeni stil checkboxa kako bi odabrano i neodabrano stanje bilo jasno vidljivo u dark temi, te primijeni novu klasu na checkboxe u modalima slanja emaila.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-21 14:56:09 +02:00
mariomitte
5945423346 feat: async email slanje s cache PDF fallbackom i admin logom
Prebaci slanje emaila putnog naloga u pozadinski task bez blokiranja UI-a i dodaj fallback kad queue nije dostupna. Kod greške slanja spremi generirane PDF-ove u cache i pošalji notifikaciju s download linkovima.

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

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-21 14:41:45 +02:00
mariomitte
42fcd836ed feat: dodaj servisni kontekst u task detalji modal
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
Dodaj gumb 'Postavi servisni kontekst' u zaglavlje modala Radni zadatak detalji i poveži ga s postojećom logikom postavljanja konteksta prema dizalici zadatka.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-20 12:00:54 +02:00
mariomitte
b92596f6a6 feat: proširi prikaz dizalice i vlasnika u zadacima
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
Dodaj detalje dizalice (proizvođač, model i SN) te vlasnika u modal radnog zadatka i kalendar zadataka, uz zajednički frontend helper za DRY prikaz.

Proširi Task API serializer dodatnim poljima za prikaz dizalice/vlasnika i ažuriraj testove serializera za nova izlazna polja.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-20 09:53:09 +02:00
mariomitte
1a18498b30 feat: dodaj bilješke widget i admin kalendar/notifikacije
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
Uveden je novi Notes drawer s kalendar integracijom, zajednički DRY
left-slide mehanizam te međusobno isključivo prikazivanje s Kalendar
widgetom.

Backend i frontend su prošireni za servisne bilješke (uključujući slanje
na više servisera), dodan je users/servicers endpoint, te su uvedene
warning notifikacije kada serviser ima više od 2 zadatka isti dan.

U admin su dodane stavke za Bilješke i zaseban Kalendar zadataka (proxy
TaskCalendar) radi lakšeg operativnog pregleda.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-20 06:33:36 +02:00
mariomitte
61996bd1a0 fix: premjesti gumb tablice sati u zaglavlje servisnih zapisa
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
U /putni-nalozi/racuni gumb 'Uredi tablicu radnih sati' premješten je
iz kartica pojedinih taskova u zaglavlje grupe Servisni zapisi, pokraj
gumba za preuzimanje PDF-a, po istom UX obrascu kao dodatni troškovi.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-19 21:41:00 +02:00
mariomitte
ae35a6041d feat: dodaj dodatne troškove s OCR sinkronizacijom
Dodana je tablica DODATNI TROŠKOVI za putni nalog (backend model + API +
frontend modal) s izračunom ukupnog iznosa za isplatu.

Upload računa sada odmah upisuje redak u dodatne troškove, a asinkroni OCR
(Celery) naknadno pokušava popuniti broj računa i iznos iz slike/PDF-a.

Uvedena je invalidacija PDF cache-a nakon promjena računa/dodatnih troškova
kako bi generirani PDF uvijek prikazivao najnovije podatke.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-19 21:23:29 +02:00
mariomitte
d35eaeccf4 feat: dodaj ishodište i PDF račune za putni nalog
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
Dodano je origin_location polje kroz backend model, serializer, admin i UI
modal za kreiranje/uređivanje putnog naloga (default: Zagreb).

Računi putnog naloga sada prihvaćaju slike i PDF datoteke, uz backend
validaciju formata, podršku za serving PDF-a i prilagodbu preview prikaza.

Za greške uploada računa dodan je error toast kako bi poruke validacije bile
vidljive korisniku odmah u sučelju.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-19 19:54:15 +02:00
81 changed files with 10944 additions and 715 deletions

View File

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

View File

@@ -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

@@ -9,7 +9,7 @@ class CustomUserAdmin(UserAdmin):
list_display = ('email', 'first_name', 'last_name', 'occupation', 'work_position', 'residence', 'is_serviser', 'is_kupac', 'is_team_member', 'is_active', 'client_profile')
fieldsets = (
(None, {'fields': ('email', 'password')}),
('Osobni podaci', {'fields': ('first_name', 'last_name', 'telefon', 'oib', 'occupation', 'work_position', 'residence')}),
('Osobni podaci', {'fields': ('first_name', 'last_name', 'telefon', 'oib', 'occupation', 'work_position', 'residence', 'signature')}),
('ERP Status', {'fields': ('is_active', 'is_staff', 'is_superuser', 'is_serviser', 'is_kupac', 'is_team_member', 'is_verified', 'client_profile')}),
('Važni datumi', {'fields': ('last_login', 'date_joined')}),
)
@@ -18,7 +18,7 @@ class CustomUserAdmin(UserAdmin):
add_fieldsets = (
(None, {
'classes': ('wide',),
'fields': ('email', 'password', 'is_team_member'),
'fields': ('email', 'password', 'first_name', 'last_name', 'telefon', 'occupation', 'signature', 'is_team_member'),
}),
)

View File

@@ -0,0 +1,16 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('users', '0006_customuser_work_position'),
]
operations = [
migrations.AddField(
model_name='customuser',
name='signature',
field=models.TextField(blank=True, default='', verbose_name='Email potpis'),
),
]

View File

@@ -0,0 +1,39 @@
from django.db import migrations
def prefill_empty_signatures(apps, schema_editor):
CustomUser = apps.get_model('users', 'CustomUser')
for user in CustomUser.objects.all().iterator():
signature_value = user.signature if isinstance(user.signature, str) else ''
if signature_value.strip():
continue
first_name = (user.first_name or '').strip()
last_name = (user.last_name or '').strip()
full_name = f"{first_name} {last_name}".strip()
fallback_name = (user.email or '').strip() or 'Ime i prezime'
display_name = full_name or fallback_name
occupation = (user.occupation or '').strip()
name_line = f"{occupation} {display_name}".strip() if occupation else display_name
mobile = (user.telefon or '').strip() or "+385 91 636 3466"
signature = (
"Poslano iz aplikacije\n"
"S poštovanjem / Freundliche Grüße / Kind regards\n\n"
f"{name_line}\n\n"
"KNEZ LJUBO d.o.o.\n"
"Ivlje 44, 10040 Zagreb\n\n"
"Office: Donje Svetice 40, 10000 Zagreb\n\n"
f"Mobile: {mobile}\n\n"
"www.knezljubo.hr"
)
user.signature = signature
user.save(update_fields=['signature'])
class Migration(migrations.Migration):
dependencies = [
('users', '0007_customuser_signature'),
]
operations = [
migrations.RunPython(prefill_empty_signatures, migrations.RunPython.noop),
]

View File

@@ -3,6 +3,17 @@ from django.core.exceptions import ValidationError
from django.db import models
from .managers import CustomUserManager
DEFAULT_EMAIL_SIGNATURE_TEMPLATE = (
"Poslano iz aplikacije\n"
"S poštovanjem / Freundliche Grüße / Kind regards\n\n"
"{name_line}\n\n"
"KNEZ LJUBO d.o.o.\n"
"Ivlje 44, 10040 Zagreb\n\n"
"Office: Donje Svetice 40, 10000 Zagreb\n\n"
"Mobile: {mobile}\n\n"
"www.knezljubo.hr"
)
class CustomUser(AbstractUser):
# Email koristimo za login, pa mora biti jedinstven
username=None
@@ -19,6 +30,7 @@ class CustomUser(AbstractUser):
occupation = models.CharField(max_length=120, blank=True, default='', verbose_name="Zanimanje")
residence = models.CharField(max_length=255, blank=True, default='', verbose_name="Prebivalište")
work_position = models.CharField(max_length=120, blank=True, default='', verbose_name="Na radnom mjestu")
signature = models.TextField(blank=True, default='', verbose_name="Email potpis")
licence_number = models.CharField(max_length=50, blank=True, null=True, verbose_name="Broj licence")
is_verified = models.BooleanField(default=False, verbose_name="Verificiran profil")
@@ -47,6 +59,27 @@ class CustomUser(AbstractUser):
if self.is_kupac and not self.client_profile:
raise ValidationError({'client_profile': "Korisnik koji je označen kao kupac mora imati povezan klijentski profil."})
def build_default_signature(self):
full_name = self.get_full_name().strip()
fallback_name = self.email.strip() if isinstance(self.email, str) and self.email.strip() else 'Ime i prezime'
display_name = full_name or fallback_name
occupation = self.occupation.strip() if isinstance(self.occupation, str) else ''
name_line = f"{occupation} {display_name}".strip() if occupation else display_name
mobile = self.telefon.strip() if isinstance(self.telefon, str) and self.telefon.strip() else "+385 91 636 3466"
return DEFAULT_EMAIL_SIGNATURE_TEMPLATE.format(
name_line=name_line,
mobile=mobile,
)
def get_email_signature(self):
stored_signature = self.signature.strip() if isinstance(self.signature, str) else ''
if stored_signature:
return stored_signature
return self.build_default_signature()
def save(self, *args, **kwargs):
signature = self.signature.strip() if isinstance(self.signature, str) else ''
if not signature:
self.signature = self.build_default_signature()
self.full_clean() # Prisiljava pokretanje clean() prije svakog save()
super().save(*args, **kwargs)
super().save(*args, **kwargs)

View File

@@ -0,0 +1,39 @@
import uuid
import pytest
from django.contrib.auth import get_user_model
@pytest.mark.django_db
def test_new_user_gets_prefilled_signature():
User = get_user_model()
suffix = uuid.uuid4().hex[:8]
user = User.objects.create_user(
email=f"signature-{suffix}@example.test",
password="test1234",
first_name="Mario",
last_name="Tkalac",
telefon="+385916363466",
)
signature = user.signature
assert signature.startswith("Poslano iz aplikacije\n")
assert "S poštovanjem / Freundliche Grüße / Kind regards" in signature
assert "Mario Tkalac" in signature
assert "KNEZ LJUBO d.o.o." in signature
assert "Office: Donje Svetice 40, 10000 Zagreb" in signature
assert "Mobile: +385916363466" in signature
assert "www.knezljubo.hr" in signature
@pytest.mark.django_db
def test_custom_signature_is_preserved():
User = get_user_model()
user = User.objects.create_user(
email=f"custom-signature-{uuid.uuid4().hex[:8]}@example.test",
password="test1234",
signature="Custom email signature",
)
assert user.signature == "Custom email signature"
assert user.get_email_signature() == "Custom email signature"

View File

@@ -58,4 +58,29 @@ class TestUserAPI:
self.user.refresh_from_db()
assert response.status_code == status.HTTP_200_OK
assert self.user.first_name == "Inženjer"
assert self.user.first_name == "Inženjer"
def test_get_servicers_returns_only_servicers(self):
serviser = User.objects.create_user(
email="serviser@test.hr",
first_name="Serviser",
last_name="Jedan",
password="strongpassword123",
is_serviser=True,
is_team_member=True,
)
non_serviser = User.objects.create_user(
email="nons@test.hr",
first_name="Nije",
last_name="Serviser",
password="strongpassword123",
is_serviser=False,
is_team_member=True,
)
self.client.force_authenticate(user=self.user)
url = reverse('user-servicers')
response = self.client.get(url)
assert response.status_code == status.HTTP_200_OK
returned_ids = {str(item['id']) for item in response.data}
assert str(serviser.id) in returned_ids
assert str(non_serviser.id) not in returned_ids

View File

@@ -30,6 +30,12 @@ class UserViewSet(viewsets.ModelViewSet):
serializer = TeamMemberSerializer(queryset, many=True)
return Response(serializer.data)
@action(detail=False, methods=['get'], url_path='servicers')
def servicers(self, request):
queryset = User.objects.filter(is_active=True, is_serviser=True).order_by('first_name', 'last_name', 'email')
serializer = TeamMemberSerializer(queryset, many=True)
return Response(serializer.data)
def perform_update(self, serializer):
# Delegiramo logiku servisu
UserService.update_user_profile(

View File

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

View File

@@ -1,3 +1,4 @@
from pathlib import Path
from django.contrib import admin
from django.contrib.admin import ModelAdmin
from django.utils.html import format_html
@@ -7,7 +8,10 @@ from .models import (
WorkOrder,
WorkOrderPhoto,
WorkOrderInvoice,
WorkOrderAdditionalCostsTable,
ServiceContextNote,
GeneratedWorkOrderPdf,
EmailDispatchLog,
VehicleServiceRecord,
VehicleServicePhoto,
VehicleServiceAttachment,
@@ -44,7 +48,7 @@ class ServiceAttachmentInline(admin.TabularInline):
class WorkOrderInline(admin.TabularInline):
model = WorkOrder
extra = 0
fields = ('display_code', 'creator', 'date', 'start_mileage', 'end_mileage', 'location', 'travel_start_at', 'travel_end_at', 'purpose', 'status')
fields = ('display_code', 'creator', 'date', 'start_mileage', 'end_mileage', 'origin_location', 'location', 'travel_start_at', 'travel_end_at', 'purpose', 'status')
readonly_fields = ('date',)
@@ -58,8 +62,27 @@ class ServiceRecordInline(admin.TabularInline):
class WorkOrderInvoiceInline(admin.TabularInline):
model = WorkOrderInvoice
extra = 0
fields = ('naziv_racuna', 'lokacija', 'datum', 'opis', 'image', 'created_by', 'created_at', 'is_active')
readonly_fields = ('created_at',)
fields = ('thumbnail_preview', 'naziv_racuna', 'lokacija', 'datum', 'opis', 'image', 'created_by', 'created_at', 'is_active')
readonly_fields = ('thumbnail_preview', 'created_at')
@admin.display(description="Pregled")
def thumbnail_preview(self, obj):
if not obj.pk or not obj.image:
return ""
suffix = Path(obj.image.name or '').suffix.lower()
if suffix == '.pdf':
return format_html(
'<a href="/api/fleet/work-order-invoices/{}/image/" target="_blank">📄 PDF</a>',
obj.pk,
)
return format_html(
'<a href="/api/fleet/work-order-invoices/{}/image/?w=1600&q=90&fmt=jpeg" target="_blank">'
'<img src="/api/fleet/work-order-invoices/{}/image/?w=200&q=75&fmt=jpeg" '
'style="height:64px;width:auto;border-radius:4px;object-fit:cover;" />'
'</a>',
obj.pk,
obj.pk,
)
class WorkOrderPhotoInline(admin.TabularInline):
@@ -176,7 +199,7 @@ class CraneAdmin(VehicleAdmin):
@admin.register(WorkOrder)
class WorkOrderAdmin(admin.ModelAdmin):
list_display = ('display_code', 'id', 'vehicle', 'creator', 'date', 'location', 'travel_start_at', 'travel_end_at', 'start_mileage', 'end_mileage', 'distance_display', 'status')
list_display = ('display_code', 'id', 'vehicle', 'creator', 'date', 'origin_location', 'location', 'travel_start_at', 'travel_end_at', 'start_mileage', 'end_mileage', 'distance_display', 'status')
list_filter = ('status', 'date', 'vehicle')
search_fields = ('display_code', 'vehicle__registration_number', 'creator__username', 'purpose')
readonly_fields = ('date',)
@@ -190,10 +213,47 @@ class WorkOrderAdmin(admin.ModelAdmin):
@admin.register(WorkOrderInvoice)
class WorkOrderInvoiceAdmin(admin.ModelAdmin):
list_display = ('id', 'work_order', 'naziv_racuna', 'lokacija', 'datum', 'created_by', 'is_active')
list_display = ('id', 'image_preview', 'work_order', 'naziv_racuna', 'lokacija', 'datum', 'created_by', 'is_active')
list_filter = ('datum', 'is_active', 'work_order')
search_fields = ('naziv_racuna', 'lokacija', 'opis', 'work_order__id')
ordering = ('-datum', '-created_at')
readonly_fields = ('image_preview', 'created_at')
@admin.display(description="Slika")
def image_preview(self, obj):
if not obj.pk or not obj.image:
return ""
suffix = Path(obj.image.name or '').suffix.lower()
if suffix == '.pdf':
return format_html(
'<a href="/api/fleet/work-order-invoices/{}/image/" target="_blank">📄 PDF</a>',
obj.pk,
)
return format_html(
'<a href="/api/fleet/work-order-invoices/{}/image/?w=1600&q=90&fmt=jpeg" target="_blank">'
'<img src="/api/fleet/work-order-invoices/{}/image/?w=200&q=75&fmt=jpeg" '
'style="height:80px;width:auto;border-radius:4px;object-fit:cover;" />'
'</a>',
obj.pk,
obj.pk,
)
@admin.register(WorkOrderAdditionalCostsTable)
class WorkOrderAdditionalCostsTableAdmin(admin.ModelAdmin):
list_display = ('id', 'work_order', 'total_for_payout', 'updated_at')
search_fields = ('work_order__display_code', 'work_order__vehicle__registration_number')
readonly_fields = ('created_at', 'updated_at')
ordering = ('-updated_at',)
@admin.register(ServiceContextNote)
class ServiceContextNoteAdmin(admin.ModelAdmin):
list_display = ('id', 'recipient', 'created_by', 'audience_source', 'note_date', 'is_closed', 'created_at')
list_filter = ('audience_source', 'is_closed', 'note_date')
search_fields = ('note', 'recipient__email', 'recipient__first_name', 'recipient__last_name', 'created_by__email')
readonly_fields = ('created_at', 'updated_at', 'closed_at')
ordering = ('is_closed', 'note_date', '-created_at')
@admin.register(GeneratedWorkOrderPdf)
@@ -225,4 +285,50 @@ class VehicleServiceRecordAdmin(admin.ModelAdmin):
def short_description(self, obj):
if obj.description:
return obj.description[:60] + ('' if len(obj.description) > 60 else '')
return ""
return ""
@admin.register(EmailDispatchLog)
class EmailDispatchLogAdmin(admin.ModelAdmin):
list_display = (
'id',
'dispatch_type',
'status',
'requested_by',
'work_order',
'service_record',
'recipients_count',
'sent_at',
'created_at',
)
list_filter = ('dispatch_type', 'status', 'created_at', 'sent_at')
search_fields = (
'subject',
'message',
'error_message',
'requested_by__email',
'requested_by__username',
'work_order__display_code',
)
readonly_fields = (
'dispatch_type',
'requested_by',
'work_order',
'service_record',
'recipients',
'subject',
'message',
'attachments',
'status',
'error_message',
'metadata',
'sent_at',
'created_at',
'updated_at',
)
ordering = ('-created_at',)
@admin.display(description='Primatelja')
def recipients_count(self, obj):
recipients = obj.recipients or []
return len(recipients) if isinstance(recipients, list) else 0

Binary file not shown.

View File

@@ -0,0 +1,17 @@
def append_user_signature(body, user):
body_text = str(body or '').strip()
if user is None:
return body_text
signature = ''
get_signature = getattr(user, 'get_email_signature', None)
if callable(get_signature):
signature = str(get_signature() or '').strip()
else:
signature = str(getattr(user, 'signature', '') or '').strip()
if not signature:
return body_text
if not body_text:
return signature
return f"{body_text}\n\n{signature}"

View File

@@ -0,0 +1,16 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fleet', '0026_backfill_workorder_display_code'),
]
operations = [
migrations.AddField(
model_name='workorder',
name='origin_location',
field=models.CharField(blank=True, max_length=255, verbose_name='Lokacija ishodišta'),
),
]

View File

@@ -0,0 +1,18 @@
from django.db import migrations, models
import modules.fleet.models
class Migration(migrations.Migration):
dependencies = [
('fleet', '0027_workorder_origin_location'),
]
operations = [
migrations.AlterField(
model_name='workorderinvoice',
name='image',
field=models.FileField(blank=True, null=True, upload_to=modules.fleet.models._work_order_invoice_upload_to, verbose_name='Datoteka računa'),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.2.15 on 2026-07-19 17:54
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fleet', '0028_alter_workorderinvoice_image'),
]
operations = [
migrations.AlterField(
model_name='workorder',
name='purpose',
field=models.CharField(blank=True, choices=[('defektaza', 'Defektaža'), ('kontrola', 'Kontrola'), ('redovni_pregled', 'Redovni pregled'), ('popravak', 'Popravak')], max_length=50, verbose_name='Svrha'),
),
]

View File

@@ -0,0 +1,31 @@
from decimal import Decimal
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('fleet', '0028_alter_workorderinvoice_image'),
]
operations = [
migrations.CreateModel(
name='WorkOrderAdditionalCostsTable',
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)),
('data', models.JSONField(blank=True, default=None, null=True, verbose_name='Podaci tablice dodatnih troškova')),
('total_for_payout', models.DecimalField(decimal_places=2, default=Decimal('0.00'), max_digits=12, verbose_name='Ukupno za isplatu')),
('work_order', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='additional_costs_table', to='fleet.workorder', verbose_name='Putni nalog')),
],
options={
'verbose_name': 'Tablica dodatnih troškova putnog naloga',
'verbose_name_plural': 'Tablice dodatnih troškova putnog naloga',
'ordering': ['-updated_at'],
},
),
]

View File

@@ -0,0 +1,11 @@
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('fleet', '0029_alter_workorder_purpose'),
('fleet', '0029_workorderadditionalcoststable'),
]
operations = []

View File

@@ -0,0 +1,34 @@
# Generated by Django 5.2.15 on 2026-07-19 19:01
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fleet', '0030_merge_20260719_2057'),
]
operations = [
migrations.AlterField(
model_name='workorderadditionalcoststable',
name='created_at',
field=models.DateTimeField(auto_now_add=True, verbose_name='Vrijeme kreiranja'),
),
migrations.AlterField(
model_name='workorderadditionalcoststable',
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='workorderadditionalcoststable',
name='is_active',
field=models.BooleanField(default=True, verbose_name='Aktivan zapis'),
),
migrations.AlterField(
model_name='workorderadditionalcoststable',
name='updated_at',
field=models.DateTimeField(auto_now=True, verbose_name='Vrijeme zadnje izmjene'),
),
]

View File

@@ -0,0 +1,41 @@
# Generated by Django 5.2.15 on 2026-07-20 03:05
import uuid
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('task_management', '0010_alter_tasktemplate_status'),
('fleet', '0031_alter_workorderadditionalcoststable_created_at_and_more'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='ServiceContextNote',
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')),
('audience_source', models.CharField(choices=[('self', 'Samo autor'), ('member', 'Odabrani član tima'), ('team', 'Svi članovi tima')], default='self', max_length=16, verbose_name='Publika')),
('note', models.TextField(verbose_name='Bilješka')),
('note_date', models.DateField(blank=True, db_index=True, null=True, verbose_name='Datum podsjetnika')),
('is_closed', models.BooleanField(default=False, verbose_name='Ručno zatvoreno')),
('closed_at', models.DateTimeField(blank=True, null=True, verbose_name='Vrijeme zatvaranja')),
('created_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='created_service_context_notes', to=settings.AUTH_USER_MODEL, verbose_name='Kreirao')),
('recipient', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='service_context_notes', to=settings.AUTH_USER_MODEL, verbose_name='Primatelj')),
('task', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='service_context_notes', to='task_management.task', verbose_name='Radni zadatak')),
('work_order', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='service_context_notes', to='fleet.workorder', verbose_name='Putni nalog')),
],
options={
'verbose_name': 'Bilješka servisnog konteksta',
'verbose_name_plural': 'Bilješke servisnog konteksta',
'ordering': ['is_closed', 'note_date', '-created_at'],
},
),
]

View File

@@ -0,0 +1,43 @@
# Generated by Django 5.2.15 on 2026-07-21 11:50
import uuid
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('fleet', '0032_servicecontextnote'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='EmailDispatchLog',
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')),
('dispatch_type', models.CharField(choices=[('work_order', 'Putni nalog'), ('service_record', 'Servisni zapis')], db_index=True, max_length=32, verbose_name='Tip slanja')),
('recipients', models.JSONField(blank=True, default=list, verbose_name='Primatelji')),
('subject', models.CharField(blank=True, max_length=255, verbose_name='Naslov')),
('message', models.TextField(blank=True, verbose_name='Poruka')),
('attachments', models.JSONField(blank=True, default=list, verbose_name='Privitci')),
('status', models.CharField(choices=[('queued', 'U obradi'), ('sent', 'Poslano'), ('failed', 'Neuspješno')], db_index=True, default='queued', max_length=16, verbose_name='Status')),
('error_message', models.TextField(blank=True, verbose_name='Greška')),
('metadata', models.JSONField(blank=True, default=dict, verbose_name='Metapodaci')),
('sent_at', models.DateTimeField(blank=True, null=True, verbose_name='Poslano u')),
('requested_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='requested_email_dispatch_logs', to=settings.AUTH_USER_MODEL, verbose_name='Zatražio')),
('service_record', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='email_dispatch_logs', to='fleet.vehicleservicerecord', verbose_name='Servisni zapis')),
('work_order', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='email_dispatch_logs', to='fleet.workorder', verbose_name='Putni nalog')),
],
options={
'verbose_name': 'Log slanja emaila',
'verbose_name_plural': 'Logovi slanja emaila',
'ordering': ['-created_at'],
},
),
]

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")),
@@ -285,6 +359,7 @@ class WorkOrder(BaseModel):
blank=True,
verbose_name=_("Vozilo servisera - trošak goriva (EUR)"),
)
origin_location = models.CharField(max_length=255, blank=True, verbose_name=_("Lokacija ishodišta"))
location = models.CharField(max_length=255, blank=True, verbose_name=_("Lokacija odredišta"))
travel_start_at = models.DateTimeField(null=True, blank=True, verbose_name=_("Početak puta"))
travel_end_at = models.DateTimeField(null=True, blank=True, verbose_name=_("Kraj puta"))
@@ -351,11 +426,11 @@ class WorkOrderInvoice(BaseModel):
lokacija = models.CharField(max_length=255, blank=True, verbose_name=_("Lokacija"))
datum = models.DateField(default=timezone.now, verbose_name=_("Datum"))
opis = models.TextField(blank=True, verbose_name=_("Opis"))
image = models.ImageField(
image = models.FileField(
upload_to=_work_order_invoice_upload_to,
null=True,
blank=True,
verbose_name=_("Slika računa"),
verbose_name=_("Datoteka računa"),
)
created_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
@@ -376,6 +451,92 @@ class WorkOrderInvoice(BaseModel):
return f"{self.naziv_racuna} ({self.work_order_id})"
class WorkOrderAdditionalCostsTable(BaseModel):
work_order = models.OneToOneField(
WorkOrder,
on_delete=models.CASCADE,
related_name='additional_costs_table',
verbose_name=_("Putni nalog"),
)
data = models.JSONField(
null=True,
blank=True,
default=None,
verbose_name=_("Podaci tablice dodatnih troškova"),
)
total_for_payout = models.DecimalField(
max_digits=12,
decimal_places=2,
default=Decimal('0.00'),
verbose_name=_("Ukupno za isplatu"),
)
class Meta:
ordering = ['-updated_at']
verbose_name = _("Tablica dodatnih troškova putnog naloga")
verbose_name_plural = _("Tablice dodatnih troškova putnog naloga")
def __str__(self):
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,
@@ -449,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.
@@ -535,4 +749,235 @@ class VehicleNotification(BaseModel):
ordering = ['-created_at']
def __str__(self):
return f"{self.title} -> {self.recipient} ({self.level})"
return f"{self.title} -> {self.recipient} ({self.level})"
class EmailDispatchLog(BaseModel):
DISPATCH_TYPE_CHOICES = [
('work_order', _("Putni nalog")),
('service_record', _("Servisni zapis")),
]
STATUS_CHOICES = [
('queued', _("U obradi")),
('sent', _("Poslano")),
('failed', _("Neuspješno")),
]
dispatch_type = models.CharField(max_length=32, choices=DISPATCH_TYPE_CHOICES, db_index=True, verbose_name=_("Tip slanja"))
requested_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name='requested_email_dispatch_logs',
verbose_name=_("Zatražio"),
)
work_order = models.ForeignKey(
'fleet.WorkOrder',
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name='email_dispatch_logs',
verbose_name=_("Putni nalog"),
)
service_record = models.ForeignKey(
'fleet.VehicleServiceRecord',
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name='email_dispatch_logs',
verbose_name=_("Servisni zapis"),
)
recipients = models.JSONField(default=list, blank=True, verbose_name=_("Primatelji"))
subject = models.CharField(max_length=255, blank=True, verbose_name=_("Naslov"))
message = models.TextField(blank=True, verbose_name=_("Poruka"))
attachments = models.JSONField(default=list, blank=True, verbose_name=_("Privitci"))
status = models.CharField(max_length=16, choices=STATUS_CHOICES, default='queued', db_index=True, verbose_name=_("Status"))
error_message = models.TextField(blank=True, verbose_name=_("Greška"))
metadata = models.JSONField(default=dict, blank=True, verbose_name=_("Metapodaci"))
sent_at = models.DateTimeField(null=True, blank=True, verbose_name=_("Poslano u"))
class Meta:
verbose_name = _("Log slanja emaila")
verbose_name_plural = _("Logovi slanja emaila")
ordering = ['-created_at']
def __str__(self):
return f"{self.dispatch_type} -> {len(self.recipients or [])} ({self.status})"
def clean(self):
super().clean()
if self.dispatch_type == 'work_order' and not self.work_order_id:
raise ValidationError({'work_order': _("Putni nalog je obavezan za ovaj tip slanja.")})
if self.dispatch_type == 'service_record' and not self.service_record_id:
raise ValidationError({'service_record': _("Servisni zapis je obavezan za ovaj tip slanja.")})
class ServiceContextNote(BaseModel):
AUDIENCE_SOURCE_CHOICES = [
('self', 'Samo autor'),
('member', 'Odabrani član tima'),
('team', 'Svi članovi tima'),
]
created_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name='created_service_context_notes',
verbose_name=_("Kreirao"),
)
recipient = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name='service_context_notes',
verbose_name=_("Primatelj"),
)
audience_source = models.CharField(
max_length=16,
choices=AUDIENCE_SOURCE_CHOICES,
default='self',
verbose_name=_("Publika"),
)
work_order = models.ForeignKey(
WorkOrder,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='service_context_notes',
verbose_name=_("Putni nalog"),
)
task = models.ForeignKey(
'task_management.Task',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='service_context_notes',
verbose_name=_("Radni zadatak"),
)
note = models.TextField(verbose_name=_("Bilješka"))
note_date = models.DateField(
null=True,
blank=True,
db_index=True,
verbose_name=_("Datum podsjetnika"),
)
is_closed = models.BooleanField(default=False, verbose_name=_("Ručno zatvoreno"))
closed_at = models.DateTimeField(null=True, blank=True, verbose_name=_("Vrijeme zatvaranja"))
class Meta:
verbose_name = _("Bilješka servisnog konteksta")
verbose_name_plural = _("Bilješke servisnog konteksta")
ordering = ['is_closed', 'note_date', '-created_at']
def __str__(self):
return f"Note {self.pk} -> {self.recipient_id}"
def clean(self):
super().clean()
if self.task_id and self.work_order_id and self.task and self.task.work_order_id:
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

@@ -1,12 +1,20 @@
from rest_framework import serializers
import re
from pathlib import Path
from decimal import Decimal, InvalidOperation
from django.contrib.auth import get_user_model
from django.urls import reverse
from django.utils import timezone
from modules.task_management.models import Task
from .models import (
Crane,
Vehicle,
ServiceContextNote,
MonthlyServicerDayEntry,
VehicleNotification,
WorkOrder,
WorkOrderAdditionalCostsTable,
WorkOrderTravelExpensesTable,
WorkOrderPhoto,
WorkOrderInvoice,
VehicleServiceRecord,
@@ -15,6 +23,8 @@ from .models import (
)
from . import services
User = get_user_model()
class VehicleSerializer(serializers.ModelSerializer):
# Čitljivo ime klijenta — read-only, za prikaz u frontendu i adminu
@@ -30,7 +40,7 @@ class VehicleSerializer(serializers.ModelSerializer):
'is_company_vehicle', 'client', 'client_name',
'assigned_servicer', 'is_active',
]
read_only_fields = ['id', 'current_mileage', 'client_name']
read_only_fields = ['id', 'client_name']
def get_client_name(self, obj):
if obj.client_id:
@@ -92,6 +102,161 @@ class VehicleNotificationSerializer(serializers.ModelSerializer):
read_only_fields = ['id', 'title', 'message', 'metadata', 'level', 'is_sent', 'created_at']
# is_read is writable via the "mark_read" action, but normally read-only in list
class ServiceContextNoteSerializer(serializers.ModelSerializer):
created_by_name = serializers.SerializerMethodField()
recipient_name = serializers.SerializerMethodField()
work_order_label = serializers.SerializerMethodField()
task_title = serializers.SerializerMethodField()
class Meta:
model = ServiceContextNote
fields = [
'id',
'created_by',
'created_by_name',
'recipient',
'recipient_name',
'audience_source',
'work_order',
'work_order_label',
'task',
'task_title',
'note',
'note_date',
'is_closed',
'closed_at',
'created_at',
'updated_at',
]
read_only_fields = fields
def get_created_by_name(self, obj):
return obj.created_by.get_full_name() or obj.created_by.email
def get_recipient_name(self, obj):
return obj.recipient.get_full_name() or obj.recipient.email
def get_work_order_label(self, obj):
if not obj.work_order_id:
return None
display_code = str(getattr(obj.work_order, 'display_code', '') or '').strip().upper()
return display_code or f"WO-{obj.work_order_id}"
def get_task_title(self, obj):
if not obj.task_id:
return None
return obj.task.title
class ServiceContextNoteCreateSerializer(serializers.Serializer):
AUDIENCE_CHOICES = ['self', 'member', 'team']
note = serializers.CharField(required=True, allow_blank=False, trim_whitespace=True)
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, is_team_member=True),
required=False,
allow_null=True,
)
target_users = serializers.ListField(
child=serializers.PrimaryKeyRelatedField(queryset=User.objects.filter(is_active=True, is_team_member=True)),
required=False,
allow_empty=False,
)
work_order = serializers.PrimaryKeyRelatedField(
queryset=WorkOrder.objects.filter(is_active=True),
required=False,
allow_null=True,
)
task = serializers.PrimaryKeyRelatedField(
queryset=Task.objects.filter(is_active=True),
required=False,
allow_null=True,
)
def validate(self, attrs):
request = self.context.get('request')
user = getattr(request, 'user', None)
if user is None:
raise serializers.ValidationError('Autentikacija je obavezna.')
audience = attrs.get('audience', 'self')
target_user = attrs.get('target_user')
target_users = attrs.get('target_users') or []
work_order = attrs.get('work_order')
task = attrs.get('task')
note = str(attrs.get('note', '') or '').strip()
can_manage_recipients = bool(
getattr(user, 'is_serviser', False)
or getattr(user, 'is_staff', False)
or (getattr(user, 'is_team_member', False) and not getattr(user, 'is_serviser', False))
)
if not note:
raise serializers.ValidationError({'note': 'Bilješka ne može biti prazna.'})
if not can_manage_recipients and audience != 'self':
raise serializers.ValidationError({'audience': 'Samo supervisor ili serviser može slati bilješke drugim članovima tima.'})
if audience == 'member' and not target_user and len(target_users) == 0:
raise serializers.ValidationError({'target_users': 'Odaberite barem jednog servisera.'})
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 č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_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':
raise serializers.ValidationError({'work_order': 'Moguće je povezati samo aktivni otvoreni putni nalog.'})
if task:
status = str(task.status or '').lower()
if status in {'zavrsen', 'neaktivan'}:
raise serializers.ValidationError({'task': 'Moguće je povezati samo aktivni radni zadatak.'})
if task and work_order and task.work_order_id and str(task.work_order_id) != str(work_order.id):
raise serializers.ValidationError({'task': 'Radni zadatak ne pripada odabranom putnom nalogu.'})
attrs['note'] = note
if task and not work_order and task.work_order_id:
attrs['work_order'] = task.work_order
if audience == 'member' and len(target_users) == 0 and target_user:
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()
@@ -104,7 +269,7 @@ class WorkOrderSerializer(serializers.ModelSerializer):
'servicer_vehicle_registration', 'servicer_vehicle_make_model',
'servicer_vehicle_start_mileage', 'servicer_vehicle_end_mileage',
'servicer_vehicle_fuel_cost',
'location', 'travel_start_at', 'travel_end_at',
'origin_location', 'location', 'travel_start_at', 'travel_end_at',
'purpose', 'notes', 'status'
]
read_only_fields = ['id', 'date']
@@ -119,6 +284,7 @@ class WorkOrderSerializer(serializers.ModelSerializer):
'servicer_vehicle_start_mileage': {'required': False, 'allow_null': True},
'servicer_vehicle_end_mileage': {'required': False, 'allow_null': True},
'servicer_vehicle_fuel_cost': {'required': False, 'allow_null': True},
'origin_location': {'required': False, 'allow_blank': True},
'location': {'required': False, 'allow_blank': True},
'travel_start_at': {'required': False, 'allow_null': True},
'travel_end_at': {'required': False, 'allow_null': True},
@@ -211,18 +377,22 @@ 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',
'opis',
'image',
'image_url',
'image_content_type',
'created_by',
'created_at',
]
@@ -234,9 +404,201 @@ class WorkOrderInvoiceSerializer(serializers.ModelSerializer):
return None
if request is None:
return None
return request.build_absolute_uri(
base = request.build_absolute_uri(
reverse('work-order-invoice-image', kwargs={'pk': obj.pk})
)
suffix = Path(obj.image.name or '').suffix.lower()
if suffix == '.pdf':
return base
return f"{base}?w=1280&q=80&fmt=jpeg"
def get_image_content_type(self, obj):
if not obj.image:
return None
suffix = Path(obj.image.name or '').suffix.lower()
if suffix == '.pdf':
return 'application/pdf'
if suffix in {'.jpg', '.jpeg'}:
return 'image/jpeg'
if suffix == '.png':
return 'image/png'
if suffix == '.webp':
return 'image/webp'
if suffix == '.gif':
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
suffix = Path(getattr(value, 'name', '')).suffix.lower()
allowed_suffixes = {'.jpg', '.jpeg', '.png', '.webp', '.gif', '.pdf'}
if suffix not in allowed_suffixes:
raise serializers.ValidationError('Datoteka računa mora biti slika (.jpg, .jpeg, .png, .webp, .gif) ili PDF (.pdf).')
return value
class WorkOrderAdditionalCostsTableSerializer(serializers.ModelSerializer):
class Meta:
model = WorkOrderAdditionalCostsTable
fields = ['id', 'work_order', 'data', '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 Decimal('0.00')
try:
normalized = str(value).strip().replace('', '').replace(' ', '').replace(',', '.')
return Decimal(normalized)
except (InvalidOperation, ValueError, TypeError):
return Decimal('0.00')
def _normalize_row(self, row):
return {
'naziv': str(row.get('naziv', '') or '').strip(),
'broj_racuna': str(row.get('broj_racuna', '') or '').strip(),
'ukupan_iznos': str(row.get('ukupan_iznos', '') or '').strip(),
'prilog': str(row.get('prilog', '') or '').strip(),
'source_invoice_id': str(row.get('source_invoice_id', '') or '').strip(),
}
def validate_data(self, value):
if value is None:
return {'rows': []}
if not isinstance(value, dict):
raise serializers.ValidationError("Podaci tablice moraju biti JSON objekt.")
rows = value.get('rows', [])
if not isinstance(rows, list):
raise serializers.ValidationError({"rows": "Polje 'rows' mora biti lista redaka."})
normalized_rows = []
for row in rows[:100]:
if not isinstance(row, dict):
raise serializers.ValidationError({"rows": "Svaki redak mora biti JSON objekt."})
normalized_rows.append(self._normalize_row(row))
return {'rows': normalized_rows}
def _calculate_total_for_payout(self, data):
rows = data.get('rows', []) if isinstance(data, dict) else []
total = sum((self._parse_decimal(row.get('ukupan_iznos')) for row in rows), Decimal('0.00'))
return total.quantize(Decimal('0.01'))
def create(self, validated_data):
validated_data['total_for_payout'] = self._calculate_total_for_payout(validated_data.get('data'))
return super().create(validated_data)
def update(self, instance, validated_data):
data = validated_data.get('data', instance.data or {'rows': []})
validated_data['total_for_payout'] = self._calculate_total_for_payout(data)
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):

View File

@@ -1,18 +1,37 @@
# 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
from io import StringIO
from pathlib import Path
import base64
import re
import csv
import mimetypes
from decimal import Decimal, InvalidOperation
from smtplib import SMTPSenderRefused
from datetime import timedelta
from django.core.exceptions import ValidationError as DjangoValidationError
from django.core.validators import validate_email
from PIL import Image, UnidentifiedImageError
from django.db import transaction
from pypdf import PdfReader
from pypdf.errors import PdfReadError
import pytesseract
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
logger = logging.getLogger(__name__)
register_unicode_fonts()
@@ -85,6 +104,451 @@ def send_email_task(recipient_email, subject, message, notif_id):
logger.error(f"Async mail failed: {exc}")
def _guess_content_type(filename):
mime, _ = mimetypes.guess_type(str(filename or ''))
return mime or 'application/octet-stream'
def _file_attachment(file_field, fallback_name):
if not file_field:
return None
file_field.open('rb')
try:
content = file_field.read()
finally:
file_field.close()
if not content:
return None
filename = Path(str(getattr(file_field, 'name', '') or fallback_name)).name or fallback_name
return (filename, content, _guess_content_type(filename))
def _build_image_attachments_for_work_order(work_order):
from .models import WorkOrderPhoto, WorkOrderInvoice
attachments = []
photos = WorkOrderPhoto.objects.filter(is_active=True, work_order=work_order).order_by('created_at')
for index, photo in enumerate(photos, start=1):
attachment = _file_attachment(photo.image, fallback_name=f"work-order-photo-{index}.bin")
if attachment:
attachments.append(attachment)
invoices = WorkOrderInvoice.objects.filter(is_active=True, work_order=work_order).order_by('created_at')
for index, invoice in enumerate(invoices, start=1):
attachment = _file_attachment(invoice.image, fallback_name=f"work-order-invoice-{index}.bin")
if attachment:
attachments.append(attachment)
return attachments
def _user_display_name(user):
if not user:
return None
full_name = user.get_full_name()
if full_name:
return full_name
if getattr(user, 'email', None):
return user.email
if getattr(user, 'username', None):
return user.username
return str(getattr(user, 'pk', ''))
def _build_monthly_tasks_csv_attachment(work_order, *, year, month, month_key):
from modules.task_management.models import Task
tasks = (
Task.objects
.filter(
is_active=True,
vehicle_id=work_order.vehicle_id,
scheduled_date__year=year,
scheduled_date__month=month,
)
.select_related('assigned_to', 'work_order')
.order_by('scheduled_date', 'created_at')
)
csv_buffer = StringIO()
writer = csv.writer(csv_buffer)
writer.writerow(['Datum', 'Naslov zadatka', 'Status', 'Serviser', 'Putni nalog', 'Opis'])
for task in tasks:
assignee_name = _user_display_name(task.assigned_to) or '-'
work_order_label = str(getattr(getattr(task, 'work_order', None), 'display_code', '') or '').strip().upper() or '-'
writer.writerow([
task.scheduled_date.isoformat() if task.scheduled_date else '',
task.title or '',
task.status or '',
assignee_name,
work_order_label,
task.description or '',
])
csv_content = csv_buffer.getvalue().encode('utf-8')
filename = f"{_work_order_display_code(work_order)}.monthly-tasks-{month_key}.csv"
return (filename, csv_content, 'text/csv'), tasks.count()
def _resolve_from_email():
candidates = [
getattr(settings, 'DEFAULT_FROM_EMAIL', None),
getattr(settings, 'EMAIL_HOST_USER', None),
]
for candidate in candidates:
if not candidate:
continue
normalized = str(candidate).strip()
if not normalized:
continue
try:
validate_email(normalized)
return normalized
except DjangoValidationError:
continue
return None
def _detect_generated_pdf_type(filename):
normalized = str(filename or '').strip().lower()
if normalized.endswith('.work-order-invoices.pdf'):
return 'invoices'
if normalized.endswith('.work-order-service-records.pdf'):
return 'service_records'
if normalized.endswith('.work-order.pdf'):
return 'work_order'
return None
def _cache_pdf_attachments_for_download(*, work_order, requested_by, attachments):
cached_payload = []
ttl_hours = int(getattr(settings, 'GENERATED_PDF_TTL_HOURS', 24))
expires_at = timezone.now() + timedelta(hours=ttl_hours)
for filename, content, content_type in attachments:
if str(content_type or '').lower() != 'application/pdf':
continue
pdf_type = _detect_generated_pdf_type(filename)
if not pdf_type:
continue
generated = GeneratedWorkOrderPdf.objects.create(
work_order=work_order,
requested_by=requested_by,
pdf_type=pdf_type,
status='ready',
filename=filename,
expires_at=expires_at,
)
generated.file.save(filename, ContentFile(content), save=False)
generated.save(update_fields=['file', 'updated_at'])
cached_payload.append(
{
'generated_pdf_id': str(generated.pk),
'pdf_type': pdf_type,
'filename': filename,
'download_url': f"fleet/work-orders/{work_order.pk}/generated-pdfs/{generated.pk}/download/",
'expires_at': expires_at.isoformat(),
}
)
return cached_payload
@shared_task
def send_work_order_email_bundle_task(
*,
work_order_id,
requested_by_id,
recipients,
subject='',
message='',
include_work_order_pdf=True,
include_service_records_pdf=False,
include_invoices_pdf=False,
include_images=False,
include_monthly_tasks=False,
month_year=None,
month_number=None,
month_key='',
service_note_id=None,
dispatch_log_id=None,
):
from django.contrib.auth import get_user_model
from .models import WorkOrder, ServiceContextNote, EmailDispatchLog
from .services import NotificationService
from .views import _build_work_order_pdf, _build_work_order_service_records_pdf
User = get_user_model()
requested_by = User.objects.filter(pk=requested_by_id, is_active=True).first()
dispatch_log = EmailDispatchLog.objects.filter(pk=dispatch_log_id).first() if dispatch_log_id else None
work_order = WorkOrder.objects.select_related('vehicle', 'creator', 'vehicle__client').filter(pk=work_order_id).first()
if work_order is None:
if dispatch_log:
dispatch_log.status = 'failed'
dispatch_log.error_message = 'Putni nalog nije pronađen.'
dispatch_log.save(update_fields=['status', 'error_message', 'updated_at'])
if requested_by:
NotificationService.create_notification(
recipient=requested_by,
title='Greška kod slanja emaila',
message='Putni nalog za slanje emaila nije pronađen.',
level='warning',
send_email=False,
metadata={
'entity_type': 'work_order_email',
'stage': 'failed',
'work_order_id': str(work_order_id),
},
)
return {'status': 'failed', 'error': 'work-order-not-found'}
body_lines = [str(message or 'U prilogu je dokumentacija putnog naloga.')]
if service_note_id:
note = ServiceContextNote.objects.select_related('created_by').filter(
pk=service_note_id,
is_active=True,
).first()
if note:
creator_name = _user_display_name(note.created_by) or '-'
note_date = note.note_date.isoformat() if note.note_date else '-'
body_lines.append("")
body_lines.append("Odabrana bilješka servisnog konteksta:")
body_lines.append(f"- Autor: {creator_name}")
body_lines.append(f"- Datum bilješke: {note_date}")
body_lines.append(f"- Tekst: {note.note}")
attachments = []
if include_work_order_pdf:
attachments.append((
f"{_work_order_display_code(work_order)}.work-order.pdf",
_build_work_order_pdf(work_order),
'application/pdf',
))
if include_service_records_pdf:
attachments.append((
f"{_work_order_display_code(work_order)}.work-order-service-records.pdf",
_build_work_order_service_records_pdf(work_order),
'application/pdf',
))
if include_invoices_pdf:
attachments.append((
f"{_work_order_display_code(work_order)}.work-order-invoices.pdf",
_build_work_order_invoices_pdf(work_order),
'application/pdf',
))
if include_images:
image_attachments = _build_image_attachments_for_work_order(work_order)
attachments.extend(image_attachments)
body_lines.append("")
body_lines.append(f"Dodano slika/datoteka: {len(image_attachments)}.")
monthly_tasks_count = 0
if include_monthly_tasks and month_year and month_number and month_key:
monthly_attachment, monthly_tasks_count = _build_monthly_tasks_csv_attachment(
work_order,
year=month_year,
month=month_number,
month_key=month_key,
)
attachments.append(monthly_attachment)
body_lines.append("")
body_lines.append(f"Priložen je popis mjesečnih radnih taskova za {month_key} (ukupno: {monthly_tasks_count}).")
cached_pdf_payload = []
if attachments:
cached_pdf_payload = _cache_pdf_attachments_for_download(
work_order=work_order,
requested_by=requested_by,
attachments=attachments,
)
from_email = _resolve_from_email()
if not from_email:
if dispatch_log:
dispatch_log.status = 'failed'
dispatch_log.error_message = 'SMTP pošiljatelj nije valjan.'
dispatch_log.metadata = {
**(dispatch_log.metadata if isinstance(dispatch_log.metadata, dict) else {}),
'cached_pdfs': cached_pdf_payload,
}
dispatch_log.save(update_fields=['status', 'error_message', 'metadata', 'updated_at'])
if requested_by:
NotificationService.create_notification(
recipient=requested_by,
title='Greška kod slanja emaila',
message='SMTP pošiljatelj nije valjan. Provjerite DEFAULT_FROM_EMAIL/EMAIL_HOST_USER postavke.',
level='warning',
send_email=False,
metadata={
'entity_type': 'work_order_email',
'stage': 'failed',
'work_order_id': str(work_order.pk),
'cached_pdfs': cached_pdf_payload,
},
)
return {'status': 'failed', 'error': 'invalid-from-email'}
total_attachment_bytes = sum(len(content) for _, content, _ in attachments)
max_attachment_bytes = int(getattr(settings, 'WORK_ORDER_EMAIL_MAX_ATTACHMENT_BYTES', 17 * 1024 * 1024))
if total_attachment_bytes > max_attachment_bytes:
if dispatch_log:
dispatch_log.status = 'failed'
dispatch_log.error_message = 'Ukupna veličina privitaka je prevelika.'
dispatch_log.metadata = {
**(dispatch_log.metadata if isinstance(dispatch_log.metadata, dict) else {}),
'attachments_bytes': total_attachment_bytes,
'max_attachments_bytes': max_attachment_bytes,
'cached_pdfs': cached_pdf_payload,
}
dispatch_log.save(update_fields=['status', 'error_message', 'metadata', 'updated_at'])
if requested_by:
NotificationService.create_notification(
recipient=requested_by,
title='Email nije poslan — preveliki privitci',
message='Ukupna veličina privitaka je prevelika za slanje. Smanjite broj/veličinu privitaka.',
level='warning',
send_email=False,
metadata={
'entity_type': 'work_order_email',
'stage': 'failed',
'work_order_id': str(work_order.pk),
'attachments_bytes': total_attachment_bytes,
'max_attachments_bytes': max_attachment_bytes,
'cached_pdfs': cached_pdf_payload,
},
)
return {'status': 'failed', 'error': 'attachments-too-large'}
if not attachments:
if dispatch_log:
dispatch_log.status = 'failed'
dispatch_log.error_message = 'Nije odabran nijedan privitak.'
dispatch_log.save(update_fields=['status', 'error_message', 'updated_at'])
if requested_by:
NotificationService.create_notification(
recipient=requested_by,
title='Greška kod slanja emaila',
message='Nije odabran nijedan PDF/prilog za slanje.',
level='warning',
send_email=False,
metadata={
'entity_type': 'work_order_email',
'stage': 'failed',
'work_order_id': str(work_order_id),
},
)
return {'status': 'failed', 'error': 'no-attachments'}
email_message = EmailMessage(
subject=subject or f"Putni nalog {_work_order_display_code(work_order)}",
body=append_user_signature("\n".join(body_lines).strip(), requested_by),
from_email=from_email,
to=recipients or [],
)
for filename, content, content_type in attachments:
email_message.attach(filename, content, content_type)
if dispatch_log:
dispatch_log.attachments = [
{
'filename': filename,
'content_type': content_type,
'size_bytes': len(content),
}
for filename, content, content_type in attachments
]
dispatch_log.message = email_message.body
dispatch_log.save(update_fields=['attachments', 'message', 'updated_at'])
try:
email_message.send(fail_silently=False)
except SMTPSenderRefused as exc:
logger.exception("SMTP sender refused for work order email bundle: %s", exc)
if dispatch_log:
dispatch_log.status = 'failed'
dispatch_log.error_message = f'SMTP sender refused ({exc.smtp_code}).'
dispatch_log.metadata = {
**(dispatch_log.metadata if isinstance(dispatch_log.metadata, dict) else {}),
'smtp_code': exc.smtp_code,
'from_email': from_email,
'cached_pdfs': cached_pdf_payload,
}
dispatch_log.save(update_fields=['status', 'error_message', 'metadata', 'updated_at'])
if requested_by:
NotificationService.create_notification(
recipient=requested_by,
title='Greška kod slanja emaila',
message='SMTP server je odbio pošiljatelja. Provjerite email konfiguraciju.',
level='warning',
send_email=False,
metadata={
'entity_type': 'work_order_email',
'stage': 'failed',
'work_order_id': str(work_order.pk),
'smtp_code': exc.smtp_code,
'from_email': from_email,
'cached_pdfs': cached_pdf_payload,
},
)
return {'status': 'failed', 'error': 'smtp-sender-refused'}
except Exception as exc:
logger.exception("Greška kod slanja work order email bundle: %s", exc)
if dispatch_log:
dispatch_log.status = 'failed'
dispatch_log.error_message = str(exc)
dispatch_log.metadata = {
**(dispatch_log.metadata if isinstance(dispatch_log.metadata, dict) else {}),
'cached_pdfs': cached_pdf_payload,
}
dispatch_log.save(update_fields=['status', 'error_message', 'metadata', 'updated_at'])
if requested_by:
NotificationService.create_notification(
recipient=requested_by,
title='Greška kod slanja emaila',
message=f'Slanje emaila za putni nalog {_work_order_display_code(work_order)} nije uspjelo.',
level='warning',
send_email=False,
metadata={
'entity_type': 'work_order_email',
'stage': 'failed',
'work_order_id': str(work_order.pk),
'recipients_count': len(recipients or []),
'cached_pdfs': cached_pdf_payload,
},
)
return {'status': 'failed', 'error': str(exc)}
if requested_by:
NotificationService.create_notification(
recipient=requested_by,
title='Email uspješno poslan',
message=f'Email za putni nalog {_work_order_display_code(work_order)} je uspješno poslan.',
level='success',
send_email=False,
metadata={
'entity_type': 'work_order_email',
'stage': 'completed',
'work_order_id': str(work_order.pk),
'recipients_count': len(recipients or []),
'attachments_count': len(attachments),
'monthly_tasks_count': monthly_tasks_count,
},
)
if dispatch_log:
dispatch_log.status = 'sent'
dispatch_log.error_message = ''
dispatch_log.sent_at = timezone.now()
dispatch_log.metadata = {
**(dispatch_log.metadata if isinstance(dispatch_log.metadata, dict) else {}),
'recipients_count': len(recipients or []),
'attachments_count': len(attachments),
'monthly_tasks_count': monthly_tasks_count,
}
dispatch_log.save(update_fields=['status', 'error_message', 'sent_at', 'metadata', 'updated_at'])
return {
'status': 'ok',
'work_order_id': str(work_order.pk),
'recipients_count': len(recipients or []),
'attachments_count': len(attachments),
}
def _build_work_order_invoices_pdf(work_order):
buffer = BytesIO()
pdf = canvas.Canvas(buffer, pagesize=A4)
@@ -165,6 +629,11 @@ def _build_work_order_invoices_pdf(work_order):
pdf.drawString(margin, y, "slika: Nema slike.")
continue
suffix = Path(invoice.image.name or '').suffix.lower()
if suffix == '.pdf':
pdf.drawString(margin, y, "prilog: PDF račun (otvorite datoteku računa za pregled).")
continue
try:
invoice.image.open('rb')
with Image.open(invoice.image) as source:
@@ -178,10 +647,19 @@ def _build_work_order_invoices_pdf(work_order):
y = content_top
max_height = y - content_bottom
# Kompresiraj u JPEG u memoriji radi manje veličine PDF-a
compress_w = min(image.width, 1280)
if image.width > compress_w:
ratio_c = compress_w / float(image.width)
image = image.resize((compress_w, max(1, int(image.height * ratio_c))), Image.LANCZOS)
buf = BytesIO()
image.save(buf, format='JPEG', quality=75, optimize=True)
buf.seek(0)
ratio = min(max_width / float(image.width), max_height / float(image.height), 1.0)
draw_width = max(1, int(image.width * ratio))
draw_height = max(1, int(image.height * ratio))
image_reader = ImageReader(image)
image_reader = ImageReader(buf)
pdf.drawImage(
image_reader,
margin,
@@ -217,6 +695,249 @@ def build_work_order_invoices_pdf_task(work_order_id):
}
def _parse_decimal(value):
if value in (None, ''):
return Decimal('0.00')
try:
normalized = str(value).strip().replace('', '').replace(' ', '').replace(',', '.')
return Decimal(normalized)
except (InvalidOperation, ValueError, TypeError):
return Decimal('0.00')
def _extract_invoice_text(invoice):
if not invoice.image:
return ''
suffix = Path(invoice.image.name or '').suffix.lower()
if suffix == '.pdf':
invoice.image.open('rb')
try:
reader = PdfReader(invoice.image)
return '\n'.join((page.extract_text() or '') for page in reader.pages)
finally:
invoice.image.close()
invoice.image.open('rb')
try:
with Image.open(invoice.image) as source:
image = source.convert('RGB')
return pytesseract.image_to_string(image, lang='hrv+eng')
finally:
invoice.image.close()
def _extract_invoice_number(text, fallback):
if not text:
return fallback
patterns = [
r'(?i)(?:broj\s*računa|račun\s*broj|invoice\s*no\.?)\s*[:#]?\s*([A-Z0-9\/\-\._]+)',
r'(?i)(?:broj)\s*[:#]?\s*([A-Z0-9\/\-\._]{4,})',
]
for pattern in patterns:
match = re.search(pattern, text)
if match:
candidate = (match.group(1) or '').strip()
if candidate:
return candidate
return fallback
def _extract_total_amount(text):
if not text:
return Decimal('0.00')
def _line_candidates(line):
number_pattern = r'(?<!\d)(\d{1,3}(?:[.\s]\d{3})*(?:,\d{2})|\d+(?:[.,]\d{2}))(?!\d)'
return [_parse_decimal(match) for match in re.findall(number_pattern, line)]
preferred_lines = []
for line in text.splitlines():
lower = line.lower()
if any(token in lower for token in ('ukupno', 'za platiti', 'iznos', 'total')):
preferred_lines.append(line)
for line in preferred_lines:
candidates = [value for value in _line_candidates(line) if value > Decimal('0.00')]
if candidates:
return max(candidates)
all_candidates = []
for line in text.splitlines():
all_candidates.extend([value for value in _line_candidates(line) if value > Decimal('0.00')])
if all_candidates:
return max(all_candidates)
return Decimal('0.00')
@shared_task
def process_work_order_invoice_ocr(invoice_id):
from .models import WorkOrderInvoice, WorkOrderAdditionalCostsTable, GeneratedWorkOrderPdf
invoice = (
WorkOrderInvoice.objects
.select_related('work_order')
.filter(pk=invoice_id, is_active=True)
.first()
)
if invoice is None or invoice.work_order_id is None:
return {"status": "skipped", "reason": "invoice-not-found"}
try:
text = _extract_invoice_text(invoice)
except (UnidentifiedImageError, OSError, TesseractNotFoundError, ValueError, RuntimeError, PdfReadError):
text = ''
attachment_name = Path(invoice.image.name).name if invoice.image and getattr(invoice.image, 'name', '') else ''
row = {
'naziv': (invoice.naziv_racuna or '').strip() or attachment_name or f'Račun {invoice.pk}',
'broj_racuna': _extract_invoice_number(text, fallback=str(invoice.pk)),
'ukupan_iznos': f"{_extract_total_amount(text):.2f}",
'prilog': attachment_name,
'source_invoice_id': str(invoice.pk),
}
with transaction.atomic():
table, _ = WorkOrderAdditionalCostsTable.objects.select_for_update().get_or_create(
work_order=invoice.work_order,
defaults={'data': {'rows': []}},
)
existing_rows = table.data.get('rows', []) if isinstance(table.data, dict) else []
existing_row = next(
(
item for item in existing_rows
if isinstance(item, dict) and str(item.get('source_invoice_id', '')) == str(invoice.pk)
),
None,
)
normalized_rows = [
item for item in existing_rows
if isinstance(item, dict) and str(item.get('source_invoice_id', '')) != str(invoice.pk)
]
if existing_row and row['ukupan_iznos'] == '0.00':
existing_amount = str(existing_row.get('ukupan_iznos', '') or '').strip()
if existing_amount:
row['ukupan_iznos'] = existing_amount
if existing_row and not row['broj_racuna']:
row['broj_racuna'] = str(existing_row.get('broj_racuna', '') or '').strip()
normalized_rows.append(row)
total = sum((_parse_decimal(item.get('ukupan_iznos')) for item in normalized_rows if isinstance(item, dict)), Decimal('0.00'))
table.data = {'rows': normalized_rows}
table.total_for_payout = total.quantize(Decimal('0.01'))
table.save(update_fields=['data', 'total_for_payout', 'updated_at'])
GeneratedWorkOrderPdf.objects.filter(
is_active=True,
work_order=invoice.work_order,
pdf_type__in=['work_order', 'invoices'],
).update(
is_active=False,
status='failed',
error_message='PDF cache invalidiran zbog OCR ažuriranja dodatnih troškova.',
)
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

@@ -0,0 +1,169 @@
import uuid
from django.contrib.auth import get_user_model
from django.test import TestCase
from django.urls import reverse
from rest_framework.test import APIClient
from modules.fleet.models import ServiceContextNote, Vehicle, WorkOrder
from modules.task_management.models import Task
class ServiceContextNotesApiTests(TestCase):
def setUp(self):
user_model = get_user_model()
suffix = uuid.uuid4().hex[:8]
self.supervisor = user_model.objects.create_user(
username=f'supervisor-{suffix}',
email=f'supervisor-{suffix}@example.test',
password='test1234',
is_team_member=True,
is_serviser=False,
)
self.servicer = user_model.objects.create_user(
username=f'servicer-{suffix}',
email=f'servicer-{suffix}@example.test',
password='test1234',
is_team_member=True,
is_serviser=True,
)
self.other = user_model.objects.create_user(
username=f'other-{suffix}',
email=f'other-{suffix}@example.test',
password='test1234',
is_team_member=False,
is_serviser=False,
)
self.second_servicer = user_model.objects.create_user(
username=f'servicer2-{suffix}',
email=f'servicer2-{suffix}@example.test',
password='test1234',
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',
registration_number=f'SN-{suffix[:6].upper()}',
crane_serial_number='654321',
assigned_servicer=self.servicer,
)
self.work_order = WorkOrder.objects.create(
vehicle=self.vehicle,
creator=self.supervisor,
purpose='kontrola',
status='open',
)
self.task = Task.objects.create(
title='Aktivni servis',
assigned_to=self.servicer,
vehicle=self.vehicle,
work_order=self.work_order,
status='aktivan',
)
self.client = APIClient()
def test_supervisor_can_create_team_note_for_active_context(self):
self.client.force_authenticate(user=self.supervisor)
response = self.client.post(
reverse('service-context-note-list'),
data={
'note': 'Provjeriti opremu prije izlaska na teren.',
'note_date': '2026-07-21',
'audience': 'team',
'work_order': str(self.work_order.id),
'task': str(self.task.id),
},
format='json',
)
self.assertEqual(response.status_code, 201, response.content)
self.assertGreaterEqual(int(response.data.get('created_count', 0)), 2)
self.client.force_authenticate(user=self.servicer)
list_response = self.client.get(reverse('service-context-note-list'))
self.assertEqual(list_response.status_code, 200, list_response.content)
self.assertEqual(len(list_response.data), 1)
self.assertEqual(list_response.data[0]['audience_source'], 'team')
self.assertEqual(str(list_response.data[0]['task']), str(self.task.id))
def test_servicer_can_create_member_note_for_other_servicer(self):
self.client.force_authenticate(user=self.servicer)
response = self.client.post(
reverse('service-context-note-list'),
data={
'note': 'Serviser šalje bilješku drugom serviseru.',
'audience': 'member',
'target_user': str(self.second_servicer.id),
},
format='json',
)
self.assertEqual(response.status_code, 201, response.content)
self.assertEqual(int(response.data.get('created_count', 0)), 1)
def test_recipient_can_close_personal_note(self):
note = ServiceContextNote.objects.create(
created_by=self.supervisor,
recipient=self.servicer,
audience_source='member',
note='Osobna bilješka',
)
self.client.force_authenticate(user=self.servicer)
close_url = reverse('service-context-note-close', kwargs={'pk': note.id})
close_response = self.client.post(close_url, data={}, format='json')
self.assertEqual(close_response.status_code, 200, close_response.content)
note.refresh_from_db()
self.assertTrue(note.is_closed)
list_response = self.client.get(reverse('service-context-note-list'))
self.assertEqual(list_response.status_code, 200)
self.assertEqual(len(list_response.data), 0)
def test_supervisor_can_target_multiple_servicers(self):
self.client.force_authenticate(user=self.supervisor)
response = self.client.post(
reverse('service-context-note-list'),
data={
'note': 'Zajednička bilješka za dva servisera.',
'audience': 'member',
'target_users': [str(self.servicer.id), str(self.second_servicer.id)],
},
format='json',
)
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

@@ -0,0 +1,83 @@
from unittest.mock import patch
from django.contrib.auth import get_user_model
from django.core import mail
from django.test import TestCase, override_settings
from modules.fleet.models import GeneratedWorkOrderPdf, Vehicle, VehicleNotification, WorkOrder
from modules.fleet.tasks import send_work_order_email_bundle_task
@override_settings(
EMAIL_BACKEND='django.core.mail.backends.locmem.EmailBackend',
DEFAULT_FROM_EMAIL='no-reply@example.test',
)
class WorkOrderEmailTaskCachedPdfTests(TestCase):
def setUp(self):
user_model = get_user_model()
self.user = user_model.objects.create_user(
username='task-mail-user',
email='task-mail-user@example.test',
password='test1234',
)
self.vehicle = Vehicle.objects.create(
asset_type='crane',
registration_number='TASK-CACHE-001',
crane_serial_number='112233',
)
self.work_order = WorkOrder.objects.create(
vehicle=self.vehicle,
creator=self.user,
display_code='MT010126',
purpose='kontrola',
)
@patch('django.core.mail.message.EmailMessage.send', side_effect=Exception('smtp-fail'))
def test_failed_email_creates_cached_pdf_links_in_notification(self, _mock_send):
result = send_work_order_email_bundle_task(
work_order_id=str(self.work_order.pk),
requested_by_id=str(self.user.pk),
recipients=['client@example.test'],
include_work_order_pdf=True,
include_service_records_pdf=False,
include_invoices_pdf=False,
include_images=False,
include_monthly_tasks=False,
)
self.assertEqual(result.get('status'), 'failed')
cached = GeneratedWorkOrderPdf.objects.filter(work_order=self.work_order, pdf_type='work_order', status='ready')
self.assertTrue(cached.exists())
notif = VehicleNotification.objects.filter(
recipient=self.user,
metadata__entity_type='work_order_email',
metadata__stage='failed',
).order_by('-created_at').first()
self.assertIsNotNone(notif)
cached_pdfs = notif.metadata.get('cached_pdfs') if isinstance(notif.metadata, dict) else None
self.assertTrue(isinstance(cached_pdfs, list) and len(cached_pdfs) > 0)
self.assertIn('download_url', cached_pdfs[0])
def test_success_email_appends_requester_signature(self):
mail.outbox.clear()
result = send_work_order_email_bundle_task(
work_order_id=str(self.work_order.pk),
requested_by_id=str(self.user.pk),
recipients=['client@example.test'],
message='Test poruka bez potpisa.',
include_work_order_pdf=True,
include_service_records_pdf=False,
include_invoices_pdf=False,
include_images=False,
include_monthly_tasks=False,
)
self.assertEqual(result.get('status'), 'ok')
self.assertEqual(len(mail.outbox), 1)
sent_body = mail.outbox[0].body
self.assertIn('Test poruka bez potpisa.', sent_body)
self.assertIn('S poštovanjem / Freundliche Grüße / Kind regards', sent_body)
self.assertIn(self.user.email, sent_body)

View File

@@ -1,13 +1,37 @@
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
from django.utils import timezone
from rest_framework.test import APIClient
import uuid
from io import BytesIO
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
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'):
@@ -22,6 +46,15 @@ def create_test_image(filename='test.jpg', size=(40, 40), color='red'):
)
def create_test_pdf(filename='test.pdf'):
content = b'%PDF-1.4\n1 0 obj<</Type/Catalog>>endobj\ntrailer<</Root 1 0 R>>\n%%EOF'
return SimpleUploadedFile(
name=filename,
content=content,
content_type='application/pdf',
)
class WorkOrderImagesEndpointTests(TestCase):
def setUp(self):
user_model = get_user_model()
@@ -37,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',
@@ -138,3 +187,804 @@ class WorkOrderImagesEndpointTests(TestCase):
image_response = self.client.get(image_url)
self.assertEqual(image_response.status_code, 200)
self.assertTrue(image_response['Content-Type'].startswith('image/'))
def test_work_order_invoice_serializer_supports_pdf_file(self):
invoice = WorkOrderInvoice.objects.create(
work_order=self.work_order,
naziv_racuna='PDF račun',
image=create_test_pdf('invoice.pdf'),
created_by=self.user,
)
list_url = f"{reverse('work-order-invoice-list')}?work_order_id={self.work_order.pk}"
list_response = self.client.get(list_url)
self.assertEqual(list_response.status_code, 200)
self.assertEqual(len(list_response.data), 1)
image_url = list_response.data[0].get('image_url')
self.assertIsNotNone(image_url)
self.assertIn(f'/api/fleet/work-order-invoices/{invoice.pk}/image/', image_url)
self.assertEqual(list_response.data[0].get('image_content_type'), 'application/pdf')
file_response = self.client.get(image_url)
self.assertEqual(file_response.status_code, 200)
self.assertEqual(file_response['Content-Type'], 'application/pdf')
def test_additional_costs_table_can_be_saved_and_total_is_computed(self):
url = f"/api/fleet/work-orders/{self.work_order.pk}/additional-costs-table/"
response = self.client.put(
url,
data={
"rows": [
{"naziv": "Cestarina", "broj_racuna": "R-100", "ukupan_iznos": "12,50", "prilog": "racun-1.pdf"},
{"naziv": "Gorivo", "broj_racuna": "R-101", "ukupan_iznos": "7.50", "prilog": "racun-2.jpg"},
]
},
format='json',
)
self.assertEqual(response.status_code, 200, response.content)
payload = response.json()
self.assertEqual(payload.get('total_for_payout'), '20.00')
rows = payload.get('data', {}).get('rows', [])
self.assertEqual(len(rows), 2)
def test_task_service_context_contains_additional_costs_table(self):
table_url = f"/api/fleet/work-orders/{self.work_order.pk}/additional-costs-table/"
save_response = self.client.put(
table_url,
data={"rows": [{"naziv": "Parking", "broj_racuna": "P-1", "ukupan_iznos": "5.00", "prilog": "parking.pdf"}]},
format='json',
)
self.assertEqual(save_response.status_code, 200, save_response.content)
context_response = self.client.get(f"/api/fleet/work-orders/{self.work_order.pk}/task-service-context/")
self.assertEqual(context_response.status_code, 200, context_response.content)
payload = context_response.json()
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'),
data={
'work_order': str(self.work_order.pk),
'naziv_racuna': 'Test2',
'datum': '2026-07-19',
'image': create_test_image('test2.jpg'),
},
format='multipart',
)
self.assertEqual(create_response.status_code, 201, create_response.content)
table_response = self.client.get(f"/api/fleet/work-orders/{self.work_order.pk}/additional-costs-table/")
self.assertEqual(table_response.status_code, 200, table_response.content)
rows = table_response.json().get('data', {}).get('rows', [])
self.assertEqual(len(rows), 1)
self.assertEqual(rows[0].get('naziv'), 'Test2')
def test_additional_costs_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='MT010101.work-order.pdf',
expires_at=timezone.now() + timedelta(hours=1),
)
response = self.client.put(
f"/api/fleet/work-orders/{self.work_order.pk}/additional-costs-table/",
data={"rows": [{"naziv": "Test", "broj_racuna": "R-1", "ukupan_iznos": "10.00", "prilog": "r1.pdf"}]},
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_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

@@ -0,0 +1,128 @@
import uuid
from datetime import date
from io import BytesIO
from PIL import Image
from django.contrib.auth import get_user_model
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import TestCase, override_settings
from django.urls import reverse
from rest_framework.test import APIClient
from unittest.mock import patch
from modules.fleet.models import ServiceContextNote, Vehicle, WorkOrder, WorkOrderInvoice, WorkOrderPhoto
from modules.task_management.models import Task
def _test_image(filename='test.jpg', size=(40, 40), color='blue'):
file = BytesIO()
image = Image.new('RGB', size=size, color=color)
image.save(file, 'JPEG')
file.seek(0)
return SimpleUploadedFile(
name=filename,
content=file.getvalue(),
content_type='image/jpeg',
)
@override_settings(
EMAIL_BACKEND='django.core.mail.backends.locmem.EmailBackend',
DEFAULT_FROM_EMAIL='no-reply@example.test',
)
class WorkOrderSendEmailTests(TestCase):
def setUp(self):
user_model = get_user_model()
suffix = uuid.uuid4().hex[:8]
self.user = user_model.objects.create_user(
username=f'wo-mail-user-{suffix}',
email=f'wo-mail-user-{suffix}@example.test',
password='test1234',
)
self.client = APIClient()
self.client.force_authenticate(user=self.user)
self.vehicle = Vehicle.objects.create(
asset_type='crane',
registration_number='WO-MAIL-001',
crane_serial_number='654321',
)
self.work_order = WorkOrder.objects.create(
vehicle=self.vehicle,
creator=self.user,
display_code='MT010126',
purpose='kontrola',
)
def test_send_email_supports_multiple_recipients_and_all_requested_attachments(self):
WorkOrderPhoto.objects.create(
work_order=self.work_order,
image=_test_image('work-order-photo.jpg'),
description='Work order image',
uploaded_by=self.user,
)
WorkOrderInvoice.objects.create(
work_order=self.work_order,
naziv_racuna='Račun 1',
image=_test_image('invoice-image.jpg'),
created_by=self.user,
)
note = ServiceContextNote.objects.create(
created_by=self.user,
recipient=self.user,
audience_source='self',
work_order=self.work_order,
note='Odabrana test bilješka.',
)
Task.objects.create(
title='Mjesečni task',
description='Provjera mjesečnog task popisa.',
assigned_to=self.user,
vehicle=self.vehicle,
work_order=self.work_order,
scheduled_date=date(2026, 7, 10),
)
with patch('modules.fleet.views.send_work_order_email_bundle_task.delay') as mocked_delay:
response = self.client.post(
reverse('workorder-send-email', kwargs={'pk': self.work_order.pk}),
data={
'recipients': ['client@example.test', 'supervisor@example.test'],
'subject': 'Test slanje dokumentacije',
'message': 'U privitku su svi traženi dokumenti.',
'include_work_order_pdf': True,
'include_service_records_pdf': True,
'include_invoices_pdf': True,
'include_images': True,
'include_monthly_tasks': True,
'month': '2026-07',
'service_note_id': str(note.pk),
},
format='json',
)
self.assertEqual(response.status_code, 202, response.data)
self.assertTrue(response.data.get('queued'))
self.assertEqual(len(response.data.get('recipients', [])), 2)
mocked_delay.assert_called_once()
call_kwargs = mocked_delay.call_args.kwargs
self.assertEqual(call_kwargs.get('work_order_id'), str(self.work_order.pk))
self.assertEqual(call_kwargs.get('requested_by_id'), str(self.user.id))
self.assertEqual(call_kwargs.get('month_key'), '2026-07')
self.assertEqual(call_kwargs.get('service_note_id'), str(note.pk))
def test_send_email_keeps_backward_compatible_single_recipient_payload(self):
with patch('modules.fleet.views.send_work_order_email_bundle_task.delay') as mocked_delay:
response = self.client.post(
reverse('workorder-send-email', kwargs={'pk': self.work_order.pk}),
data={
'recipient': 'single@example.test',
'subject': 'Legacy send',
},
format='json',
)
self.assertEqual(response.status_code, 202, response.data)
self.assertTrue(response.data.get('queued'))
self.assertEqual(response.data.get('recipients'), ['single@example.test'])
mocked_delay.assert_called_once()

View File

@@ -2,7 +2,11 @@ from rest_framework.routers import DefaultRouter
from django.urls import path
from .views import (
CraneViewSet, VehicleViewSet, WorkOrderViewSet, VehicleServiceRecordViewSet,
WorkOrderInvoiceViewSet, VehicleNotificationViewSet, VehicleServicePhotoViewSet, VehicleServiceAttachmentViewSet, pusher_auth,
WorkOrderInvoiceViewSet, ServiceContextNoteViewSet, VehicleNotificationViewSet, VehicleServicePhotoViewSet, VehicleServiceAttachmentViewSet, pusher_auth,
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()
@@ -10,13 +14,22 @@ router.register(r'vehicles', VehicleViewSet, basename='vehicle')
router.register(r'cranes', CraneViewSet, basename='crane')
router.register(r'work-orders', WorkOrderViewSet, basename='workorder')
router.register(r'work-order-invoices', WorkOrderInvoiceViewSet, basename='work-order-invoice')
router.register(r'service-notes', ServiceContextNoteViewSet, basename='service-context-note')
router.register(r'service-records', VehicleServiceRecordViewSet, basename='service-record')
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
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

@@ -2,7 +2,7 @@
from django.contrib import admin
from .models import Task, TaskTemplate, TaskTemplateEntry, TaskWorkEntry, TaskWorkHoursTable
from .models import Task, TaskCalendar, TaskTemplate, TaskTemplateEntry, TaskWorkEntry, TaskWorkHoursTable
class TaskWorkEntryInline(admin.TabularInline):
@@ -33,6 +33,19 @@ class TaskAdmin(admin.ModelAdmin):
inlines = (TaskWorkHoursTableInline, TaskWorkEntryInline)
@admin.register(TaskCalendar)
class TaskCalendarAdmin(admin.ModelAdmin):
list_display = ('scheduled_date', 'title', 'status', 'assigned_to', 'vehicle', 'work_order')
list_filter = ('scheduled_date', 'status', 'assigned_to')
search_fields = ('title', 'assigned_to__email', 'vehicle__registration_number', 'work_order__display_code')
readonly_fields = ('id', 'created_at', 'updated_at')
ordering = ('scheduled_date', 'assigned_to__first_name', 'assigned_to__last_name')
def get_queryset(self, request):
queryset = super().get_queryset(request)
return queryset.filter(is_active=True, scheduled_date__isnull=False)
@admin.register(TaskTemplate)
class TaskTemplateAdmin(admin.ModelAdmin):
list_display = ('code', 'title', 'status', 'is_active', 'created_at')

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.2.15 on 2026-07-19 17:54
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('task_management', '0009_task_scheduled_date'),
]
operations = [
migrations.AlterField(
model_name='tasktemplate',
name='status',
field=models.CharField(choices=[('aktivan', 'Aktivan'), ('servis', 'U tijeku'), ('spreman_za_zavrsetak', 'Spreman za završetak'), ('zavrsen', 'Završen'), ('neaktivan', 'Otkazano')], default='aktivan', max_length=20),
),
]

View File

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

View File

@@ -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']
@@ -69,6 +75,13 @@ class Task(BaseModel):
})
class TaskCalendar(Task):
class Meta:
proxy = True
verbose_name = _("Kalendar zadatka")
verbose_name_plural = _("Kalendar zadataka")
class TaskWorkHoursTable(BaseModel):
"""Interaktivna tablica radnih sati povezana s radnim zadatkom."""

View File

@@ -86,6 +86,12 @@ class TaskSerializer(serializers.ModelSerializer):
assigned_to_name = serializers.SerializerMethodField()
work_order_label = serializers.SerializerMethodField()
vehicle_registration = serializers.SerializerMethodField()
vehicle_make = serializers.SerializerMethodField()
vehicle_model = serializers.SerializerMethodField()
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)
@@ -94,9 +100,11 @@ class TaskSerializer(serializers.ModelSerializer):
fields = [
'id', 'title', 'description', 'status',
'assigned_to', 'assigned_to_name',
'vehicle', 'vehicle_registration',
'vehicle', 'vehicle_registration', 'vehicle_make', 'vehicle_model',
'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',
]
@@ -108,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):
@@ -140,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({
@@ -180,3 +188,34 @@ class TaskSerializer(serializers.ModelSerializer):
if not obj.vehicle_id:
return None
return obj.vehicle.registration_number
def get_vehicle_make(self, obj):
if not obj.vehicle_id:
return None
return obj.vehicle.make or None
def get_vehicle_model(self, obj):
if not obj.vehicle_id:
return None
return obj.vehicle.model or None
def get_vehicle_owner_name(self, obj):
if not obj.vehicle_id or not obj.vehicle.client_id:
return None
return obj.vehicle.client.name
def get_vehicle_asset_type(self, obj):
if not obj.vehicle_id:
return None
return obj.vehicle.asset_type
def get_crane_serial_number(self, obj):
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

@@ -1,8 +1,9 @@
# backend/modules/task_management/services.py
from django.db import transaction
from django.db import transaction, models
from rest_framework.exceptions import ValidationError
from django.core.exceptions import ValidationError as DjangoValidationError
from django.contrib.auth import get_user_model
import logging
from .models import Task, TaskTemplate, TaskWorkEntry
@@ -10,6 +11,21 @@ from .exceptions import OpenWorkOrderBlockingTaskClose
from modules.fleet.models import VehicleServiceRecord
logger = logging.getLogger(__name__)
User = get_user_model()
def _task_overload_recipients(task):
recipients = []
if task.assigned_to_id:
recipients.append(task.assigned_to)
supervisors = User.objects.filter(is_active=True).filter(
models.Q(is_staff=True) | (models.Q(is_team_member=True) & models.Q(is_serviser=False))
)
recipients.extend(supervisors)
unique = {}
for recipient in recipients:
unique[str(recipient.id)] = recipient
return list(unique.values())
class TaskService:
@@ -53,6 +69,7 @@ class TaskService:
with transaction.atomic():
entry.save()
TaskService._notify_if_servicer_overloaded(entry)
if template:
TaskService._create_work_entries_from_template(task=entry, template=template)
TaskService._create_service_records_from_template(task=entry, template=template, user=user)
@@ -60,6 +77,49 @@ class TaskService:
logger.info(f"Zadatak uspješno kreiran: {entry.id}")
return entry
@staticmethod
def _notify_if_servicer_overloaded(entry: Task):
if not entry.assigned_to_id or not entry.scheduled_date:
return
if not getattr(entry.assigned_to, 'is_serviser', False):
return
task_count = Task.objects.filter(
is_active=True,
assigned_to=entry.assigned_to,
scheduled_date=entry.scheduled_date,
status__in=('aktivan', 'servis', 'spreman_za_zavrsetak'),
).count()
if task_count <= 2:
return
from modules.fleet.services import NotificationService
schedule_label = entry.scheduled_date.strftime('%d.%m.%Y')
title = f"Preopterećenje rasporeda: {entry.assigned_to.get_full_name() or entry.assigned_to.email}"
message = (
f"Serviseru {entry.assigned_to.get_full_name() or entry.assigned_to.email} "
f"je dodijeljeno {task_count} zadatka za {schedule_label}. "
f"Sustav je dopustio i dodatni zadatak."
)
metadata = {
"entity_type": "task_overload",
"assigned_to_id": str(entry.assigned_to_id),
"task_id": str(entry.id),
"scheduled_date": entry.scheduled_date.isoformat(),
"task_count": task_count,
"section": "dashboard",
}
for recipient in _task_overload_recipients(entry):
NotificationService.create_notification(
recipient=recipient,
title=title,
message=message,
level='warning',
send_email=False,
metadata=metadata,
)
@staticmethod
def _create_work_entries_from_template(*, task: Task, template: TaskTemplate):
template_entries = list(template.entries.filter(is_active=True).order_by('sort_order', 'created_at')[:10])

View File

@@ -2,15 +2,25 @@ 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
class TaskSerializerTests(TestCase):
def setUp(self):
User = get_user_model()
self.user = User.objects.create_user(username="task-user-base", email="task-user-base@example.test", password="pass")
self.vehicle = Vehicle.objects.create(registration_number="ZG-TSK-01", asset_type='crane')
self.client = Client.objects.create(name="Klijent zadatka")
self.vehicle = Vehicle.objects.create(
registration_number="ZG-TSK-01",
asset_type='crane',
make='Liebherr',
model='LTM',
crane_serial_number='123456',
client=self.client,
is_company_vehicle=False,
)
def test_validate_title_too_short_raises(self):
serializer = TaskSerializer(data={"title": "ab", "description": "", "status": "aktivan", "vehicle": str(self.vehicle.id)})
@@ -67,6 +77,31 @@ class TaskSerializerTests(TestCase):
serializer = TaskSerializer(instance=task)
assert serializer.data['scheduled_date'] is None
def test_vehicle_details_are_included_in_serialized_output(self):
task = Task.objects.create(title="Task crane details", assigned_to=self.user, vehicle=self.vehicle)
serializer = TaskSerializer(instance=task)
assert serializer.data['vehicle_make'] == 'Liebherr'
assert serializer.data['vehicle_model'] == 'LTM'
assert serializer.data['crane_serial_number'] == '123456'
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",
@@ -80,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

@@ -1,5 +1,7 @@
from django.test import TestCase
from django.contrib.auth import get_user_model
from unittest.mock import patch
from datetime import date
from rest_framework.exceptions import ValidationError
from modules.task_management.services import TaskService
from modules.task_management.exceptions import OpenWorkOrderBlockingTaskClose
@@ -192,4 +194,76 @@ class TaskServiceTests(TestCase):
)
self.assertEqual(TaskWorkEntry.objects.filter(task=created).count(), 10)
self.assertEqual(VehicleServiceRecord.objects.filter(task=created).count(), 10)
self.assertEqual(VehicleServiceRecord.objects.filter(task=created).count(), 10)
@patch('modules.fleet.services.NotificationService.create_notification')
def test_create_task_entry_sends_overload_notifications_after_two_tasks(self, mock_notify):
servicer = get_user_model().objects.create_user(
username='servicer-overload',
email='servicer-overload@example.test',
password='test1234',
is_serviser=True,
is_team_member=True,
)
supervisor = get_user_model().objects.create_user(
username='supervisor-overload',
email='supervisor-overload@example.test',
password='test1234',
is_team_member=True,
is_serviser=False,
)
vehicle = Vehicle.objects.create(registration_number="ZG-TASK-OVER1", asset_type='crane')
scheduled = date(2026, 7, 21)
Task.objects.create(title="Postojeći 1", assigned_to=servicer, vehicle=vehicle, status='aktivan', scheduled_date=scheduled)
Task.objects.create(title="Postojeći 2", assigned_to=servicer, vehicle=vehicle, status='servis', scheduled_date=scheduled)
TaskService.create_task_entry(
data={
"title": "Treći task",
"status": "aktivan",
"assigned_to": servicer,
"vehicle": vehicle,
"scheduled_date": scheduled,
},
user=self.staff,
)
recipients = [call.kwargs.get('recipient') for call in mock_notify.call_args_list]
recipient_ids = {str(recipient.id) for recipient in recipients if recipient}
self.assertIn(str(servicer.id), recipient_ids)
self.assertIn(str(supervisor.id), recipient_ids)
@patch('modules.fleet.services.NotificationService.create_notification')
def test_create_task_entry_does_not_send_overload_notifications_when_two_or_less(self, mock_notify):
servicer = get_user_model().objects.create_user(
username='servicer-normal-load',
email='servicer-normal-load@example.test',
password='test1234',
is_serviser=True,
is_team_member=True,
)
vehicle = Vehicle.objects.create(registration_number="ZG-TASK-OVER2", asset_type='crane')
scheduled = date(2026, 7, 21)
TaskService.create_task_entry(
data={
"title": "Prvi task",
"status": "aktivan",
"assigned_to": servicer,
"vehicle": vehicle,
"scheduled_date": scheduled,
},
user=self.staff,
)
TaskService.create_task_entry(
data={
"title": "Drugi task",
"status": "aktivan",
"assigned_to": servicer,
"vehicle": vehicle,
"scheduled_date": scheduled,
},
user=self.staff,
)
self.assertEqual(mock_notify.call_count, 0)

View File

@@ -22,7 +22,7 @@ class TaskViewSet(viewsets.ModelViewSet):
def get_queryset(self):
qs = Task.objects.filter(is_active=True).select_related(
'assigned_to', 'vehicle', 'work_order', 'work_order__vehicle', 'work_hours_table'
'assigned_to', 'vehicle', 'vehicle__client', 'work_order', 'work_order__vehicle', 'work_hours_table'
)
vehicle_id = self.request.query_params.get('vehicle')
if vehicle_id:
@@ -30,6 +30,9 @@ class TaskViewSet(viewsets.ModelViewSet):
scheduled_date = self.request.query_params.get('scheduled_date')
if scheduled_date:
qs = qs.filter(scheduled_date=scheduled_date)
work_order_id = self.request.query_params.get('work_order')
if work_order_id:
qs = qs.filter(work_order_id=work_order_id)
return qs
def perform_create(self, serializer):

View File

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

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

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

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

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

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}
@@ -1364,6 +1624,10 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
setServiceRecordCrane(taskCrane ?? null);
setServiceRecordOpen(true);
}}
onSetServiceContext={(task) => {
const taskCrane = cranes.find((item) => String(item.id) === String(task?.vehicle));
handleSetServiceContextForCrane(taskCrane);
}}
/>
</AnimatedPage>
);

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,10 +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);
@@ -35,6 +41,7 @@ export default function TaskServiceRecordsModal({
onAssignWorkOrder,
onUpdateTask,
onCreateServiceRecord,
onSetServiceContext,
}) {
const [showWorkOrderEditor, setShowWorkOrderEditor] = useState(false);
const [selectedWorkOrderId, setSelectedWorkOrderId] = useState('');
@@ -49,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;
@@ -70,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 || '');
@@ -155,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"
@@ -169,14 +243,32 @@ export default function TaskServiceRecordsModal({
Radni zadatak: {task.title || '-'} Status: {getStatusLabel(task.status || '-')} Zapisa: {records.length}
</p>
</div>
<button
type="button"
onClick={onClose}
className="rounded-md px-2 py-1 text-sm text-text-muted hover:bg-canvas-deep"
aria-label="Zatvori"
>
</button>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => onSetServiceContext?.(task)}
className="btn-erp rounded-md bg-emerald-600 px-3 py-1 text-xs font-semibold normal-case tracking-normal text-white hover:bg-emerald-700"
>
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}
className="rounded-md px-2 py-1 text-sm text-text-muted hover:bg-canvas-deep"
aria-label="Zatvori"
>
</button>
</div>
</div>
<div className="max-h-[calc(100vh-12rem)] overflow-y-auto p-4">
@@ -211,9 +303,25 @@ export default function TaskServiceRecordsModal({
<dt className="text-xs text-text-muted">Dodijeljeno</dt>
<dd className="text-text-main">{task.assigned_to_name || '-'}</dd>
</div>
<div>
<dt className="text-xs text-text-muted">Vlasnik</dt>
<dd className="text-text-main">{getTaskCraneOwner(task)}</dd>
</div>
<div>
<dt className="text-xs text-text-muted">Dizalica</dt>
<dd className="text-text-main">{task.vehicle_registration || '-'}</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>
@@ -255,6 +363,26 @@ export default function TaskServiceRecordsModal({
className="resize-none rounded-lg border border-border-hairline bg-canvas-elevated px-3 py-2 text-text-main disabled:opacity-60"
/>
</label>
<div className="grid gap-3 sm:grid-cols-2">
<label className="flex flex-col gap-1 text-sm">
<span className="text-xs text-text-muted">Vlasnik</span>
<input
type="text"
value={getTaskCraneOwner(task)}
disabled
className="rounded-lg border border-border-hairline bg-canvas-deep px-3 py-2 text-text-main disabled:opacity-80"
/>
</label>
<label className="flex flex-col gap-1 text-sm">
<span className="text-xs text-text-muted">Dizalica</span>
<input
type="text"
value={getTaskCraneDisplay(task)}
disabled
className="rounded-lg border border-border-hairline bg-canvas-deep px-3 py-2 text-text-main disabled:opacity-80"
/>
</label>
</div>
<label className="flex flex-col gap-1 text-sm">
<span className="text-xs text-text-muted">Status</span>
<select
@@ -320,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"
@@ -339,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>
)}
@@ -413,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

@@ -0,0 +1,186 @@
import { useEffect, useMemo, useState } from 'preact/hooks';
import ModalShell from '../ui/ModalShell';
const EMPTY_ROW = {
naziv: '',
broj_racuna: '',
ukupan_iznos: '',
prilog: '',
source_invoice_id: '',
};
function parseAmount(value) {
const normalized = String(value || '').trim().replace('€', '').replace(/\s+/g, '').replace(',', '.');
const parsed = Number(normalized);
return Number.isFinite(parsed) ? parsed : 0;
}
export default function WorkOrderAdditionalCostsModal({
open,
workOrder,
tableData,
saving = false,
onClose,
onSave,
}) {
const [rows, setRows] = useState([]);
useEffect(() => {
if (!open) {
setRows([]);
return;
}
const incoming = Array.isArray(tableData?.data?.rows) ? tableData.data.rows : [];
setRows(incoming.length > 0 ? incoming.map((row) => ({ ...EMPTY_ROW, ...(row || {}) })) : [{ ...EMPTY_ROW }]);
}, [open, tableData]);
const total = useMemo(() => {
const value = rows.reduce((sum, row) => sum + parseAmount(row.ukupan_iznos), 0);
return value.toFixed(2);
}, [rows]);
if (!open) return null;
const updateRowField = (index, field, value) => {
setRows((prev) => prev.map((row, i) => (i === index ? { ...row, [field]: value } : row)));
};
const addRow = () => setRows((prev) => [...prev, { ...EMPTY_ROW }]);
const removeRow = (index) => setRows((prev) => prev.filter((_, i) => i !== index));
const handleSave = async () => {
const payloadRows = rows.map((row) => ({
naziv: String(row.naziv || '').trim(),
broj_racuna: String(row.broj_racuna || '').trim(),
ukupan_iznos: String(row.ukupan_iznos || '').trim(),
prilog: String(row.prilog || '').trim(),
source_invoice_id: String(row.source_invoice_id || '').trim(),
}));
await onSave?.({ rows: payloadRows });
};
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-5xl 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">DODATNI TROŠKOVI</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="flex flex-wrap gap-2">
<button
type="button"
onClick={addRow}
className="inline-flex items-center gap-1 rounded-lg border border-gray-300 bg-white px-3 py-1.5 text-xs font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700"
>
+ Dodaj redak
</button>
</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">NAZIV</th>
<th className="px-2 py-2.5 font-semibold text-gray-700 dark:text-gray-200">Broj računa</th>
<th className="px-2 py-2.5 font-semibold text-gray-700 dark:text-gray-200">Ukupan iznos</th>
<th className="px-2 py-2.5 font-semibold text-gray-700 dark:text-gray-200">Prilog</th>
<th className="px-2 py-2.5 text-gray-400">Akcija</th>
</tr>
</thead>
<tbody>
{rows.map((row, index) => (
<tr key={index} className="border-t border-gray-100 dark:border-gray-700">
<td className="px-2 py-1.5 text-center text-[11px] text-gray-400">{index + 1}</td>
<td className="px-1 py-1">
<input
type="text"
value={row.naziv}
onInput={(event) => updateRowField(index, 'naziv', 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.broj_racuna}
onInput={(event) => updateRowField(index, 'broj_racuna', 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.ukupan_iznos}
onInput={(event) => updateRowField(index, 'ukupan_iznos', 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.prilog}
onInput={(event) => updateRowField(index, 'prilog', 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">
<button
type="button"
onClick={() => removeRow(index)}
className="rounded-md border border-red-200 px-2 py-1 text-[11px] font-medium text-red-600 hover:bg-red-50"
>
Obriši
</button>
</td>
</tr>
))}
<tr className="border-t-2 border-gray-300 bg-gray-100 text-xs font-bold dark:border-gray-600 dark:bg-gray-800">
<td colSpan={3} className="px-3 py-2 text-gray-700 dark:text-gray-200">UKUPNO ZA ISPLATU</td>
<td className="px-2 py-2 text-right text-gray-800 dark:text-gray-100">{total} </td>
<td colSpan={2} />
</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 dodatne troškove'}
</button>
</div>
</div>
</ModalShell>
);
}

View File

@@ -1,17 +1,23 @@
import { useEffect, useMemo, useState } from 'preact/hooks';
import { useStore } from '@nanostores/preact';
import WorkOrderImageCarousel from './WorkOrderImageCarousel';
import WorkOrderPhotoUpload from '../fleet/WorkOrderPhotoUpload';
import TaskCreateModal from './TaskCreateModal';
import ModalShell from '../ui/ModalShell';
import {
createWorkOrderInvoice,
fetchTasksByWorkOrder,
fetchTeamMembers,
fetchWorkOrderInvoices,
fetchWorkOrderImages,
openWorkOrderInvoicesPdfPage,
sendWorkOrderEmail,
updateVehicle,
} from '../../stores/fleetDashboardStore';
import { getStatusLabel } from '../../stores/taskStore';
import { showToast } from '../../stores/toastStore';
import { formatWorkOrderDisplayCode } from '../../lib/displayIds';
import { $serviceNotes, $serviceNotesLoading, fetchServiceNotes } from '../../stores/serviceNotesStore';
const PURPOSE_OPTIONS = [
{ value: 'defektaza', label: 'Defektaža' },
@@ -20,6 +26,8 @@ const PURPOSE_OPTIONS = [
];
export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmit }) {
const serviceNotes = useStore($serviceNotes);
const serviceNotesLoading = useStore($serviceNotesLoading);
const [isEditing, setIsEditing] = useState(false);
const [form, setForm] = useState({
has_travel_order: false,
@@ -32,6 +40,7 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi
servicer_vehicle_start_mileage: '',
servicer_vehicle_end_mileage: '',
servicer_vehicle_fuel_cost: '',
origin_location: 'Zagreb',
location: '',
travel_start_at: '',
travel_end_at: '',
@@ -51,6 +60,13 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi
const [emailPayload, setEmailPayload] = useState({
subject: '',
message: '',
includeWorkOrderPdf: true,
includeServiceRecordsPdf: false,
includeInvoicesPdf: false,
includeImages: false,
includeMonthlyTasks: false,
month: new Date().toISOString().slice(0, 7),
serviceNoteId: '',
});
const [invoices, setInvoices] = useState([]);
const [invoiceModalOpen, setInvoiceModalOpen] = useState(false);
@@ -69,6 +85,12 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi
const [selectedLocation, setSelectedLocation] = useState(null);
const [searchingLocation, setSearchingLocation] = useState(false);
const [mapError, setMapError] = useState('');
const [workOrderTasks, setWorkOrderTasks] = useState([]);
const [addTaskModalOpen, setAddTaskModalOpen] = useState(false);
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 craneLabel = useMemo(() => {
if (!workOrder) return '-';
@@ -82,6 +104,16 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi
}
return PURPOSE_OPTIONS;
}, [form.purpose]);
const selectableServiceNotes = useMemo(() => {
if (!workOrder?.id) return [];
return serviceNotes.filter((note) => (
!note?.is_closed
&& (
!note?.work_order
|| String(note.work_order) === String(workOrder.id)
)
));
}, [serviceNotes, workOrder?.id]);
useEffect(() => {
if (!open || typeof window === 'undefined') return;
@@ -92,11 +124,12 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi
if (!open || !workOrder) return;
setIsEditing(false);
setForm({
has_travel_order: !!(workOrder.travel_start_at || workOrder.travel_end_at || workOrder.location || workOrder.start_mileage != null || workOrder.end_mileage != null),
has_travel_order: !!(workOrder.travel_start_at || workOrder.travel_end_at || workOrder.origin_location || workOrder.location || workOrder.start_mileage != null || workOrder.end_mileage != null),
display_code: workOrder.display_code || '',
status: workOrder.status || 'open',
start_mileage: String(workOrder.start_mileage ?? ''),
end_mileage: workOrder.end_mileage == null ? '' : String(workOrder.end_mileage),
origin_location: workOrder.origin_location || 'Zagreb',
location: workOrder.location || '',
travel_start_at: workOrder.travel_start_at ? String(workOrder.travel_start_at).slice(0, 16) : '',
travel_end_at: workOrder.travel_end_at ? String(workOrder.travel_end_at).slice(0, 16) : '',
@@ -120,6 +153,13 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi
setEmailPayload({
subject: '',
message: '',
includeWorkOrderPdf: true,
includeServiceRecordsPdf: false,
includeInvoicesPdf: false,
includeImages: false,
includeMonthlyTasks: false,
month: new Date().toISOString().slice(0, 7),
serviceNoteId: '',
});
setInvoices([]);
setInvoiceModalOpen(false);
@@ -138,6 +178,12 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi
setSelectedLocation(null);
setSearchingLocation(false);
setMapError('');
setWorkOrderTasks([]);
setAddTaskModalOpen(false);
setCraneDataModalOpen(false);
setCraneDataForm({ superstructure_working_hours: '', chassis_working_hours: '', current_mileage: '' });
setSavingCraneData(false);
setCraneDataError('');
}, [open, workOrder]);
useEffect(() => {
@@ -146,7 +192,10 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi
setLoadingTeamMembers(true);
(async () => {
try {
const members = await fetchTeamMembers();
const [members] = await Promise.all([
fetchTeamMembers(),
fetchServiceNotes(),
]);
if (!cancelled) {
setTeamMembers(members);
}
@@ -218,6 +267,23 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi
};
}, [open, workOrder?.id]);
useEffect(() => {
if (!open || !workOrder?.id) {
setWorkOrderTasks([]);
return;
}
let cancelled = false;
(async () => {
try {
const tasks = await fetchTasksByWorkOrder(workOrder.id);
if (!cancelled) setWorkOrderTasks(tasks);
} catch {
if (!cancelled) setWorkOrderTasks([]);
}
})();
return () => { cancelled = true; };
}, [open, workOrder?.id]);
const selectedMapEmbedUrl = (() => {
if (!selectedLocation) return null;
const lat = Number(selectedLocation.lat);
@@ -232,6 +298,60 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi
return null;
}
const craneVehicleId = workOrder.vehicle || workOrder.crane;
const craneVehicle = workOrder.craneInfo || null;
const openCraneDataModal = () => {
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 () => {
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);
showToast('Podaci dizalice su uspješno ažurirani.', 'success');
setCraneDataModalOpen(false);
} catch (err) {
setCraneDataError(err?.message || 'Ažuriranje podataka dizalice nije uspjelo.');
} finally {
setSavingCraneData(false);
}
};
const searchLocation = async () => {
const query = locationQuery.trim();
if (!query) {
@@ -332,6 +452,7 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi
servicer_vehicle_start_mileage: form.servicer_vehicle_start_mileage === '' ? null : Number(form.servicer_vehicle_start_mileage),
servicer_vehicle_end_mileage: form.servicer_vehicle_end_mileage === '' ? null : Number(form.servicer_vehicle_end_mileage),
servicer_vehicle_fuel_cost: form.servicer_vehicle_fuel_cost === '' ? null : Number(form.servicer_vehicle_fuel_cost),
origin_location: form.origin_location.trim() || 'Zagreb',
location: form.location.trim(),
travel_start_at: travelStart ? travelStart.toISOString() : null,
travel_end_at: travelEnd ? travelEnd.toISOString() : null,
@@ -400,29 +521,34 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi
setWidgetError('Odaberite barem jednog člana tima ili uključite slanje svima.');
return;
}
let failed = 0;
for (const recipient of recipients) {
try {
await sendWorkOrderEmail(
workOrder.id,
{
recipient,
subject: emailPayload.subject.trim() || undefined,
message: emailPayload.message.trim() || undefined,
},
{ toast: false }
);
showToast(`Email je poslan: ${recipient}`, 'success');
} catch {
failed += 1;
showToast(`Neuspješno slanje: ${recipient}`, 'error');
}
}
if (failed > 0) {
setWidgetError(`Slanje nije uspjelo za ${failed} primatelja.`);
if (
!emailPayload.includeWorkOrderPdf
&& !emailPayload.includeServiceRecordsPdf
&& !emailPayload.includeInvoicesPdf
&& !emailPayload.includeImages
&& !emailPayload.includeMonthlyTasks
) {
setWidgetError('Odaberite barem jedan PDF/prilog za slanje.');
return;
}
await sendWorkOrderEmail(
workOrder.id,
{
recipients,
subject: emailPayload.subject.trim() || undefined,
message: emailPayload.message.trim() || undefined,
include_work_order_pdf: emailPayload.includeWorkOrderPdf,
include_service_records_pdf: emailPayload.includeServiceRecordsPdf,
include_invoices_pdf: emailPayload.includeInvoicesPdf,
include_images: emailPayload.includeImages,
include_monthly_tasks: emailPayload.includeMonthlyTasks,
month: emailPayload.includeMonthlyTasks ? emailPayload.month : undefined,
service_note_id: emailPayload.serviceNoteId || undefined,
},
{ toast: false }
);
showToast(`Slanje je pokrenuto u pozadini za ${recipients.length} primatelja. Rezultat je u Notifikacijama.`, 'info');
setEmailModalOpen(false);
} catch (err) {
setWidgetError(err?.message || 'Neuspješno slanje emaila za putni nalog.');
@@ -501,6 +627,33 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi
)}
</div>
<div className="rounded-xl border border-border-hairline bg-canvas-base p-3">
<div className="mb-2 flex items-center justify-between">
<h4 className="text-sm font-semibold text-text-main">Zadaci putnog naloga ({workOrderTasks.length})</h4>
<button
type="button"
onClick={() => setAddTaskModalOpen(true)}
className="rounded-md border border-border-hairline px-2 py-1 text-xs font-medium text-text-main hover:bg-canvas-deep"
>
+ Dodaj zadatak
</button>
</div>
{workOrderTasks.length === 0 ? (
<p className="text-xs text-text-muted">Nema zadataka za ovaj putni nalog.</p>
) : (
<ul className="space-y-1">
{workOrderTasks.map((task) => (
<li key={task.id} className="flex items-center justify-between rounded-lg bg-canvas-deep px-3 py-2 text-sm">
<span className="font-medium text-text-main">{task.title}</span>
<span className="rounded-full bg-canvas-base px-2 py-0.5 text-[11px] text-text-muted border border-border-hairline">
{getStatusLabel(task.status)}
</span>
</li>
))}
</ul>
)}
</div>
<div className="rounded-xl border border-border-hairline bg-canvas-base p-3">
<h4 className="mb-2 text-sm font-semibold text-text-main">Status</h4>
<label className="mb-3 flex flex-col gap-1 text-sm">
@@ -599,6 +752,16 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi
<div className="rounded-xl border border-border-hairline bg-canvas-base p-3">
<h4 className="mb-2 text-sm font-semibold text-text-main">Putni podaci</h4>
<div className="grid gap-4 sm:grid-cols-2">
<label className="sm:col-span-2 flex flex-col gap-1 text-sm">
<span className="font-medium text-text-main">Lokacija ishodišta</span>
<input
type="text"
value={form.origin_location}
onInput={(event) => setForm({ ...form, origin_location: event.currentTarget.value })}
className="rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-text-main disabled:bg-canvas-deep"
disabled={!isEditing}
/>
</label>
<label className="sm:col-span-2 flex flex-col gap-1 text-sm">
<span className="font-medium text-text-main">Lokacija odredišta {form.has_travel_order ? '*' : ''}</span>
<div className="flex gap-2">
@@ -652,12 +815,14 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi
<label className="mt-3 flex items-center gap-2 text-sm text-text-main">
<input
type="checkbox"
className="erp-checkbox"
checked={form.has_travel_order}
onChange={(event) => {
const checked = event.currentTarget.checked;
setForm((prev) => ({
...prev,
has_travel_order: checked,
origin_location: checked ? (prev.origin_location || 'Zagreb') : 'Zagreb',
location: checked ? prev.location : '',
travel_start_at: checked ? prev.travel_start_at : '',
travel_end_at: checked ? prev.travel_end_at : '',
@@ -861,6 +1026,7 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi
<label className="flex items-center gap-2 text-xs text-text-muted">
<input
type="checkbox"
className="erp-checkbox"
checked={sendToAllTeamMembers}
onChange={(event) => setSendToAllTeamMembers(event.currentTarget.checked)}
/>
@@ -903,6 +1069,79 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi
placeholder="Poruka (opcionalno)"
className="min-h-24 w-full rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-sm text-text-main"
/>
<div className="grid gap-2 rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-sm text-text-main sm:grid-cols-2">
<label className="flex items-center gap-2">
<input
type="checkbox"
className="erp-checkbox"
checked={emailPayload.includeWorkOrderPdf}
onChange={(event) => setEmailPayload({ ...emailPayload, includeWorkOrderPdf: event.currentTarget.checked })}
/>
PDF putnog naloga
</label>
<label className="flex items-center gap-2">
<input
type="checkbox"
className="erp-checkbox"
checked={emailPayload.includeServiceRecordsPdf}
onChange={(event) => setEmailPayload({ ...emailPayload, includeServiceRecordsPdf: event.currentTarget.checked })}
/>
PDF servisnih zapisa
</label>
<label className="flex items-center gap-2">
<input
type="checkbox"
className="erp-checkbox"
checked={emailPayload.includeInvoicesPdf}
onChange={(event) => setEmailPayload({ ...emailPayload, includeInvoicesPdf: event.currentTarget.checked })}
/>
PDF računa
</label>
<label className="flex items-center gap-2">
<input
type="checkbox"
className="erp-checkbox"
checked={emailPayload.includeImages}
onChange={(event) => setEmailPayload({ ...emailPayload, includeImages: event.currentTarget.checked })}
/>
Dodaj slike
</label>
<label className="flex items-center gap-2 sm:col-span-2">
<input
type="checkbox"
className="erp-checkbox"
checked={emailPayload.includeMonthlyTasks}
onChange={(event) => setEmailPayload({ ...emailPayload, includeMonthlyTasks: event.currentTarget.checked })}
/>
Pošalji popis mjesečnih taskova
</label>
<label className="flex flex-col gap-1 sm:col-span-2">
<span className="text-xs text-text-muted">Mjesec (YYYY-MM)</span>
<input
type="month"
value={emailPayload.month}
disabled={!emailPayload.includeMonthlyTasks}
onInput={(event) => setEmailPayload({ ...emailPayload, month: event.currentTarget.value })}
className="w-full rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-sm text-text-main disabled:bg-canvas-deep"
/>
</label>
<label className="flex flex-col gap-1 sm:col-span-2">
<span className="text-xs text-text-muted">Odaberi kreiranu bilješku (opcionalno)</span>
<select
value={emailPayload.serviceNoteId}
onChange={(event) => setEmailPayload({ ...emailPayload, serviceNoteId: event.currentTarget.value })}
disabled={serviceNotesLoading}
className="w-full rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-sm text-text-main disabled:bg-canvas-deep"
>
<option value="">Bez bilješke</option>
{selectableServiceNotes.map((note) => (
<option key={note.id} value={String(note.id)}>
{(note.note_date || '-') + ' • ' + (note.note || '').slice(0, 70)}
</option>
))}
</select>
</label>
</div>
</div>
<div className="flex justify-end gap-2 border-t border-border-hairline px-4 py-3">
<button
@@ -980,10 +1219,10 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi
/>
</label>
<label className="flex flex-col gap-1 text-sm sm:col-span-2">
<span className="font-medium text-text-main">slika_računa</span>
<span className="font-medium text-text-main">slika_računa / pdf_računa</span>
<input
type="file"
accept="image/*"
accept="image/*,.pdf,application/pdf"
onChange={(event) => {
const file = event.currentTarget.files && event.currentTarget.files[0] ? event.currentTarget.files[0] : null;
setInvoiceForm((prev) => ({ ...prev, imageFile: file }));
@@ -1040,6 +1279,99 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi
}
}}
/>
<TaskCreateModal
open={addTaskModalOpen}
workOrders={[workOrder]}
contextCrane={craneVehicle || { id: craneVehicleId, registration_number: craneLabel }}
onClose={() => setAddTaskModalOpen(false)}
onSuccess={async () => {
const tasks = await fetchTasksByWorkOrder(workOrder.id).catch(() => []);
setWorkOrderTasks(tasks);
}}
/>
{craneDataModalOpen && (
<ModalShell
onClose={() => setCraneDataModalOpen(false)}
overlayClassName="z-[60] overflow-y-auto p-4 pt-16"
contentClassName="flex items-start justify-center min-h-full"
panelClassName="w-full max-w-md rounded-xl border border-border-hairline bg-canvas-elevated shadow-xl"
>
<div>
<div className="flex items-center justify-between border-b border-border-hairline px-4 py-3">
<div>
<h4 className="text-sm font-semibold text-text-main">Izmijeni podatke dizalice</h4>
<p className="text-xs text-text-muted">{craneLabel}</p>
</div>
<button
type="button"
onClick={() => setCraneDataModalOpen(false)}
className="rounded-md px-2 py-1 text-sm text-text-muted hover:bg-canvas-deep"
aria-label="Zatvori"
>
</button>
</div>
<div className="space-y-4 px-4 py-4">
<label className="flex flex-col gap-1 text-sm">
<span className="font-medium text-text-main">Radni sati superstrukture</span>
<input
type="number"
min="0"
value={craneDataForm.superstructure_working_hours}
onInput={(e) => setCraneDataForm((p) => ({ ...p, superstructure_working_hours: e.currentTarget.value }))}
className="rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-text-main"
placeholder={String(craneVehicle?.superstructure_working_hours ?? '0')}
/>
</label>
<label className="flex flex-col gap-1 text-sm">
<span className="font-medium text-text-main">Radni sati podvozja</span>
<input
type="number"
min="0"
value={craneDataForm.chassis_working_hours}
onInput={(e) => setCraneDataForm((p) => ({ ...p, chassis_working_hours: e.currentTarget.value }))}
className="rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-text-main"
placeholder={String(craneVehicle?.chassis_working_hours ?? '0')}
/>
</label>
<label className="flex flex-col gap-1 text-sm">
<span className="font-medium text-text-main">Prijeđeni kilometri dizalice</span>
<input
type="number"
min="0"
value={craneDataForm.current_mileage}
onInput={(e) => setCraneDataForm((p) => ({ ...p, current_mileage: e.currentTarget.value }))}
className="rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-text-main"
placeholder={String(craneVehicle?.current_mileage ?? '0')}
/>
</label>
{craneDataError && (
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
{craneDataError}
</div>
)}
</div>
<div className="flex justify-end gap-2 border-t border-border-hairline px-4 py-3">
<button
type="button"
onClick={() => setCraneDataModalOpen(false)}
className="rounded-md border border-border-hairline px-3 py-2 text-xs font-medium text-text-main hover:bg-canvas-deep"
disabled={savingCraneData}
>
Odustani
</button>
<button
type="button"
onClick={handleSaveCraneData}
disabled={savingCraneData}
className="rounded-md bg-indigo-600 px-3 py-2 text-xs font-semibold text-white hover:bg-indigo-700 disabled:opacity-60"
>
{savingCraneData ? 'Spremam…' : 'Spremi podatke'}
</button>
</div>
</div>
</ModalShell>
)}
</>
);
}

View File

@@ -140,6 +140,9 @@ export function useAuthenticatedMediaSources(mediaPaths = []) {
if (!resolved) {
return '';
}
if (isProtectedMediaUrl(resolved)) {
return authenticatedSources[resolved] || '';
}
return authenticatedSources[resolved] || resolved;
};

View File

@@ -1,19 +1,30 @@
import { useEffect, useMemo, useState } from 'preact/hooks';
import { useStore } from '@nanostores/preact';
import {
downloadWorkOrderDocx,
downloadWorkOrderPdf,
downloadWorkOrderInvoicesDocx,
downloadWorkOrderInvoicesPdf,
downloadWorkOrderServiceRecordsDocx,
downloadWorkOrderServiceRecordsPdf,
fetchWorkOrderAdditionalCostsTable,
fetchWorkOrderById,
fetchWorkOrderInvoices,
fetchWorkOrderTravelExpensesTable,
fetchWorkOrderTaskServiceContext,
updateTaskWorkHoursTable,
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 { resolveMediaUrl, useAuthenticatedMediaSources } from './WorkOrderImageCarousel';
import { useAuthenticatedMediaSources } from './WorkOrderImageCarousel';
import TaskWorkHoursTableModal from './TaskWorkHoursTableModal';
import WorkOrderAdditionalCostsModal from './WorkOrderAdditionalCostsModal';
import WorkOrderTravelExpensesModal from './WorkOrderTravelExpensesModal';
import WorkOrderServiceNotesModal from './WorkOrderServiceNotesModal';
function readWorkOrderIdFromQuery() {
if (typeof window === 'undefined') {
@@ -23,8 +34,32 @@ function readWorkOrderIdFromQuery() {
return params.get('workOrderId') || '';
}
function InvoiceImagePreview({ imageUrl, alt }) {
function InvoiceImagePreview({ imageUrl, rawUrl, alt, contentType, onClick }) {
const [broken, setBroken] = useState(false);
const isPdf = String(contentType || '').toLowerCase() === 'application/pdf';
const isProtected = typeof rawUrl === 'string' && /\/api\/fleet\//i.test(rawUrl);
const isLoading = isProtected && !imageUrl && !broken;
if (isPdf && imageUrl) {
return (
<a
href={imageUrl}
target="_blank"
rel="noopener noreferrer"
className="flex h-28 w-28 items-center justify-center rounded-lg border border-border-hairline bg-canvas-deep text-center text-[11px] text-text-main hover:bg-canvas-base"
>
Otvori PDF
</a>
);
}
if (isLoading) {
return (
<div className="flex h-28 w-28 items-center justify-center rounded-lg border border-border-hairline bg-canvas-deep text-center text-[11px] text-text-muted">
<span className="animate-pulse">Učitavanje</span>
</div>
);
}
if (!imageUrl || broken) {
return (
@@ -35,14 +70,76 @@ function InvoiceImagePreview({ imageUrl, alt }) {
}
return (
<a href={imageUrl} target="_blank" rel="noopener noreferrer" className="inline-flex">
<button
type="button"
onClick={onClick}
className="inline-flex cursor-pointer focus:outline-none"
aria-label={`Otvori pregled: ${alt}`}
>
<img
src={imageUrl}
alt={alt}
onError={() => setBroken(true)}
className="h-28 w-28 rounded-lg border border-border-hairline object-cover"
className="h-28 w-28 rounded-lg border border-border-hairline object-cover hover:opacity-90 transition-opacity"
/>
</a>
</button>
);
}
function InvoiceDetailModal({ invoice, imageUrl, contentType, onClose }) {
const isPdf = String(contentType || '').toLowerCase() === 'application/pdf';
useEffect(() => {
const handleKey = (e) => { if (e.key === 'Escape') onClose(); };
window.addEventListener('keydown', handleKey);
return () => window.removeEventListener('keydown', handleKey);
}, [onClose]);
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
>
<div className="relative flex max-h-[95vh] w-full max-w-3xl flex-col overflow-auto rounded-xl bg-canvas-base shadow-2xl">
<div className="flex items-center justify-between border-b border-border-hairline px-5 py-3">
<h2 className="text-base font-semibold text-text-main">{invoice.naziv_racuna || 'Račun'}</h2>
<button
type="button"
onClick={onClose}
className="rounded p-1 text-text-muted hover:text-text-main focus:outline-none"
aria-label="Zatvori"
>
</button>
</div>
<div className="space-y-1 px-5 py-3 text-sm text-text-main border-b border-border-hairline">
<div><span className="font-semibold">Naziv:</span> {invoice.naziv_racuna || '-'}</div>
<div><span className="font-semibold">Lokacija:</span> {invoice.lokacija || '-'}</div>
<div><span className="font-semibold">Datum:</span> {invoice.datum || '-'}</div>
<div><span className="font-semibold">Opis:</span> {invoice.opis || '-'}</div>
</div>
<div className="flex flex-1 items-center justify-center p-5">
{isPdf && imageUrl ? (
<a
href={imageUrl}
target="_blank"
rel="noopener noreferrer"
className="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-700"
>
Otvori PDF u novom tabu
</a>
) : imageUrl ? (
<img
src={imageUrl}
alt={invoice.naziv_racuna}
className="max-h-[60vh] max-w-full rounded-lg object-contain shadow"
/>
) : (
<p className="text-sm text-text-muted">Slika nije dostupna.</p>
)}
</div>
</div>
</div>
);
}
@@ -53,10 +150,20 @@ 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);
const [savingWorkHours, setSavingWorkHours] = useState(false);
const [additionalCostsTable, setAdditionalCostsTable] = useState({ data: { rows: [] }, total_for_payout: '0.00' });
const [editingAdditionalCosts, setEditingAdditionalCosts] = useState(false);
const [savingAdditionalCosts, setSavingAdditionalCosts] = useState(false);
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(() => {
hydrateAuthFromStorage();
@@ -91,10 +198,12 @@ export default function WorkOrderInvoicesPdfPage() {
(async () => {
try {
const [contextPayload, order, invoiceItems] = 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;
@@ -102,6 +211,14 @@ export default function WorkOrderInvoicesPdfPage() {
setTaskContext(contextPayload);
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.');
@@ -173,6 +290,59 @@ 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);
try {
const response = await updateWorkOrderAdditionalCostsTable(workOrderId, tableData);
setAdditionalCostsTable(response || { data: { rows: [] }, total_for_payout: '0.00' });
setEditingAdditionalCosts(false);
} finally {
setSavingAdditionalCosts(false);
}
};
const handleSaveTravelExpensesTable = async (tableData) => {
if (!workOrderId) return;
setSavingTravelExpenses(true);
try {
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 {
setSavingTravelExpenses(false);
}
};
return (
<section className="mx-auto w-full max-w-5xl space-y-4 p-4 sm:p-6">
<div className="rounded-xl border border-border-hairline bg-canvas-elevated p-4">
@@ -183,28 +353,56 @@ 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>
<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>
<div className="rounded-xl border border-border-hairline bg-canvas-elevated p-4">
<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>
<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
</button>
<div className="flex flex-wrap items-center gap-2">
<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 svih servisnih zapisa
</button>
<button
type="button"
onClick={() => downloadWorkOrderServiceRecordsDocx(workOrderId)}
disabled={!workOrderId}
className="rounded-lg border border-border-hairline px-4 py-2 text-sm font-semibold text-text-main hover:bg-canvas-deep disabled:opacity-60"
>
Preuzmi DOCX svih servisnih zapisa
</button>
</div>
</div>
{loading && <p className="text-sm text-text-muted">Učitavanje task konteksta...</p>}
{!loading && !error && (!Array.isArray(taskContext.tasks) || taskContext.tasks.length === 0) && (
@@ -214,22 +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-2">
<button
type="button"
onClick={() => setEditingTask(task)}
className="rounded-md border border-border-hairline px-3 py-1.5 text-xs font-medium text-text-main hover:bg-canvas-deep"
>
Uredi tablicu radnih sati
</button>
</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>
) : (
@@ -275,15 +517,38 @@ export default function WorkOrderInvoicesPdfPage() {
<div className="rounded-xl border border-border-hairline bg-canvas-elevated p-4">
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<h2 className="text-lg font-semibold text-text-main">Računi</h2>
<button
type="button"
onClick={() => downloadWorkOrderInvoicesPdf(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 račune putnog naloga
</button>
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={() => setEditingAdditionalCosts(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 dodatne troškove
</button>
<button
type="button"
onClick={() => downloadWorkOrderInvoicesPdf(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 račune putnog naloga
</button>
<button
type="button"
onClick={() => downloadWorkOrderInvoicesDocx(workOrderId)}
disabled={!workOrderId}
className="rounded-lg border border-border-hairline px-4 py-2 text-sm font-semibold text-text-main hover:bg-canvas-deep disabled:opacity-60"
>
Preuzmi DOCX računa
</button>
</div>
</div>
{!loading && !error && (
<p className="mb-3 text-sm text-text-muted">
Ukupno dodatni troškovi: <span className="font-semibold text-text-main">{additionalCostsTable?.total_for_payout || '0.00'} </span>
</p>
)}
{loading && <p className="text-sm text-text-muted">Učitavanje računa...</p>}
{!loading && error && (
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
@@ -295,33 +560,83 @@ export default function WorkOrderInvoicesPdfPage() {
)}
{!loading && !error && invoices.length > 0 && (
<div className="space-y-3">
{invoices.map((invoice) => (
<article key={invoice.id} className="rounded-lg border border-border-hairline bg-canvas-base p-3">
<div className="grid gap-3 sm:grid-cols-[1fr_auto] sm:items-start">
<div className="space-y-1 text-sm text-text-main">
<div><span className="font-semibold">naziv_računa:</span> {invoice.naziv_racuna}</div>
<div><span className="font-semibold">lokacija:</span> {invoice.lokacija || '-'}</div>
<div><span className="font-semibold">datum:</span> {invoice.datum || '-'}</div>
<div><span className="font-semibold">opis:</span> {invoice.opis || '-'}</div>
{invoices.map((invoice) => {
const rawUrl = invoice.image_url || invoice.image || '';
const blobUrl = getAuthenticatedMediaSrc(rawUrl);
return (
<article
key={invoice.id}
className="cursor-pointer rounded-lg border border-border-hairline bg-canvas-base p-3 hover:bg-canvas-deep transition-colors"
onClick={() => setInvoiceDetailModal(invoice)}
role="button"
tabIndex={0}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') setInvoiceDetailModal(invoice); }}
>
<div className="grid gap-3 sm:grid-cols-[1fr_auto] sm:items-start">
<div className="space-y-1 text-sm text-text-main">
<div><span className="font-semibold">naziv_računa:</span> {invoice.naziv_racuna}</div>
<div><span className="font-semibold">lokacija:</span> {invoice.lokacija || '-'}</div>
<div><span className="font-semibold">datum:</span> {invoice.datum || '-'}</div>
<div><span className="font-semibold">opis:</span> {invoice.opis || '-'}</div>
</div>
<InvoiceImagePreview
imageUrl={blobUrl}
rawUrl={rawUrl}
alt={`Račun ${invoice.naziv_racuna}`}
contentType={invoice.image_content_type}
onClick={(e) => { e.stopPropagation(); setInvoiceDetailModal(invoice); }}
/>
</div>
</article>
);
})}
</div>
<InvoiceImagePreview
imageUrl={getAuthenticatedMediaSrc(invoice.image_url || invoice.image) || resolveMediaUrl(invoice.image_url || invoice.image)}
alt={`Račun ${invoice.naziv_racuna}`}
/>
</div>
</article>
))}
</div>
)}
)}
</div>
<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}
/>
<WorkOrderAdditionalCostsModal
open={editingAdditionalCosts}
workOrder={workOrder}
tableData={additionalCostsTable}
saving={savingAdditionalCosts}
onClose={() => setEditingAdditionalCosts(false)}
onSave={handleSaveAdditionalCostsTable}
/>
<WorkOrderTravelExpensesModal
open={editingTravelExpenses}
workOrder={workOrder}
tableData={travelExpensesTable}
saving={savingTravelExpenses}
onClose={() => setEditingTravelExpenses(false)}
onSave={handleSaveTravelExpensesTable}
/>
<WorkOrderServiceNotesModal
open={!!editingTaskNote}
task={editingTaskNote}
saving={savingTaskNote}
error={taskNoteError}
onClose={() => {
setEditingTaskNote(null);
setTaskNoteError('');
}}
onSave={handleSaveTaskServiceReportNote}
/>
{invoiceDetailModal && (
<InvoiceDetailModal
invoice={invoiceDetailModal}
imageUrl={getAuthenticatedMediaSrc(invoiceDetailModal.image_url || invoiceDetailModal.image || '')}
contentType={invoiceDetailModal.image_content_type}
onClose={() => setInvoiceDetailModal(null)}
/>
)}
</section>
);
}

View File

@@ -20,6 +20,7 @@ const INITIAL_FORM = {
servicer_vehicle_start_mileage: '',
servicer_vehicle_end_mileage: '',
servicer_vehicle_fuel_cost: '',
origin_location: 'Zagreb',
location: '',
travel_start_at: '',
travel_end_at: '',
@@ -211,6 +212,7 @@ export default function WorkOrderModal({ open, selectedCrane, onClose, onSubmit
servicer_vehicle_start_mileage: servicerStartMileage,
servicer_vehicle_end_mileage: servicerEndMileage,
servicer_vehicle_fuel_cost: servicerFuelCost,
origin_location: form.origin_location.trim() || 'Zagreb',
location: form.has_travel_order ? form.location.trim() : '',
travel_start_at: travelStart ? travelStart.toISOString() : null,
travel_end_at: travelEnd ? travelEnd.toISOString() : null,
@@ -393,6 +395,18 @@ export default function WorkOrderModal({ open, selectedCrane, onClose, onSubmit
</p>
)}
<label className="sm:col-span-2 flex flex-col gap-1 text-sm">
<span className="font-medium text-text-main">Lokacija ishodišta</span>
<input
type="text"
value={form.origin_location}
onInput={(event) => setForm({ ...form, origin_location: event.currentTarget.value })}
className="rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-text-main disabled:bg-canvas-deep"
placeholder="Npr. Zagreb"
disabled={!form.has_travel_order}
/>
</label>
<label className="sm:col-span-2 flex flex-col gap-1 text-sm">
<span className="font-medium text-text-main">Lokacija odredišta {form.has_travel_order ? '*' : ''}</span>
<div className="flex gap-2">
@@ -458,6 +472,7 @@ export default function WorkOrderModal({ open, selectedCrane, onClose, onSubmit
has_travel_order: checked,
start_mileage: checked ? (prev.start_mileage || String(selectedCrane?.current_mileage ?? '')) : '',
end_mileage: checked ? prev.end_mileage : '',
origin_location: checked ? (prev.origin_location || 'Zagreb') : 'Zagreb',
location: checked ? prev.location : '',
travel_start_at: checked ? prev.travel_start_at : '',
travel_end_at: checked ? prev.travel_end_at : '',

View File

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

View File

@@ -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

@@ -174,31 +174,24 @@ export default function ServiceRecordDetailModal({
return;
}
let failed = 0;
for (const recipient of recipients) {
try {
await sendServiceRecordEmail(
serviceRecord.id,
{
recipient,
subject: emailPayload.subject.trim() || undefined,
message: emailPayload.message.trim() || undefined,
},
{ toast: false }
);
showToast(`Email je poslan: ${recipient}`, 'success');
} catch {
failed += 1;
showToast(`Neuspješno slanje: ${recipient}`, 'error');
}
}
if (failed > 0) {
setWidgetError(`Slanje nije uspjelo za ${failed} primatelja.`);
return;
}
await sendServiceRecordEmail(
serviceRecord.id,
{
recipients,
subject: emailPayload.subject.trim() || undefined,
message: emailPayload.message.trim() || undefined,
},
{ toast: false }
);
showToast(`Email je poslan za ${recipients.length} primatelja.`, 'success');
setEmailModalOpen(false);
} catch (err) {
setWidgetError(err?.message || 'Neuspješno slanje emaila za servisni zapis.');
const failedCount = Array.isArray(err?.failures) ? err.failures.length : 0;
if (failedCount > 0) {
setWidgetError(`Slanje nije uspjelo za ${failedCount} primatelja.`);
} else {
setWidgetError(err?.message || 'Neuspješno slanje emaila za servisni zapis.');
}
} finally {
setEmailSending(false);
}
@@ -479,6 +472,7 @@ export default function ServiceRecordDetailModal({
<label className="flex items-center gap-2 text-xs text-text-muted">
<input
type="checkbox"
className="erp-checkbox"
checked={sendToAllTeamMembers}
onChange={(event) => setSendToAllTeamMembers(event.currentTarget.checked)}
/>

View File

@@ -0,0 +1,110 @@
import { useEffect } from 'preact/hooks';
import { useStore } from '@nanostores/preact';
import { animated, useSpring } from '@react-spring/web';
import { $activeLeftDrawer, closeLeftDrawer, openLeftDrawer } from '../../stores/leftDrawerStore';
const DEFAULT_PANEL_WIDTH = 300;
export default function LeftSlideDrawer({
drawerId,
title,
tabLabel,
tabTitle,
panelWidth = DEFAULT_PANEL_WIDTH,
tabTopClassName = 'top-1/2',
backdropClassName = 'z-[45]',
panelClassName = 'z-[46]',
tabClassName = 'z-[47]',
children,
}) {
const activeDrawer = useStore($activeLeftDrawer);
const isOpen = String(activeDrawer || '') === String(drawerId || '');
const isHiddenByOtherDrawer = Boolean(activeDrawer) && !isOpen;
const panelSpring = useSpring({
left: isOpen ? 0 : -panelWidth,
config: { tension: 260, friction: 28 },
});
const buttonSpring = useSpring({
left: isOpen ? panelWidth : 0,
config: { tension: 260, friction: 28 },
});
useEffect(() => {
return () => {
closeLeftDrawer(drawerId);
};
}, [drawerId]);
if (!drawerId) {
return null;
}
if (isHiddenByOtherDrawer) {
return null;
}
function handleToggle() {
if (isOpen) {
closeLeftDrawer(drawerId);
return;
}
openLeftDrawer(drawerId);
}
return (
<>
{isOpen && (
<div
role="presentation"
className={`fixed inset-0 bg-black/20 backdrop-blur-[1px] ${backdropClassName}`}
onClick={() => closeLeftDrawer(drawerId)}
/>
)}
<animated.div
style={{
width: panelSpring.left.to((value) => `${Math.max(0, value + panelWidth)}px`),
pointerEvents: isOpen ? 'auto' : 'none',
}}
className={`fixed left-0 top-0 h-screen overflow-hidden ${panelClassName}`}
>
<div
style={{ width: `${panelWidth}px` }}
className="absolute right-0 top-0 flex h-full flex-col border-r border-border-hairline bg-canvas-elevated shadow-2xl"
>
<div className="flex items-center justify-between border-b border-border-hairline px-4 py-3">
<h2 className="text-sm font-semibold text-text-main">{title}</h2>
<button
type="button"
onClick={() => closeLeftDrawer(drawerId)}
className="rounded-md p-1 text-text-muted hover:bg-canvas-deep"
aria-label="Zatvori panel"
>
</button>
</div>
{typeof children === 'function'
? children({ open: isOpen, close: () => closeLeftDrawer(drawerId) })
: children}
</div>
</animated.div>
<animated.button
type="button"
style={{ left: buttonSpring.left.to((value) => `${value}px`) }}
onClick={handleToggle}
className={`fixed ${tabTopClassName} -translate-y-1/2 rounded-r-xl border border-l-0 border-border-hairline bg-canvas-elevated px-1.5 py-4 shadow-md hover:bg-indigo-50 focus:outline-none ${tabClassName}`}
aria-label={isOpen ? `Zatvori ${tabTitle || 'panel'}` : `Otvori ${tabTitle || 'panel'}`}
title={tabTitle || tabLabel}
>
<span
style={{ writingMode: 'vertical-rl', textOrientation: 'mixed' }}
className="select-none text-[11px] font-medium text-text-main"
>
{tabLabel}
</span>
</animated.button>
</>
);
}

View File

@@ -0,0 +1,248 @@
import { useMemo, useState } from 'preact/hooks';
import LeftSlideDrawer from './LeftSlideDrawer';
function formatDate(value) {
if (!value) return '-';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '-';
return new Intl.DateTimeFormat('hr-HR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
}).format(date);
}
function workOrderLabel(item) {
const code = String(item?.display_code || '').trim().toUpperCase();
if (code) return code;
return `WO-${String(item?.id || '').slice(0, 8)}`;
}
export default function ServiceNotesWidget({
notes = [],
loading = false,
canManageRecipients = false,
activeTasks = [],
activeWorkOrders = [],
teamMembers = [],
onCreate,
onClose,
}) {
const [note, setNote] = useState('');
const [noteDate, setNoteDate] = useState('');
const [audience, setAudience] = useState('self');
const [targetUsers, setTargetUsers] = useState([]);
const [contextType, setContextType] = useState('none');
const [contextValue, setContextValue] = useState('');
const [submitting, setSubmitting] = useState(false);
const undatedNotes = useMemo(
() => notes.filter((item) => !item.note_date),
[notes]
);
const datedNotes = useMemo(
() => notes.filter((item) => !!item.note_date),
[notes]
);
async function handleSubmit(event) {
event.preventDefault();
if (!String(note || '').trim()) return;
const payload = {
note: String(note || '').trim(),
note_date: noteDate || null,
audience,
};
if (audience === 'member' && targetUsers.length > 0) {
payload.target_users = targetUsers;
}
if (contextType === 'work_order' && contextValue) {
payload.work_order = contextValue;
}
if (contextType === 'task' && contextValue) {
payload.task = contextValue;
}
setSubmitting(true);
try {
await onCreate?.(payload);
setNote('');
setNoteDate('');
setContextType('none');
setContextValue('');
setAudience('self');
setTargetUsers([]);
} finally {
setSubmitting(false);
}
}
return (
<LeftSlideDrawer
drawerId="notes"
title="🗒️ Bilješke"
tabLabel="🗒️ Bilješke"
tabTitle="Bilješke"
tabTopClassName="top-[calc(50%+96px)]"
backdropClassName="z-[48]"
panelClassName="z-[49]"
tabClassName="z-[50]"
>
<p className="border-b border-border-hairline px-4 py-2 text-[11px] text-text-muted">
Bilješke i podsjetnici u servisnom kontekstu
</p>
<form onSubmit={handleSubmit} className="space-y-2 border-b border-border-hairline px-4 py-3">
<textarea
value={note}
onInput={(event) => setNote(event.currentTarget.value)}
className="h-16 w-full rounded-md border border-border-hairline bg-canvas-base px-2 py-1.5 text-xs text-text-main"
placeholder="Upiši bilješku..."
required
/>
<input
type="date"
value={noteDate}
onInput={(event) => setNoteDate(event.currentTarget.value)}
className="w-full rounded-md border border-border-hairline bg-canvas-base px-2 py-1.5 text-xs text-text-main"
/>
<select
value={contextType}
onChange={(event) => {
setContextType(event.currentTarget.value);
setContextValue('');
}}
className="w-full rounded-md border border-border-hairline bg-canvas-base px-2 py-1.5 text-xs text-text-main"
>
<option value="none">Bez vezanog naloga/zadatka</option>
<option value="work_order">Aktivni putni nalog</option>
<option value="task">Aktivni radni zadatak</option>
</select>
{contextType === 'work_order' && (
<select
value={contextValue}
onChange={(event) => setContextValue(event.currentTarget.value)}
className="w-full rounded-md border border-border-hairline bg-canvas-base px-2 py-1.5 text-xs text-text-main"
>
<option value="">Odaberi putni nalog</option>
{activeWorkOrders.map((item) => (
<option key={item.id} value={item.id}>
{workOrderLabel(item)}
</option>
))}
</select>
)}
{contextType === 'task' && (
<select
value={contextValue}
onChange={(event) => setContextValue(event.currentTarget.value)}
className="w-full rounded-md border border-border-hairline bg-canvas-base px-2 py-1.5 text-xs text-text-main"
>
<option value="">Odaberi radni zadatak</option>
{activeTasks.map((item) => (
<option key={item.id} value={item.id}>
{item.title || `Task ${String(item.id).slice(0, 8)}`}
</option>
))}
</select>
)}
<select
value={audience}
onChange={(event) => {
setAudience(event.currentTarget.value);
if (event.currentTarget.value !== 'member') {
setTargetUsers([]);
}
}}
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 odabranim članovima tima</option>}
{canManageRecipients && <option value="team">Notifikacija svim članovima tima</option>}
</select>
{canManageRecipients && audience === 'member' && (
<select
multiple
value={targetUsers}
onChange={(event) => {
const selected = Array.from(event.currentTarget.selectedOptions).map((option) => option.value);
setTargetUsers(selected);
}}
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"
>
{teamMembers.map((member) => (
<option key={member.id} value={member.id}>
{member.full_name || member.email}
</option>
))}
</select>
)}
{canManageRecipients && audience === 'member' && (
<p className="text-[10px] text-text-muted">
Držite Ctrl (ili Cmd) za odabir više članova tima.
</p>
)}
<button
type="submit"
disabled={submitting}
className="w-full rounded-md bg-indigo-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-indigo-700 disabled:opacity-60"
>
{submitting ? 'Spremanje...' : 'Spremi bilješku'}
</button>
</form>
<div className="flex-1 overflow-y-auto px-4 py-3">
{loading && <p className="text-xs text-text-muted">Učitavanje bilješki...</p>}
{!loading && undatedNotes.length === 0 && datedNotes.length === 0 && (
<p className="text-xs text-text-muted">Nema aktivnih bilješki.</p>
)}
{undatedNotes.length > 0 && (
<div className="mb-3">
<p className="mb-1 text-[11px] font-semibold uppercase tracking-wide text-violet-700">Bez datuma (permanentno)</p>
<ul className="space-y-1.5">
{undatedNotes.map((item) => (
<li key={item.id} className="rounded-md border border-violet-200 bg-violet-50 px-2 py-1.5">
<p className="text-xs text-violet-900">{item.note}</p>
<div className="mt-1 flex items-center justify-between">
<span className="text-[10px] text-violet-700">{item.task_title || item.work_order_label || 'Općenito'}</span>
<button
type="button"
onClick={() => onClose?.(item.id)}
className="text-[10px] font-semibold text-violet-700 hover:text-violet-900"
>
Zatvori
</button>
</div>
</li>
))}
</ul>
</div>
)}
{datedNotes.length > 0 && (
<div>
<p className="mb-1 text-[11px] font-semibold uppercase tracking-wide text-text-muted">Podsjetnici</p>
<ul className="space-y-1.5">
{datedNotes.map((item) => (
<li key={item.id} className="rounded-md border border-border-hairline bg-canvas-base px-2 py-1.5">
<p className="text-xs text-text-main">{item.note}</p>
<div className="mt-1 flex items-center justify-between text-[10px] text-text-muted">
<span>{formatDate(item.note_date)}</span>
<button
type="button"
onClick={() => onClose?.(item.id)}
className="font-semibold text-indigo-600 hover:text-indigo-800"
>
Zatvori
</button>
</div>
</li>
))}
</ul>
</div>
)}
</div>
</LeftSlideDrawer>
);
}

View File

@@ -1,8 +1,17 @@
import { useMemo } from 'preact/hooks';
import { useEffect, useMemo, useState } from 'preact/hooks';
import { useStore } from '@nanostores/preact';
import { $tasks } from '../../stores/taskStore';
import { $user } from '../../stores/authStore';
import { $workOrdersWithVehicle, fetchTeamMembers } from '../../stores/fleetDashboardStore';
import {
$serviceNotes,
$serviceNotesLoading,
closeServiceNote,
createServiceNote,
fetchServiceNotes,
} from '../../stores/serviceNotesStore';
import TaskCalendarWidget from '../dashboard/TaskCalendarWidget';
import ServiceNotesWidget from './ServiceNotesWidget';
/**
* Mountan u Layout.astro (van AnimatedPage transforma) pa fixed pozicija
@@ -17,11 +26,16 @@ import TaskCalendarWidget from '../dashboard/TaskCalendarWidget';
*/
export default function TaskCalendarPortal() {
const allTasks = useStore($tasks);
const workOrders = useStore($workOrdersWithVehicle);
const notes = useStore($serviceNotes);
const notesLoading = useStore($serviceNotesLoading);
const user = useStore($user);
const [teamMembers, setTeamMembers] = useState([]);
const isServiser = Boolean(user?.is_serviser);
const isTeamMember = Boolean(user?.is_team_member);
const isSupervisorNotServiser = isTeamMember && !isServiser;
const canManageRecipients = isSupervisorNotServiser || isServiser;
const tasks = useMemo(() => {
if (!user?.id) return [];
@@ -32,6 +46,60 @@ export default function TaskCalendarPortal() {
);
}, [allTasks, user, isSupervisorNotServiser]);
const activeTasks = useMemo(
() => tasks.filter((task) => !['zavrsen', 'neaktivan'].includes(String(task.status || '').toLowerCase())),
[tasks]
);
const activeWorkOrders = useMemo(() => {
const base = Array.isArray(workOrders) ? workOrders : [];
if (isSupervisorNotServiser) {
return base.filter((item) => String(item.status || '').toLowerCase() === 'open');
}
return base.filter((item) => (
String(item.status || '').toLowerCase() === 'open'
&& (
String(item.creator || '') === String(user?.id || '')
|| String(item.craneInfo?.assigned_servicer || '') === String(user?.id || '')
)
));
}, [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(() => {});
}, [user?.id]);
useEffect(() => {
if (!canManageRecipients) {
setTeamMembers([]);
return;
}
let active = true;
fetchTeamMembers()
.then((rows) => {
if (!active) return;
setTeamMembers(Array.isArray(rows) ? rows : []);
})
.catch(() => {
if (!active) return;
setTeamMembers([]);
});
return () => {
active = false;
};
}, [canManageRecipients]);
// Ne renderiramo ništa ako korisnik nije prijavljen
if (!user?.id) return null;
@@ -42,9 +110,23 @@ export default function TaskCalendarPortal() {
}
return (
<TaskCalendarWidget
tasks={tasks}
onTaskClick={handleTaskClick}
/>
<>
<TaskCalendarWidget
tasks={tasks}
notes={notes}
workOrders={calendarWorkOrders}
onTaskClick={handleTaskClick}
/>
<ServiceNotesWidget
notes={notes}
loading={notesLoading}
canManageRecipients={canManageRecipients}
activeTasks={activeTasks}
activeWorkOrders={activeWorkOrders}
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,9 +22,11 @@ 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';
if (title.includes('email')) return 'work_order_email';
if (title.includes('putni nalog kreiran')) return 'work_order_created';
if (title.includes('novi putni nalog')) return 'work_order_assigned';
if (title.includes('putni nalog zatvoren')) return 'work_order_closed';
@@ -78,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',
@@ -124,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',
@@ -167,6 +199,14 @@ 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
: [];
if (!open || !notification) {
return null;
@@ -208,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()}
@@ -217,6 +257,23 @@ export default function NotificationDetailModal({ open, notification, onClose })
{action.label}
</button>
)}
{failedEmailCachedPdfs.length > 0 && (
<div className="mt-3 space-y-2">
<p className="text-xs font-semibold uppercase tracking-wide text-text-muted">
Cached PDF datoteke
</p>
{failedEmailCachedPdfs.map((item, index) => (
<button
key={`${item.generated_pdf_id || index}-${item.filename || 'pdf'}`}
type="button"
onClick={() => downloadGeneratedPdfByUrl(item.download_url, item.filename || `work-order-${index + 1}.pdf`)}
className="block text-left text-sm font-medium text-indigo-600 underline underline-offset-2 hover:text-indigo-700"
>
{item.filename || `PDF ${index + 1}`}
</button>
))}
</div>
)}
</div>
<div className="rounded-lg border border-border-hairline bg-canvas-base p-3">

View File

@@ -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

@@ -0,0 +1,35 @@
function normalizeText(value) {
const text = String(value || '').trim();
return text || '';
}
export function getTaskCraneKind(task) {
const make = normalizeText(task?.vehicle_make);
const model = normalizeText(task?.vehicle_model);
const kind = [make, model].filter(Boolean).join(' ').trim();
return kind || '-';
}
export function getTaskCraneSerial(task) {
const serial = normalizeText(task?.crane_serial_number);
return serial || '-';
}
export function getTaskCraneOwner(task) {
return normalizeText(task?.vehicle_owner_name) || '-';
}
export function getTaskCraneDisplay(task) {
const kind = getTaskCraneKind(task);
const serial = getTaskCraneSerial(task);
if (kind !== '-' && serial !== '-') {
return `${kind} (SN ${serial})`;
}
if (kind !== '-') {
return kind;
}
if (serial !== '-') {
return `SN ${serial}`;
}
return normalizeText(task?.vehicle_registration) || '-';
}

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';
@@ -257,6 +260,16 @@ function replaceWorkOrderItem(workOrderId, nextValue) {
);
}
function upsertWorkOrderItem(workOrderId, nextValue) {
const current = $workOrders.get();
const exists = current.some((item) => String(item.id) === String(workOrderId));
if (exists) {
replaceWorkOrderItem(workOrderId, nextValue);
return;
}
$workOrders.set([nextValue, ...current]);
}
function appendLocalDraft(entity, payload, tempId) {
const base = {
...payload,
@@ -547,15 +560,12 @@ export async function createWorkOrder(data) {
export async function updateWorkOrder(workOrderId, changes) {
const existing = $workOrders.get().find((item) => String(item.id) === String(workOrderId));
if (!existing) {
throw new Error('Work order nije pronađen u lokalnom stanju.');
}
const canSendNow = isBrowser() && navigator.onLine && !!$accessToken.get();
if (canSendNow) {
try {
const response = await api.patch(`fleet/work-orders/${workOrderId}/`, changes);
replaceWorkOrderItem(workOrderId, response);
upsertWorkOrderItem(workOrderId, response);
await persistDashboardCache();
await logSync(
{ entity: 'work_orders', endpoint: `fleet/work-orders/${workOrderId}/`, method: 'PATCH' },
@@ -575,6 +585,10 @@ export async function updateWorkOrder(workOrderId, changes) {
}
}
if (!existing) {
throw new Error('Work order nije dostupan offline. Otvorite dashboard ili pokušajte ponovno kada ste online.');
}
const optimistic = {
...existing,
...changes,
@@ -710,12 +724,80 @@ export async function fetchWorkOrderById(workOrderId) {
return await api.get(`fleet/work-orders/${encodeURIComponent(workOrderId)}/`);
}
export async function fetchTasksByWorkOrder(workOrderId) {
if (!workOrderId) {
return [];
}
const data = await api.get(`tasks/tasks/?work_order=${encodeURIComponent(workOrderId)}`);
return Array.isArray(data) ? data : (data?.results ?? []);
}
export async function fetchWorkOrderTaskServiceContext(workOrderId) {
if (!workOrderId) {
return { tasks: [] };
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: [] };
return payload && typeof payload === 'object'
? payload
: { tasks: [], additional_costs_table: null, travel_expenses_table: null };
}
export async function fetchWorkOrderAdditionalCostsTable(workOrderId) {
if (!workOrderId) {
return { data: { rows: [] }, total_for_payout: '0.00' };
}
const payload = await api.get(`fleet/work-orders/${encodeURIComponent(workOrderId)}/additional-costs-table/`);
return payload && typeof payload === 'object'
? payload
: { data: { rows: [] }, total_for_payout: '0.00' };
}
export async function updateWorkOrderAdditionalCostsTable(workOrderId, data) {
if (!workOrderId) {
throw new Error('Work order ID je obavezan.');
}
const payload = await api.put(
`fleet/work-orders/${encodeURIComponent(workOrderId)}/additional-costs-table/`,
data ?? { rows: [] },
);
showToast('Dodatni troškovi su uspješno spremljeni.', 'success');
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) {
@@ -727,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.');
@@ -747,9 +840,14 @@ export async function createWorkOrderInvoice(workOrderId, data = {}, signal = nu
formData.append('image', data.imageFile);
}
const options = signal ? { signal } : {};
const response = await api.post('fleet/work-order-invoices/', formData, options);
showToast('Račun je uspješno spremljen.', 'success');
return response;
try {
const response = await api.post('fleet/work-order-invoices/', formData, options);
showToast('Račun je uspješno spremljen.', 'success');
return response;
} catch (error) {
showToast(error?.message || 'Neuspješno spremanje računa.', 'error');
throw error;
}
}
async function _refreshNotificationsAsync() {
@@ -771,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.');
@@ -822,6 +984,144 @@ export async function downloadWorkOrderServiceRecordsPdf(workOrderId) {
return payload;
}
export async function downloadWorkOrderDocx(workOrderId) {
if (!workOrderId) {
throw new Error('Work order ID je obavezan.');
}
try {
const blob = await api.get(`fleet/work-orders/${encodeURIComponent(workOrderId)}/docx/`, { responseType: 'blob' });
saveBlobToFile(blob, `${workOrderId}.work-order.docx`);
showToast('DOCX putnog naloga je preuzet.', 'success');
} catch (err) {
const msg = err?.message || 'Preuzimanje DOCX putnog naloga nije uspjelo.';
showToast(msg, 'error');
throw err;
}
}
export async function downloadWorkOrderServiceRecordsDocx(workOrderId) {
if (!workOrderId) {
throw new Error('Work order ID je obavezan.');
}
try {
const blob = await api.get(`fleet/work-orders/${encodeURIComponent(workOrderId)}/service-records-docx/`, { responseType: 'blob' });
saveBlobToFile(blob, `${workOrderId}.work-order-service-records.docx`);
showToast('DOCX servisnih zapisa je preuzet.', 'success');
} catch (err) {
const msg = err?.message || 'Preuzimanje DOCX servisnih zapisa nije uspjelo.';
showToast(msg, 'error');
throw err;
}
}
export async function downloadWorkOrderInvoicesDocx(workOrderId) {
if (!workOrderId) {
throw new Error('Work order ID je obavezan.');
}
try {
const blob = await api.get(`fleet/work-orders/${encodeURIComponent(workOrderId)}/invoices-docx/`, { responseType: 'blob' });
saveBlobToFile(blob, `${workOrderId}.work-order-invoices.docx`);
showToast('DOCX računa putnog naloga je preuzet.', 'success');
} catch (err) {
const msg = err?.message || 'Preuzimanje DOCX računa nije uspjelo.';
showToast(msg, 'error');
throw err;
}
}
export async function 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(
`fleet/reports/monthly-servicer/?year=${year}&month=${month}`,
{ responseType: 'blob' },
);
saveBlobToFile(blob, `izvjestaj-servisera-${year}-${String(month).padStart(2, '0')}.docx`);
showToast('Izvještaj servisera je preuzet.', 'success');
} catch (err) {
showToast(err?.message || 'Preuzimanje izvještaja servisera nije uspjelo.', 'error');
throw err;
}
}
export async function downloadMonthlyCostsReport(year, month) {
try {
const blob = await api.get(
`fleet/reports/monthly-costs/?year=${year}&month=${month}`,
{ responseType: 'blob' },
);
saveBlobToFile(blob, `troskovi-servisera-${year}-${String(month).padStart(2, '0')}.docx`);
showToast('Izvještaj troškova je preuzet.', 'success');
} catch (err) {
showToast(err?.message || 'Preuzimanje izvještaja troškova nije uspjelo.', 'error');
throw err;
}
}
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');
@@ -838,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;
@@ -851,13 +1167,28 @@ export async function fetchTeamMembers() {
return Array.isArray(members) ? members : [];
}
export async function fetchServicers() {
const members = await api.get('users/servicers/');
return Array.isArray(members) ? members : [];
}
export async function sendWorkOrderEmail(workOrderId, payload = {}, options = {}) {
if (!workOrderId) {
throw new Error('Work order ID je obavezan.');
}
const response = await api.post(`fleet/work-orders/${encodeURIComponent(workOrderId)}/send-email/`, payload);
const recipients = Array.isArray(payload?.recipients)
? payload.recipients.filter(Boolean)
: [];
const normalizedPayload = {
...payload,
recipients: recipients.length > 0 ? recipients : undefined,
};
if (!normalizedPayload.recipients || normalizedPayload.recipients.length === 0) {
delete normalizedPayload.recipients;
}
const response = await api.post(`fleet/work-orders/${encodeURIComponent(workOrderId)}/send-email/`, normalizedPayload);
if (options.toast !== false) {
showToast('Email za putni nalog je poslan.', 'success');
showToast('Zahtjev za slanje emaila je pokrenut u pozadini.', 'info');
}
return response;
}
@@ -875,6 +1206,34 @@ export async function sendServiceRecordEmail(serviceRecordId, payload = {}, opti
if (!serviceRecordId) {
throw new Error('Service record ID je obavezan.');
}
const recipients = Array.isArray(payload?.recipients)
? payload.recipients.filter(Boolean)
: [];
if (recipients.length > 0) {
const failures = [];
for (const recipient of recipients) {
try {
await api.post(`fleet/service-records/${encodeURIComponent(serviceRecordId)}/send-email/`, {
...payload,
recipient,
});
} catch (error) {
failures.push({ recipient, error });
}
}
if (failures.length > 0) {
const firstError = failures[0]?.error;
const message = firstError?.message || `Neuspješno slanje za ${failures.length} primatelja.`;
const batchError = new Error(message);
batchError.failures = failures;
throw batchError;
}
if (options.toast !== false) {
showToast('Email za servisni zapis je poslan.', 'success');
}
return { sent: true, recipients };
}
const response = await api.post(`fleet/service-records/${encodeURIComponent(serviceRecordId)}/send-email/`, payload);
if (options.toast !== false) {
showToast('Email za servisni zapis je poslan.', 'success');

View File

@@ -0,0 +1,17 @@
import { atom } from 'nanostores';
export const $activeLeftDrawer = atom(null);
export function openLeftDrawer(drawerId) {
$activeLeftDrawer.set(drawerId || null);
}
export function closeLeftDrawer(drawerId = null) {
if (!drawerId) {
$activeLeftDrawer.set(null);
return;
}
if (String($activeLeftDrawer.get() || '') === String(drawerId)) {
$activeLeftDrawer.set(null);
}
}

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

@@ -0,0 +1,44 @@
import { atom } from 'nanostores';
import { api } from '../services/apiClient';
import { showToast } from './toastStore';
export const $serviceNotes = atom([]);
export const $serviceNotesLoading = atom(false);
export async function fetchServiceNotes() {
$serviceNotesLoading.set(true);
try {
const payload = await api.get('fleet/service-notes/');
$serviceNotes.set(Array.isArray(payload) ? payload : []);
} catch (error) {
if (error?.status !== 401) {
showToast('Neuspješno dohvaćanje bilješki.', 'error');
}
throw error;
} finally {
$serviceNotesLoading.set(false);
}
}
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 && nextNotes.length > 0) {
$serviceNotes.set(nextNotes);
} else {
await fetchServiceNotes();
}
showToast('Bilješka je spremljena.', 'success');
return payload;
}
export async function closeServiceNote(noteId) {
if (!noteId) {
throw new Error('ID bilješke je obavezan.');
}
await api.post(`fleet/service-notes/${encodeURIComponent(noteId)}/close/`, {});
$serviceNotes.set(
$serviceNotes.get().filter((item) => String(item.id) !== String(noteId))
);
showToast('Bilješka je zatvorena.', 'success');
}

View File

@@ -71,4 +71,46 @@ body.modal-open #main-content {
@keyframes fade-in-page {
from { opacity: 0; }
to { opacity: 1; }
}
/* Checkbox readability across light/dark themes */
input[type="checkbox"].erp-checkbox {
appearance: none;
width: 1rem;
height: 1rem;
border: 1px solid var(--app-border-hairline);
border-radius: 0.25rem;
background: var(--app-canvas-base);
display: inline-grid;
place-content: center;
cursor: pointer;
}
input[type="checkbox"].erp-checkbox::before {
content: "";
width: 0.55rem;
height: 0.55rem;
transform: scale(0);
transition: transform 120ms ease-in-out;
clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0, 43% 62%);
box-shadow: inset 1em 1em #ffffff;
}
input[type="checkbox"].erp-checkbox:checked {
background: var(--app-brand-primary);
border-color: var(--app-brand-primary);
}
input[type="checkbox"].erp-checkbox:checked::before {
transform: scale(1);
}
input[type="checkbox"].erp-checkbox:disabled {
opacity: 0.65;
cursor: not-allowed;
}
input[type="checkbox"].erp-checkbox:focus-visible {
outline: 2px solid var(--app-brand-primary);
outline-offset: 2px;
}