95 Commits

Author SHA1 Message Date
mariomitte
242c8dc9b2 .zip error creation due to ownership
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
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
mariomitte
cbcbf0b3af fix: sortiranje putnih naloga i upload modal u detaljima
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 sortiranje stupca Putni nalog (↑/↓) u tablicama gdje je prikazan work order kod i uspoređuje kodove u formatu MTDDMMYY po godini, mjesecu i danu.

Popravlja tok uređivanja servisnog zapisa tako da upload fotografija ostaje u višem overlayju bez zatvaranja parent modala te nakon uspješnog uploada osvježava listu datoteka u detaljima.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-19 19:16:19 +02:00
mariomitte
ad7b293f54 fix: filtriraj putne naloge i prosiri servisni zapis modal
Ogranicava prikaz 'Dohvati sve putne naloge' na naloge trenutnog servisera, dok supervisor bez serviser uloge i dalje vidi sve.

U detaljima servisnog zapisa dodaje gumb za upload datoteka i povezuje ga s postojecim ServicePhotoUpload modalom.

U modalu Novi servisni zapis dodaje polje Naziv servisnog zapisa iznad opisa i salje ga kao service_title.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-19 19:02:05 +02:00
mariomitte
8485431180 fix: zamijeni auth polling health endpointom
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 backend /api/health/ endpoint i prebacuje frontend heartbeat sa /api/token/ OPTIONS na /api/health/ GET kako bi se izbjeglo periodicko gadanje auth rute.

Dodaje TTL cache za clientStore (lastFetchedAt + force opcija) kako bi se smanjili redundantni crm/clients pozivi pri navigaciji.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-19 16:27:20 +02:00
mariomitte
bbdd02c9e9 fix: stabiliziraj navbar tranziciju i ukloni white splash
Zadrzava Navbar i ostale UI otoke kroz Astro tranzicije te sinkronizira aktivni path preko astro:page-load kako bi react-spring indikator animirao iz trenutne pozicije.

Uklanja bijeli splash pri promjeni stranice i prelasku teme postavljanjem html background boje inline, prilagodbom ThemeToggle logike i uklanjanjem body color tranzicije.

Dodano je i TTL kesiranje za dashboard/task dohvat kako bi se smanjili redundantni API pozivi pri navigaciji.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-19 16:18:15 +02:00
mariomitte
fe7edf5643 fix: premjesti kalendar widget u Layout, popravi PDF toast i Servisi danas
- TaskCalendarPortal u Layout.astro (van AnimatedPage transforma) pa fixed
  pozicija uvijek prati viewport rub bez probijanja sadržaja
- Kalendar widgeta panel koristi clipping wrapper (overflow:hidden + animirana
  width) umjesto translate3d — potpuno skriva sjenu i border kad je zatvoren
- Serviser vidi samo svoje taskove, supervisor vidi sve; neprijavljeni korisnik
  ne vidi widget (return null)
- FleetDashboardShell sluša calendar:open-task event i otvara TaskServiceRecordsModal
- Servisi danas broji Taskove (scheduled_date=danas, status aktivan/servis)
  umjesto Servisnih zapisa
- downloadGeneratedPdfByUrl hvata grešku i prikazuje Toast (npr. PDF nije
  dostupan ili je istekao)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-19 15:27:20 +02:00
mariomitte
0c9e1b7d50 feat: dodaj scheduled_date na Task i kalendar widget
- Novo polje Task.scheduled_date (DateField, nullable, db_index) za planiranje
- Migracija 0009_task_scheduled_date
- Celery task notify_upcoming_tasks: dnevno u 08:00 šalje Pusher + email za sutrašnje zadatke
- TaskCalendarWidget: react-spring slide panel s lijeve strane, grid prikaz mjeseca, dot indikatori, klik otvara TaskServiceRecordsModal
- FleetDashboardShell: import + render kalendara, Planirano stupac u tablici zadataka
- TaskCreateModal + TaskServiceRecordsModal: scheduled_date input i prikaz
- Admin + serializer + views: scheduled_date filter i prikaz
- 47 backend testova prolaze (uključuje 8 novih za notify_upcoming_tasks)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-19 14:52:10 +02:00
mariomitte
bf8e25efe7 feat: dodaj display_code polje za putne naloge (MT070626 format)
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 polje 'display_code' na WorkOrder model (format: 2 inicijala + ddmmyy)
- Migracije za kreiranje polja i backfill postojećih naloga
- Ažuriran WorkOrderSerializer za validaciju i serijalizaciju display_code
- Backend tasks za auto-generiranje display_code pri kreiranju
- Frontend komponente ažurirane za prikaz display_code umjesto UUID:
  * FleetDashboardShell tablica putnih naloga
  * WorkOrderDetailModal
  * TaskCreateModal
  * Sve PDF stranice
- displayIds.js utility za formatiranjeDisplay ID-eva
- Admin interface prikazuje display_code
2026-07-17 23:53:47 +02:00
mariomitte
ef8718a4cf fix: ukloni dupli toast provider u layoutu
Uklonjen je paralelni ToastProvider iz Layout.astro kako bi ostao samo postojeći ToastContainer (nanostore), čime se sprječava hydration crash i null.map greška na /putni-nalozi.\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-17 19:39:04 +02:00
mariomitte
75257525c4 fix: osvjezi taskove nakon azuriranja putnog naloga
Nakon spremanja izmjena u Uredi putni nalog, frontend sada uz fleet podatke ponovno dohvaća i taskove, pa se status taska odmah prikaže kao 'spreman_za_zavrsetak' kada backend zatvori povezani putni nalog.\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-17 19:32:50 +02:00
mariomitte
7acaef941f feat: integritet zatvaranja taska i putnog naloga
Dodaje status 'spreman_za_zavrsetak' i backend provjeru koja blokira zatvaranje taska kada je povezani putni nalog otvoren, uz 409 odgovor i opcionalni auto-close flow.\n\nPri zatvaranju putnog naloga taskovi u statusu 'servis' automatski prelaze u 'spreman_za_zavrsetak'. Frontend modal sada podržava potvrdu i retry s auto_close_work_order=true.\n\nUključeni su i ciljani testovi za novi behavior, te ažuriran prikaz statusa u dashboardu.\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-17 19:20:33 +02:00
mariomitte
e14665d8ec fix: dev proxy za frontend API pozive
U dev modu frontend sada koristi /api preko Astro/Vite proxy sloja prema backend servisu, umjesto direktnog gađanja localhost:8001 iz browsera.\n\nTime se uklanja ERR_CONNECTION_REFUSED u lokalnom Docker development okruženju.\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-17 18:58:09 +02:00
mariomitte
ef51036bc7 fix: refresh notifikacija nakon PDF zahtjeva (cache hit i async 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
Dodan _refreshNotificationsAsync() koji odmah osvjezi notifikacijski feed nakon cache hit PDF downloada, te _schedulePdfNotificationPolling() koji pollingom na 5s/20s/60s hvata Celery completion kad Pusher nije dostupan ili worker kasni.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-17 17:21:46 +02:00
mariomitte
13de9e113c fix: ispravljen dvostruki /api/ prefiks u download_url za generirani PDF
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
Backend je vraćao download_url s /api/ prefiksom, a apiClient.js rezolvira URL relativno na BASE_URL koji vec sadrži /api/ - rezultat je bio /api/api/ i 404. Uklonjen /api/ prefiks na svim mjestima (views.py x2, tasks.py x1).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-17 17:03:11 +02:00
mariomitte
e8330473cd feat: async PDF cache i API media putanje za putne naloge
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 background request tok za generiranje PDF-a s 24h cache-om i cleanup taskom preko Celery beat-a. Dodaje notification metadata download_url za ponovno preuzimanje bez regeneracije, API image endpointe za putne naloge i račune, te frontend integraciju za async download i produkcijski prikaz slika.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-17 16:42:02 +02:00
mariomitte
5e7ec72a4e fix: ispravljen prikaz Vozilo servisera u WorkOrderDetailModal
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
- Vozilo servisera sada prikazuje servicer_vehicle_* polja (registracija,
  marka/model, km start/end, trosak goriva) umjesto craneInfo podataka
- start_mileage/end_mileage (km dizalice na nalogu) premjesteni u Podaci dizalice
- servicer_vehicle_* polja dodana u form state, useEffect init i submit payload
- Trošak goriva (EUR) dodan kao novo polje u sekciji Vozilo servisera

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-17 11:12:14 +02:00
mariomitte
346682b172 config: disable CommonPasswordValidator za interni ERP
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-07-16 21:56:11 +02:00
mariomitte
8e4b3f8aea feat: WorkOrderPhoto izolacija, upload modal i SN stupac u tablici putnih naloga
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 WorkOrderPhoto model (FK na WorkOrder, odvojen od VehicleServicePhoto)
- Migracija 0022_workorderphoto
- WorkOrderViewSet.images action refaktoriran: GET/POST koristi WorkOrderPhoto
- WorkOrderPhotoSerializer dodan u serializers.py
- WorkOrderPhotoInline dodan u admin.py
- Novi WorkOrderPhotoUpload.jsx modal s podrskom za onBack prop i z-[60]
- WorkOrderDetailModal: gumb Dodaj fotografije u editMode, photoUploadOpen state,
  ispravljen onSuccess callback (map na URL stringove), reset pri zatvaranju
- FleetDashboardShell: stupac SN u tablici putnih naloga (craneInfo.crane_serial_number)
- fleetDashboardStore: uklonjen dupli toast iz uploadWorkOrderPhoto
- Dodan test test_work_order_images_endpoint.py

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-16 06:33:08 +02:00
mariomitte
1ab2bfb3c3 patch prikaži stranicu servisnih zapisa sa SN dizalice
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-07-14 23:09:00 +02:00
mariomitte
3ad536a23e interaktivna Tablica radnih sati, db
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-07-13 00:36:28 +02:00
70151f1225 production with django admin template
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-07-12 22:44:03 +02:00
mariomitte
ac5f25a99c patch ne otkriva entrypoint.sh na windows 2026-07-12 15:25:57 +02:00
118 changed files with 15561 additions and 1108 deletions

1
.gitattributes vendored Normal file
View File

@@ -0,0 +1 @@
*.sh text eol=lf

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

@@ -1,5 +1,6 @@
import os
from pathlib import Path
from celery.schedules import crontab
from datetime import timedelta
BASE_DIR = Path(__file__).resolve().parent.parent.parent
@@ -11,6 +12,7 @@ SECRET_KEY = os.environ.get('SECRET_KEY', 'django-insecure-fk#ywtdyi8=t6^vdbo!=b
DEBUG = os.environ.get('DEBUG', 'True') == 'True'
INSTALLED_APPS = [
'unfold',
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
@@ -27,6 +29,14 @@ INSTALLED_APPS = [
'modules.crm',
'modules.fleet'
]
UNFOLD = {
'SITE_TITLE': 'ERP Admin',
'SITE_HEADER': 'ERP Administration',
'SITE_SYMBOL': 'dashboard',
'SHOW_HISTORY': True,
}
MIDDLEWARE = [
'corsheaders.middleware.CorsMiddleware', # Mora biti prvi
'django.middleware.security.SecurityMiddleware',
@@ -61,7 +71,7 @@ WSGI_APPLICATION = 'core.wsgi.application'
AUTH_PASSWORD_VALIDATORS = [
{'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator'},
{'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator'},
{'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator'},
# {'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator'},
{'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator'},
]
@@ -144,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
@@ -160,6 +174,24 @@ CACHES = {
CELERY_ACCEPT_CONTENT = ['json']
CELERY_TASK_SERIALIZER = 'json'
CELERY_BEAT_SCHEDULE = {
'cleanup-expired-generated-pdfs-hourly': {
'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),
},
}
# URL kojim će preglednik dohvaćati statiku
STATIC_URL = '/static/'

View File

@@ -46,4 +46,14 @@ INSTALLED_APPS += ['debug_toolbar']
MIDDLEWARE += ['debug_toolbar.middleware.DebugToolbarMiddleware']
INTERNAL_IPS = ['127.0.0.1']
CELERY_TASK_ALWAYS_EAGER = True
CELERY_TASK_ALWAYS_EAGER = True
# URL kojim će preglednik dohvaćati statiku
STATIC_URL = '/static/'
# Direktorij gdje će 'collectstatic' skupljati sve statičke datoteke (za produkciju)
STATIC_ROOT = BASE_DIR / '/tmp/staticfiles'
# (Opcionalno, ali preporučeno za ERP) Ako koristiš i medije (dokumente)
MEDIA_URL = '/media/'
MEDIA_ROOT = BASE_DIR / 'media'

View File

@@ -40,3 +40,6 @@ STATIC_URL = '/static/'
STATIC_ROOT = '/app/staticfiles'
MEDIA_URL = '/media/'
MEDIA_ROOT = '/app/media'
# Privremeni fallback dok se media ne servira preko reverse proxy-ja.
SERVE_MEDIA_FILES = os.environ.get('SERVE_MEDIA_FILES', '1').strip().lower() in ('1', 'true', 'yes', 'on')

View File

@@ -2,14 +2,19 @@
from django.conf import settings
from django.conf.urls.static import static
from django.contrib import admin
from django.http import JsonResponse
from django.urls import path, include
from rest_framework_simplejwt.views import (
TokenObtainPairView,
TokenRefreshView,
)
def api_health(_request):
return JsonResponse({'status': 'ok'})
urlpatterns = [
path('admin/', admin.site.urls),
path('api/health/', api_health, name='api_health'),
# Uključujemo rute iz modula (svaki modul ima svoj urls.py)
path('api/crm/', include('modules.crm.urls')),
@@ -30,4 +35,6 @@ if settings.DEBUG:
urlpatterns += [
path('__debug__/', include(debug_toolbar.urls)),
]
if settings.DEBUG or getattr(settings, 'SERVE_MEDIA_FILES', False):
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

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

@@ -4,3 +4,5 @@ serviser2@example.com,Ime2,Prezime2,password123,true,false,true,false,false,true
serviser3@example.com,Ime3,Prezime3,password123,true,false,true,false,false,true,+38591111222,12345678901,Serviser,Zagreb,Serviser dizalice,LIC-001,true,,
admin1@example.com,Admin1,Prezime1,password123,false,false,true,true,true,true,+38591111222,12345678901,Admin,Zagreb,Direktor,LIC-001,true,,
admin2@example.com,Admin2,Prezime2,password123,false,false,true,true,true,true,+38591111222,12345678901,Admin,Zagreb,Direktor,LIC-001,true,,
ramo@knezljubo.hr,Ramo,Husanović,password123,true,false,true,false,false,true,+38591111222,12345678901,Serviser,Zagreb,Serviser dizalice,LIC-001,true,,
domagoj@knezljubo.hr,Domagoj,Knez,password123,false,false,true,true,true,true,+38591111222,12345678901,Admin,Zagreb,Direktor,LIC-001,true,,
1 email first_name last_name password is_serviser is_kupac is_team_member is_staff is_superuser is_active telefon oib occupation residence work_position licence_number is_verified client_id client_tax_id
4 serviser3@example.com Ime3 Prezime3 password123 true false true false false true +38591111222 12345678901 Serviser Zagreb Serviser dizalice LIC-001 true
5 admin1@example.com Admin1 Prezime1 password123 false false true true true true +38591111222 12345678901 Admin Zagreb Direktor LIC-001 true
6 admin2@example.com Admin2 Prezime2 password123 false false true true true true +38591111222 12345678901 Admin Zagreb Direktor LIC-001 true
7 ramo@knezljubo.hr Ramo Husanović password123 true false true false false true +38591111222 12345678901 Serviser Zagreb Serviser dizalice LIC-001 true
8 domagoj@knezljubo.hr Domagoj Knez password123 false false true true true true +38591111222 12345678901 Admin Zagreb Direktor LIC-001 true

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

@@ -66,7 +66,7 @@ class TeamMemberSerializer(serializers.ModelSerializer):
class Meta:
model = CustomUser
fields = ['id', 'email', 'full_name', 'is_team_member']
fields = ['id', 'email', 'full_name', 'is_team_member', 'is_serviser']
read_only_fields = fields
def get_full_name(self, obj):

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

@@ -1,101 +1,22 @@
#!/bin/sh
set -e
export PATH="/usr/local/bin:$PATH"
APP_DIR="/app"
APP_USER="appuser"
APP_UID=1000
STATIC_ROOT="${STATIC_ROOT:-/app/staticfiles}"
MEDIA_ROOT="${MEDIA_ROOT:-/app/media}"
# Kreiraj korisnika 'appuser' s UID 1000 ako ne postoji
if ! getent passwd "${APP_USER}" > /dev/null; then
echo "--> Kreiram ${APP_USER}..."
addgroup --system --gid ${APP_UID} ${APP_USER} || true
adduser --system --uid ${APP_UID} --ingroup ${APP_USER} --no-create-home --disabled-login ${APP_USER} || true
fi
# Ako smo root, pokušaj osigurati ownership. Ako nismo root, samo logiramo i nastavljamo.
if [ "$(id -u)" -eq 0 ]; then
echo "--> (root) Kreiram/ajustiram direktorije i dozvole..."
mkdir -p "${STATIC_ROOT}" "${MEDIA_ROOT}" "${APP_DIR}"
# pokušaj chown, ali ne izlazi na grešku ako neuspije (možda host FS to ne dozvoljava)
if chown -R ${APP_USER}:${APP_USER} "${STATIC_ROOT}" "${MEDIA_ROOT}" "${APP_DIR}"; then
echo "--> Chown uspješan."
else
echo "--> Upozorenje: chown nije uspio (moguće root_squash ili mount bez chown dopuštenja)."
fi
else
echo "--> Nismo root (uid=$(id -u)), preskačem chown. Provjeri vlasništvo na hostu ako su problemi s permisijama."
fi
# Helper: run command as appuser but return to the script (do not replace shell)
run_as_appuser_now() {
if command -v gosu >/dev/null 2>&1; then
gosu ${APP_USER} "$@"
elif command -v su-exec >/dev/null 2>&1; then
su-exec ${APP_USER} "$@"
elif command -v runuser >/dev/null 2>&1; then
runuser -u ${APP_USER} -- "$@"
else
# If we're already non-root (appuser), run directly; if root and no helpers, run directly anyway (with warning)
if [ "$(id -u)" -ne 0 ]; then
"$@"
else
echo "--> Upozorenje: nema gosu/su-exec/runuser; izvršavam kao root (nepoželjno)."
"$@"
fi
fi
}
# Helper: exec the given command as appuser (replace current shell / PID 1)
exec_as_appuser() {
# Ako smo već appuser, samo izvrši naredbu direktno
if [ "$(id -u -n)" = "${APP_USER}" ]; then
exec "$@"
# Inače, pokušaj koristiti alate za switch
elif command -v gosu >/dev/null 2>&1; then
exec gosu ${APP_USER} "$@"
elif command -v su-exec >/dev/null 2>&1; then
exec su-exec ${APP_USER} "$@"
else
# Fallback
exec "$@"
fi
}
if [ "$1" = "gunicorn" ]; then
echo "--> Pokrećem migracije..."
# Ako smo root, pokrenimo migrate kao appuser; ako smo non-root (već appuser), pokreni direktno
if [ "$(id -u)" -eq 0 ]; then
run_as_appuser_now python manage.py migrate --noinput
else
python manage.py migrate --noinput
fi
echo "--> Pokrećem migracije..."
python manage.py migrate --noinput
echo "--> Osiguravam prava nad statikom prije collectstatic..."
mkdir -p "${STATIC_ROOT}"
if [ "$(id -u)" -eq 0 ]; then
chown -R ${APP_USER}:${APP_USER} "${STATIC_ROOT}" || echo "--> chown statika nije uspio"
fi
echo "--> Skupljam statiku..."
python manage.py collectstatic --noinput
echo "--> Skupljam statiku..."
if command -v gosu >/dev/null 2>&1 || command -v su-exec >/dev/null 2>&1 || command -v runuser >/dev/null 2>&1; then
run_as_appuser_now python manage.py collectstatic --noinput
else
python manage.py collectstatic --noinput
fi
echo "--> Pokrećem Gunicorn kao appuser..."
exec_as_appuser gunicorn --bind 0.0.0.0:8000 --workers 3 core.wsgi:application
echo "--> Pokrećem Gunicorn..."
exec gunicorn --bind 0.0.0.0:8000 --workers 3 core.wsgi:application
elif [ "$1" = "celery" ]; then
echo "--> Pokrećem Celery kao appuser..."
shift
# Replace PID1 with celery running as appuser
exec_as_appuser celery "$@"
echo "--> Pokrećem Celery..."
shift
exec celery "$@"
else
exec "$@"
fi
exec "$@"
fi

View File

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
@@ -5,7 +6,12 @@ from .models import (
Crane,
Vehicle,
WorkOrder,
WorkOrderPhoto,
WorkOrderInvoice,
WorkOrderAdditionalCostsTable,
ServiceContextNote,
GeneratedWorkOrderPdf,
EmailDispatchLog,
VehicleServiceRecord,
VehicleServicePhoto,
VehicleServiceAttachment,
@@ -42,7 +48,7 @@ class ServiceAttachmentInline(admin.TabularInline):
class WorkOrderInline(admin.TabularInline):
model = WorkOrder
extra = 0
fields = ('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',)
@@ -56,8 +62,47 @@ 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):
model = WorkOrderPhoto
extra = 0
fields = ('thumbnail_preview', 'image', 'description', 'uploaded_by', 'created_at', 'is_active')
readonly_fields = ('thumbnail_preview', 'created_at')
show_change_link = True
@admin.display(description="Pregled")
def thumbnail_preview(self, obj):
if obj.image:
return format_html(
'<a href="{}" target="_blank">'
'<img src="{}" style="height:64px;width:auto;border-radius:4px;object-fit:cover;" />'
'</a>',
obj.image.url,
obj.image.url,
)
return ""
@admin.register(Vehicle)
@@ -154,12 +199,12 @@ class CraneAdmin(VehicleAdmin):
@admin.register(WorkOrder)
class WorkOrderAdmin(admin.ModelAdmin):
list_display = ('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 = ('vehicle__registration_number', 'creator__username', 'purpose')
search_fields = ('display_code', 'vehicle__registration_number', 'creator__username', 'purpose')
readonly_fields = ('date',)
ordering = ('-date',)
inlines = (WorkOrderInvoiceInline,)
inlines = (WorkOrderInvoiceInline, WorkOrderPhotoInline)
def distance_display(self, obj):
return obj.distance
@@ -168,10 +213,56 @@ 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)
class GeneratedWorkOrderPdfAdmin(admin.ModelAdmin):
list_display = ('id', 'work_order', 'pdf_type', 'status', 'requested_by', 'expires_at', 'is_active', 'created_at')
list_filter = ('pdf_type', 'status', 'is_active', 'expires_at')
search_fields = ('work_order__id', 'requested_by__username', 'requested_by__email', 'filename')
readonly_fields = ('created_at', 'updated_at')
ordering = ('-created_at',)
@admin.register(VehicleServiceRecord)
class VehicleServiceRecordAdmin(admin.ModelAdmin):
@@ -194,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,43 @@
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import uuid
def work_order_photo_upload_to(instance, filename):
vehicle_reg = 'unknown'
try:
vehicle_reg = instance.work_order.vehicle.registration_number
except Exception:
vehicle_reg = 'unknown'
unique_name = f"{uuid.uuid4().hex}_{filename}"
return f"fleet/{vehicle_reg}/work_order_photos/{unique_name}"
class Migration(migrations.Migration):
dependencies = [
('fleet', '0021_alter_vehicle_assigned_servicer_many_to_one'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='WorkOrderPhoto',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('image', models.ImageField(upload_to=work_order_photo_upload_to, verbose_name='Fotografija putnog naloga')),
('description', models.CharField(blank=True, max_length=255, verbose_name='Opis')),
('is_active', models.BooleanField(default=True, verbose_name='Aktivan')),
('uploaded_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='uploaded_work_order_photos', to=settings.AUTH_USER_MODEL, verbose_name='Postavio')),
('work_order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='photos', to='fleet.workorder', verbose_name='Putni nalog')),
],
options={
'verbose_name': 'Fotografija putnog naloga',
'verbose_name_plural': 'Fotografije putnog naloga',
'ordering': ['-created_at'],
},
),
]

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', '0022_workorderphoto'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='GeneratedWorkOrderPdf',
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')),
('pdf_type', models.CharField(choices=[('work_order', 'Putni nalog'), ('service_records', 'Servisni zapisi'), ('invoices', 'Računi')], db_index=True, max_length=32, verbose_name='Tip PDF-a')),
('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_work_order_pdf_upload_to, verbose_name='PDF 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_work_order_pdfs', to=settings.AUTH_USER_MODEL, verbose_name='Zatražio')),
('work_order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='generated_pdfs', to='fleet.workorder', verbose_name='Putni nalog')),
],
options={
'verbose_name': 'Generirani PDF putnog naloga',
'verbose_name_plural': 'Generirani PDF-ovi putnih naloga',
'ordering': ['-created_at'],
},
),
]

View File

@@ -0,0 +1,35 @@
# Generated by Django 5.2.15 on 2026-07-17 14:32
import modules.fleet.models
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fleet', '0023_generatedworkorderpdf'),
]
operations = [
migrations.AlterField(
model_name='workorderphoto',
name='created_at',
field=models.DateTimeField(auto_now_add=True, verbose_name='Vrijeme kreiranja'),
),
migrations.AlterField(
model_name='workorderphoto',
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='workorderphoto',
name='image',
field=models.ImageField(upload_to=modules.fleet.models._work_order_photo_upload_to, verbose_name='Fotografija putnog naloga'),
),
migrations.AlterField(
model_name='workorderphoto',
name='updated_at',
field=models.DateTimeField(auto_now=True, verbose_name='Vrijeme zadnje izmjene'),
),
]

View File

@@ -0,0 +1,24 @@
# Generated by Django 5.2.15 on 2026-07-17 21:55
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fleet', '0024_alter_workorderphoto_created_at_and_more'),
]
operations = [
migrations.AddField(
model_name='workorder',
name='display_code',
field=models.CharField(
blank=True,
db_index=True,
help_text='Format: MT070626 (2 slova + 6 znamenki).',
max_length=8,
verbose_name='Prikaz putnog naloga',
),
),
]

View File

@@ -0,0 +1,30 @@
from django.db import migrations
def backfill_workorder_display_code(apps, schema_editor):
WorkOrder = apps.get_model('fleet', 'WorkOrder')
for work_order in WorkOrder.objects.select_related('creator').all():
current_code = str(getattr(work_order, 'display_code', '') or '').strip().upper()
if current_code:
continue
creator = getattr(work_order, 'creator', None)
first_name = str(getattr(creator, 'first_name', '') or '').strip()
last_name = str(getattr(creator, 'last_name', '') or '').strip()
if not first_name or not last_name:
continue
order_date = getattr(work_order, 'date', None)
if not order_date:
continue
display_code = f"{first_name[0].upper()}{last_name[0].upper()}{order_date.strftime('%d%m%y')}"
WorkOrder.objects.filter(pk=work_order.pk).update(display_code=display_code)
class Migration(migrations.Migration):
dependencies = [
('fleet', '0025_workorder_display_code'),
]
operations = [
migrations.RunPython(backfill_workorder_display_code, migrations.RunPython.noop),
]

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
@@ -45,6 +48,34 @@ def _work_order_invoice_upload_to(instance, filename):
unique_name = f"{uuid.uuid4().hex}_{filename}"
return f"fleet/{vehicle_reg}/work_order_invoices/{unique_name}"
def _work_order_photo_upload_to(instance, filename):
vehicle_reg = 'unknown'
try:
vehicle_reg = instance.work_order.vehicle.registration_number
except Exception:
vehicle_reg = 'unknown'
unique_name = f"{uuid.uuid4().hex}_{filename}"
return f"fleet/{vehicle_reg}/work_order_photos/{unique_name}"
def _generated_work_order_pdf_upload_to(instance, filename):
vehicle_reg = 'unknown'
try:
vehicle_reg = instance.work_order.vehicle.registration_number
except Exception:
vehicle_reg = 'unknown'
unique_name = f"{uuid.uuid4().hex}_{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):
"""
@@ -113,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")),
@@ -232,6 +324,7 @@ class WorkOrder(BaseModel):
('defektaza', _("Defektaža")),
('kontrola', _("Kontrola")),
('redovni_pregled', _("Redovni pregled")),
('popravak', _("Popravak")),
]
vehicle = models.ForeignKey(Vehicle, on_delete=models.PROTECT, related_name='work_orders', verbose_name=_("Vozilo"))
@@ -266,9 +359,17 @@ 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"))
display_code = models.CharField(
max_length=8,
blank=True,
db_index=True,
verbose_name=_("Prikaz putnog naloga"),
help_text=_("Format: MT070626 (2 slova + 6 znamenki)."),
)
purpose = models.CharField(max_length=50, choices=PURPOSE_CHOICES, blank=True, verbose_name=_("Svrha"))
notes = models.TextField(blank=True, verbose_name=_("Bilješke"))
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='open', verbose_name=_("Status"))
@@ -304,6 +405,12 @@ class WorkOrder(BaseModel):
def clean(self):
super().clean()
if self.display_code:
self.display_code = str(self.display_code).strip().upper()
if not re.fullmatch(r"[A-Z]{2}[0-9]{6}", self.display_code):
raise ValidationError({
"display_code": _("Prikaz putnog naloga mora biti u formatu MT070626 (2 slova + 6 znamenki).")
})
if self.travel_start_at and self.travel_end_at and self.travel_end_at < self.travel_start_at:
raise ValidationError({"travel_end_at": _("Kraj puta ne može biti prije početka puta.")})
@@ -319,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,
@@ -344,6 +451,218 @@ 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,
on_delete=models.CASCADE,
related_name='photos',
verbose_name=_("Putni nalog"),
)
image = models.ImageField(
upload_to=_work_order_photo_upload_to,
verbose_name=_("Fotografija putnog naloga"),
)
description = models.CharField(max_length=255, blank=True, verbose_name=_("Opis"))
uploaded_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name='uploaded_work_order_photos',
verbose_name=_("Postavio"),
)
is_active = models.BooleanField(default=True, verbose_name=_("Aktivan"))
class Meta:
verbose_name = _("Fotografija putnog naloga")
verbose_name_plural = _("Fotografije putnog naloga")
ordering = ['-created_at']
def __str__(self):
return f"WorkOrderPhoto {self.pk} ({self.work_order_id})"
class GeneratedWorkOrderPdf(BaseModel):
PDF_TYPE_CHOICES = [
('work_order', _("Putni nalog")),
('service_records', _("Servisni zapisi")),
('invoices', _("Računi")),
]
STATUS_CHOICES = [
('pending', _("U obradi")),
('ready', _("Spremno")),
('failed', _("Neuspješno")),
]
work_order = models.ForeignKey(
WorkOrder,
on_delete=models.CASCADE,
related_name='generated_pdfs',
verbose_name=_("Putni nalog"),
)
requested_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name='requested_generated_work_order_pdfs',
verbose_name=_("Zatražio"),
)
pdf_type = models.CharField(max_length=32, choices=PDF_TYPE_CHOICES, db_index=True, verbose_name=_("Tip PDF-a"))
status = models.CharField(max_length=16, choices=STATUS_CHOICES, default='pending', db_index=True, verbose_name=_("Status"))
file = models.FileField(upload_to=_generated_work_order_pdf_upload_to, null=True, blank=True, verbose_name=_("PDF 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 = _("Generirani PDF putnog naloga")
verbose_name_plural = _("Generirani PDF-ovi putnih naloga")
ordering = ['-created_at']
def __str__(self):
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.
@@ -430,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,10 +1,21 @@
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,
VehicleServicePhoto,
@@ -12,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
@@ -27,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:
@@ -89,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()
@@ -96,12 +264,12 @@ class WorkOrderSerializer(serializers.ModelSerializer):
class Meta:
model = WorkOrder
fields = [
'id', 'vehicle', 'crane', 'crane_label', 'creator', 'date',
'id', 'display_code', 'vehicle', 'crane', 'crane_label', 'creator', 'date',
'start_mileage', 'end_mileage',
'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']
@@ -110,11 +278,13 @@ class WorkOrderSerializer(serializers.ModelSerializer):
'creator': {'required': False},
'start_mileage': {'required': False, 'allow_null': True},
'end_mileage': {'required': False, 'allow_null': True},
'display_code': {'required': False, 'allow_blank': False},
'servicer_vehicle_registration': {'required': False, 'allow_blank': True},
'servicer_vehicle_make_model': {'required': False, 'allow_blank': True},
'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},
@@ -144,6 +314,14 @@ class WorkOrderSerializer(serializers.ModelSerializer):
if user and 'creator' not in validated_data:
validated_data['creator'] = user
if not validated_data.get('display_code'):
creator = validated_data.get('creator')
generated_display_code = self._generate_display_code(creator, timezone.localdate())
if not generated_display_code:
raise serializers.ValidationError({
'display_code': 'Serviser mora imati ime i prezime za automatsko generiranje oznake. Unesite oznaku ručno u formatu MT070626.'
})
validated_data['display_code'] = generated_display_code
return services.VehicleService.create_work_order(validated_data)
@@ -180,23 +358,258 @@ class WorkOrderSerializer(serializers.ModelSerializer):
raise serializers.ValidationError({'servicer_vehicle_fuel_cost': 'Trošak goriva ne može biti negativan.'})
return attrs
def validate_display_code(self, value):
normalized = str(value or '').strip().upper()
if not re.fullmatch(r"[A-Z]{2}[0-9]{6}", normalized):
raise serializers.ValidationError('Prikaz putnog naloga mora biti u formatu MT070626 (2 slova + 6 znamenki).')
return normalized
@staticmethod
def _generate_display_code(user, target_date):
if user is None:
return None
first_name = str(getattr(user, 'first_name', '') or '').strip()
last_name = str(getattr(user, 'last_name', '') or '').strip()
if not first_name or not last_name:
return None
return f"{first_name[0].upper()}{last_name[0].upper()}{target_date.strftime('%d%m%y')}"
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',
]
read_only_fields = ['id', 'created_by', 'created_at']
def get_image_url(self, obj):
request = self.context.get('request')
if not obj.image:
return None
if request is None:
return None
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):
class Meta:
model = WorkOrderPhoto
fields = ['id', 'work_order', 'image', 'description', 'uploaded_by', 'created_at']
read_only_fields = ['id', 'uploaded_by', 'created_at']
extra_kwargs = {
'work_order': {'required': False},
}
class VehicleServiceRecordSerializer(serializers.ModelSerializer):
crane = serializers.SerializerMethodField()
@@ -207,12 +620,13 @@ class VehicleServiceRecordSerializer(serializers.ModelSerializer):
performed_by_name = serializers.SerializerMethodField()
vehicle_registration = serializers.SerializerMethodField()
task_label = serializers.SerializerMethodField()
crane_serial_number = serializers.SerializerMethodField()
class Meta:
model = VehicleServiceRecord
fields = [
'id', 'vehicle', 'crane', 'vehicle_registration', 'crane_registration',
'task', 'task_label',
'task', 'task_label', 'crane_serial_number',
'performed_by', 'performed_by_name',
'service_title', 'description', 'parts', 'cost',
'service_date', 'mileage', 'next_service_due_at',
@@ -261,6 +675,12 @@ class VehicleServiceRecordSerializer(serializers.ModelSerializer):
return None
return obj.task.title
def get_crane_serial_number(self, obj):
# Provjeri je li povezano vozilo/dizalica postoji i vrati serijski broj
if hasattr(obj, 'vehicle') and obj.vehicle:
return getattr(obj.vehicle, 'crane_serial_number', None)
return None
def create(self, validated_data):
request = self.context.get('request')
user = getattr(request, 'user', None)

View File

@@ -126,6 +126,7 @@ class VehicleService:
@staticmethod
def update_work_order(instance: WorkOrder, data: dict) -> WorkOrder:
previous_status = instance.status
for k, v in data.items():
setattr(instance, k, v)
try:
@@ -134,8 +135,17 @@ class VehicleService:
raise ValidationError({"detail": str(e)})
with transaction.atomic():
instance.save()
# Ako je nalog zatvoren, obavijestimo kreatora/servisera
if instance.status == 'closed':
transitioned_to_closed = previous_status != 'closed' and instance.status == 'closed'
if transitioned_to_closed:
from modules.task_management.models import Task
Task.objects.filter(
is_active=True,
work_order=instance,
status__in=('aktivan', 'servis'),
).update(status='spreman_za_zavrsetak')
# Ako je nalog upravo zatvoren, obavijestimo kreatora/servisera
if transitioned_to_closed:
try:
NotificationService.create_notification(
recipient=instance.creator,

View File

@@ -1,22 +1,49 @@
# 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
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()
def _work_order_display_code(work_order):
normalized = str(getattr(work_order, 'display_code', '') or '').strip().upper()
if normalized:
return normalized
return 'NALOG'
@shared_task(bind=True, max_retries=3, default_retry_delay=60)
def send_notification_email(self, notification_id):
"""
@@ -77,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)
@@ -94,6 +566,7 @@ def _build_work_order_invoices_pdf(work_order):
chassis_hours = str(getattr(work_order.vehicle, "chassis_working_hours", "-") or "-")
mileage = str(getattr(work_order.vehicle, "current_mileage", "-") or "-")
generated_date = timezone.localtime(timezone.now()).strftime("%d.%m.%Y")
display_code = _work_order_display_code(work_order)
def draw_header_footer(page_num):
draw_standard_header_footer(
@@ -106,7 +579,7 @@ def _build_work_order_invoices_pdf(work_order):
upgrade_hours=upgrade_hours,
chassis_hours=chassis_hours,
mileage=mileage,
work_order_number=str(work_order.pk),
work_order_number=display_code,
generated_date=generated_date,
report_title="Računi putnog naloga",
page_size=A4,
@@ -121,7 +594,7 @@ def _build_work_order_invoices_pdf(work_order):
y -= 8
pdf.setFont("Vera-Bold", 13)
pdf.drawString(margin, y, f"Računi putnog naloga WO-{work_order.pk}")
pdf.drawString(margin, y, f"Računi putnog naloga {display_code}")
y -= 20
pdf.setFont("Vera", 10)
pdf.drawString(margin, y, f"Datum naloga: {work_order.date}")
@@ -156,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:
@@ -169,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,
@@ -201,8 +688,353 @@ def build_work_order_invoices_pdf_task(work_order_id):
pdf_bytes = _build_work_order_invoices_pdf(work_order)
pdf_b64 = base64.b64encode(pdf_bytes).decode('ascii')
filename = f"{work_order.pk}.work-order-invoices.pdf"
filename = f"{_work_order_display_code(work_order)}.work-order-invoices.pdf"
return {
"filename": filename,
"pdf_base64": pdf_b64,
}
}
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()
expired = GeneratedWorkOrderPdf.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 = 'PDF cache istekao.'
item.save(update_fields=['is_active', 'status', 'error_message', 'updated_at'])
deleted += 1
return {"deleted": deleted}
@shared_task
def build_work_order_pdf_cached_task(generated_pdf_id):
from .views import _build_work_order_pdf, _build_work_order_service_records_pdf
from .services import NotificationService
generated = (
GeneratedWorkOrderPdf.objects
.select_related('work_order', 'work_order__vehicle', 'requested_by')
.filter(pk=generated_pdf_id, is_active=True)
.first()
)
if generated is None:
return {"error": "Generated PDF zapis nije pronađen."}
work_order = generated.work_order
try:
if generated.pdf_type == 'invoices':
payload = build_work_order_invoices_pdf_task(str(work_order.pk))
if not isinstance(payload, dict) or payload.get('error'):
raise ValueError(payload.get('error') if isinstance(payload, dict) else 'Neuspješno generiranje PDF računa.')
pdf_b64 = payload.get('pdf_base64')
if not pdf_b64:
raise ValueError('PDF sadržaj nije dostupan.')
pdf_bytes = base64.b64decode(pdf_b64)
elif generated.pdf_type == 'service_records':
pdf_bytes = _build_work_order_service_records_pdf(work_order)
else:
pdf_bytes = _build_work_order_pdf(work_order)
filename = generated.filename or f"{_work_order_display_code(work_order)}.{generated.pdf_type}.pdf"
generated.file.save(filename, ContentFile(pdf_bytes), save=False)
generated.status = 'ready'
generated.error_message = ''
generated.save(update_fields=['file', 'status', 'error_message', 'updated_at'])
if generated.requested_by:
NotificationService.create_notification(
recipient=generated.requested_by,
title="PDF spreman",
message=f"PDF dokument je uspješno generiran za putni nalog {_work_order_display_code(work_order)}.",
level="success",
send_email=False,
metadata={
"entity_type": "work_order_pdf",
"work_order_id": str(work_order.pk),
"vehicle_id": str(work_order.vehicle_id),
"section": "work-orders",
"pdf_type": generated.pdf_type,
"stage": "completed",
"generated_pdf_id": str(generated.pk),
"download_url": f"fleet/work-orders/{work_order.pk}/generated-pdfs/{generated.pk}/download/",
"filename": generated.filename or filename,
"expires_at": generated.expires_at.isoformat() if generated.expires_at else None,
},
)
return {"status": "ready", "generated_pdf_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:
NotificationService.create_notification(
recipient=generated.requested_by,
title="Greška kod PDF-a",
message=f"Generiranje PDF dokumenta nije uspjelo za putni nalog {_work_order_display_code(work_order)}.",
level="warning",
send_email=False,
metadata={
"entity_type": "work_order_pdf",
"work_order_id": str(work_order.pk),
"vehicle_id": str(work_order.vehicle_id),
"section": "work-orders",
"pdf_type": generated.pdf_type,
"stage": "failed",
"generated_pdf_id": str(generated.pk),
},
)
logger.exception("Greška kod build_work_order_pdf_cached_task: %s", exc)
return {"status": "failed", "error": str(exc)}

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

@@ -3,6 +3,9 @@ from django.test import TestCase
from django.contrib.auth import get_user_model
from modules.fleet.services import VehicleService
from modules.fleet.models import Vehicle, WorkOrder, VehicleServiceRecord
from modules.fleet.serializers import WorkOrderSerializer
from modules.task_management.models import Task
from rest_framework.test import APIRequestFactory
class FleetServiceTests(TestCase):
@@ -49,4 +52,80 @@ class FleetServiceTests(TestCase):
# Nalog sa završnom kilometražom
wo.end_mileage = 1100
assert wo.distance == 100
assert wo.distance == 100
def test_closing_work_order_moves_task_to_ready_for_completion(self):
work_order = WorkOrder.objects.create(
vehicle=self.vehicle,
creator=self.user,
purpose="kontrola",
status="open",
start_mileage=5000,
end_mileage=5200,
)
task = Task.objects.create(
title="Servis",
description="",
status="servis",
assigned_to=self.user,
vehicle=self.vehicle,
work_order=work_order,
)
VehicleService.update_work_order(work_order, {"status": "closed"})
task.refresh_from_db()
self.assertEqual(task.status, "spreman_za_zavrsetak")
def test_closing_work_order_moves_active_task_to_ready_for_completion(self):
work_order = WorkOrder.objects.create(
vehicle=self.vehicle,
creator=self.user,
purpose="kontrola",
status="open",
start_mileage=5000,
end_mileage=5200,
)
task = Task.objects.create(
title="Preventiva",
description="",
status="aktivan",
assigned_to=self.user,
vehicle=self.vehicle,
work_order=work_order,
)
VehicleService.update_work_order(work_order, {"status": "closed"})
task.refresh_from_db()
self.assertEqual(task.status, "spreman_za_zavrsetak")
def test_work_order_serializer_autogenerates_display_code(self):
self.user.first_name = "Mario"
self.user.last_name = "Tkalac"
self.user.save(update_fields=["first_name", "last_name"])
request = APIRequestFactory().post('/api/fleet/work-orders/', {})
request.user = self.user
serializer = WorkOrderSerializer(
data={
"vehicle": str(self.vehicle.id),
"purpose": "kontrola",
},
context={"request": request},
)
self.assertTrue(serializer.is_valid(), serializer.errors)
work_order = serializer.save()
self.assertRegex(work_order.display_code, r'^MT[0-9]{6}$')
def test_work_order_serializer_rejects_invalid_display_code(self):
request = APIRequestFactory().post('/api/fleet/work-orders/', {})
request.user = self.user
serializer = WorkOrderSerializer(
data={
"vehicle": str(self.vehicle.id),
"purpose": "kontrola",
"display_code": "BAD-CODE",
},
context={"request": request},
)
self.assertFalse(serializer.is_valid())
self.assertIn("display_code", serializer.errors)

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

@@ -0,0 +1,990 @@
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,
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'):
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',
)
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()
suffix = uuid.uuid4().hex[:8]
self.user = user_model.objects.create_user(
username=f'wo-img-user-{suffix}',
email=f'wo-img-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-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',
)
def test_work_order_images_endpoint_isolated_from_service_record_photos(self):
# Existing service photo must NOT appear in work-order images response
VehicleServicePhoto.objects.create(
service_record=self.service_record,
image=create_test_image('service.jpg'),
description='Service photo',
uploaded_by=self.user,
)
upload_url = reverse('workorder-images', kwargs={'pk': self.work_order.pk})
response = self.client.post(
upload_url,
data={
'image': create_test_image('workorder.jpg'),
'description': 'Work order photo',
},
format='multipart',
)
self.assertEqual(response.status_code, 201, response.data)
self.assertEqual(str(response.data.get('work_order')), str(self.work_order.pk))
list_response = self.client.get(upload_url)
self.assertEqual(list_response.status_code, 200)
images = list_response.data.get('images', [])
self.assertEqual(len(images), 1)
self.assertEqual(str(images[0].get('work_order_id')), str(self.work_order.pk))
def test_service_record_files_endpoint_does_not_return_work_order_images(self):
upload_url = reverse('workorder-images', kwargs={'pk': self.work_order.pk})
self.client.post(
upload_url,
data={
'image': create_test_image('workorder-2.jpg'),
'description': 'WO isolated',
},
format='multipart',
)
service_photos_url = f"{reverse('service-photo-list')}?service_record_id={self.service_record.pk}"
response = self.client.get(service_photos_url)
self.assertEqual(response.status_code, 200)
self.assertEqual(len(response.data), 0)
def test_work_order_images_endpoint_returns_api_file_urls(self):
upload_url = reverse('workorder-images', kwargs={'pk': self.work_order.pk})
create_response = self.client.post(
upload_url,
data={
'image': create_test_image('workorder-url.jpg'),
'description': 'URL test',
},
format='multipart',
)
self.assertEqual(create_response.status_code, 201, create_response.data)
list_response = self.client.get(upload_url)
self.assertEqual(list_response.status_code, 200)
images = list_response.data.get('images', [])
self.assertEqual(len(images), 1)
photo_id = images[0]['id']
self.assertIn('/api/fleet/work-orders/', images[0]['original_url'])
self.assertIn(f'/images/{photo_id}/file', images[0]['original_url'])
file_response = self.client.get(images[0]['original_url'])
self.assertEqual(file_response.status_code, 200)
self.assertTrue(file_response['Content-Type'].startswith('image/'))
def test_work_order_invoice_serializer_exposes_image_api_url(self):
invoice = WorkOrderInvoice.objects.create(
work_order=self.work_order,
naziv_racuna='Test račun',
image=create_test_image('invoice.jpg'),
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)
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

@@ -1,21 +1,69 @@
# backend/modules/task_management/admin.py
# backend/modules/task_management/admin.py
from django.contrib import admin
from .models import Task
from .models import Task, TaskCalendar, TaskTemplate, TaskTemplateEntry, TaskWorkEntry, TaskWorkHoursTable
class TaskWorkEntryInline(admin.TabularInline):
model = TaskWorkEntry
extra = 0
fields = ('sort_order', 'title', 'work_description', 'active_faults', 'explanation_hr', 'template_entry')
readonly_fields = ('template_entry',)
class TaskWorkHoursTableInline(admin.StackedInline):
model = TaskWorkHoursTable
extra = 0
fields = ('data', 'is_active')
class TaskTemplateEntryInline(admin.TabularInline):
model = TaskTemplateEntry
extra = 0
fields = ('sort_order', 'title', 'work_description', 'active_faults', 'explanation_hr', 'is_active')
@admin.register(Task)
class TaskAdmin(admin.ModelAdmin):
"""
Konfiguracija prikaza modela u Admin panelu.
"""
# Koja polja želimo vidjeti u tabličnom prikazu
list_display = ('title', 'status', 'assigned_to', 'work_order', 'created_at')
# Omogućuje filtriranje s desne strane
list_filter = ('status', 'created_at', 'work_order')
# Omogućuje pretraživanje po tekstu
list_display = ('title', 'status', 'scheduled_date', 'assigned_to', 'vehicle', 'work_order', 'created_at')
list_filter = ('status', 'scheduled_date', 'created_at', 'work_order')
search_fields = ('title', 'description', 'work_order__id', 'work_order__vehicle__registration_number')
# Polja koja su samo za čitanje (npr. UUID koji se ne smije mijenjati)
readonly_fields = ('id', 'created_at', 'updated_at')
readonly_fields = ('id', 'created_at', 'updated_at')
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')
list_filter = ('status', 'is_active', 'created_at')
search_fields = ('code', 'title', 'description')
inlines = (TaskTemplateEntryInline,)
@admin.register(TaskWorkEntry)
class TaskWorkEntryAdmin(admin.ModelAdmin):
list_display = ('task', 'sort_order', 'title', 'template_entry', 'created_at')
list_filter = ('created_at',)
search_fields = ('task__title', 'title', 'active_faults', 'explanation_hr')
readonly_fields = ('id', 'created_at', 'updated_at')
@admin.register(TaskWorkHoursTable)
class TaskWorkHoursTableAdmin(admin.ModelAdmin):
list_display = ('task', 'updated_at', 'is_active')
search_fields = ('task__title', 'task__vehicle__registration_number')
readonly_fields = ('id', 'created_at', 'updated_at')

View File

@@ -0,0 +1,7 @@
from rest_framework.exceptions import APIException
class OpenWorkOrderBlockingTaskClose(APIException):
status_code = 409
default_code = 'open_work_order_blocking_task_close'
default_detail = 'Povezani putni nalog je još otvoren.'

View File

@@ -0,0 +1,72 @@
# Generated by Django 5.2.15 on 2026-07-12
import django.db.models.deletion
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('task_management', '0003_task_vehicle'),
]
operations = [
migrations.CreateModel(
name='TaskTemplate',
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')),
('code', models.SlugField(max_length=64, unique=True)),
('title', models.CharField(max_length=255)),
('description', models.TextField(blank=True)),
('status', models.CharField(choices=[('aktivan', 'Aktivan'), ('servis', 'U tijeku'), ('neaktivan', 'Otkazano')], default='aktivan', max_length=20)),
],
options={
'ordering': ['title'],
},
),
migrations.CreateModel(
name='TaskTemplateEntry',
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')),
('sort_order', models.PositiveSmallIntegerField(default=1)),
('title', models.CharField(max_length=255)),
('work_description', models.TextField(blank=True)),
('active_faults', models.TextField(blank=True, help_text='Aktivne greške.')),
('explanation_hr', models.TextField(blank=True, help_text='Obrazloženje na hrvatskom jeziku.')),
('task_template', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='entries', to='task_management.tasktemplate')),
],
options={
'ordering': ['sort_order', 'created_at'],
},
),
migrations.CreateModel(
name='TaskWorkEntry',
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')),
('sort_order', models.PositiveSmallIntegerField(default=1)),
('title', models.CharField(max_length=255)),
('work_description', models.TextField(blank=True)),
('active_faults', models.TextField(blank=True)),
('explanation_hr', models.TextField(blank=True)),
('task', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='work_entries', to='task_management.task')),
('template_entry', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='task_work_entries', to='task_management.tasktemplateentry')),
],
options={
'ordering': ['sort_order', 'created_at'],
},
),
migrations.AddConstraint(
model_name='tasktemplateentry',
constraint=models.UniqueConstraint(fields=('task_template', 'sort_order'), name='task_template_entry_sort_order_unique'),
),
]

View File

@@ -0,0 +1,100 @@
# Generated by Django 5.2.15 on 2026-07-12
from django.db import migrations
def seed_task_templates(apps, schema_editor):
TaskTemplate = apps.get_model('task_management', 'TaskTemplate')
TaskTemplateEntry = apps.get_model('task_management', 'TaskTemplateEntry')
templates = [
{
'code': 'zamjena-ulja-500h',
'title': 'Zamjena ulja nakon 500h',
'description': 'Redovni servis uljnog sustava nakon 500 radnih sati.',
'status': 'aktivan',
'entries': [
('Provjera razine i stanja ulja', 'Izvršena kontrola razine i vizualna kontrola kontaminacije ulja.', 'Nema aktivnih grešaka.', 'Sustav uredan, bez upozorenja.'),
('Ispuštanje starog ulja', 'Sigurno ispušteno staro ulje prema servisnoj proceduri.', 'Nema aktivnih grešaka.', 'Ulje zamijenjeno prema uputama proizvođača.'),
('Zamjena filtera ulja', 'Demontiran stari i ugrađen novi filter ulja.', 'Nema aktivnih grešaka.', 'Filter promijenjen i sustav pripremljen za punjenje.'),
('Punjenje i test sustava', 'Sustav napunjen novim uljem i odrađen probni rad.', 'Nema aktivnih grešaka.', 'Nakon testa nema curenja ni grešaka.'),
],
},
{
'code': 'zamjena-ulja-1000h',
'title': 'Zamjena ulja nakon 1000h',
'description': 'Veliki servis uljnog sustava nakon 1000 radnih sati.',
'status': 'aktivan',
'entries': [
('Dijagnostika sustava prije servisa', 'Očitani parametri rada i zabilježeno početno stanje.', 'Provjeriti povremeni pad tlaka.', 'Prijevod poruke greške i potvrda simptoma na hrvatskom.'),
('Zamjena ulja i glavnih filtera', 'Zamijenjeno ulje i svi pripadajući filter elementi.', 'Nema kritičnih aktivnih grešaka.', 'Servisni interval resetiran.'),
('Kontrola brtvi i spojeva', 'Pregledani spojevi i brtve na mogućnost curenja.', 'Nema aktivnih grešaka.', 'Spojevi su suhi i ispravni.'),
('Test pod opterećenjem', 'Izvršen funkcionalni test pod radnim opterećenjem.', 'Nema aktivnih grešaka.', 'Sustav stabilan pri opterećenju.'),
],
},
{
'code': 'promjena-senzora',
'title': 'Promjena senzora',
'description': 'Zamjena neispravnog senzora i verifikacija rada.',
'status': 'aktivan',
'entries': [
('Identifikacija neispravnog senzora', 'Dijagnostikom potvrđena neispravnost senzora.', 'Aktivna greška senzora.', 'Greška prevedena s engleskog i objašnjena korisniku.'),
('Demontaža i montaža novog senzora', 'Neispravan senzor uklonjen i ugrađen novi originalni dio.', 'Nema novih aktivnih grešaka.', 'Montaža izvedena prema specifikaciji.'),
('Kalibracija senzora', 'Izvršena kalibracija i sinkronizacija s upravljačkom jedinicom.', 'Nema aktivnih grešaka.', 'Kalibracija uspješna.'),
('Završno testiranje', 'Provjeren odziv sustava tijekom rada.', 'Nema aktivnih grešaka.', 'Senzor radi unutar tolerancije.'),
],
},
{
'code': 'aktivna-osovina-ukoso',
'title': 'Aktivna osovina ide ukoso',
'description': 'Dijagnostika i otklanjanje kvara aktivne osovine.',
'status': 'aktivan',
'entries': [
('Dijagnostika geometrije osovine', 'Provjereni kutovi i odstupanja geometrije osovine.', 'Aktivna greška upravljanja osovinom.', 'Opis greške preveden i pojašnjen na hrvatskom.'),
('Pregled hidrauličkih komponenti', 'Provjeren tlak i stanje hidrauličkih vodova.', 'Mogući pad tlaka na upravljačkom krugu.', 'Predložena zamjena kritičnog ventila.'),
('Korekcija i podešavanje', 'Izvršeno mehaničko/hidrauličko podešavanje osovine.', 'Nema aktivnih grešaka nakon podešavanja.', 'Osovina vraćena u radne tolerancije.'),
('Probna vožnja i završna potvrda', 'Izvršena probna vožnja i kontrola stabilnosti pravca.', 'Nema aktivnih grešaka.', 'Kvar otklonjen, vozilo spremno za rad.'),
],
},
]
for template_data in templates:
entries = template_data.pop('entries')
template, _ = TaskTemplate.objects.update_or_create(
code=template_data['code'],
defaults=template_data,
)
TaskTemplateEntry.objects.filter(task_template=template).delete()
for idx, entry in enumerate(entries, start=1):
title, work_description, active_faults, explanation_hr = entry
TaskTemplateEntry.objects.create(
task_template=template,
sort_order=idx,
title=title,
work_description=work_description,
active_faults=active_faults,
explanation_hr=explanation_hr,
)
def reverse_seed_task_templates(apps, schema_editor):
TaskTemplate = apps.get_model('task_management', 'TaskTemplate')
TaskTemplate.objects.filter(
code__in=[
'zamjena-ulja-500h',
'zamjena-ulja-1000h',
'promjena-senzora',
'aktivna-osovina-ukoso',
]
).delete()
class Migration(migrations.Migration):
dependencies = [
('task_management', '0004_tasktemplate_taskworkentry'),
]
operations = [
migrations.RunPython(seed_task_templates, reverse_seed_task_templates),
]

View File

@@ -0,0 +1,39 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('task_management', '0005_seed_task_templates'),
]
operations = [
migrations.AlterField(
model_name='task',
name='status',
field=models.CharField(
choices=[
('aktivan', 'Aktivan'),
('servis', 'U tijeku'),
('zavrsen', 'Završen'),
('neaktivan', 'Otkazano'),
],
default='aktivan',
max_length=20,
),
),
migrations.AlterField(
model_name='tasktemplate',
name='status',
field=models.CharField(
choices=[
('aktivan', 'Aktivan'),
('servis', 'U tijeku'),
('zavrsen', 'Završen'),
('neaktivan', 'Otkazano'),
],
default='aktivan',
max_length=20,
),
),
]

View File

@@ -0,0 +1,29 @@
import django.db.models.deletion
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('task_management', '0006_add_finished_task_status'),
]
operations = [
migrations.CreateModel(
name='TaskWorkHoursTable',
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')),
('data', models.JSONField(blank=True, default=None, null=True, verbose_name='Podaci tablice radnih sati')),
('task', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='work_hours_table', to='task_management.task', verbose_name='Radni zadatak')),
],
options={
'verbose_name': 'Tablica radnih sati',
'verbose_name_plural': 'Tablice radnih sati',
'ordering': ['-updated_at'],
},
),
]

View File

@@ -0,0 +1,26 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('task_management', '0007_taskworkhourstable'),
]
operations = [
migrations.AlterField(
model_name='task',
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,22 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('task_management', '0008_task_add_ready_for_completion_status'),
]
operations = [
migrations.AddField(
model_name='task',
name='scheduled_date',
field=models.DateField(
null=True,
blank=True,
db_index=True,
verbose_name='Planirani datum izvršenja',
help_text='Datum kada je planirano izvršenje radnog zadatka.',
),
),
]

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

@@ -1,10 +1,11 @@
# backend/modules/task_management/models.py
# backend/modules/task_management/models.py
from django.db import models
from django.conf import settings
from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _
from core.base_models import BaseModel # Uvozimo naš Base
from core.base_models import BaseModel
class Task(BaseModel):
"""
@@ -13,6 +14,8 @@ class Task(BaseModel):
STATUS_CHOICES = [
('aktivan', 'Aktivan'),
('servis', 'U tijeku'),
('spreman_za_zavrsetak', 'Spreman za završetak'),
('zavrsen', 'Završen'),
('neaktivan', 'Otkazano'),
]
title = models.CharField(max_length=255)
@@ -23,9 +26,9 @@ class Task(BaseModel):
default='aktivan'
)
assigned_to = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
related_name='tasks'
)
vehicle = models.ForeignKey(
@@ -44,6 +47,19 @@ class Task(BaseModel):
related_name='work_order_tasks',
verbose_name=_("Putni nalog"),
)
scheduled_date = models.DateField(
null=True,
blank=True,
db_index=True,
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']
@@ -53,7 +69,109 @@ class Task(BaseModel):
def clean(self):
super().clean()
if self.status == 'neaktivan' and not self.work_order_id:
if self.status in {'neaktivan', 'zavrsen'} and not self.work_order_id:
raise ValidationError({
'work_order': "Putni nalog je obavezan prije zatvaranja zadatka."
})
})
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."""
task = models.OneToOneField(
Task,
on_delete=models.CASCADE,
related_name='work_hours_table',
verbose_name=_("Radni zadatak"),
)
data = models.JSONField(
null=True,
blank=True,
default=None,
verbose_name=_("Podaci tablice radnih sati"),
)
class Meta:
ordering = ['-updated_at']
verbose_name = _("Tablica radnih sati")
verbose_name_plural = _("Tablice radnih sati")
def __str__(self):
return f"Work hours table for task {self.task_id}"
class TaskTemplate(BaseModel):
"""Predložak za brzo kreiranje servisnog radnog zadatka."""
code = models.SlugField(max_length=64, unique=True)
title = models.CharField(max_length=255)
description = models.TextField(blank=True)
status = models.CharField(max_length=20, choices=Task.STATUS_CHOICES, default='aktivan')
class Meta:
ordering = ['title']
def __str__(self):
return f"{self.title} ({self.code})"
class TaskTemplateEntry(BaseModel):
"""Predlošci radnih unosa koji se generiraju prilikom kreiranja Taska."""
task_template = models.ForeignKey(
TaskTemplate,
on_delete=models.CASCADE,
related_name='entries',
)
sort_order = models.PositiveSmallIntegerField(default=1)
title = models.CharField(max_length=255)
work_description = models.TextField(blank=True)
active_faults = models.TextField(blank=True, help_text=_("Aktivne greške."))
explanation_hr = models.TextField(blank=True, help_text=_("Obrazloženje na hrvatskom jeziku."))
class Meta:
ordering = ['sort_order', 'created_at']
constraints = [
models.UniqueConstraint(
fields=['task_template', 'sort_order'],
name='task_template_entry_sort_order_unique',
)
]
def __str__(self):
return f"{self.task_template.code} #{self.sort_order} - {self.title}"
class TaskWorkEntry(BaseModel):
"""Radni unos na konkretnom Tasku (generiran iz templatea ili ručno)."""
task = models.ForeignKey(
Task,
on_delete=models.CASCADE,
related_name='work_entries',
)
template_entry = models.ForeignKey(
TaskTemplateEntry,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='task_work_entries',
)
sort_order = models.PositiveSmallIntegerField(default=1)
title = models.CharField(max_length=255)
work_description = models.TextField(blank=True)
active_faults = models.TextField(blank=True)
explanation_hr = models.TextField(blank=True)
class Meta:
ordering = ['sort_order', 'created_at']
def __str__(self):
return f"{self.task_id} #{self.sort_order} - {self.title}"

View File

@@ -1,50 +1,162 @@
# backend/modules/task_management/serializers.py
# backend/modules/task_management/serializers.py
from rest_framework import serializers
from .models import Task
from .models import Task, TaskTemplate, TaskTemplateEntry, TaskWorkEntry, TaskWorkHoursTable
WORK_HOURS_ROW_FIELDS = [
'day',
'date',
'work_time_from',
'work_time_to',
'travel_time_from',
'travel_time_to',
'break_hours',
'work_hours',
'travel_hours',
'departure_place',
'arrival_place',
'vehicle_km',
]
class TaskWorkHoursTableSerializer(serializers.ModelSerializer):
class Meta:
model = TaskWorkHoursTable
fields = ['id', 'task', 'data', 'created_at', 'updated_at']
read_only_fields = ['id', 'created_at', 'updated_at']
def _normalize_row(self, row):
normalized = {}
for field in WORK_HOURS_ROW_FIELDS:
value = row.get(field, '')
normalized[field] = '' if value is None else str(value).strip()
return normalized
def validate_data(self, value):
if value is None:
return None
if not isinstance(value, dict):
raise serializers.ValidationError("Podaci tablice moraju biti JSON objekt ili null.")
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[:30]:
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}
class TaskWorkEntrySerializer(serializers.ModelSerializer):
class Meta:
model = TaskWorkEntry
fields = [
'id', 'task', 'template_entry', 'sort_order', 'title',
'work_description', 'active_faults', 'explanation_hr',
'created_at',
]
read_only_fields = ['id', 'created_at']
class TaskTemplateEntrySerializer(serializers.ModelSerializer):
class Meta:
model = TaskTemplateEntry
fields = [
'id', 'sort_order', 'title', 'work_description',
'active_faults', 'explanation_hr',
]
read_only_fields = ['id']
class TaskTemplateSerializer(serializers.ModelSerializer):
entries = TaskTemplateEntrySerializer(many=True, read_only=True)
class Meta:
model = TaskTemplate
fields = ['id', 'code', 'title', 'description', 'status', 'entries']
read_only_fields = ['id']
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)
class Meta:
model = Task
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',
]
read_only_fields = ['id', 'created_at']
extra_kwargs = {
'title': {'required': False, 'allow_blank': True},
'description': {'required': False, 'allow_blank': True},
'status': {'required': False},
'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):
"""Primjer custom validacije: naziv ne smije biti prekratak."""
if not value:
return value
if len(value) < 3:
raise serializers.ValidationError("Naslov zadatka mora imati barem 3 znaka.")
return value
def validate(self, attrs):
current = getattr(self, 'instance', None)
template_id = attrs.get('template_id')
status_value = attrs.get('status', getattr(current, 'status', 'aktivan'))
work_order = attrs.get('work_order', getattr(current, 'work_order', None))
vehicle = attrs.get('vehicle', getattr(current, 'vehicle', None))
title = (attrs.get('title', getattr(current, 'title', '')) or '').strip()
if template_id:
template = TaskTemplate.objects.filter(id=template_id, is_active=True).first()
if not template:
raise serializers.ValidationError({'template_id': 'Odabrani template ne postoji ili nije aktivan.'})
if current is None and not vehicle:
raise serializers.ValidationError({
'vehicle': "Dizalica je obavezna pri kreiranju taska."
})
if current is None and not template_id and not title:
raise serializers.ValidationError({
'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 == 'neaktivan' and not work_order:
if status_value in {'neaktivan', 'zavrsen'} and not work_order:
raise serializers.ValidationError({
'work_order': "Putni nalog je obavezan prije zatvaranja zadatka."
})
@@ -53,7 +165,10 @@ class TaskSerializer(serializers.ModelSerializer):
def get_work_order_label(self, obj):
if not obj.work_order_id:
return None
return f"PN-{str(obj.work_order_id).split('-')[0].upper()}"
display_code = str(getattr(obj.work_order, 'display_code', '') or '').strip().upper()
if display_code:
return display_code
return None
def get_assigned_to_name(self, obj):
if not obj.assigned_to_id:
@@ -72,4 +187,35 @@ class TaskSerializer(serializers.ModelSerializer):
def get_vehicle_registration(self, obj):
if not obj.vehicle_id:
return None
return obj.vehicle.registration_number
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,11 +1,32 @@
# backend/modules/task_management/services.py
# backend/modules/task_management/services.py
from .models import Task
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
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:
@staticmethod
@@ -13,14 +34,29 @@ class TaskService:
"""
Univerzalna metoda: prihvaća podatke i opcionalnog korisnika.
"""
# Poslovna pravila
if data.get('status') == 'DONE' and (user and not user.is_staff):
payload = dict(data)
template_id = payload.pop('template_id', None)
if payload.get('status') == 'DONE' and (user and not user.is_staff):
raise ValidationError({"status": "Samo admini mogu odmah označiti zadatak kao gotov."})
payload = dict(data)
template = None
if template_id:
template = TaskTemplate.objects.filter(id=template_id, is_active=True).prefetch_related('entries').first()
if not template:
raise ValidationError({"template_id": "Odabrani template nije pronađen."})
payload['title'] = template.title
payload['description'] = template.description
payload['status'] = 'aktivan'
payload.setdefault('work_order', None)
title = (payload.get('title') or '').strip()
if title and len(title) < 3:
raise ValidationError({"title": "Naslov zadatka mora imati barem 3 znaka."})
if not title:
raise ValidationError({"title": "Naslov zadatka je obavezan."})
if user and 'assigned_to' not in payload:
payload['assigned_to'] = user
@@ -30,18 +66,138 @@ class TaskService:
except DjangoValidationError as exc:
logger.error(f"Neuspješna validacija zadatka: {exc}")
raise ValidationError(exc.message_dict if hasattr(exc, 'message_dict') else exc.messages)
entry.save()
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)
logger.info(f"Zadatak uspješno kreiran: {entry.id}")
return entry
@staticmethod
def update_task_entry(instance, data, user):
# Validacija za update (npr. samo vlasnik ili admin smije ažurirati)
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])
for idx, source in enumerate(template_entries, start=1):
TaskWorkEntry.objects.create(
task=task,
template_entry=source,
sort_order=idx,
title=source.title,
work_description=source.work_description,
active_faults=source.active_faults,
explanation_hr=source.explanation_hr,
)
@staticmethod
def _create_service_records_from_template(*, task: Task, template: TaskTemplate, user):
if not user:
raise ValidationError({
"template_id": "Kreiranje taska iz templatea zahtijeva prijavljenog korisnika za servisne zapise."
})
if not task.vehicle_id:
raise ValidationError({
"vehicle": "Task iz templatea mora imati dodijeljenu dizalicu za generiranje servisnih zapisa."
})
template_entries = list(template.entries.filter(is_active=True).order_by('sort_order', 'created_at')[:10])
for source in template_entries:
description_parts = []
if source.work_description:
description_parts.append(source.work_description.strip())
if source.explanation_hr:
description_parts.append(f"Obrazloženje (HR): {source.explanation_hr.strip()}")
if source.active_faults:
description_parts.append(f"Aktivne greške: {source.active_faults.strip()}")
description = "\n\n".join([part for part in description_parts if part]).strip() or source.title
VehicleServiceRecord.objects.create(
vehicle=task.vehicle,
task=task,
performed_by=user,
service_title=source.title,
description=description,
parts=(source.active_faults or '').strip(),
cost=0,
)
@staticmethod
def update_task_entry(instance, data, user, auto_close_work_order=False):
if not user.is_staff and instance.assigned_to != user:
raise ValidationError("Nemate dozvolu za izmjenu ovog zadatka.")
# Spremanje
target_status = data.get('status', instance.status)
target_work_order = data.get('work_order', instance.work_order)
closing_statuses = {'neaktivan', 'zavrsen'}
if target_status in closing_statuses:
if not target_work_order:
raise ValidationError({"work_order": "Putni nalog je obavezan prije zatvaranja zadatka."})
if target_work_order.status == 'open':
if not auto_close_work_order:
work_order_label = f"WO-{str(target_work_order.id).split('-')[0].upper()}"
raise OpenWorkOrderBlockingTaskClose(detail={
"code": "OPEN_WORK_ORDER_BLOCKING_TASK_CLOSE",
"message": f"Putni nalog {work_order_label} je još otvoren. Želite li ga automatski zatvoriti?",
"work_order_id": str(target_work_order.id),
"work_order_label": work_order_label,
})
if target_work_order.end_mileage is None:
raise ValidationError({
"work_order": "Automatsko zatvaranje putnog naloga nije moguće bez završne kilometraže."
})
from modules.fleet.services import VehicleService
target_work_order = VehicleService.update_work_order(target_work_order, {"status": "closed"})
data['work_order'] = target_work_order
for attr, value in data.items():
if attr == 'template_id':
continue
setattr(instance, attr, value)
if 'title' in data and len(str(data.get('title') or '').strip()) < 3:
raise ValidationError({"title": "Naslov zadatka mora imati barem 3 znaka."})
@@ -50,4 +206,4 @@ class TaskService:
except DjangoValidationError as exc:
raise ValidationError(exc.message_dict if hasattr(exc, 'message_dict') else exc.messages)
instance.save()
return instance
return instance

View File

@@ -1,10 +1,64 @@
# backend/modules/task_management/tasks.py
import logging
from celery import shared_task
from .models import Task
from django.contrib.auth import get_user_model
from modules.fleet.models import Vehicle, WorkOrder
logger = logging.getLogger(__name__)
@shared_task
def notify_upcoming_tasks():
"""
Dnevna Celery periodik task koji šalje Pusher + email notifikacije
za radne zadatke čiji je planirani datum izvršenja sutra.
"""
from datetime import date, timedelta
from modules.fleet.services import NotificationService
tomorrow = date.today() + timedelta(days=1)
upcoming = Task.objects.filter(
is_active=True,
scheduled_date=tomorrow,
status__in=('aktivan', 'servis'),
).select_related('assigned_to', 'vehicle')
notified = 0
for task in upcoming:
recipient = task.assigned_to
if not recipient:
continue
vehicle_label = (
task.vehicle.registration_number if task.vehicle else 'nepoznato'
)
try:
NotificationService.create_notification(
recipient=recipient,
title=f"Sutra planirani zadatak: {task.title}",
message=(
f"Radni zadatak '{task.title}' na dizalici {vehicle_label} "
f"je planiran za sutra ({tomorrow.strftime('%d.%m.%Y')})."
),
level="info",
send_email=True,
metadata={
"entity_type": "task",
"task_id": str(task.id),
"vehicle_id": str(task.vehicle_id) if task.vehicle_id else None,
"section": "dashboard",
"scheduled_date": tomorrow.isoformat(),
},
)
notified += 1
except Exception:
logger.exception("Greška pri slanju notifikacije za task %s", task.id)
logger.info("notify_upcoming_tasks: obaviješteno %d servisera za datum %s", notified, tomorrow)
return {"notified": notified, "date": tomorrow.isoformat()}
@shared_task
def create_task_from_invoice(invoice_data):
"""
@@ -25,7 +79,7 @@ def create_task_from_invoice(invoice_data):
vehicle = work_order.vehicle
if vehicle is None and vehicle_id:
vehicle = Vehicle.objects.filter(pk=vehicle_id).first()
if amount > 1000:
Task.objects.create(
title=f"Provjera računa: {invoice_data['invoice_number']}",
@@ -34,4 +88,4 @@ def create_task_from_invoice(invoice_data):
status='aktivan',
vehicle=vehicle,
work_order=work_order,
)
)

View File

@@ -1,5 +1,6 @@
from django.test import TestCase
from django.contrib.auth import get_user_model
import datetime
from modules.task_management.models import Task
@@ -9,4 +10,25 @@ class TaskModelTests(TestCase):
user = get_user_model().objects.create_user(username="u1", email="u1@example.test", password="pass")
task = Task.objects.create(title="My Task", description="Desc", assigned_to=user)
assert str(task).startswith("My Task")
assert "aktivan" in str(task) or "(" in str(task) # basic sanity
assert "aktivan" in str(task) or "(" in str(task)
def test_scheduled_date_defaults_to_null(self):
user = get_user_model().objects.create_user(username="u2", email="u2@example.test", password="pass")
task = Task.objects.create(title="Task bez datuma", assigned_to=user)
assert task.scheduled_date is None
def test_scheduled_date_is_saved_and_retrieved(self):
user = get_user_model().objects.create_user(username="u3", email="u3@example.test", password="pass")
target = datetime.date(2026, 8, 15)
task = Task.objects.create(title="Planirani task", assigned_to=user, scheduled_date=target)
task.refresh_from_db()
assert task.scheduled_date == target
def test_scheduled_date_can_be_updated_to_null(self):
user = get_user_model().objects.create_user(username="u4", email="u4@example.test", password="pass")
target = datetime.date(2026, 8, 15)
task = Task.objects.create(title="Task s datumom", assigned_to=user, scheduled_date=target)
task.scheduled_date = None
task.save()
task.refresh_from_db()
assert task.scheduled_date is None

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)})
@@ -31,4 +41,132 @@ class TaskSerializerTests(TestCase):
task = Task.objects.create(title="Task 2", description="", status="servis", assigned_to=self.user, vehicle=self.vehicle)
serializer = TaskSerializer(instance=task, data={"status": "neaktivan", "work_order": str(work_order.id)}, partial=True)
assert serializer.is_valid(), serializer.errors
assert serializer.is_valid(), serializer.errors
def test_cannot_mark_finished_without_work_order(self):
serializer = TaskSerializer(data={"title": "Task 3", "description": "", "status": "zavrsen", "vehicle": str(self.vehicle.id)})
with self.assertRaises(ValidationError):
serializer.is_valid(raise_exception=True)
def test_can_mark_finished_with_work_order(self):
work_order = WorkOrder.objects.create(vehicle=self.vehicle, creator=self.user, purpose='kontrola')
task = Task.objects.create(title="Task 4", description="", 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
def test_scheduled_date_is_optional_on_create(self):
serializer = TaskSerializer(data={"title": "Task planned", "status": "aktivan", "vehicle": str(self.vehicle.id)})
assert serializer.is_valid(), serializer.errors
assert 'scheduled_date' not in serializer.errors
def test_scheduled_date_is_included_in_serialized_output(self):
import datetime
task = Task.objects.create(
title="Task with date",
assigned_to=self.user,
vehicle=self.vehicle,
scheduled_date=datetime.date(2026, 9, 1),
)
serializer = TaskSerializer(instance=task)
assert 'scheduled_date' in serializer.data
assert str(serializer.data['scheduled_date']) == '2026-09-01'
def test_scheduled_date_can_be_null_in_serialized_output(self):
task = Task.objects.create(title="Task no date", assigned_to=self.user, vehicle=self.vehicle)
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",
"vehicle": str(self.vehicle.id), "scheduled_date": "2026-12-25"}
)
assert serializer.is_valid(), serializer.errors
def test_scheduled_date_rejects_invalid_date_string(self):
serializer = TaskSerializer(
data={"title": "Task bad date", "status": "aktivan",
"vehicle": str(self.vehicle.id), "scheduled_date": "not-a-date"}
)
assert not serializer.is_valid()
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,9 +1,12 @@
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.models import Task
from modules.fleet.models import Vehicle, WorkOrder
from modules.task_management.exceptions import OpenWorkOrderBlockingTaskClose
from modules.task_management.models import Task, TaskTemplate, TaskTemplateEntry, TaskWorkEntry
from modules.fleet.models import Vehicle, WorkOrder, VehicleServiceRecord
class TaskServiceTests(TestCase):
@@ -42,7 +45,14 @@ class TaskServiceTests(TestCase):
def test_update_task_entry_allows_closing_with_work_order(self):
vehicle = Vehicle.objects.create(registration_number="ZG-TASK-01", asset_type='crane')
work_order = WorkOrder.objects.create(vehicle=vehicle, creator=self.user, purpose='defektaza')
work_order = WorkOrder.objects.create(
vehicle=vehicle,
creator=self.user,
purpose='defektaza',
status='closed',
start_mileage=100,
end_mileage=120,
)
task = Task.objects.create(title="Za zatvaranje", description="", assigned_to=self.user, status='servis')
updated = TaskService.update_task_entry(
instance=task,
@@ -50,4 +60,210 @@ class TaskServiceTests(TestCase):
user=self.user,
)
assert updated.status == "neaktivan"
assert updated.work_order == work_order
assert updated.work_order == work_order
def test_update_task_entry_rejects_finished_without_work_order(self):
task = Task.objects.create(title="Za završetak", description="", assigned_to=self.user, status='servis')
with self.assertRaises(ValidationError):
TaskService.update_task_entry(instance=task, data={"status": "zavrsen"}, user=self.user)
def test_update_task_entry_allows_finished_with_work_order(self):
vehicle = Vehicle.objects.create(registration_number="ZG-TASK-01A", asset_type='crane')
work_order = WorkOrder.objects.create(
vehicle=vehicle,
creator=self.user,
purpose='kontrola',
status='closed',
start_mileage=100,
end_mileage=150,
)
task = Task.objects.create(title="Za završetak", description="", assigned_to=self.user, status='servis', vehicle=vehicle)
updated = TaskService.update_task_entry(
instance=task,
data={"status": "zavrsen", "work_order": work_order},
user=self.user,
)
assert updated.status == "zavrsen"
assert updated.work_order == work_order
def test_update_task_entry_blocks_closing_when_work_order_open(self):
vehicle = Vehicle.objects.create(registration_number="ZG-TASK-01B", asset_type='crane')
work_order = WorkOrder.objects.create(vehicle=vehicle, creator=self.user, purpose='kontrola', status='open')
task = Task.objects.create(
title="Integritet",
description="",
assigned_to=self.user,
status='servis',
vehicle=vehicle,
work_order=work_order,
)
with self.assertRaises(OpenWorkOrderBlockingTaskClose):
TaskService.update_task_entry(
instance=task,
data={"status": "zavrsen"},
user=self.user,
)
def test_update_task_entry_can_auto_close_open_work_order(self):
vehicle = Vehicle.objects.create(registration_number="ZG-TASK-01C", asset_type='crane')
work_order = WorkOrder.objects.create(
vehicle=vehicle,
creator=self.user,
purpose='kontrola',
status='open',
start_mileage=100,
end_mileage=150,
)
task = Task.objects.create(
title="Auto close",
description="",
assigned_to=self.user,
status='servis',
vehicle=vehicle,
work_order=work_order,
)
updated = TaskService.update_task_entry(
instance=task,
data={"status": "zavrsen"},
user=self.user,
auto_close_work_order=True,
)
work_order.refresh_from_db()
self.assertEqual(updated.status, "zavrsen")
self.assertEqual(work_order.status, "closed")
def test_create_task_entry_from_template_creates_service_records(self):
vehicle = Vehicle.objects.create(registration_number="ZG-TASK-02", asset_type='crane')
template = TaskTemplate.objects.create(
code='template-servis-zapisi',
title='Template servis',
description='Opis templatea',
status='aktivan',
)
TaskTemplateEntry.objects.create(
task_template=template,
sort_order=1,
title='Pregled sustava',
work_description='Provjera hidraulike',
active_faults='Greška E101',
explanation_hr='Prevedeno i objašnjeno.',
)
TaskTemplateEntry.objects.create(
task_template=template,
sort_order=2,
title='Zamjena senzora',
work_description='Zamijenjen senzor kuta',
active_faults='',
explanation_hr='Senzor kalibriran.',
)
created = TaskService.create_task_entry(
data={'template_id': template.id, 'vehicle': vehicle},
user=self.user,
)
self.assertEqual(created.vehicle_id, vehicle.id)
self.assertEqual(TaskWorkEntry.objects.filter(task=created).count(), 2)
records = VehicleServiceRecord.objects.filter(task=created).order_by('created_at')
self.assertEqual(records.count(), 2)
self.assertEqual(records.first().service_title, 'Pregled sustava')
self.assertIn('Obrazloženje (HR): Prevedeno i objašnjeno.', records.first().description)
self.assertIn('Aktivne greške: Greška E101', records.first().description)
self.assertEqual(records.first().performed_by_id, self.user.id)
def test_create_task_entry_from_template_limits_service_records_to_ten(self):
vehicle = Vehicle.objects.create(registration_number="ZG-TASK-03", asset_type='crane')
template = TaskTemplate.objects.create(
code='template-limit-10',
title='Template limit',
description='Opis',
status='aktivan',
)
for index in range(1, 13):
TaskTemplateEntry.objects.create(
task_template=template,
sort_order=index,
title=f'Korak {index}',
work_description=f'Opis {index}',
)
created = TaskService.create_task_entry(
data={'template_id': template.id, 'vehicle': vehicle},
user=self.user,
)
self.assertEqual(TaskWorkEntry.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

@@ -0,0 +1,133 @@
import datetime
from unittest.mock import patch
from django.test import TestCase
from django.contrib.auth import get_user_model
from modules.task_management.tasks import notify_upcoming_tasks
from modules.task_management.models import Task
from modules.fleet.models import Vehicle, WorkOrder
class NotifyUpcomingTasksTests(TestCase):
def setUp(self):
User = get_user_model()
self.user = User.objects.create_user(
username="serviser-notif",
email="serviser@example.test",
password="pass",
)
self.vehicle = Vehicle.objects.create(
registration_number="ZG-NOTIF-01",
asset_type='crane',
)
@patch('modules.fleet.services.NotificationService.create_notification')
def test_notifies_for_task_scheduled_tomorrow(self, mock_notify):
tomorrow = datetime.date.today() + datetime.timedelta(days=1)
Task.objects.create(
title="Sutrašnji zadatak",
assigned_to=self.user,
vehicle=self.vehicle,
status='aktivan',
scheduled_date=tomorrow,
)
result = notify_upcoming_tasks()
self.assertEqual(result['notified'], 1)
mock_notify.assert_called_once()
call_kwargs = mock_notify.call_args.kwargs
self.assertEqual(call_kwargs['recipient'], self.user)
self.assertIn('Sutrašnji zadatak', call_kwargs['title'])
self.assertEqual(call_kwargs['level'], 'info')
self.assertTrue(call_kwargs.get('send_email'))
@patch('modules.fleet.services.NotificationService.create_notification')
def test_notifies_multiple_upcoming_tasks(self, mock_notify):
tomorrow = datetime.date.today() + datetime.timedelta(days=1)
Task.objects.create(title="Task A", assigned_to=self.user, vehicle=self.vehicle,
status='aktivan', scheduled_date=tomorrow)
Task.objects.create(title="Task B", assigned_to=self.user, vehicle=self.vehicle,
status='servis', scheduled_date=tomorrow)
result = notify_upcoming_tasks()
self.assertEqual(result['notified'], 2)
self.assertEqual(mock_notify.call_count, 2)
@patch('modules.fleet.services.NotificationService.create_notification')
def test_skips_task_without_scheduled_date(self, mock_notify):
Task.objects.create(
title="Bez datuma", assigned_to=self.user, vehicle=self.vehicle,
status='aktivan', scheduled_date=None,
)
result = notify_upcoming_tasks()
self.assertEqual(result['notified'], 0)
mock_notify.assert_not_called()
@patch('modules.fleet.services.NotificationService.create_notification')
def test_skips_task_scheduled_today(self, mock_notify):
today = datetime.date.today()
Task.objects.create(
title="Danas", assigned_to=self.user, vehicle=self.vehicle,
status='aktivan', scheduled_date=today,
)
result = notify_upcoming_tasks()
self.assertEqual(result['notified'], 0)
mock_notify.assert_not_called()
@patch('modules.fleet.services.NotificationService.create_notification')
def test_skips_task_scheduled_in_past(self, mock_notify):
yesterday = datetime.date.today() - datetime.timedelta(days=1)
Task.objects.create(
title="Jučer", assigned_to=self.user, vehicle=self.vehicle,
status='aktivan', scheduled_date=yesterday,
)
result = notify_upcoming_tasks()
self.assertEqual(result['notified'], 0)
mock_notify.assert_not_called()
@patch('modules.fleet.services.NotificationService.create_notification')
def test_skips_finished_task(self, mock_notify):
tomorrow = datetime.date.today() + datetime.timedelta(days=1)
vehicle2 = Vehicle.objects.create(registration_number="ZG-NOTIF-02", asset_type='crane')
work_order = WorkOrder.objects.create(
vehicle=vehicle2, creator=self.user, purpose='defektaza',
status='closed', start_mileage=100, end_mileage=200,
)
Task.objects.create(
title="Završeni", assigned_to=self.user, vehicle=vehicle2,
status='zavrsen', scheduled_date=tomorrow, work_order=work_order,
)
result = notify_upcoming_tasks()
self.assertEqual(result['notified'], 0)
mock_notify.assert_not_called()
@patch('modules.fleet.services.NotificationService.create_notification')
def test_skips_cancelled_task(self, mock_notify):
tomorrow = datetime.date.today() + datetime.timedelta(days=1)
vehicle3 = Vehicle.objects.create(registration_number="ZG-NOTIF-03", asset_type='crane')
work_order = WorkOrder.objects.create(
vehicle=vehicle3, creator=self.user, purpose='kontrola',
status='closed', start_mileage=50, end_mileage=100,
)
Task.objects.create(
title="Otkazani", assigned_to=self.user, vehicle=vehicle3,
status='neaktivan', scheduled_date=tomorrow, work_order=work_order,
)
result = notify_upcoming_tasks()
self.assertEqual(result['notified'], 0)
mock_notify.assert_not_called()
@patch('modules.fleet.services.NotificationService.create_notification')
def test_skips_task_without_assigned_user(self, mock_notify):
tomorrow = datetime.date.today() + datetime.timedelta(days=1)
Task.objects.create(
title="Bez servisera", assigned_to=None, vehicle=self.vehicle,
status='aktivan', scheduled_date=tomorrow,
)
result = notify_upcoming_tasks()
self.assertEqual(result['notified'], 0)
mock_notify.assert_not_called()
@patch('modules.fleet.services.NotificationService.create_notification')
def test_result_includes_date_string(self, mock_notify):
result = notify_upcoming_tasks()
tomorrow = datetime.date.today() + datetime.timedelta(days=1)
self.assertEqual(result['date'], tomorrow.isoformat())
self.assertEqual(result['notified'], 0)

View File

@@ -1,10 +1,11 @@
from django.test import TestCase, RequestFactory
from django.contrib.auth import get_user_model
from unittest.mock import patch
from rest_framework.test import APIClient
from modules.task_management.views import TaskViewSet
from modules.task_management.serializers import TaskSerializer
from modules.task_management.models import Task
from modules.task_management.models import Task, TaskTemplate, TaskTemplateEntry
from modules.fleet.models import Vehicle
@@ -17,6 +18,8 @@ class TaskViewSetTests(TestCase):
self.view.request = self.factory.post("/")
self.view.request.user = self.user
self.vehicle = Vehicle.objects.create(registration_number="ZG-TV-01", asset_type='crane')
self.api_client = APIClient()
self.api_client.force_authenticate(user=self.user)
@patch("modules.task_management.views.TaskService.create_task_entry")
def test_perform_create_calls_service(self, mock_create):
@@ -44,7 +47,12 @@ class TaskViewSetTests(TestCase):
serializer.instance = task
self.view.perform_update(serializer)
mock_update.assert_called_once_with(instance=task, data=serializer.validated_data, user=self.view.request.user)
mock_update.assert_called_once_with(
instance=task,
data=serializer.validated_data,
user=self.view.request.user,
auto_close_work_order=False,
)
# confirm instance saved with new title
task.refresh_from_db()
assert task.title == "Updated"
@@ -53,4 +61,32 @@ class TaskViewSetTests(TestCase):
task = Task.objects.create(title="ToDelete", description="", assigned_to=self.user)
self.view.perform_destroy(task)
task.refresh_from_db()
assert task.is_active is False
assert task.is_active is False
def test_template_created_task_is_visible_in_service_records_endpoint(self):
template = TaskTemplate.objects.create(
code='template-api-service-records',
title='Template API',
description='Opis',
status='aktivan',
)
TaskTemplateEntry.objects.create(
task_template=template,
sort_order=1,
title='Korak API',
work_description='Opis API koraka',
)
response = self.api_client.post(
"/api/tasks/tasks/",
data={"template_id": str(template.id), "vehicle": str(self.vehicle.id)},
format="json",
)
self.assertEqual(response.status_code, 201, response.content)
created_task_id = str(response.json()["id"])
service_records_response = self.api_client.get("/api/fleet/service-records/")
self.assertEqual(service_records_response.status_code, 200, service_records_response.content)
payload = service_records_response.json()
rows = payload if isinstance(payload, list) else payload.get("results", [])
self.assertTrue(any(str(row.get("task") or "") == created_task_id for row in rows))

View File

@@ -0,0 +1,97 @@
from django.test import TestCase
from django.contrib.auth import get_user_model
from rest_framework.test import APIClient
from modules.fleet.models import Vehicle, WorkOrder, VehicleServiceRecord
from modules.task_management.models import Task, TaskWorkHoursTable
class TaskWorkHoursTableTests(TestCase):
def setUp(self):
User = get_user_model()
self.user = User.objects.create_user(username="wh-user", email="wh-user@example.test", password="Pass123!abc")
self.client = APIClient()
self.client.force_authenticate(user=self.user)
self.vehicle = Vehicle.objects.create(registration_number="ZG-WH-01", asset_type='crane')
self.work_order = WorkOrder.objects.create(
vehicle=self.vehicle,
creator=self.user,
purpose='kontrola',
location='Zagreb',
)
self.task = Task.objects.create(
title="Task tablica sati",
description="",
status="aktivan",
assigned_to=self.user,
vehicle=self.vehicle,
work_order=self.work_order,
)
def test_put_work_hours_table_accepts_null_data(self):
response = self.client.put(
f"/api/tasks/tasks/{self.task.id}/work-hours-table/",
data=None,
format="json",
)
self.assertEqual(response.status_code, 200, response.content)
self.assertIsNone(response.json().get('data'))
def test_put_work_hours_table_accepts_partial_row_and_normalizes(self):
response = self.client.put(
f"/api/tasks/tasks/{self.task.id}/work-hours-table/",
data={"rows": [{"day": "NED", "travel_hours": "2,5", "departure_place": "Zagreb"}]},
format="json",
)
self.assertEqual(response.status_code, 200, response.content)
payload = response.json()
rows = payload.get('data', {}).get('rows', [])
self.assertEqual(len(rows), 1)
self.assertEqual(rows[0].get('day'), 'NED')
self.assertEqual(rows[0].get('travel_hours'), '2,5')
self.assertEqual(rows[0].get('departure_place'), 'Zagreb')
self.assertIn('arrival_place', rows[0])
self.assertEqual(rows[0].get('arrival_place'), '')
def test_put_work_hours_table_rejects_invalid_json_shape(self):
response = self.client.put(
f"/api/tasks/tasks/{self.task.id}/work-hours-table/",
data="invalid-shape",
format="json",
)
self.assertEqual(response.status_code, 400, response.content)
def test_task_service_context_includes_work_hours_table_data(self):
TaskWorkHoursTable.objects.create(
task=self.task,
data={"rows": [{"day": "NED", "date": "12.07.2026"}]},
)
VehicleServiceRecord.objects.create(
vehicle=self.vehicle,
task=self.task,
performed_by=self.user,
service_title="Servis zapis",
description="Opis",
cost=0,
)
response = self.client.get(f"/api/fleet/work-orders/{self.work_order.id}/task-service-context/")
self.assertEqual(response.status_code, 200, response.content)
payload = response.json()
tasks = payload.get('tasks', [])
self.assertEqual(len(tasks), 1)
self.assertEqual(tasks[0].get('work_hours_table'), {"rows": [{"day": "NED", "date": "12.07.2026"}]})
def test_service_records_pdf_works_with_empty_work_hours_table(self):
VehicleServiceRecord.objects.create(
vehicle=self.vehicle,
task=self.task,
performed_by=self.user,
service_title="Servis zapis",
description="Opis",
cost=0,
)
response = self.client.get(f"/api/fleet/work-orders/{self.work_order.id}/service-records-pdf/")
self.assertEqual(response.status_code, 200, response.content)
self.assertEqual(response["Content-Type"], "application/pdf")

View File

@@ -1,15 +1,18 @@
# backend/modules/task_management/views.py
# backend/modules/task_management/views.py
import logging
from rest_framework import viewsets, permissions
from rest_framework.decorators import action
from rest_framework.response import Response
from rest_framework.exceptions import ValidationError
from .models import Task
from .serializers import TaskSerializer
from .models import Task, TaskTemplate
from .serializers import TaskSerializer, TaskTemplateSerializer, TaskWorkHoursTableSerializer
from .services import TaskService
# Postavljanje loggera za modul
logger = logging.getLogger(__name__)
class TaskViewSet(viewsets.ModelViewSet):
"""
ViewSet koji koristi TaskService za rukovanje operacijama.
@@ -19,46 +22,76 @@ 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'
'assigned_to', 'vehicle', 'vehicle__client', 'work_order', 'work_order__vehicle', 'work_hours_table'
)
vehicle_id = self.request.query_params.get('vehicle')
if vehicle_id:
qs = qs.filter(vehicle_id=vehicle_id)
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):
"""
Umjesto direktnog serializer.save(), pozivamo servis.
Proslijeđujemo validirane podatke (dict) i korisnika.
"""
try:
entry = TaskService.create_task_entry(
data=serializer.validated_data,
user=self.request.user)
user=self.request.user,
)
serializer.instance = entry
except ValidationError as e:
# Ponovno podižemo iznimku kako bi DRF ispravno vratio 400 Bad Request
logger.warning(f"Validacijska greška pri kreiranju zadatka od strane {self.request.user}: {e}")
raise e
def perform_update(self, serializer):
"""
Servis obavlja biznis logiku (validaciju/modifikaciju),
a serializer.save() finalizira zapis.
"""
# Servis sada samo modificira instancu u memoriji
auto_close_work_order = serializer.validated_data.pop('auto_close_work_order', False)
TaskService.update_task_entry(
instance=serializer.instance,
data=serializer.validated_data,
user=self.request.user
user=self.request.user,
auto_close_work_order=auto_close_work_order,
)
# Sada DRF sigurno sprema izmijenjenu instancu
serializer.save()
def perform_destroy(self, instance):
"""
Umjesto fizičkog brisanja, radimo soft-delete ako je potrebno.
"""
instance.is_active = False
instance.save()
logger.info(f"Zapis zadatka {instance.id} je deaktiviran od strane {self.request.user}")
@action(detail=False, methods=['get'], url_path='templates')
def templates(self, request):
templates = TaskTemplate.objects.filter(is_active=True).prefetch_related('entries').order_by('title')
serializer = TaskTemplateSerializer(templates, many=True)
return Response(serializer.data)
@action(detail=True, methods=['get', 'put', 'patch'], url_path='work-hours-table')
def work_hours_table(self, request, pk=None):
task = self.get_object()
table = getattr(task, 'work_hours_table', None)
if request.method.lower() == 'get':
if table:
serializer = TaskWorkHoursTableSerializer(table)
return Response(serializer.data)
return Response({'task': str(task.id), 'data': None})
if isinstance(request.data, dict) and 'data' in request.data:
payload_data = request.data.get('data')
else:
payload_data = request.data
if payload_data == {}:
payload_data = None
serializer = TaskWorkHoursTableSerializer(
table,
data={
'task': str(task.id),
'data': payload_data,
},
partial=bool(table),
)
serializer.is_valid(raise_exception=True)
instance = serializer.save(task=task)
return Response(TaskWorkHoursTableSerializer(instance).data)

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

@@ -5,7 +5,7 @@ services:
context: ./backend
dockerfile: Dockerfile
command: python manage.py runserver 0.0.0.0:8000
user: "1001:1001"
user: "1000:1000"
volumes:
- ./backend:/app
- media_volume:/app/media
@@ -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,13 +31,15 @@ services:
- /app/node_modules
environment:
- NODE_ENV=development
- PUBLIC_ALLOWED_HOSTS=serviseri-004.captain.mitteworkspace.cloud
- PUBLIC_HMR_HOST=serviseri-004.captain.mitteworkspace.cloud
- 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
- PUBLIC_HMR_CLIENT_PORT=4321
- PUBLIC_HMR_PROTOCOL=wss
- PUBLIC_HMR_CLIENT_PORT=443
- PUBLIC_API_URL=https://api-004.captain.mitteworkspace.cloud/api/
- PUBLIC_API_URL=http://localhost:4321/api/
- PUBLIC_API_PROXY_TARGET=http://backend:8000
flower:
container_name: 004erpflower
@@ -52,6 +55,22 @@ services:
- redis
- worker
beat:
container_name: 004erpbeat
build:
context: ./backend
dockerfile: Dockerfile
command: celery -A core beat --loglevel=info
user: "1000:1000"
volumes:
- ./backend:/app
- media_volume:/app/media
env_file:
- .env
depends_on:
- redis
- worker
volumes:
media_volume:
external: true # Ako je definiran u glavnom file-u, ovo ga povezuje

127
docker-compose.prod.yml Normal file
View File

@@ -0,0 +1,127 @@
services:
db:
restart: unless-stopped
redis:
restart: unless-stopped
backend:
build:
context: ./backend
dockerfile: Dockerfile
container_name: 004erpbackend
restart: unless-stopped
env_file:
- .env
environment:
DJANGO_SETTINGS_MODULE: core.settings.production
command: gunicorn core.wsgi:application --bind 0.0.0.0:8000 --workers 3 --timeout 120
volumes:
- media_volume:/app/media
ports:
- "8001:8000"
depends_on:
db:
condition: service_healthy
redis:
condition: service_started
media-permissions:
condition: service_completed_successfully
worker:
build:
context: ./backend
dockerfile: Dockerfile
container_name: 004erpworker
restart: unless-stopped
env_file:
- .env
environment:
DEBUG: "False"
DJANGO_SETTINGS_MODULE: core.settings.production
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:
db:
condition: service_healthy
redis:
condition: service_started
media-permissions:
condition: service_completed_successfully
beat:
build:
context: ./backend
dockerfile: Dockerfile
container_name: 004erpbeat
restart: unless-stopped
env_file:
- .env
environment:
DEBUG: "False"
DJANGO_SETTINGS_MODULE: core.settings.production
command: celery -A core beat --loglevel=info
volumes:
- media_volume:/app/media
depends_on:
redis:
condition: service_started
worker:
condition: service_started
media-permissions:
condition: service_completed_successfully
flower:
build:
context: ./backend
dockerfile: Dockerfile
container_name: 004erpflower
restart: unless-stopped
env_file:
- .env
environment:
DEBUG: "False"
DJANGO_SETTINGS_MODULE: core.settings.production
command: celery -A core flower --port=5555
ports:
- "5555:5555"
depends_on:
redis:
condition: service_started
worker:
condition: service_started
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
container_name: 004erpfrontend
restart: unless-stopped
environment:
NODE_ENV: production
PUBLIC_API_URL: ${PUBLIC_API_URL}
command: sh -c "npm run build && node ./dist/server/entry.mjs"
ports:
- "4321:4321"
depends_on:
backend:
condition: service_started
webhook:
build: /opt/erp/deploy-webhook
container_name: 004erpwebhook
restart: unless-stopped
environment:
WEBHOOK_SECRET: ${GITEA_WEBHOOK_SECRET}
ports:
- "9001:9001"
volumes:
- /opt/erp/app:/opt/erp/app
- /var/run/docker.sock:/var/run/docker.sock
volumes:
postgres_data:
redis_data:
media_volume:
external: true

View File

@@ -21,13 +21,24 @@ 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:
context: ./backend
dockerfile: Dockerfile
command: celery -A core worker --loglevel=info
user: "1000:1000"
user: "1001:1001"
volumes:
- ./backend:/app
- media_volume:/app/media
@@ -36,6 +47,7 @@ services:
depends_on:
db: { condition: service_healthy }
redis: { condition: service_started }
media-permissions: { condition: service_completed_successfully }
deploy:
resources:
limits:
@@ -44,4 +56,4 @@ services:
volumes:
postgres_data:
redis_data:
media_volume: # Ovo je ključno za tvoj ERP
media_volume: # Ovo je ključno za tvoj ERP

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

@@ -19,6 +19,7 @@ const allowedHosts = Array.from(
const hmrHost = process.env.PUBLIC_HMR_HOST || 'localhost';
const hmrProtocol = process.env.PUBLIC_HMR_PROTOCOL || 'ws';
const hmrClientPort = Number(process.env.PUBLIC_HMR_CLIENT_PORT || 4321);
const apiProxyTarget = process.env.PUBLIC_API_PROXY_TARGET || 'http://localhost:8001';
export default defineConfig({
output: 'server',
@@ -59,9 +60,20 @@ export default defineConfig({
host: hmrHost,
clientPort: hmrClientPort,
},
proxy: {
'/api': {
target: apiProxyTarget,
changeOrigin: true,
},
},
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

@@ -2,8 +2,9 @@ import NotificationBell from './NotificationBell';
import ThemeToggle from './ui/ThemeToggle';
import AuthWidget from './AuthWidget';
import UserDisplay from './ui/UserDisplay';
import { useEffect, useMemo, useState } from 'preact/hooks';
import { hydrateAuthFromStorage } from '../stores/authStore';
import { useEffect, useRef, useState } from 'preact/hooks';
import { useSpring, animated } from '@react-spring/web';
import { hydrateAuthFromStorage, loadCurrentUser, $user, $accessToken } from '../stores/authStore';
const ITEMS = [
{ id: 'dashboard', label: 'Dashboard', href: '/' },
@@ -13,30 +14,93 @@ const ITEMS = [
{ id: 'clients', label: 'Klijenti', href: '/clients' },
];
function normalizePath(p) {
const value = String(p || '/');
if (value.length > 1 && value.endsWith('/')) return value.slice(0, -1);
return value;
}
function getActiveIndex(path) {
// 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 }) {
const [pathname, setPathname] = useState('/');
const normalizedPath = useMemo(() => {
const value = String(pathname || '/');
if (value.length > 1 && value.endsWith('/')) return value.slice(0, -1);
return value;
}, [pathname]);
useEffect(() => {
const updatePath = () => setPathname(window.location.pathname || '/');
updatePath();
window.addEventListener('astro:page-load', updatePath);
// Hidratira auth iz storage pri prvi puta učitavanja Navbar-a
hydrateAuthFromStorage();
return () => window.removeEventListener('astro:page-load', updatePath);
}, []);
const currentItem = useMemo(
() => ITEMS.find((item) => item.href === normalizedPath) || ITEMS[0],
[normalizedPath]
// Navbar ostaje montiran (transition:persist) — pratimo promjene pathname preko astro:page-load
const [pathname, setPathname] = useState(() =>
typeof window !== 'undefined' ? normalizePath(window.location.pathname) : '/'
);
const itemRefs = useRef([]);
const [indicatorStyle, setIndicatorStyle] = useState({ left: 0, width: 0 });
// Na prvom renderu preskočimo animaciju (pill se pojavljuje na ispravnoj poziciji bez slide-a od 0)
const [skipAnimation, setSkipAnimation] = useState(true);
const activeIndex = getActiveIndex(pathname);
const measureIndicator = (currentIndex) => {
const idx = currentIndex ?? activeIndex;
const el = itemRefs.current[idx];
if (!el) return;
const parent = el.closest('ul');
if (!parent) return;
const parentRect = parent.getBoundingClientRect();
const elRect = el.getBoundingClientRect();
setIndicatorStyle({
left: elRect.left - parentRect.left,
width: elRect.width,
});
};
// 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();
setSkipAnimation(false);
}, 30);
// Sluša Astro view-transition navigaciju (Navbar ostaje montiran zahvaljujući transition:persist)
const handlePageLoad = () => {
setPathname(normalizePath(window.location.pathname));
};
document.addEventListener('astro:page-load', handlePageLoad);
return () => {
clearTimeout(timer);
document.removeEventListener('astro:page-load', handlePageLoad);
};
}, []);
// Kad se promijeni activeIndex (nova stranica), animiraj pill na novu poziciju
useEffect(() => {
if (!skipAnimation) {
measureIndicator();
}
}, [activeIndex]);
// react-spring: animirani klizač
// immediate=true na prvom renderu → pill se ne animira od lijevog ruba, već skače na pravo mjesto
const springStyle = useSpring({
left: indicatorStyle.left,
width: indicatorStyle.width,
immediate: skipAnimation,
config: { tension: 340, friction: 28 },
});
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">
@@ -44,16 +108,29 @@ export default function Navbar({ minimal = false }) {
{!minimal && <UserDisplay field="ime_prezime" className="text-xs text-text-muted" />}
</a>
{/* Navigacijski linkovi */}
<ul className="hidden items-center gap-1 text-sm sm:flex">
{ITEMS.map((item) => (
<li key={item.id}>
{/* Navigacijski linkovi s react-spring klizačem */}
<ul className="relative hidden items-center gap-1 text-sm sm:flex">
{/* Animirani pozadinski klizač */}
<animated.li
aria-hidden="true"
style={{
position: 'absolute',
top: 0,
bottom: 0,
borderRadius: '0.5rem',
backgroundColor: 'rgb(238 242 255)',
pointerEvents: 'none',
...springStyle,
}}
/>
{ITEMS.map((item, idx) => (
<li key={item.id} ref={(el) => (itemRefs.current[idx] = el)}>
<a
href={item.href}
className={
normalizedPath === item.href
? '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'
pathname === item.href
? 'relative z-10 block rounded-lg px-3 py-2 font-medium text-indigo-700'
: 'relative z-10 block rounded-lg px-3 py-2 text-text-main hover:text-indigo-600'
}
>
{item.label}
@@ -64,11 +141,13 @@ export default function Navbar({ minimal = false }) {
{/* Desna strana */}
<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>
@@ -78,11 +157,12 @@ export default function Navbar({ minimal = false }) {
<a
href={item.href}
className={
normalizedPath === 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'
pathname === item.href
? '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>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,995 @@
import { useEffect, useMemo, useState } from 'preact/hooks';
import { getStatusLabel } from '../../stores/taskStore';
import {
downloadMonthlyServiceTasksArchive,
downloadMonthlyCostsReport,
downloadMonthlyServiserReport,
downloadMonthlyWorkOrdersArchive,
fetchMonthlyCostInvoices,
fetchMonthlyServicerEntries,
upsertMonthlyServicerEntry,
} from '../../stores/fleetDashboardStore';
import LeftSlideDrawer from '../layout/LeftSlideDrawer';
import { getTaskCraneKind, getTaskCraneOwner, getTaskCraneSerial } from '../../lib/taskCraneDisplay';
const WEEKDAY_LABELS = ['Po', 'Ut', 'Sr', 'Ce', 'Pe', 'Su', 'Ne'];
const MONTH_NAMES = [
'Sijecanj', 'Veljaca', 'Ozujak', 'Travanj', 'Svibanj', 'Lipanj',
'Srpanj', 'Kolovoz', 'Rujan', 'Listopad', 'Studeni', 'Prosinac',
];
const SERVICER_HEADERS = ['DATUM', 'OPIS POSLA', 'BR. DIZALICE', 'KOMITENT', 'MJESTO RADA', 'POCETAK RADA', 'KRAJ RADA', 'REDOVAN RAD (h)', 'PREKOVREMENI (h)', 'RADNI NALOG'];
const COST_HEADERS = ['DATUM', 'NAZIV RACUNA', 'LOKACIJA', 'OPIS', 'RADNI NALOG'];
function toDateKey(value) {
const text = String(value || '').trim();
if (!text) return null;
if (/^\d{4}-\d{2}-\d{2}$/.test(text)) return text;
const croatian = text.match(/^(\d{2})\.(\d{2})\.(\d{4})\.?$/);
if (croatian) {
return `${croatian[3]}-${croatian[2]}-${croatian[1]}`;
}
const parsed = new Date(text);
if (Number.isNaN(parsed.getTime())) return null;
return `${parsed.getFullYear()}-${String(parsed.getMonth() + 1).padStart(2, '0')}-${String(parsed.getDate()).padStart(2, '0')}`;
}
function formatDayLabel(year, month, day) {
return `${String(day).padStart(2, '0')}.${String(month + 1).padStart(2, '0')}.${year}`;
}
function formatDateFromKey(dateKey) {
const [year, month, day] = String(dateKey || '').split('-');
if (!year || !month || !day) return '-';
return `${day}.${month}.${year}.`;
}
function formatTime(value) {
if (!value) return '-';
if (typeof value === 'string' && /^\d{2}:\d{2}/.test(value)) {
return value.slice(0, 5);
}
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return '-';
return parsed.toLocaleTimeString('hr-HR', { hour: '2-digit', minute: '2-digit' });
}
function toTimeInputValue(value) {
const formatted = formatTime(value);
return formatted === '-' ? '' : formatted;
}
function calculateHours(startTime, endTime) {
if (!startTime || !endTime) return null;
const [startHour, startMinute] = String(startTime).split(':').map((part) => Number(part));
const [endHour, endMinute] = String(endTime).split(':').map((part) => Number(part));
if (![startHour, startMinute, endHour, endMinute].every((value) => Number.isFinite(value))) return null;
const startTotal = (startHour * 60) + startMinute;
const endTotal = (endHour * 60) + endMinute;
if (endTotal <= startTotal) return null;
return ((endTotal - startTotal) / 60).toFixed(2).replace(/\.?0+$/, '');
}
function formatHourValue(value, fallback = '-') {
if (value == null || value === '') return fallback;
const numeric = Number(value);
if (!Number.isFinite(numeric)) return String(value);
if (Number.isInteger(numeric)) return String(numeric);
return numeric.toFixed(2).replace(/\.?0+$/, '');
}
function joinUnique(values) {
return Array.from(new Set(values.filter(Boolean).map((item) => String(item).trim()).filter(Boolean))).join(', ');
}
function parseReportHours(value) {
if (value == null || value === '') return 0;
const numeric = Number(String(value).replace(',', '.'));
return Number.isFinite(numeric) ? numeric : 0;
}
function parseReportDateTime(dateKey, value) {
if (value == null || value === '') return null;
if (value instanceof Date) {
return new Date(value.getTime());
}
const text = String(value).trim();
if (!text) return null;
const timeMatch = text.match(/^(\d{1,2}):(\d{2})(?::(\d{2}))?$/);
if (dateKey && timeMatch) {
const hours = String(timeMatch[1]).padStart(2, '0');
const minutes = timeMatch[2];
const seconds = timeMatch[3] || '00';
return new Date(`${dateKey}T${hours}:${minutes}:${seconds}`);
}
const parsed = new Date(text);
return Number.isNaN(parsed.getTime()) ? null : parsed;
}
function getTaskWorkHoursEntries(task, workOrdersById) {
const tableData = task?.work_hours_table?.data ?? task?.work_hours_table ?? null;
const rows = Array.isArray(tableData?.rows) ? tableData.rows : [];
const workOrder = workOrdersById.get(String(task?.work_order || ''));
const base = {
title: task?.title || '',
serial: getTaskCraneSerial(task) || '',
client: getTaskCraneOwner(task) || '',
location: workOrder?.location || '',
workOrderLabel: task?.work_order_label || '',
};
const entries = [];
for (const row of rows) {
if (!row || typeof row !== 'object') continue;
const dateKey = toDateKey(row.date) || toDateKey(task?.scheduled_date);
if (!dateKey) continue;
const startAt = parseReportDateTime(dateKey, row.work_time_from || row.travel_time_from || '');
const endAt = parseReportDateTime(dateKey, row.work_time_to || row.travel_time_to || '');
entries.push({
...base,
dateKey,
startAt,
endAt,
totalHours: parseReportHours(row.work_hours) + parseReportHours(row.travel_hours),
});
}
if (entries.length > 0) {
return entries;
}
const travelStart = workOrder?.travel_start_at;
const travelEnd = workOrder?.travel_end_at;
if (travelStart && travelEnd) {
const startAt = parseReportDateTime(null, travelStart);
const endAt = parseReportDateTime(null, travelEnd);
const dateKey = toDateKey(task?.scheduled_date) || toDateKey(travelStart);
const totalHours = startAt && endAt ? Math.max(0, (endAt.getTime() - startAt.getTime()) / 3600000) : 0;
if (dateKey) {
entries.push({
...base,
dateKey,
startAt,
endAt,
totalHours,
});
}
}
return entries;
}
export default function TaskCalendarWidget({ tasks = [], notes = [], workOrders = [], onTaskClick }) {
const [viewYear, setViewYear] = useState(() => new Date().getFullYear());
const [viewMonth, setViewMonth] = useState(() => new Date().getMonth());
const [selectedDay, setSelectedDay] = useState(null);
const [reportType, setReportType] = useState(null);
const [downloading, setDownloading] = useState(false);
const [manualEntries, setManualEntries] = useState([]);
const [costInvoices, setCostInvoices] = useState([]);
const [loadingManualEntries, setLoadingManualEntries] = useState(false);
const [loadingCostInvoices, setLoadingCostInvoices] = useState(false);
const [manualEntryTarget, setManualEntryTarget] = useState(null);
const [manualEntryDraft, setManualEntryDraft] = useState(null);
const [manualEntryError, setManualEntryError] = useState('');
const [savingManualEntry, setSavingManualEntry] = useState(false);
const [bulkDownloadOpen, setBulkDownloadOpen] = useState(false);
const [downloadingAllTasks, setDownloadingAllTasks] = useState(false);
const [downloadingAllWorkOrders, setDownloadingAllWorkOrders] = useState(false);
const reportOpen = reportType !== null;
const panelWidth = reportOpen ? 920 : 300;
const tasksByDate = useMemo(() => {
const map = {};
for (const task of tasks) {
const key = toDateKey(task.scheduled_date);
if (!key) continue;
if (!map[key]) map[key] = [];
map[key].push(task);
}
return map;
}, [tasks]);
const notesByDate = useMemo(() => {
const map = {};
for (const note of notes) {
const key = toDateKey(note.note_date);
if (!key) continue;
if (!map[key]) map[key] = [];
map[key].push(note);
}
return map;
}, [notes]);
const workOrdersById = useMemo(() => {
const map = new Map();
for (const workOrder of workOrders) {
if (workOrder?.id) {
map.set(String(workOrder.id), workOrder);
}
}
return map;
}, [workOrders]);
const manualEntriesByDate = useMemo(() => {
const map = {};
for (const entry of manualEntries) {
const key = toDateKey(entry.entry_date);
if (key) {
map[key] = entry;
}
}
return map;
}, [manualEntries]);
const calendarDays = useMemo(() => {
const firstDay = new Date(viewYear, viewMonth, 1);
let startDow = firstDay.getDay();
startDow = (startDow + 6) % 7;
const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate();
const cells = [];
for (let i = 0; i < startDow; i += 1) cells.push(null);
for (let d = 1; d <= daysInMonth; d += 1) cells.push(d);
return cells;
}, [viewYear, viewMonth]);
useEffect(() => {
if (!reportOpen || reportType !== 'servicer') return undefined;
let active = true;
setLoadingManualEntries(true);
fetchMonthlyServicerEntries(viewYear, viewMonth + 1)
.then((rows) => {
if (!active) return;
setManualEntries(Array.isArray(rows) ? rows : []);
})
.catch(() => {
if (!active) return;
setManualEntries([]);
})
.finally(() => {
if (!active) return;
setLoadingManualEntries(false);
});
return () => {
active = false;
};
}, [reportOpen, reportType, viewYear, viewMonth]);
useEffect(() => {
if (!reportOpen || reportType !== 'costs') return undefined;
let active = true;
setLoadingCostInvoices(true);
fetchMonthlyCostInvoices(viewYear, viewMonth + 1)
.then((rows) => {
if (!active) return;
setCostInvoices(Array.isArray(rows) ? rows : []);
})
.catch(() => {
if (!active) return;
setCostInvoices([]);
})
.finally(() => {
if (!active) return;
setLoadingCostInvoices(false);
});
return () => {
active = false;
};
}, [reportOpen, reportType, viewYear, viewMonth]);
const reportTaskEntriesByDate = useMemo(() => {
const map = {};
for (const task of tasks) {
for (const entry of getTaskWorkHoursEntries(task, workOrdersById)) {
if (!map[entry.dateKey]) map[entry.dateKey] = [];
map[entry.dateKey].push(entry);
}
}
return map;
}, [tasks, workOrdersById]);
const servicerRows = useMemo(() => {
if (reportType !== 'servicer') return [];
const rows = [];
const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate();
const monthPrefix = `${viewYear}-${String(viewMonth + 1).padStart(2, '0')}`;
for (let day = 1; day <= daysInMonth; day += 1) {
const key = `${monthPrefix}-${String(day).padStart(2, '0')}`;
const dayEntries = reportTaskEntriesByDate[key] ?? [];
const manualEntry = manualEntriesByDate[key];
if (dayEntries.length > 0) {
const titles = [];
const serials = [];
const clients = [];
const locations = [];
const workOrderLabels = [];
const startTimes = [];
const endTimes = [];
let totalHours = 0;
for (const entry of dayEntries) {
if (entry.title) titles.push(entry.title);
if (entry.serial) serials.push(entry.serial);
if (entry.client) clients.push(entry.client);
if (entry.location) locations.push(entry.location);
if (entry.workOrderLabel) workOrderLabels.push(entry.workOrderLabel);
if (entry.startAt) startTimes.push(entry.startAt);
if (entry.endAt) endTimes.push(entry.endAt);
totalHours += Number(entry.totalHours || 0);
}
const regularHours = 8;
const overtimeHours = Math.max(0, totalHours - regularHours);
rows.push({
key,
clickable: false,
source: 'task',
cells: [
formatDateFromKey(key),
joinUnique(titles) || '-',
joinUnique(serials) || '-',
joinUnique(clients) || '-',
joinUnique(locations) || '-',
startTimes.length ? formatTime(new Date(Math.min(...startTimes.map((item) => item.getTime())))) : '-',
endTimes.length ? formatTime(new Date(Math.max(...endTimes.map((item) => item.getTime())))) : '-',
formatHourValue(regularHours, '0'),
formatHourValue(overtimeHours, '0'),
joinUnique(workOrderLabels) || '-',
],
});
continue;
}
if (manualEntry) {
rows.push({
key,
clickable: true,
source: manualEntry.entry_type,
cells: [
formatDateFromKey(key),
manualEntry.description || '-',
'-',
'-',
manualEntry.location || '-',
formatTime(manualEntry.start_time),
formatTime(manualEntry.end_time),
formatHourValue(manualEntry.regular_hours, '0'),
formatHourValue(manualEntry.overtime_hours, '0'),
'-',
],
});
continue;
}
rows.push({
key,
clickable: true,
source: 'empty',
cells: [formatDateFromKey(key), 'Klikni za unos', '-', '-', '-', '-', '-', '-', '0', '-'],
});
}
return rows;
}, [manualEntriesByDate, reportTaskEntriesByDate, reportType, viewMonth, viewYear]);
const costsRows = useMemo(() => {
if (reportType !== 'costs') return [];
return costInvoices.map((invoice) => ([
invoice?.datum ? new Date(invoice.datum).toLocaleDateString('hr-HR') : '-',
invoice?.naziv_racuna || '-',
invoice?.lokacija || '-',
invoice?.opis || '-',
invoice?.work_order_display_code || '-',
]));
}, [costInvoices, reportType]);
const selectedDayKey = selectedDay
? `${viewYear}-${String(viewMonth + 1).padStart(2, '0')}-${String(selectedDay).padStart(2, '0')}`
: null;
const selectedDayTasks = selectedDayKey ? (tasksByDate[selectedDayKey] ?? []) : [];
const selectedDayNotes = selectedDayKey ? (notesByDate[selectedDayKey] ?? []) : [];
const today = new Date();
const totalScheduled = Object.values(tasksByDate).flat().length;
const totalNoteReminders = Object.values(notesByDate).flat().length;
function prevMonth() {
setSelectedDay(null);
if (viewMonth === 0) {
setViewYear((y) => y - 1);
setViewMonth(11);
return;
}
setViewMonth((m) => m - 1);
}
function nextMonth() {
setSelectedDay(null);
if (viewMonth === 11) {
setViewYear((y) => y + 1);
setViewMonth(0);
return;
}
setViewMonth((m) => m + 1);
}
function handleDayClick(day) {
setSelectedDay((prev) => (prev === day ? null : day));
}
function handleTaskClick(task, closePanel) {
onTaskClick?.(task);
closePanel?.();
}
function toggleReport(type) {
setReportType((prev) => (prev === type ? null : type));
setManualEntryTarget(null);
if (type !== 'servicer') {
setBulkDownloadOpen(false);
}
}
async function handleDownload() {
if (downloading) return;
setDownloading(true);
try {
if (reportType === 'servicer') {
await downloadMonthlyServiserReport(viewYear, viewMonth + 1);
} else if (reportType === 'costs') {
await downloadMonthlyCostsReport(viewYear, viewMonth + 1);
}
} finally {
setDownloading(false);
}
}
async function handleDownloadAllTasksArchive() {
if (downloadingAllTasks) return;
setBulkDownloadOpen(false);
setDownloadingAllTasks(true);
try {
await downloadMonthlyServiceTasksArchive(viewYear, viewMonth + 1);
} finally {
setDownloadingAllTasks(false);
}
}
async function handleDownloadAllWorkOrdersArchive() {
if (downloadingAllWorkOrders) return;
setBulkDownloadOpen(false);
setDownloadingAllWorkOrders(true);
try {
await downloadMonthlyWorkOrdersArchive(viewYear, viewMonth + 1);
} finally {
setDownloadingAllWorkOrders(false);
}
}
function handleServicerRowClick(row) {
if (!row?.clickable) return;
setManualEntryTarget(row);
setManualEntryDraft(null);
setManualEntryError('');
}
function startManualEntry(entryType) {
if (!manualEntryTarget) return;
const isOffice = entryType === 'office';
setManualEntryError('');
setManualEntryDraft({
entryType,
startTime: isOffice ? '08:00' : '',
endTime: isOffice ? '16:00' : '',
});
}
async function handleSaveManualEntry() {
if (!manualEntryTarget || !manualEntryDraft || savingManualEntry) return;
const { entryType, startTime, endTime } = manualEntryDraft;
if (startTime && endTime && !calculateHours(startTime, endTime)) {
setManualEntryError('Kraj rada mora biti nakon početka rada.');
return;
}
setManualEntryError('');
setSavingManualEntry(true);
try {
const saved = await upsertMonthlyServicerEntry({
entry_date: manualEntryTarget.key,
entry_type: entryType,
start_time: startTime || undefined,
end_time: endTime || undefined,
});
setManualEntries((prev) => {
const next = Array.isArray(prev) ? [...prev] : [];
const index = next.findIndex((item) => toDateKey(item.entry_date) === toDateKey(saved.entry_date));
if (index >= 0) {
next[index] = saved;
} else {
next.push(saved);
}
return next.sort((a, b) => String(a.entry_date || '').localeCompare(String(b.entry_date || '')));
});
setManualEntryTarget(null);
setManualEntryDraft(null);
setManualEntryError('');
} finally {
setSavingManualEntry(false);
}
}
return (
<LeftSlideDrawer
drawerId="calendar"
title="Kalendar zadataka"
tabLabel="Kalendar"
tabTitle="Kalendar zadataka"
tabTopClassName="top-1/2"
backdropClassName="z-[45]"
panelClassName="z-[46]"
tabClassName="z-[47]"
panelWidth={panelWidth}
>
{({ close }) => (
<div className="relative flex min-h-0 flex-1 overflow-hidden">
<div
className="flex w-[300px] flex-shrink-0 flex-col overflow-hidden"
style={reportOpen ? { borderRight: '1px solid var(--color-border-hairline, #e5e7eb)' } : {}}
>
<div className="flex gap-1.5 border-b border-border-hairline px-3 py-2">
<button
type="button"
onClick={() => toggleReport('servicer')}
className={[
'flex-1 rounded px-2 py-1 text-[11px] font-medium transition-colors',
reportType === 'servicer'
? 'bg-indigo-600 text-white'
: 'bg-canvas-deep text-text-main hover:bg-indigo-50 hover:text-indigo-700',
].join(' ')}
title="Otvori panel za mjesecni izvjestaj servisera"
>
Izvjestaj servisera
</button>
<button
type="button"
onClick={() => toggleReport('costs')}
className={[
'flex-1 rounded px-2 py-1 text-[11px] font-medium transition-colors',
reportType === 'costs'
? 'bg-emerald-600 text-white'
: 'bg-canvas-deep text-text-main hover:bg-emerald-50 hover:text-emerald-700',
].join(' ')}
title="Otvori panel za mjesecni izvjestaj troskova"
>
Izvjestaj troskova
</button>
</div>
<div className="flex items-center justify-between px-4 py-2">
<button
type="button"
onClick={prevMonth}
className="rounded p-1 text-sm text-text-main hover:bg-canvas-deep"
aria-label="Prethodni mjesec"
>
&lt;
</button>
<span className="text-sm font-medium text-text-main">
{MONTH_NAMES[viewMonth]} {viewYear}
</span>
<button
type="button"
onClick={nextMonth}
className="rounded p-1 text-sm text-text-main hover:bg-canvas-deep"
aria-label="Sljedeci mjesec"
>
&gt;
</button>
</div>
<div className="px-3">
<div className="grid grid-cols-7 text-center">
{WEEKDAY_LABELS.map((label) => (
<div key={label} className="py-1 text-[11px] font-medium text-text-muted">
{label}
</div>
))}
</div>
<div className="grid grid-cols-7 gap-y-0.5 text-center">
{calendarDays.map((day, idx) => {
if (!day) return <div key={`e-${idx}`} />;
const key = `${viewYear}-${String(viewMonth + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
const hasTasks = Boolean(tasksByDate[key]?.length);
const hasNotes = Boolean(notesByDate[key]?.length);
const taskCount = tasksByDate[key]?.length ?? 0;
const noteCount = notesByDate[key]?.length ?? 0;
const isToday =
today.getFullYear() === viewYear
&& today.getMonth() === viewMonth
&& today.getDate() === day;
const isSelected = selectedDay === day;
return (
<button
key={key}
type="button"
onClick={() => handleDayClick(day)}
title={hasTasks || hasNotes ? `${taskCount} zadatak/a, ${noteCount} biljeski` : undefined}
className={[
'relative mx-auto flex h-8 w-8 items-center justify-center rounded-full text-xs transition-colors',
isSelected
? 'bg-indigo-600 font-semibold text-white'
: isToday
? 'bg-indigo-50 font-semibold text-indigo-700'
: hasTasks
? 'font-medium text-text-main hover:bg-canvas-deep'
: 'text-text-main hover:bg-canvas-deep',
].join(' ')}
>
{day}
{(hasTasks || hasNotes) && (
<span className="absolute bottom-0.5 left-1/2 flex -translate-x-1/2 items-center gap-0.5">
{hasTasks && (
<span className={['h-1.5 w-1.5 rounded-full', isSelected ? 'bg-white' : 'bg-indigo-500'].join(' ')} />
)}
{hasNotes && (
<span className={['h-1.5 w-1.5 rounded-full', isSelected ? 'bg-violet-200' : 'bg-violet-500'].join(' ')} />
)}
</span>
)}
</button>
);
})}
</div>
</div>
<div className="mt-2 border-t border-border-hairline" />
<div className="flex-1 overflow-y-auto px-4 py-3">
{selectedDay ? (
<>
<p className="mb-2 text-[11px] font-semibold uppercase tracking-wide text-text-muted">
{formatDayLabel(viewYear, viewMonth, selectedDay)}
</p>
{selectedDayTasks.length === 0 && selectedDayNotes.length === 0 ? (
<p className="text-sm text-text-muted">Nema dogadjaja za odabrani datum.</p>
) : (
<div className="space-y-3">
{selectedDayNotes.length > 0 && (
<div>
<p className="mb-1 text-[11px] font-semibold uppercase tracking-wide text-violet-700">Biljeske</p>
<ul className="space-y-1.5">
{selectedDayNotes.map((note) => (
<li key={note.id} className="rounded-lg border border-violet-200 bg-violet-50 px-3 py-2 text-sm text-violet-900">
<span className="block">{note.note}</span>
<span className="mt-0.5 block text-[11px] text-violet-700">
{note.task_title || note.work_order_label || 'Servisni kontekst'}
</span>
</li>
))}
</ul>
</div>
)}
{selectedDayTasks.length > 0 && (
<div>
<p className="mb-1 text-[11px] font-semibold uppercase tracking-wide text-indigo-700">Zadaci</p>
<ul className="space-y-1.5">
{selectedDayTasks.map((task) => (
<li key={task.id}>
<button
type="button"
onClick={() => handleTaskClick(task, close)}
className="w-full rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-left text-sm transition-colors hover:border-indigo-200 hover:bg-indigo-50"
>
<span className="block truncate font-medium text-text-main">
{task.title || '-'}
</span>
<span className="mt-0.5 block text-[11px] text-text-muted">
{task.assigned_to_name || '-'} | {getStatusLabel(task.status || '-')}
</span>
<span className="mt-0.5 block text-[11px] text-text-muted">
{getTaskCraneSerial(task)} | {getTaskCraneKind(task)} | {getTaskCraneOwner(task)}
</span>
</button>
</li>
))}
</ul>
</div>
)}
</div>
)}
</>
) : (
<>
<p className="text-xs text-text-muted">
Odaberite datum u kalendaru za prikaz zadataka.
</p>
{totalScheduled > 0 && (
<p className="mt-1.5 text-xs font-medium text-indigo-600">
{totalScheduled} zadatak/a s planiranim datumom.
</p>
)}
{totalNoteReminders > 0 && (
<p className="mt-1 text-xs font-medium text-violet-600">
{totalNoteReminders} biljeski/podsjetnika u kalendaru.
</p>
)}
{totalScheduled === 0 && (
<p className="mt-1.5 text-[11px] text-text-muted">
Nijedan zadatak nema postavljen planirani datum.
</p>
)}
</>
)}
</div>
</div>
{reportOpen && (
<div className="flex flex-1 flex-col overflow-hidden">
<div className="flex items-center justify-between border-b border-border-hairline px-4 py-2">
<div>
<span className="text-sm font-semibold text-text-main">
{reportType === 'servicer' ? 'Mjesecni izvjestaj servisera' : 'Mjesecni izvjestaj troskova'}
</span>
<span className="ml-2 text-xs text-text-muted">
{MONTH_NAMES[viewMonth]} {viewYear}
</span>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={handleDownload}
disabled={downloading}
className="rounded-md bg-indigo-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-indigo-700 disabled:opacity-60"
>
{downloading ? 'Preuzimanje...' : reportType === 'servicer' ? 'Preuzmi mjesečni izvještaj' : 'Preuzmi DOCX'}
</button>
{reportType === 'servicer' && (
<button
type="button"
onClick={() => setBulkDownloadOpen(true)}
className="rounded-md border border-border-hairline px-3 py-1.5 text-xs font-medium text-text-main hover:bg-canvas-deep"
>
Preuzmi ZIP
</button>
)}
</div>
</div>
<p className="border-b border-border-hairline bg-canvas-deep px-4 py-1.5 text-[11px] text-text-muted">
{reportType === 'servicer'
? 'Tablica prikazuje sve dane u mjesecu. Klikni na red bez putnog naloga i odaberi status dana.'
: 'Pregled troskova iz racuna vezanih uz putne naloge u odabranom mjesecu.'}
</p>
<div className="flex-1 overflow-auto">
{reportType === 'servicer' && (
loadingManualEntries ? (
<p className="px-4 py-8 text-center text-sm text-text-muted">Ucitam dnevne unose...</p>
) : (
<table className="w-full min-w-[980px] border-collapse text-[11px]">
<thead>
<tr className="bg-canvas-deep">
{SERVICER_HEADERS.map((header) => (
<th key={header} className="border border-border-hairline px-2 py-1.5 text-left font-semibold text-text-muted whitespace-nowrap">
{header}
</th>
))}
</tr>
</thead>
<tbody>
{servicerRows.map((row, index) => (
<tr
key={row.key}
onClick={() => handleServicerRowClick(row)}
className={[
index % 2 === 0 ? 'bg-canvas-base' : 'bg-canvas-deep',
row.clickable ? 'cursor-pointer hover:bg-indigo-50' : '',
].join(' ')}
title={row.clickable ? 'Klikni za unos ili izmjenu dana bez putnog naloga.' : undefined}
>
{row.cells.map((cell, cellIndex) => (
<td key={`${row.key}-${cellIndex}`} className="border border-border-hairline px-2 py-1 text-text-main whitespace-nowrap">
{cell}
</td>
))}
</tr>
))}
</tbody>
</table>
)
)}
{reportType === 'costs' && (
loadingCostInvoices ? (
<p className="px-4 py-8 text-center text-sm text-text-muted">Ucitam troskove...</p>
) : costsRows.length === 0 ? (
<p className="px-4 py-8 text-center text-sm text-text-muted">
Nema troskova za {MONTH_NAMES[viewMonth]} {viewYear}.
</p>
) : (
<table className="w-full min-w-[640px] border-collapse text-[11px]">
<thead>
<tr className="bg-canvas-deep">
{COST_HEADERS.map((header) => (
<th key={header} className="border border-border-hairline px-2 py-1.5 text-left font-semibold text-text-muted whitespace-nowrap">
{header}
</th>
))}
</tr>
</thead>
<tbody>
{costsRows.map((row, index) => (
<tr key={`${row[0]}-${row[1]}-${index}`} className={index % 2 === 0 ? 'bg-canvas-base' : 'bg-canvas-deep'}>
{row.map((cell, cellIndex) => (
<td key={cellIndex} className="border border-border-hairline px-2 py-1 text-text-main whitespace-nowrap">
{cell}
</td>
))}
</tr>
))}
</tbody>
</table>
)
)}
</div>
</div>
)}
{manualEntryTarget && (
<div className="absolute inset-0 z-10 flex items-center justify-center bg-black/20">
<div className="w-full max-w-sm rounded-xl border border-border-hairline bg-canvas-elevated p-4 shadow-2xl">
<div className="flex items-start justify-between gap-3">
<div>
<h3 className="text-sm font-semibold text-text-main">
{manualEntryDraft ? 'Unos radnog dana' : 'Odaberi vrstu unosa'}
</h3>
<p className="mt-1 text-xs text-text-muted">
{formatDateFromKey(manualEntryTarget.key)} nema putni nalog. Odaberi status dana.
</p>
</div>
<button
type="button"
onClick={() => {
setManualEntryTarget(null);
setManualEntryDraft(null);
setManualEntryError('');
}}
className="rounded p-1 text-text-muted hover:bg-canvas-deep"
aria-label="Zatvori"
>
x
</button>
</div>
{!manualEntryDraft ? (
<div className="mt-4 grid gap-2">
<button
type="button"
disabled={savingManualEntry}
onClick={() => startManualEntry('office')}
className="rounded-lg border border-border-hairline px-3 py-2 text-left text-sm hover:bg-indigo-50"
>
<span className="block font-medium text-text-main">Rad u uredu</span>
<span className="block text-xs text-text-muted">08:00 - 16:00, 8h, Zagreb (ured)</span>
</button>
<button
type="button"
disabled={savingManualEntry}
onClick={() => startManualEntry('sick_leave')}
className="rounded-lg border border-border-hairline px-3 py-2 text-left text-sm hover:bg-indigo-50"
>
<span className="block font-medium text-text-main">Bolovanje</span>
<span className="block text-xs text-text-muted">Unesi vrijeme po potrebi</span>
</button>
<button
type="button"
disabled={savingManualEntry}
onClick={() => startManualEntry('vacation')}
className="rounded-lg border border-border-hairline px-3 py-2 text-left text-sm hover:bg-indigo-50"
>
<span className="block font-medium text-text-main">Godisnji</span>
<span className="block text-xs text-text-muted">Unesi vrijeme po potrebi</span>
</button>
</div>
) : (
<div className="mt-4 space-y-3">
<div className="grid grid-cols-2 gap-3">
<label className="block">
<span className="mb-1 block text-[11px] font-semibold uppercase tracking-wide text-text-muted">Početak rada</span>
<input
type="time"
value={manualEntryDraft.startTime}
onChange={(event) => setManualEntryDraft((prev) => ({ ...prev, startTime: event.target.value }))}
className="w-full rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-sm text-text-main"
/>
</label>
<label className="block">
<span className="mb-1 block text-[11px] font-semibold uppercase tracking-wide text-text-muted">Kraj rada</span>
<input
type="time"
value={manualEntryDraft.endTime}
onChange={(event) => setManualEntryDraft((prev) => ({ ...prev, endTime: event.target.value }))}
className="w-full rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-sm text-text-main"
/>
</label>
</div>
<div className="rounded-lg bg-canvas-deep px-3 py-2 text-xs text-text-muted">
Redovan rad (h): {calculateHours(manualEntryDraft.startTime, manualEntryDraft.endTime) || '0'}
</div>
{manualEntryError && (
<p className="text-xs text-red-600">{manualEntryError}</p>
)}
<div className="flex gap-2">
<button
type="button"
disabled={savingManualEntry}
onClick={() => {
setManualEntryDraft(null);
setManualEntryError('');
}}
className="flex-1 rounded-lg border border-border-hairline px-3 py-2 text-sm hover:bg-canvas-deep"
>
Nazad
</button>
<button
type="button"
disabled={savingManualEntry}
onClick={handleSaveManualEntry}
className="flex-1 rounded-lg bg-indigo-600 px-3 py-2 text-sm text-white hover:bg-indigo-700 disabled:opacity-60"
>
{savingManualEntry ? 'Spremam...' : 'Spremi'}
</button>
</div>
</div>
)}
</div>
</div>
)}
{bulkDownloadOpen && reportType === 'servicer' && (
<div className="absolute inset-0 z-20 flex items-center justify-center bg-black/30 px-4">
<div className="w-full max-w-md rounded-xl border border-border-hairline bg-canvas-elevated p-4 shadow-2xl">
<div className="flex items-center justify-between gap-3">
<div>
<h3 className="text-sm font-semibold text-text-main">Preuzmi ZIP arhivu</h3>
<p className="mt-1 text-xs text-text-muted">
{MONTH_NAMES[viewMonth]} {viewYear}
</p>
</div>
<button
type="button"
onClick={() => setBulkDownloadOpen(false)}
className="rounded p-1 text-text-muted hover:bg-canvas-deep"
aria-label="Zatvori"
>
x
</button>
</div>
<div className="mt-4 grid gap-2">
<button
type="button"
disabled={downloadingAllTasks || downloadingAllWorkOrders}
onClick={handleDownloadAllTasksArchive}
className="rounded-lg border border-border-hairline px-3 py-2 text-left text-sm hover:bg-indigo-50 disabled:opacity-60"
>
{downloadingAllTasks ? 'Generiranje...' : 'Servisni taskovi (SN)'}
</button>
<button
type="button"
disabled={downloadingAllTasks || downloadingAllWorkOrders}
onClick={handleDownloadAllWorkOrdersArchive}
className="rounded-lg border border-border-hairline px-3 py-2 text-left text-sm hover:bg-indigo-50 disabled:opacity-60"
>
{downloadingAllWorkOrders ? 'Generiranje...' : 'Putni nalozi + računi (PN)'}
</button>
</div>
</div>
</div>
)}
</div>
)}
</LeftSlideDrawer>
);
}

View File

@@ -1,28 +1,143 @@
import { useEffect, useState } from 'preact/hooks';
import { createTask, getStatusLabel } from '../../stores/taskStore';
import { formatEntityCode, formatPurposeLabel } from '../../lib/displayIds';
import { useEffect, useMemo, useState } from 'preact/hooks';
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', 'neaktivan'];
const STATUS_OPTIONS = ['aktivan', 'servis', 'zavrsen', 'neaktivan'];
const INITIAL_FORM = {
title: '',
description: '',
status: 'aktivan',
work_order: '',
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);
const [error, setError] = useState('');
const [templateMode, setTemplateMode] = useState(false);
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,
[templates, templateId]
);
const availableWorkOrders = useMemo(
() => workOrders.filter((workOrder) => String(workOrder?.status || '').toLowerCase() !== 'closed'),
[workOrders]
);
useEffect(() => {
if (!open) {
setForm(INITIAL_FORM);
setSubmitting(false);
setError('');
setTemplateMode(false);
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;
setForm((prev) => ({
...prev,
title: selectedTemplate.title || '',
description: selectedTemplate.description || '',
status: 'aktivan',
work_order: '',
// scheduled_date is kept so user can set it in template mode too
}));
}, [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;
@@ -30,33 +145,70 @@ export default function TaskCreateModal({ open, workOrders = [], contextCrane =
setForm((prev) => ({ ...prev, [name]: value }));
}
async function enableTemplateMode() {
if (templateMode) {
setTemplateMode(false);
setTemplateId('');
setForm(INITIAL_FORM);
return;
}
setError('');
setTemplateLoading(true);
try {
const fetched = await fetchTaskTemplates();
setTemplates(fetched);
setTemplateMode(true);
} catch (err) {
setError(err?.message || 'Neuspješno učitavanje templatea.');
} finally {
setTemplateLoading(false);
}
}
async function submit(event) {
event.preventDefault();
setError('');
const title = String(form.title || '').trim();
if (!title) {
setError('Naslov zadatka je obavezan.');
return;
}
if (form.status === 'neaktivan' && !form.work_order) {
setError('Putni nalog je obavezan prije zatvaranja zadatka.');
return;
}
if (!contextCrane?.id) {
setError('Odaberite dizalicu u Servisnom kontekstu prije kreiranja zadatka.');
return;
}
setSubmitting(true);
try {
await createTask({
let payload;
if (templateMode) {
if (!templateId) {
setError('Odaberite template za unos zadatka.');
return;
}
payload = {
template_id: templateId,
vehicle: contextCrane.id,
work_order: null,
scheduled_date: form.scheduled_date || null,
};
} else {
const title = String(form.title || '').trim();
if (!title) {
setError('Naslov zadatka je obavezan.');
return;
}
if ((form.status === 'neaktivan' || form.status === 'zavrsen') && !form.work_order) {
setError('Putni nalog je obavezan prije zatvaranja zadatka.');
return;
}
payload = {
title,
description: String(form.description || '').trim(),
status: form.status || 'aktivan',
vehicle: contextCrane.id,
work_order: form.work_order || null,
});
scheduled_date: form.scheduled_date || null,
};
}
setSubmitting(true);
try {
await createTask(payload);
await onSuccess?.();
onClose?.();
} catch (err) {
@@ -67,15 +219,14 @@ export default function TaskCreateModal({ open, workOrders = [], contextCrane =
}
return (
<div
className="fixed inset-0 z-50 overflow-y-auto bg-slate-950/15 p-4 pt-20 backdrop-blur-[2px]"
onClick={onClose}
<>
<ModalShell
onClose={onClose}
overlayClassName="z-50 overflow-y-auto p-4 pt-20"
contentClassName="flex items-start justify-center min-h-full"
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 min-h-full items-start justify-center">
<div
className="w-full max-w-xl max-h-[calc(100vh-2rem)] overflow-hidden rounded-xl border border-border-hairline bg-canvas-elevated shadow-xl"
onClick={(event) => event.stopPropagation()}
>
<div className="w-full flex flex-col">
<div className="flex items-center justify-between border-b border-border-hairline px-5 py-4">
<div>
<h3 className="text-lg font-semibold text-text-main">Novi radni zadatak</h3>
@@ -83,26 +234,93 @@ export default function TaskCreateModal({ open, workOrders = [], contextCrane =
Kontekst dizalice: {contextCrane?.registration_number || 'Nije odabrana'}
</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 modal"
>
</button>
<div className="flex items-center gap-2">
<button
type="button"
onClick={enableTemplateMode}
disabled={templateLoading || submitting}
className="rounded-md border border-border-hairline px-3 py-1 text-xs font-medium text-text-main hover:bg-canvas-deep disabled:opacity-60"
>
{templateMode ? 'Ručni unos' : 'Unos iz templatea'}
</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 modal"
>
</button>
</div>
</div>
<form onSubmit={submit} className="max-h-[calc(100vh-8rem)] space-y-4 overflow-y-auto px-5 py-4">
{templateMode && (
<label className="flex flex-col gap-1 text-sm">
<span className="font-medium text-text-main">Template zadatka *</span>
<select
value={templateId}
onChange={(event) => setTemplateId(event.currentTarget.value)}
disabled={submitting || templateLoading}
className="rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-text-main disabled:opacity-50"
>
<option value=""> Odaberite template </option>
{templates.map((template) => (
<option key={template.id} value={String(template.id)}>
{template.title}
</option>
))}
</select>
{selectedTemplate && (
<span className="text-[11px] text-text-muted">
Generirat će se do 10 servisnih zapisa iz predloška (vidljivo u tablici Servisni zapisi).
</span>
)}
</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
type="text"
value={form.title}
onInput={(event) => setField('title', event.currentTarget.value)}
disabled={submitting}
disabled={submitting || templateMode}
className="rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-text-main disabled:opacity-50"
required
required={!templateMode}
/>
</label>
@@ -112,7 +330,7 @@ export default function TaskCreateModal({ open, workOrders = [], contextCrane =
rows={3}
value={form.description}
onInput={(event) => setField('description', event.currentTarget.value)}
disabled={submitting}
disabled={submitting || templateMode}
className="resize-none rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-text-main disabled:opacity-50"
/>
</label>
@@ -123,7 +341,7 @@ export default function TaskCreateModal({ open, workOrders = [], contextCrane =
<select
value={form.status}
onChange={(event) => setField('status', event.currentTarget.value)}
disabled={submitting}
disabled={submitting || templateMode}
className="rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-text-main disabled:opacity-50"
>
{STATUS_OPTIONS.map((status) => (
@@ -135,21 +353,43 @@ export default function TaskCreateModal({ open, workOrders = [], contextCrane =
</label>
<label className="flex flex-col gap-1 text-sm">
<span className="font-medium text-text-main">Planirani datum izvršenja</span>
<input
type="date"
value={form.scheduled_date}
onInput={(event) => setField('scheduled_date', event.currentTarget.value)}
disabled={submitting}
className="rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-text-main disabled:opacity-50"
/>
</label>
<label className="flex flex-col gap-1 text-sm sm:col-span-2">
<span className="font-medium text-text-main">Povezani putni nalog</span>
<select
value={form.work_order}
onChange={(event) => setField('work_order', event.currentTarget.value)}
disabled={submitting}
disabled={submitting || templateMode}
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>
{workOrders.map((workOrder) => (
<option key={workOrder.id} value={String(workOrder.id)}>
{formatEntityCode('PN', workOrder.id)} {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>
{workOrders.length === 0 && (
<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.
</span>
)}
{availableWorkOrders.length === 0 && (
<span className="text-[11px] text-amber-600">
Nema dostupnih naloga za odabranu dizalicu.
</span>
@@ -175,14 +415,92 @@ export default function TaskCreateModal({ open, workOrders = [], contextCrane =
<button
type="submit"
className="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-700 disabled:opacity-60"
disabled={submitting}
disabled={submitting || templateLoading}
>
{submitting ? 'Spremanje...' : 'Kreiraj radni zadatak'}
</button>
</div>
</form>
</div>
</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="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,6 +1,15 @@
import { useEffect, useMemo, useState } from 'preact/hooks';
import { useEffect, useMemo, useState } from 'preact/hooks';
import ModalShell from '../ui/ModalShell';
import { formatEntityCode } from '../../lib/displayIds';
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 '-';
@@ -9,6 +18,13 @@ function formatDate(value) {
return parsed.toLocaleDateString('hr-HR');
}
function formatDateTime(value) {
if (!value) return '-';
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return '-';
return parsed.toLocaleString('hr-HR');
}
function formatCost(value) {
const numeric = Number(value);
if (Number.isNaN(numeric)) return '-';
@@ -23,12 +39,27 @@ export default function TaskServiceRecordsModal({
onClose,
onOpenServiceRecord,
onAssignWorkOrder,
onUpdateTask,
onCreateServiceRecord,
onSetServiceContext,
}) {
const [showWorkOrderEditor, setShowWorkOrderEditor] = useState(false);
const [selectedWorkOrderId, setSelectedWorkOrderId] = useState('');
const [assigningWorkOrder, setAssigningWorkOrder] = useState(false);
const [assignError, setAssignError] = useState('');
const [showTaskEditor, setShowTaskEditor] = useState(false);
const [updatingTask, setUpdatingTask] = useState(false);
const [taskError, setTaskError] = useState('');
const [taskForm, setTaskForm] = useState({
title: '',
description: '',
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;
@@ -41,17 +72,90 @@ export default function TaskServiceRecordsModal({
setShowWorkOrderEditor(!task.work_order);
setAssignError('');
setAssigningWorkOrder(false);
setShowTaskEditor(false);
setTaskError('');
setUpdatingTask(false);
setTaskForm({
title: String(task.title || ''),
description: String(task.description || ''),
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 || '');
const handleWorkOrderChange = async (event) => {
const nextValue = String(event.currentTarget.value || '');
@@ -68,9 +172,63 @@ export default function TaskServiceRecordsModal({
}
};
const handleTaskUpdate = async (event) => {
event.preventDefault();
const title = String(taskForm.title || '').trim();
const status = String(taskForm.status || 'aktivan');
const description = String(taskForm.description || '').trim();
if (!title) {
setTaskError('Naslov zadatka je obavezan.');
return;
}
if ((status === 'zavrsen' || status === 'neaktivan') && !effectiveWorkOrderId) {
setTaskError('Putni nalog je obavezan prije zatvaranja zadatka.');
return;
}
setTaskError('');
setUpdatingTask(true);
try {
await onUpdateTask?.(task, {
title,
description,
status,
scheduled_date: taskForm.scheduled_date || null,
});
setShowTaskEditor(false);
} catch (error) {
const conflictCode = error?.details?.code;
if (error?.status === 409 && conflictCode === 'OPEN_WORK_ORDER_BLOCKING_TASK_CLOSE') {
const confirmText = error?.details?.message || error?.message || 'Povezani putni nalog je još otvoren. Želite li ga automatski zatvoriti?';
const approved = typeof window !== 'undefined' ? window.confirm(confirmText) : false;
if (approved) {
try {
await onUpdateTask?.(task, {
title,
description,
status,
scheduled_date: taskForm.scheduled_date || null,
auto_close_work_order: true,
});
setShowTaskEditor(false);
return;
} catch (retryError) {
setTaskError(retryError?.message || 'Neuspješno automatsko zatvaranje putnog naloga i završetak zadatka.');
return;
}
}
setTaskError(confirmText);
return;
}
setTaskError(error?.message || 'Neuspješno spremanje izmjena radnog zadatka.');
} finally {
setUpdatingTask(false);
}
};
if (!open || !task) return null;
return (
<>
<ModalShell
onClose={onClose}
overlayClassName="z-50 overflow-y-auto p-4 pt-2"
@@ -82,27 +240,215 @@ export default function TaskServiceRecordsModal({
<div>
<h3 className="text-lg font-semibold text-text-main">Radni zadatak detalji</h3>
<p className="text-xs text-text-muted">
Radni zadatak: {task.title || '-'} Status: {task.status || '-'} Zapisa: {records.length}
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">
<div className="space-y-4">
<div className="rounded-lg border border-border-hairline bg-canvas-base p-3">
<div className="mb-2 flex items-center justify-between gap-2">
<h4 className="text-sm font-semibold text-text-main">Podaci radnog zadatka</h4>
{!showTaskEditor ? (
<button
type="button"
onClick={() => {
setTaskError('');
setShowTaskEditor(true);
}}
className="rounded border border-border-hairline px-2 py-1 text-xs text-text-main hover:bg-canvas-base"
>
Izmijeni radni zadatak
</button>
) : null}
</div>
{!showTaskEditor ? (
<dl className="grid gap-2 text-sm sm:grid-cols-2">
<div>
<dt className="text-xs text-text-muted">Naslov</dt>
<dd className="text-text-main">{task.title || '-'}</dd>
</div>
<div>
<dt className="text-xs text-text-muted">Status</dt>
<dd className="text-text-main">{getStatusLabel(task.status || '-')}</dd>
</div>
<div>
<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="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>
<dd className="text-text-main">{task.work_order_label || '-'}</dd>
</div>
<div>
<dt className="text-xs text-text-muted">Planirani datum</dt>
<dd className="text-text-main">{task.scheduled_date ? formatDate(task.scheduled_date) : '-'}</dd>
</div>
<div>
<dt className="text-xs text-text-muted">Vrijeme izrade</dt>
<dd className="text-text-main">{formatDateTime(task.created_at)}</dd>
</div>
<div className="sm:col-span-2">
<dt className="text-xs text-text-muted">Opis</dt>
<dd className="whitespace-pre-wrap text-text-main">{task.description || '-'}</dd>
</div>
</dl>
) : (
<form onSubmit={handleTaskUpdate} className="space-y-3">
<label className="flex flex-col gap-1 text-sm">
<span className="text-xs text-text-muted">Naslov *</span>
<input
type="text"
value={taskForm.title}
onInput={(event) => setTaskForm((prev) => ({ ...prev, title: event.currentTarget.value }))}
disabled={updatingTask}
className="rounded-lg border border-border-hairline bg-canvas-elevated px-3 py-2 text-text-main disabled:opacity-60"
required
/>
</label>
<label className="flex flex-col gap-1 text-sm">
<span className="text-xs text-text-muted">Opis</span>
<textarea
rows={3}
value={taskForm.description}
onInput={(event) => setTaskForm((prev) => ({ ...prev, description: event.currentTarget.value }))}
disabled={updatingTask}
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
value={taskForm.status}
onChange={(event) => setTaskForm((prev) => ({ ...prev, status: event.currentTarget.value }))}
disabled={updatingTask}
className="rounded-lg border border-border-hairline bg-canvas-elevated px-3 py-2 text-text-main disabled:opacity-60"
>
{STATUS_OPTIONS.map((status) => (
<option key={status} value={status}>
{getStatusLabel(status)}
</option>
))}
</select>
</label>
<label className="flex flex-col gap-1 text-sm">
<span className="text-xs text-text-muted">Planirani datum izvršenja</span>
<input
type="date"
value={taskForm.scheduled_date || ''}
onInput={(event) => setTaskForm((prev) => ({ ...prev, scheduled_date: event.currentTarget.value }))}
disabled={updatingTask}
className="rounded-lg border border-border-hairline bg-canvas-elevated px-3 py-2 text-text-main disabled:opacity-60"
/>
</label>
{taskError ? (
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
{taskError}
</div>
) : null}
<div className="flex justify-end gap-2">
<button
type="button"
onClick={() => {
setTaskError('');
setShowTaskEditor(false);
setTaskForm({
title: String(task.title || ''),
description: String(task.description || ''),
status: String(task.status || 'aktivan'),
scheduled_date: String(task.scheduled_date || ''),
});
}}
disabled={updatingTask}
className="rounded border border-border-hairline px-3 py-1.5 text-xs text-text-main hover:bg-canvas-elevated disabled:opacity-60"
>
Odustani
</button>
<button
type="submit"
disabled={updatingTask}
className="rounded bg-indigo-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-indigo-700 disabled:opacity-60"
>
{updatingTask ? 'Spremanje...' : 'Spremi izmjene'}
</button>
</div>
</form>
)}
</div>
<div className="rounded-lg border border-border-hairline bg-canvas-base p-3">
<h4 className="mb-2 text-sm font-semibold text-text-main">Putni nalozi</h4>
{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">
{formatEntityCode('PN', currentWorkOrder.id)} {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"
@@ -121,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)}>
{formatEntityCode('PN', workOrder.id)} {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>
)}
@@ -195,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

@@ -0,0 +1,536 @@
import { useEffect, useMemo, useState } from 'preact/hooks';
import ModalShell from '../ui/ModalShell';
// ─── helpers ────────────────────────────────────────────────────────────────
function parseTime(str) {
if (!str || typeof str !== 'string') return null;
const match = str.trim().match(/^(\d{1,2}):(\d{2})$/);
if (!match) return null;
const h = parseInt(match[1], 10);
const m = parseInt(match[2], 10);
if (h > 23 || m > 59) return null;
return h + m / 60;
}
function calcHours(fromStr, toStr) {
const from = parseTime(fromStr);
const to = parseTime(toStr);
if (from === null || to === null) return null;
let diff = to - from;
if (diff < 0) diff += 24; // overnight
return Math.max(0, diff);
}
function parseNum(value) {
if (value == null || value === '') return 0;
const n = Number(String(value).replace(',', '.'));
return Number.isFinite(n) ? n : 0;
}
function fmtH(h) { return h.toFixed(1).replace('.', ','); }
/** Converts decimal hours back to "HH:MM" string. */
function formatTime(decimalHours) {
if (!Number.isFinite(decimalHours)) return '';
let h = ((decimalHours % 24) + 24) % 24;
const hours = Math.floor(h);
let mins = Math.round((h - hours) * 60);
if (mins === 60) return `${String(hours + 1).padStart(2, '0')}:00`;
return `${String(hours).padStart(2, '0')}:${String(mins).padStart(2, '0')}`;
}
/** Returns 'travel' | 'work' | 'empty' for a given row. */
function rowMode(row) {
if (row?.travel_time_from?.trim() || row?.travel_time_to?.trim()) return 'travel';
if (row?.work_time_from?.trim() || row?.work_time_to?.trim()) return 'work';
return 'empty';
}
/**
* Applies mutual-exclusion and auto-calculation to a single row.
* Travel mode → clears work fields, computes travel_hours from times break.
* Work mode → clears travel fields + vehicle_km, computes work_hours.
* Empty mode → clears both calculated totals.
*/
function computeRow(row) {
const r = { ...row };
const mode = rowMode(r);
if (mode === 'travel') {
r.work_time_from = '';
r.work_time_to = '';
r.work_hours = '';
const raw = calcHours(r.travel_time_from, r.travel_time_to);
r.travel_hours = raw !== null ? fmtH(Math.max(0, raw - parseNum(r.break_hours))) : '';
} else if (mode === 'work') {
r.travel_time_from = '';
r.travel_time_to = '';
r.travel_hours = '';
r.vehicle_km = '';
const raw = calcHours(r.work_time_from, r.work_time_to);
r.work_hours = raw !== null ? fmtH(Math.max(0, raw - parseNum(r.break_hours))) : '';
} else {
r.work_hours = '';
r.travel_hours = '';
}
return r;
}
const INTERNAL = ['_groupId', '_role'];
function stripInternal(row) { const r = { ...row }; for (const f of INTERNAL) delete r[f]; return r; }
let _gidSeq = 0;
function newGroupId() { _gidSeq += 1; return `g${_gidSeq}`; }
// ─── constants ───────────────────────────────────────────────────────────────
const EMPTY_ROW = {
day: '', date: '',
work_time_from: '', work_time_to: '',
travel_time_from: '', travel_time_to: '',
break_hours: '',
work_hours: '', travel_hours: '',
departure_place: '', arrival_place: '',
vehicle_km: '',
};
/**
* computed: true → value is auto-calculated, never editable directly.
* travelOnly: true → disabled when mode === 'work'.
* workOnly: true → disabled when mode === 'travel'.
*/
const FIELD_CONFIGS = [
{ key: 'day', label: 'Dan', type: 'select' },
{ key: 'date', label: 'Datum', type: 'text', placeholder: 'dd.mm.gggg' },
{ key: 'work_time_from', label: 'Vr. rada od', type: 'time', workOnly: true },
{ key: 'work_time_to', label: 'Vr. rada do', type: 'time', workOnly: true },
{ key: 'travel_time_from', label: 'Put od', type: 'time', travelOnly: true },
{ key: 'travel_time_to', label: 'Put do', type: 'time', travelOnly: true },
{ key: 'break_hours', label: 'Pauza h', type: 'number', step: '0.5', min: '0', placeholder: '0.0' },
{ key: 'work_hours', label: 'Sati rada', type: 'number', step: '0.1', min: '0', placeholder: '0.0', computed: true },
{ key: 'travel_hours', label: 'Sati puta', type: 'number', step: '0.1', min: '0', placeholder: '0.0', computed: true },
{ key: 'departure_place', label: 'Mj. polaska', type: 'text', placeholder: 'Zagreb', travelOnly: true },
{ key: 'arrival_place', label: 'Mj. dolaska', type: 'text', placeholder: 'npr. Split', travelOnly: true },
{ key: 'vehicle_km', label: 'Km vozila', type: 'number', step: '1', min: '0', placeholder: '0', travelOnly: true },
];
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',
'text-gray-900 placeholder-gray-400',
'focus:border-blue-500 focus:ring-2 focus:ring-blue-500 focus:outline-none',
'dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400',
'dark:focus:border-blue-500 dark:focus:ring-blue-500',
'transition-colors duration-150',
].join(' ');
const FB_MUTED = [
'block w-full rounded-lg border border-gray-200 bg-gray-100 px-2.5 py-2 text-xs',
'text-gray-400 cursor-default select-none',
'dark:border-gray-700 dark:bg-gray-800 dark:text-gray-500',
].join(' ');
// ─── component ───────────────────────────────────────────────────────────────
export default function TaskWorkHoursTableModal({
open,
task,
workOrder = null,
defaultLocation = '',
saving = false,
onClose,
onSave,
}) {
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, ...defaultDayDate },
{ ...EMPTY_ROW, ...defaultDayDate },
{ ...EMPTY_ROW, ...defaultDayDate },
],
);
}, [open, task?.id, task?.scheduled_date, task?.work_hours_table]);
if (!open || !task) return null;
// ── derived totals ─────────────────────────────────────────────────────
const totals = useMemo(() => {
const travel = rows.reduce((s, r) => s + parseNum(r.travel_hours), 0);
const work = rows.reduce((s, r) => s + parseNum(r.work_hours), 0);
const km = rows.reduce((s, r) => s + parseNum(r.vehicle_km), 0);
return {
travel: fmtH(travel),
work: fmtH(work),
km: Number.isInteger(km) ? String(km) : km.toFixed(1),
};
}, [rows]);
// ── row mutation ───────────────────────────────────────────────────────
const updateRowField = (index, field, value) => {
setRows((prev) => {
// Step 1: update the changed row with full mutual-exclusion + calc
let updated = prev.map((row, i) => {
if (i !== index) return row;
return computeRow({ ...row, [field]: value });
});
const changed = updated[index];
const gid = changed?._groupId;
const role = changed?._role;
if (!gid) return updated;
// Step 2: departure row changes → sync return row
if (role === 'departure') {
updated = updated.map((row, i) => {
if (i === index || row._groupId !== gid || row._role !== 'return') return row;
let returnRow = { ...row };
// Mirror day/date always
if (field === 'day' || field === 'date') {
returnRow[field] = value;
}
// Mirror break_hours
if (field === 'break_hours') {
returnRow.break_hours = value;
}
// Mirror vehicle_km
if (field === 'vehicle_km') {
returnRow.vehicle_km = value;
}
// Mirror travel times + recalculate return travel_to from duration
if (field === 'travel_time_from' || field === 'travel_time_to') {
returnRow.travel_time_from = changed.travel_time_from;
// Recalculate return's travel_to: return departs when work ends (travel_time_from stays)
// and duration equals departure trip duration
const depDuration = calcHours(changed.travel_time_from, changed.travel_time_to);
if (depDuration !== null && returnRow.travel_time_from) {
const retFrom = parseTime(returnRow.travel_time_from);
if (retFrom !== null) returnRow.travel_time_to = formatTime(retFrom + depDuration);
}
}
// Mirror places (swapped): arrival of departure → departure of return
if (field === 'arrival_place') {
returnRow.departure_place = value;
}
if (field === 'departure_place') {
returnRow.arrival_place = value;
}
return computeRow(returnRow);
});
}
// Step 3: work row's work_time_to → fill return row's travel_time_from
// and recalculate return row's travel_time_to using departure duration
if (role === 'work' && field === 'work_time_to') {
const depRow = updated.find(r => r._groupId === gid && r._role === 'departure');
const depDuration = depRow ? calcHours(depRow.travel_time_from, depRow.travel_time_to) : null;
updated = updated.map((row, i) => {
if (i === index || row._groupId !== gid || row._role !== 'return') return row;
const newFrom = value; // Put od = when work ends
const newTo = (depDuration !== null && parseTime(newFrom) !== null)
? formatTime(parseTime(newFrom) + depDuration)
: row.travel_time_to;
return computeRow({ ...row, travel_time_from: newFrom, travel_time_to: newTo });
});
}
return updated;
});
};
const addRow = () => setRows((prev) => [...prev, { ...EMPTY_ROW }]);
const addThreeRows = () => {
const defaults = getDefaultDayDate(true);
const gid = newGroupId();
const loc = defaultLocation || '';
setRows((prev) => [
...prev,
{ ...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' },
]);
};
const removeRow = (i) => setRows((prev) => prev.filter((_, idx) => idx !== i));
const handleSave = async () => {
await onSave?.({ rows: rows.map(stripInternal) });
};
// ── field rendering ────────────────────────────────────────────────────
const isDisabled = (row, f) => {
const mode = rowMode(row);
if (f.computed) return true;
if (f.travelOnly && mode === 'work') return true;
if (f.workOnly && mode === 'travel') return true;
return false;
};
const renderCell = (row, idx, f) => {
const disabled = isDisabled(row, f);
const cls = disabled ? FB_MUTED : FB_INPUT;
if (f.type === 'select') {
return (
<select
value={row[f.key] || ''}
onChange={(e) => !disabled && updateRowField(idx, f.key, e.currentTarget.value)}
disabled={disabled}
className={cls}
>
{DAY_OPTIONS.map((o) => <option key={o || '_'} value={o}>{o || '-'}</option>)}
</select>
);
}
return (
<input
type={f.type}
value={row[f.key] || ''}
onInput={(e) => !disabled && updateRowField(idx, f.key, e.currentTarget.value)}
readOnly={f.computed}
disabled={disabled}
className={cls}
placeholder={f.placeholder || ''}
step={f.step}
min={f.min}
/>
);
};
// ── render ─────────────────────────────────────────────────────────────
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-[96vw] 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">
{/* ── header ── */}
<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">Tablica radnih sati</h3>
<p className="text-xs text-text-muted">{task.title || '-'}</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">
{/* ── 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>
<span><span className="font-semibold">Rad (oddo):</span> unesi Vr. rada od/do Sati rada se računaju automatski; Km, Put, Mj. polaska/dolaska su blokirani.</span>
<span><span className="font-semibold">Pauza</span> umanjuje izračunate sate u oba slučaja.</span>
</div>
{/* ── toolbar ── */}
<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>
<button type="button" onClick={addThreeRows}
className="inline-flex items-center gap-1 rounded-lg border border-indigo-300 bg-indigo-50 px-3 py-1.5 text-xs font-medium text-indigo-700 hover:bg-indigo-100 dark:border-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-300 dark:hover:bg-indigo-900/50">
+ Dodaj standardna 3 retka (Put / Rad / Povratak)
</button>
</div>
{/* ── mobile cards (< xl) ── */}
<div className="space-y-3 xl:hidden">
{rows.length === 0 && (
<p className="text-sm text-gray-400 dark:text-gray-500">Nema unesenih redaka.</p>
)}
{rows.map((row, idx) => (
<div key={idx} className="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-700 dark:bg-gray-800">
<div className="mb-3 flex items-center justify-between">
<span className="text-xs font-bold text-gray-500 dark:text-gray-400">#{idx + 1}</span>
<button type="button" onClick={() => removeRow(idx)}
className="rounded-md border border-red-200 px-2 py-1 text-[11px] font-medium text-red-600 hover:bg-red-50 dark:border-red-800 dark:text-red-400 dark:hover:bg-red-900/20">
Obriši
</button>
</div>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
{FIELD_CONFIGS.map((f) => (
<label key={f.key} className="flex flex-col gap-0.5">
<span className="text-[11px] font-medium text-gray-600 dark:text-gray-300">{f.label}</span>
{renderCell(row, idx, f)}
</label>
))}
</div>
</div>
))}
{rows.length > 0 && (
<div className="rounded-lg border border-gray-300 bg-gray-50 px-4 py-2 text-xs font-semibold text-gray-700 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200">
UKUPNO
<span className="ml-2">Sati rada: <span className="text-green-700 dark:text-green-400">{totals.work} h</span></span>
<span className="mx-2 text-gray-300 dark:text-gray-600">·</span>
<span>Sati puta: <span className="text-blue-700 dark:text-blue-400">{totals.travel} h</span></span>
<span className="mx-2 text-gray-300 dark:text-gray-600">·</span>
<span>Km vozila: <span className="text-gray-800 dark:text-gray-100">{totals.km} km</span></span>
</div>
)}
</div>
{/* ── desktop table (≥ xl) ── */}
<div className="hidden xl:block">
<div className="overflow-auto rounded-xl border border-gray-200 shadow-sm dark:border-gray-700"> <table className="min-w-[1580px] 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 dark:text-gray-500">#</th>
{FIELD_CONFIGS.map((f) => (
<th key={f.key} className="px-2 py-2.5 font-semibold text-gray-700 dark:text-gray-200 whitespace-nowrap">
{f.label}
{f.computed && <span className="ml-1 text-[9px] font-normal text-indigo-400">(auto)</span>}
</th>
))}
<th className="px-2 py-2.5 text-gray-400 dark:text-gray-500">Akcija</th>
</tr>
</thead>
<tbody>
{rows.length === 0 && (
<tr>
<td colSpan={FIELD_CONFIGS.length + 2}
className="px-4 py-8 text-center text-sm text-gray-400 dark:text-gray-500">
Nema unesenih redaka.
</td>
</tr>
)}
{rows.map((row, idx) => (
<tr key={idx}
className="border-t border-gray-100 odd:bg-white even:bg-gray-50/60 hover:bg-blue-50/30 dark:border-gray-700 dark:odd:bg-gray-900 dark:even:bg-gray-800/60 dark:hover:bg-blue-900/10">
<td className="px-2 py-1.5 text-center text-[11px] text-gray-400 dark:text-gray-500">{idx + 1}</td>
{FIELD_CONFIGS.map((f) => (
<td key={f.key} className="px-1 py-1 align-middle">
{renderCell(row, idx, f)}
</td>
))}
<td className="px-1 py-1 align-middle">
<button type="button" onClick={() => removeRow(idx)}
className="whitespace-nowrap rounded-md border border-red-200 px-2 py-1 text-[11px] font-medium text-red-600 hover:bg-red-50 dark:border-red-800 dark:text-red-400 dark:hover:bg-red-900/20">
Obriši
</button>
</td>
</tr>
))}
{rows.length > 0 && (
/* cols: # day date work_from work_to travel_from travel_to break → 8 cols
then: work_hours travel_hours departure arrival vehicle_km action */
<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={8} className="px-3 py-2 text-gray-700 dark:text-gray-200">UKUPNO</td>
<td className="px-2 py-2 text-right text-gray-800 dark:text-gray-100 whitespace-nowrap">
<span className="font-normal text-gray-500 dark:text-gray-400 mr-1">Sati rada:</span>{totals.work} h
</td>
<td className="px-2 py-2 text-right text-gray-800 dark:text-gray-100 whitespace-nowrap">
<span className="font-normal text-gray-500 dark:text-gray-400 mr-1">Sati puta:</span>{totals.travel} h
</td>
<td colSpan={2} className="px-2 py-2" />
<td className="px-2 py-2 text-right text-gray-800 dark:text-gray-100 whitespace-nowrap">
<span className="font-normal text-gray-500 dark:text-gray-400 mr-1">Km:</span>{totals.km}
</td>
<td className="px-2 py-2" />
</tr>
)}
</tbody>
</table>
</div>
</div>
</div>
{/* ── footer ── */}
<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 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700">
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 dark:bg-blue-700 dark:hover:bg-blue-600">
{saving ? 'Spremam…' : 'Spremi tablicu radnih sati'}
</button>
</div>
</div>
</ModalShell>
);
}

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,20 +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,
fetchVehicleById,
fetchTasksByWorkOrder,
fetchTeamMembers,
updateVehicle,
fetchWorkOrderInvoices,
fetchWorkOrderImages,
openWorkOrderInvoicesPdfPage,
sendWorkOrderEmail,
updateVehicle,
} from '../../stores/fleetDashboardStore';
import { getStatusLabel } from '../../stores/taskStore';
import { showToast } from '../../stores/toastStore';
import { $craneSerialByVehicle, cacheCraneSerial, getCachedCraneSerial } from '../../stores/craneSerialStore';
import { formatEntityCode } from '../../lib/displayIds';
import { formatWorkOrderDisplayCode } from '../../lib/displayIds';
import { $serviceNotes, $serviceNotesLoading, fetchServiceNotes } from '../../stores/serviceNotesStore';
const PURPOSE_OPTIONS = [
{ value: 'defektaza', label: 'Defektaža' },
@@ -23,21 +26,26 @@ const PURPOSE_OPTIONS = [
];
export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmit }) {
const serialByVehicle = useStore($craneSerialByVehicle);
const serviceNotes = useStore($serviceNotes);
const serviceNotesLoading = useStore($serviceNotesLoading);
const [isEditing, setIsEditing] = useState(false);
const [form, setForm] = useState({
has_travel_order: false,
display_code: '',
status: 'open',
start_mileage: '',
end_mileage: '',
servicer_vehicle_registration: '',
servicer_vehicle_make_model: '',
servicer_vehicle_start_mileage: '',
servicer_vehicle_end_mileage: '',
servicer_vehicle_fuel_cost: '',
origin_location: 'Zagreb',
location: '',
travel_start_at: '',
travel_end_at: '',
purpose: '',
notes: '',
crane_current_mileage: '',
crane_superstructure_working_hours: '',
crane_chassis_working_hours: '',
});
const [imageFiles, setImageFiles] = useState([]);
const [submitting, setSubmitting] = useState(false);
@@ -52,9 +60,17 @@ 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);
const [photoUploadOpen, setPhotoUploadOpen] = useState(false);
const [invoiceSaving, setInvoiceSaving] = useState(false);
const [invoiceForm, setInvoiceForm] = useState({
naziv_racuna: '',
@@ -63,22 +79,18 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi
opis: '',
imageFile: null,
});
const [resolvedCraneSerialNumber, setResolvedCraneSerialNumber] = useState('');
const [mapPickerOpen, setMapPickerOpen] = useState(false);
const [locationQuery, setLocationQuery] = useState('');
const [locationResults, setLocationResults] = useState([]);
const [selectedLocation, setSelectedLocation] = useState(null);
const [searchingLocation, setSearchingLocation] = useState(false);
const [mapError, setMapError] = useState('');
const targetCraneId = useMemo(
() => workOrder?.crane || workOrder?.vehicle || null,
[workOrder?.crane, workOrder?.vehicle]
);
const cachedCraneSerial = useMemo(
() => getCachedCraneSerial(targetCraneId),
[serialByVehicle, targetCraneId]
);
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 '-';
@@ -92,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;
@@ -101,22 +123,23 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi
useEffect(() => {
if (!open || !workOrder) return;
setIsEditing(false);
const initialSerial = String(workOrder.craneInfo?.crane_serial_number || cachedCraneSerial || '').trim();
setResolvedCraneSerialNumber(initialSerial);
cacheCraneSerial(targetCraneId, initialSerial);
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) : '',
purpose: workOrder.purpose || '',
notes: workOrder.notes || '',
crane_current_mileage: String(workOrder.craneInfo?.current_mileage ?? ''),
crane_superstructure_working_hours: String(workOrder.craneInfo?.superstructure_working_hours ?? ''),
crane_chassis_working_hours: String(workOrder.craneInfo?.chassis_working_hours ?? ''),
servicer_vehicle_registration: workOrder.servicer_vehicle_registration || '',
servicer_vehicle_make_model: workOrder.servicer_vehicle_make_model || '',
servicer_vehicle_start_mileage: workOrder.servicer_vehicle_start_mileage == null ? '' : String(workOrder.servicer_vehicle_start_mileage),
servicer_vehicle_end_mileage: workOrder.servicer_vehicle_end_mileage == null ? '' : String(workOrder.servicer_vehicle_end_mileage),
servicer_vehicle_fuel_cost: workOrder.servicer_vehicle_fuel_cost == null ? '' : String(workOrder.servicer_vehicle_fuel_cost),
});
setSubmitting(false);
setError('');
@@ -130,9 +153,17 @@ 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);
setPhotoUploadOpen(false);
setInvoiceSaving(false);
setInvoiceForm({
naziv_racuna: '',
@@ -147,38 +178,13 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi
setSelectedLocation(null);
setSearchingLocation(false);
setMapError('');
}, [open, workOrder, cachedCraneSerial, targetCraneId]);
useEffect(() => {
if (!open || !targetCraneId) {
return;
}
let cancelled = false;
(async () => {
try {
const freshCrane = await fetchVehicleById(targetCraneId);
if (cancelled || !freshCrane) {
return;
}
const incomingSerial = String(freshCrane.crane_serial_number || '').trim();
if (incomingSerial) {
cacheCraneSerial(targetCraneId, incomingSerial);
setResolvedCraneSerialNumber(incomingSerial);
}
setForm((prev) => ({
...prev,
crane_current_mileage: String(freshCrane.current_mileage ?? prev.crane_current_mileage ?? ''),
crane_superstructure_working_hours: String(freshCrane.superstructure_working_hours ?? prev.crane_superstructure_working_hours ?? ''),
crane_chassis_working_hours: String(freshCrane.chassis_working_hours ?? prev.crane_chassis_working_hours ?? ''),
}));
} catch {
// no-op: fallback remains workOrder.craneInfo values
}
})();
return () => {
cancelled = true;
};
}, [open, workOrder?.id, targetCraneId]);
setWorkOrderTasks([]);
setAddTaskModalOpen(false);
setCraneDataModalOpen(false);
setCraneDataForm({ superstructure_working_hours: '', chassis_working_hours: '', current_mileage: '' });
setSavingCraneData(false);
setCraneDataError('');
}, [open, workOrder]);
useEffect(() => {
if (!emailModalOpen) return;
@@ -186,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);
}
@@ -258,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);
@@ -272,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) {
@@ -310,13 +390,6 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi
const endMileage = form.end_mileage === '' ? null : Number(form.end_mileage);
let travelStart = null;
let travelEnd = null;
const craneCurrentMileage = form.crane_current_mileage === '' ? null : Number(form.crane_current_mileage);
const craneSuperstructureHours = form.crane_superstructure_working_hours === '' ? null : Number(form.crane_superstructure_working_hours);
const craneChassisHours = form.crane_chassis_working_hours === '' ? null : Number(form.crane_chassis_working_hours);
const existingCraneCurrentMileage = Number(workOrder?.craneInfo?.current_mileage ?? 0);
const existingCraneSuperstructureHours = Number(workOrder?.craneInfo?.superstructure_working_hours ?? 0);
const existingCraneChassisHours = Number(workOrder?.craneInfo?.chassis_working_hours ?? 0);
if (startMileage != null && (Number.isNaN(startMileage) || startMileage < 0)) {
setError('Početna kilometraža mora biti valjan nenegativan broj.');
return;
@@ -333,18 +406,6 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi
setError('Završna kilometraža mora biti veća ili jednaka početnoj.');
return;
}
if (craneCurrentMileage != null && (Number.isNaN(craneCurrentMileage) || craneCurrentMileage < 0)) {
setError('Trenutna km dizalice mora biti valjan nenegativan broj.');
return;
}
if (craneSuperstructureHours != null && (Number.isNaN(craneSuperstructureHours) || craneSuperstructureHours < 0)) {
setError('Radni sati nadogradnje moraju biti valjan nenegativan broj.');
return;
}
if (craneChassisHours != null && (Number.isNaN(craneChassisHours) || craneChassisHours < 0)) {
setError('Radni sati podvozja moraju biti valjan nenegativan broj.');
return;
}
if ((form.travel_start_at && !form.travel_end_at) || (!form.travel_start_at && form.travel_end_at)) {
setError('Unesite i početak i kraj puta, ili ostavite oba prazna.');
return;
@@ -369,50 +430,29 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi
setError('Svrha naloga je obavezna.');
return;
}
const displayCode = String(form.display_code || '').trim().toUpperCase();
if (!displayCode) {
setError('Prikaz putnog naloga je obavezan.');
return;
}
if (!/^[A-Z]{2}[0-9]{6}$/.test(displayCode)) {
setError('Prikaz putnog naloga mora biti u formatu MT070626.');
return;
}
setSubmitting(true);
try {
const hasCraneChanges = (
craneCurrentMileage != null
&& craneSuperstructureHours != null
&& craneChassisHours != null
&& (
craneCurrentMileage !== existingCraneCurrentMileage
|| craneSuperstructureHours !== existingCraneSuperstructureHours
|| craneChassisHours !== existingCraneChassisHours
)
);
if (hasCraneChanges && targetCraneId) {
let craneSerialNumber = String(resolvedCraneSerialNumber || '').trim();
if (!craneSerialNumber) {
craneSerialNumber = cachedCraneSerial;
}
if (!craneSerialNumber) {
const freshCrane = await fetchVehicleById(targetCraneId);
craneSerialNumber = String(freshCrane?.crane_serial_number || '').trim();
if (craneSerialNumber) {
cacheCraneSerial(targetCraneId, craneSerialNumber);
setResolvedCraneSerialNumber(craneSerialNumber);
}
}
if (!craneSerialNumber) {
// nastavi sa spremanjem sati i kilometraže za legacy dizalice bez SN
}
const cranePatch = {
current_mileage: craneCurrentMileage,
superstructure_working_hours: craneSuperstructureHours,
chassis_working_hours: craneChassisHours,
};
if (craneSerialNumber) {
cranePatch.crane_serial_number = craneSerialNumber;
}
await updateVehicle(targetCraneId, cranePatch);
}
await onSubmit(workOrder, {
display_code: displayCode,
status: form.status,
start_mileage: startMileage,
end_mileage: endMileage,
servicer_vehicle_registration: form.servicer_vehicle_registration.trim(),
servicer_vehicle_make_model: form.servicer_vehicle_make_model.trim(),
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,
@@ -481,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.');
@@ -523,7 +568,7 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi
<div>
<div className="flex items-center justify-between border-b border-border-hairline px-5 py-4">
<div>
<h3 className="text-lg font-semibold text-text-main">Uredi nalog {formatEntityCode('PN', workOrder.id)}</h3>
<h3 className="text-lg font-semibold text-text-main">Uredi putni nalog {formatWorkOrderDisplayCode(workOrder)}</h3>
<p className="text-sm text-text-muted">Dizalica: {craneLabel}</p>
</div>
<button
@@ -582,8 +627,49 @@ 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">
<span className="font-medium text-text-main">Prikaz putnog naloga *</span>
<input
type="text"
value={form.display_code}
onInput={(event) => setForm({ ...form, display_code: event.currentTarget.value.toUpperCase() })}
className="rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-text-main disabled:bg-canvas-deep"
pattern="[A-Za-z]{2}[0-9]{6}"
maxLength={8}
placeholder="MT070626"
disabled={!isEditing}
required
/>
</label>
<label className="flex flex-col gap-1 text-sm">
<span className="font-medium text-text-main">Status *</span>
<select
@@ -607,91 +693,75 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi
<h4 className="mb-2 text-sm font-semibold text-text-main">Vozilo servisera</h4>
<div className="grid gap-4 sm:grid-cols-2">
<label className="flex flex-col gap-1 text-sm">
<span className="font-medium text-text-main">Registracija</span>
<span className="font-medium text-text-main">Registracija vozila servisera</span>
<input
type="text"
value={workOrder?.craneInfo?.registration_number || ''}
className="rounded-lg border border-border-hairline bg-canvas-deep px-3 py-2 text-text-muted"
disabled
/>
</label>
<label className="flex flex-col gap-1 text-sm">
<span className="font-medium text-text-main">Marka/Model</span>
<input
type="text"
value={workOrder?.craneInfo?.display_name || ''}
className="rounded-lg border border-border-hairline bg-canvas-deep px-3 py-2 text-text-muted"
disabled
/>
</label>
<label className="flex flex-col gap-1 text-sm">
<span className="font-medium text-text-main">Početna kilometraža {form.has_travel_order ? '*' : ''}</span>
<input
type="number"
min="0"
value={form.start_mileage}
onInput={(event) => setForm({ ...form, start_mileage: event.currentTarget.value })}
value={form.servicer_vehicle_registration}
onInput={(event) => setForm({ ...form, servicer_vehicle_registration: 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="flex flex-col gap-1 text-sm">
<span className="font-medium text-text-main">Završna kilometraža</span>
<span className="font-medium text-text-main">Marka/Model vozila servisera</span>
<input
type="number"
min="0"
value={form.end_mileage}
onInput={(event) => setForm({ ...form, end_mileage: event.currentTarget.value })}
type="text"
value={form.servicer_vehicle_make_model}
onInput={(event) => setForm({ ...form, servicer_vehicle_make_model: 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="flex flex-col gap-1 text-sm">
<span className="font-medium text-text-main">Početna km vozila servisera</span>
<input
type="number"
min="0"
value={form.servicer_vehicle_start_mileage}
onInput={(event) => setForm({ ...form, servicer_vehicle_start_mileage: 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="flex flex-col gap-1 text-sm">
<span className="font-medium text-text-main">Završna km vozila servisera</span>
<input
type="number"
min="0"
value={form.servicer_vehicle_end_mileage}
onInput={(event) => setForm({ ...form, servicer_vehicle_end_mileage: 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="flex flex-col gap-1 text-sm">
<span className="font-medium text-text-main">Trošak goriva (EUR)</span>
<input
type="number"
min="0"
step="0.01"
value={form.servicer_vehicle_fuel_cost}
onInput={(event) => setForm({ ...form, servicer_vehicle_fuel_cost: 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>
</div>
</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">Podaci dizalice</h4>
<div className="grid gap-4 sm:grid-cols-2">
<label className="flex flex-col gap-1 text-sm">
<span className="font-medium text-text-main">Trenutna km dizalice</span>
<input
type="number"
min="0"
value={form.crane_current_mileage}
onInput={(event) => setForm({ ...form, crane_current_mileage: 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="flex flex-col gap-1 text-sm">
<span className="font-medium text-text-main">Radni sati nadogradnje</span>
<input
type="number"
min="0"
value={form.crane_superstructure_working_hours}
onInput={(event) => setForm({ ...form, crane_superstructure_working_hours: 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="flex flex-col gap-1 text-sm">
<span className="font-medium text-text-main">Radni sati podvozja</span>
<input
type="number"
min="0"
value={form.crane_chassis_working_hours}
onInput={(event) => setForm({ ...form, crane_chassis_working_hours: 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>
</div>
</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">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">
@@ -745,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 : '',
@@ -811,14 +883,23 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi
Izmijeni
</button>
) : (
<button
type="button"
onClick={submit}
disabled={submitting}
className="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-700 disabled:opacity-60"
>
{submitting ? 'Spremam...' : 'Spremi izmjene'}
</button>
<>
<button
type="button"
onClick={() => setPhotoUploadOpen(true)}
className="rounded-lg border border-border-hairline px-4 py-2 text-sm font-medium text-text-main hover:bg-canvas-deep"
>
📷 Dodaj fotografije
</button>
<button
type="button"
onClick={submit}
disabled={submitting}
className="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-700 disabled:opacity-60"
>
{submitting ? 'Spremam...' : 'Spremi izmjene'}
</button>
</>
)}
</div>
</form>
@@ -945,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)}
/>
@@ -987,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
@@ -1064,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 }));
@@ -1110,6 +1265,113 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi
</div>
</ModalShell>
)}
<WorkOrderPhotoUpload
open={photoUploadOpen}
workOrderId={workOrder?.id}
onClose={() => setPhotoUploadOpen(false)}
onBack={() => setPhotoUploadOpen(false)}
onSuccess={() => {
setPhotoUploadOpen(false);
if (workOrder?.id) {
fetchWorkOrderImages(workOrder.id, { w: 1280, q: 75, fmt: 'webp' })
.then((files) => setImageFiles(files.map((item) => item.optimized_url || item.original_url).filter(Boolean)))
.catch(() => {});
}
}}
/>
<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,17 +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 { formatEntityCode } from '../../lib/displayIds';
import { resolveMediaUrl, useAuthenticatedMediaSources } from './WorkOrderImageCarousel';
import { formatWorkOrderDisplayCode } from '../../lib/displayIds';
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') {
@@ -21,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 (
@@ -33,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>
);
}
@@ -51,8 +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();
@@ -87,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;
@@ -98,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.');
@@ -118,7 +239,7 @@ export default function WorkOrderInvoicesPdfPage() {
if (!workOrder) {
return 'Računi putnog naloga';
}
return `${formatEntityCode('PN', workOrder.id)}`;
return formatWorkOrderDisplayCode(workOrder);
}, [workOrder]);
const mediaPaths = useMemo(() => {
@@ -139,8 +260,9 @@ export default function WorkOrderInvoicesPdfPage() {
}
if (Array.isArray(invoices)) {
invoices.forEach((invoice) => {
if (invoice?.image) {
paths.push(invoice.image);
const invoiceImage = invoice?.image_url || invoice?.image;
if (invoiceImage) {
paths.push(invoiceImage);
}
});
}
@@ -149,6 +271,78 @@ export default function WorkOrderInvoicesPdfPage() {
const { getAuthenticatedMediaSrc } = useAuthenticatedMediaSources(mediaPaths);
const handleSaveWorkHoursTable = async (tableData) => {
if (!editingTask?.id) return;
setSavingWorkHours(true);
try {
const response = await updateTaskWorkHoursTable(editingTask.id, tableData);
setTaskContext((prev) => ({
...prev,
tasks: (Array.isArray(prev.tasks) ? prev.tasks : []).map((task) => (
String(task.id) === String(editingTask.id)
? { ...task, work_hours_table: response?.data ?? null }
: task
)),
}));
setEditingTask(null);
} finally {
setSavingWorkHours(false);
}
};
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">
@@ -159,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) && (
@@ -190,13 +412,66 @@ export default function WorkOrderInvoicesPdfPage() {
<div className="space-y-4">
{taskContext.tasks.map((task) => (
<article key={task.id} className="rounded-lg border border-border-hairline bg-canvas-base p-3">
<div className="space-y-1 text-sm text-text-main">
<div><span className="font-semibold">Radni zadatak:</span> {task.title || '-'}</div>
<div><span className="font-semibold">Status:</span> {task.status || '-'}</div>
<div><span className="font-semibold">Dodijeljeno:</span> {task.assigned_to_name || '-'}</div>
<div><span className="font-semibold">Dizalica:</span> {task.vehicle_registration || '-'}</div>
<div className="mb-3 flex flex-wrap items-start justify-between gap-2">
<div className="space-y-1 text-sm text-text-main">
<div><span className="font-semibold">Radni zadatak:</span> {task.title || '-'}</div>
<div><span className="font-semibold">Status:</span> {task.status || '-'}</div>
<div><span className="font-semibold">Dodijeljeno:</span> {task.assigned_to_name || '-'}</div>
<div><span className="font-semibold">Dizalica:</span> {task.vehicle_registration || '-'}</div>
{task.service_report_note && (
<div className="mt-1 rounded bg-canvas-deep px-2 py-1 text-xs text-text-muted">
<span className="font-semibold">Napomena:</span> {task.service_report_note}
</div>
)}
</div>
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => setEditingTask(task)}
className="rounded-lg border border-border-hairline px-3 py-1.5 text-xs font-semibold text-text-main hover:bg-canvas-deep"
>
Uredi tablicu radnih sati
</button>
<button
type="button"
onClick={() => { setTaskNoteError(''); setEditingTaskNote(task); }}
className="rounded-lg border border-border-hairline px-3 py-1.5 text-xs font-semibold text-text-main hover:bg-canvas-deep"
>
Uredi tekst napomene
</button>
<button
type="button"
onClick={() => downloadWorkOrderServiceRecordsPdf(
workOrderId,
task.id,
{
workOrderDisplayCode: workOrder?.display_code,
taskTitle: task.title,
},
)}
disabled={!workOrderId || !task.id}
className="rounded-lg bg-indigo-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-indigo-700 disabled:opacity-60"
>
PDF za ovaj task
</button>
<button
type="button"
onClick={() => downloadWorkOrderServiceRecordsDocx(
workOrderId,
task.id,
{
workOrderDisplayCode: workOrder?.display_code,
taskTitle: task.title,
},
)}
disabled={!workOrderId || !task.id}
className="rounded-lg border border-border-hairline px-3 py-1.5 text-xs font-semibold text-text-main hover:bg-canvas-deep disabled:opacity-60"
>
DOCX za ovaj task
</button>
</div>
</div>
<div className="mt-3 space-y-3">
<div className="space-y-3">
{!Array.isArray(task.service_records) || task.service_records.length === 0 ? (
<p className="text-xs text-text-muted">Nema povezanih servisnih zapisa.</p>
) : (
@@ -242,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">
@@ -262,25 +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) || resolveMediaUrl(invoice.image)}
alt={`Račun ${invoice.naziv_racuna}`}
/>
</div>
</article>
))}
</div>
)}
)}
</div>
<TaskWorkHoursTableModal
open={!!editingTask}
task={editingTask}
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

@@ -2,6 +2,7 @@ import { useEffect, useState } from 'preact/hooks';
import { useStore } from '@nanostores/preact';
import ModalShell from '../ui/ModalShell';
import { $user } from '../../stores/authStore';
import { generateWorkOrderDisplayCode } from '../../lib/displayIds';
const PURPOSE_OPTIONS = [
{ value: 'defektaza', label: 'Defektaža' },
@@ -11,6 +12,7 @@ const PURPOSE_OPTIONS = [
const INITIAL_FORM = {
has_travel_order: false,
display_code: '',
start_mileage: '',
end_mileage: '',
servicer_vehicle_registration: '',
@@ -18,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: '',
@@ -54,6 +57,7 @@ export default function WorkOrderModal({ open, selectedCrane, onClose, onSubmit
setForm((prev) => ({
...prev,
display_code: generateWorkOrderDisplayCode(user?.first_name, user?.last_name, new Date()),
start_mileage: selectedCrane ? String(selectedCrane.current_mileage ?? '') : prev.start_mileage,
servicer_vehicle_registration: user?.assigned_vehicle?.registration_number || '',
servicer_vehicle_make_model: `${user?.assigned_vehicle?.make || ''} ${user?.assigned_vehicle?.model || ''}`.trim(),
@@ -116,6 +120,15 @@ export default function WorkOrderModal({ open, selectedCrane, onClose, onSubmit
setErrorMessage('Svrha naloga je obavezna.');
return;
}
const displayCode = String(form.display_code || '').trim().toUpperCase();
if (!displayCode) {
setErrorMessage('Prikaz putnog naloga je obavezan. Provjerite da serviser ima ime i prezime ili unesite oznaku ručno.');
return;
}
if (!/^[A-Z]{2}[0-9]{6}$/.test(displayCode)) {
setErrorMessage('Prikaz putnog naloga mora biti u formatu MT070626.');
return;
}
let startMileage = null;
let endMileage = null;
@@ -191,6 +204,7 @@ export default function WorkOrderModal({ open, selectedCrane, onClose, onSubmit
try {
await onSubmit({
vehicle: selectedCrane.id,
display_code: displayCode,
start_mileage: startMileage,
end_mileage: endMileage,
servicer_vehicle_registration: form.servicer_vehicle_registration.trim(),
@@ -198,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,
@@ -233,6 +248,20 @@ export default function WorkOrderModal({ open, selectedCrane, onClose, onSubmit
</div>
<form onSubmit={submit} className="max-h-[calc(100vh-8rem)] space-y-4 overflow-y-auto px-5 py-4">
<label className="flex flex-col gap-1 text-sm">
<span className="font-medium text-text-main">Prikaz putnog naloga *</span>
<input
type="text"
value={form.display_code}
onInput={(event) => setForm({ ...form, display_code: event.currentTarget.value.toUpperCase() })}
className="rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-text-main"
placeholder="MT070626"
pattern="[A-Za-z]{2}[0-9]{6}"
maxLength={8}
required
/>
</label>
<label className="flex flex-col gap-1 text-sm">
<span className="font-medium text-text-main">Status *</span>
<select
@@ -366,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">
@@ -431,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

@@ -134,7 +134,7 @@ export default function ServicePhotoUpload({ open, serviceRecordId, onClose, onS
return (
<ModalShell
onClose={handleCancel}
overlayClassName="z-50 p-4"
overlayClassName="z-[70] p-4"
contentClassName="flex items-center justify-center min-h-full"
panelClassName={`w-full max-w-lg rounded-xl border border-border-hairline bg-canvas-elevated shadow-xl ${isOffline ? 'pointer-events-none opacity-50' : ''}`}
>

View File

@@ -34,6 +34,8 @@ export default function ServiceRecordDetailModal({
serviceRecord,
onClose,
onUpdated,
onOpenPhotoUpload = null,
filesRefreshToken = 0,
onBack = null,
backLabel = 'Natrag',
}) {
@@ -116,7 +118,7 @@ export default function ServiceRecordDetailModal({
return () => {
cancelled = true;
};
}, [open, serviceRecord?.id]);
}, [open, serviceRecord?.id, filesRefreshToken]);
useEffect(() => {
if (!emailModalOpen) return;
@@ -172,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);
}
@@ -411,6 +406,14 @@ export default function ServiceRecordDetailModal({
)}
{editMode && (
<>
<button
type="button"
onClick={() => onOpenPhotoUpload?.(serviceRecord)}
disabled={editSaving}
className="rounded-md border border-border-hairline px-3 py-2 text-xs font-medium text-text-main hover:bg-canvas-deep disabled:opacity-60"
>
📎 Dodaj datoteke
</button>
<button
type="button"
onClick={() => {
@@ -469,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

@@ -11,6 +11,7 @@ import { showToast } from '../../stores/toastStore.js';
const INITIAL = {
task: '',
service_title: '',
description: '',
parts: '',
cost: '',
@@ -144,6 +145,7 @@ export default function ServiceRecordForm({
try {
const payload = {
task: form.task,
service_title: form.service_title.trim(),
description: form.description.trim(),
parts: form.parts.trim(),
cost,
@@ -268,6 +270,18 @@ export default function ServiceRecordForm({
</label>
</div>
<label className="flex flex-col gap-1 text-sm">
<span className="font-medium text-text-main">Naziv servisnog zapisa</span>
<input
type="text"
value={form.service_title}
onInput={field('service_title')}
disabled={disabled}
placeholder="Npr. Redovni servis hidraulike"
className="rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-sm text-text-main placeholder-text-muted/40 focus:outline-none disabled:opacity-50"
/>
</label>
<label className="flex flex-col gap-1 text-sm">
<span className="font-medium text-text-main">Opis posla *</span>
<textarea

View File

@@ -0,0 +1,247 @@
import { useState, useEffect, useRef } from 'preact/hooks';
import { useStore } from '@nanostores/preact';
import ModalShell from '../ui/ModalShell';
import { $isOffline } from '../../stores/networkStore.js';
import { uploadWorkOrderPhoto } from '../../stores/fleetDashboardStore.js';
import { showToast } from '../../stores/toastStore.js';
const MAX_FILE_SIZE_MB = 10;
const MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024;
const ACCEPTED_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/heic'];
export default function WorkOrderPhotoUpload({ open, workOrderId, onClose, onSuccess, onBack }) {
const isOffline = useStore($isOffline);
const [images, setImages] = useState([]);
const [description, setDescription] = useState('');
const [submitting, setSubmitting] = useState(false);
const [progress, setProgress] = useState({ current: 0, total: 0 });
const [error, setError] = useState('');
const [isMounted, setIsMounted] = useState(false);
const abortRef = useRef(null);
useEffect(() => {
setIsMounted(true);
return () => {
abortRef.current?.abort();
};
}, []);
useEffect(() => {
if (!open) {
abortRef.current?.abort();
abortRef.current = null;
setImages([]);
setDescription('');
setError('');
setSubmitting(false);
setProgress({ current: 0, total: 0 });
}
}, [open]);
if (!open || !isMounted) return null;
const handleFileChange = (e) => {
const files = Array.from(e.target.files ?? []);
const oversized = files.filter((f) => f.size > MAX_FILE_SIZE_BYTES);
const invalidType = files.filter((f) => !ACCEPTED_TYPES.includes(f.type));
if (oversized.length > 0) {
setError(`Sljedeće datoteke prelaze ${MAX_FILE_SIZE_MB} MB: ${oversized.map((f) => f.name).join(', ')}`);
return;
}
if (invalidType.length > 0) {
setError(`Nepodržan format: ${invalidType.map((f) => f.name).join(', ')}. Koristite JPG, PNG, WEBP ili HEIC.`);
return;
}
setError('');
setImages(files);
};
const handleSubmit = async (e) => {
e.preventDefault();
setError('');
if (!workOrderId) {
setError('Nije poznat putni nalog za koji se upload vrši.');
return;
}
if (images.length === 0) {
setError('Odaberite barem jednu fotografiju.');
return;
}
if (isOffline) {
showToast('Upload fotografija nije dostupan u offline modu.', 'error');
return;
}
abortRef.current?.abort();
abortRef.current = new AbortController();
const { signal } = abortRef.current;
setSubmitting(true);
setProgress({ current: 0, total: images.length });
let successCount = 0;
try {
for (const [index, file] of images.entries()) {
if (signal.aborted) break;
setProgress({ current: index + 1, total: images.length });
await uploadWorkOrderPhoto(workOrderId, file, description, signal);
successCount++;
}
if (!signal.aborted) {
showToast(`${successCount} fotografija putnog naloga je uspješno uploadano.`, 'success');
e.target.reset();
setImages([]);
setDescription('');
onSuccess?.();
onClose?.();
}
} catch (err) {
if (err?.name === 'AbortError') {
return;
}
setError(err?.message || 'Greška pri uploadu fotografija putnog naloga.');
if (successCount > 0) {
showToast(`${successCount} od ${images.length} fotografija uploadano.`, 'warning');
}
} finally {
if (!signal?.aborted) {
setSubmitting(false);
setProgress({ current: 0, total: 0 });
abortRef.current = null;
}
}
};
const handleCancel = () => {
abortRef.current?.abort();
abortRef.current = null;
onClose?.();
};
return (
<ModalShell
onClose={handleCancel}
overlayClassName="z-[60] p-4"
contentClassName="flex items-center justify-center min-h-full"
panelClassName={`w-full max-w-lg rounded-xl border border-border-hairline bg-canvas-elevated shadow-xl ${isOffline ? 'pointer-events-none opacity-50' : ''}`}
>
<div>
<div className="flex items-center justify-between border-b border-border-hairline px-5 py-4">
<div className="flex items-center gap-3">
{onBack && (
<button
type="button"
onClick={onBack}
className="rounded-md px-2 py-1 text-sm text-text-muted hover:bg-canvas-deep"
aria-label="Natrag"
>
Natrag
</button>
)}
<div>
<h3 className="text-lg font-semibold text-text-main">Upload fotografija putnog naloga</h3>
<p className="text-xs text-text-muted mt-0.5">
Putni nalog #{workOrderId}
</p>
</div>
</div>
<button
type="button"
onClick={handleCancel}
className="rounded-md px-2 py-1 text-sm text-text-muted hover:bg-canvas-deep"
aria-label="Zatvori"
>
</button>
</div>
<form onSubmit={handleSubmit} className="space-y-4 px-5 py-4">
<div>
<label className="block mb-1.5 text-[11px] font-semibold text-text-muted uppercase tracking-wide">
Fotografije putnog naloga ({images.length} odabrano)
</label>
<input
type="file"
accept={ACCEPTED_TYPES.join(',')}
multiple
onChange={handleFileChange}
disabled={submitting}
className="block w-full text-xs text-text-muted file:mr-4 file:py-1.5 file:px-3 file:rounded-md file:border-0 file:text-xs file:font-mono file:bg-indigo-50 file:text-indigo-700 hover:file:bg-indigo-100 file:cursor-pointer transition-all disabled:opacity-50"
/>
<p className="mt-1 text-[11px] text-text-muted">
JPG, PNG, WEBP, HEIC max {MAX_FILE_SIZE_MB} MB po datoteci
</p>
</div>
{images.length > 0 && (
<div className="rounded-lg border border-border-hairline bg-canvas-deep p-2 max-h-32 overflow-y-auto space-y-1">
{images.map((file, i) => (
<p key={i} className="text-[10px] font-mono text-text-muted truncate">
📎 [{i + 1}] {file.name}{' '}
<span className="text-text-muted/60">
({(file.size / 1024).toFixed(1)} KB)
</span>
</p>
))}
</div>
)}
<label className="flex flex-col gap-1 text-sm">
<span className="font-medium text-text-main">Opis (primjenjuje se na sve slike)</span>
<input
type="text"
value={description}
onInput={(e) => setDescription(e.currentTarget.value)}
disabled={submitting}
placeholder="Npr. Slike stanja na lokaciji prije/poslije intervencije"
className="rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-sm text-text-main placeholder-text-muted/40 focus:outline-none disabled:opacity-50"
/>
</label>
{submitting && progress.total > 1 && (
<div>
<div className="flex justify-between text-[11px] text-text-muted mb-1">
<span>Uploading...</span>
<span>{progress.current}/{progress.total}</span>
</div>
<div className="h-1.5 w-full rounded-full bg-canvas-deep overflow-hidden">
<div
className="h-full rounded-full bg-indigo-500 transition-all duration-300"
style={{ width: `${(progress.current / progress.total) * 100}%` }}
/>
</div>
</div>
)}
{error && (
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
{error}
</div>
)}
<div className="flex justify-end gap-2 border-t border-border-hairline pt-3">
<button
type="button"
onClick={handleCancel}
className="rounded-lg border border-border-hairline px-4 py-2 text-sm font-medium text-text-main hover:bg-canvas-deep"
>
{submitting ? 'Prekini upload' : 'Odustani'}
</button>
<button
type="submit"
disabled={submitting || images.length === 0 || isOffline}
className="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-700 disabled:opacity-60"
>
{submitting
? `Uploading ${progress.current}/${progress.total}...`
: `Upload ${images.length > 0 ? images.length + ' slike' : 'fotografija'}`}
</button>
</div>
</form>
</div>
</ModalShell>
);
}

Some files were not shown because too many files have changed in this diff Show More