Compare commits
7 Commits
6470a2996c
...
e7641e7be0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e7641e7be0 | ||
|
|
52f54e3a75 | ||
|
|
a8f441c150 | ||
|
|
2a9cac0c81 | ||
|
|
79145c58e4 | ||
|
|
fd8c6d8040 | ||
|
|
06321fc3d1 |
@@ -155,12 +155,12 @@ class ServiceContextNoteCreateSerializer(serializers.Serializer):
|
|||||||
note_date = serializers.DateField(required=False, allow_null=True)
|
note_date = serializers.DateField(required=False, allow_null=True)
|
||||||
audience = serializers.ChoiceField(choices=AUDIENCE_CHOICES, default='self')
|
audience = serializers.ChoiceField(choices=AUDIENCE_CHOICES, default='self')
|
||||||
target_user = serializers.PrimaryKeyRelatedField(
|
target_user = serializers.PrimaryKeyRelatedField(
|
||||||
queryset=User.objects.filter(is_active=True),
|
queryset=User.objects.filter(is_active=True, is_team_member=True),
|
||||||
required=False,
|
required=False,
|
||||||
allow_null=True,
|
allow_null=True,
|
||||||
)
|
)
|
||||||
target_users = serializers.ListField(
|
target_users = serializers.ListField(
|
||||||
child=serializers.PrimaryKeyRelatedField(queryset=User.objects.filter(is_active=True)),
|
child=serializers.PrimaryKeyRelatedField(queryset=User.objects.filter(is_active=True, is_team_member=True)),
|
||||||
required=False,
|
required=False,
|
||||||
allow_empty=False,
|
allow_empty=False,
|
||||||
)
|
)
|
||||||
@@ -203,12 +203,12 @@ class ServiceContextNoteCreateSerializer(serializers.Serializer):
|
|||||||
if audience in {'self', 'team'} and target_user:
|
if audience in {'self', 'team'} and target_user:
|
||||||
raise serializers.ValidationError({'target_user': 'Odabrani tip publike ne koristi ciljano polje člana tima.'})
|
raise serializers.ValidationError({'target_user': 'Odabrani tip publike ne koristi ciljano polje člana tima.'})
|
||||||
if audience in {'self', 'team'} and len(target_users) > 0:
|
if audience in {'self', 'team'} and len(target_users) > 0:
|
||||||
raise serializers.ValidationError({'target_users': 'Odabrani tip publike ne koristi listu servisera.'})
|
raise serializers.ValidationError({'target_users': 'Odabrani tip publike ne koristi listu članova tima.'})
|
||||||
if target_user and not getattr(target_user, 'is_serviser', False):
|
if target_user and not getattr(target_user, 'is_team_member', False):
|
||||||
raise serializers.ValidationError({'target_user': 'Bilješku je moguće poslati samo serviseru.'})
|
raise serializers.ValidationError({'target_user': 'Bilješku je moguće poslati samo članu tima.'})
|
||||||
for selected_user in target_users:
|
for selected_user in target_users:
|
||||||
if not getattr(selected_user, 'is_serviser', False):
|
if not getattr(selected_user, 'is_team_member', False):
|
||||||
raise serializers.ValidationError({'target_users': 'Bilješku je moguće poslati samo serviserima.'})
|
raise serializers.ValidationError({'target_users': 'Bilješku je moguće poslati samo članovima tima.'})
|
||||||
|
|
||||||
if work_order:
|
if work_order:
|
||||||
if not work_order.is_active or str(work_order.status or '').lower() != 'open':
|
if not work_order.is_active or str(work_order.status or '').lower() != 'open':
|
||||||
|
|||||||
@@ -41,6 +41,13 @@ class ServiceContextNotesApiTests(TestCase):
|
|||||||
is_team_member=True,
|
is_team_member=True,
|
||||||
is_serviser=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(
|
self.vehicle = Vehicle.objects.create(
|
||||||
asset_type='crane',
|
asset_type='crane',
|
||||||
@@ -132,3 +139,31 @@ class ServiceContextNotesApiTests(TestCase):
|
|||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, 201, response.content)
|
self.assertEqual(response.status_code, 201, response.content)
|
||||||
self.assertEqual(int(response.data.get('created_count', 0)), 2)
|
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)
|
||||||
|
|||||||
@@ -146,13 +146,12 @@ class TaskSerializer(serializers.ModelSerializer):
|
|||||||
'title': "Naslov zadatka je obavezan ako se task ne kreira iz templatea."
|
'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:
|
if work_order and not vehicle:
|
||||||
vehicle = work_order.vehicle
|
vehicle = work_order.vehicle
|
||||||
attrs['vehicle'] = vehicle
|
attrs['vehicle'] = vehicle
|
||||||
if work_order and vehicle and work_order.vehicle_id != vehicle.id:
|
|
||||||
raise serializers.ValidationError({
|
|
||||||
'work_order': "Putni nalog mora pripadati istoj dizalici kao task."
|
|
||||||
})
|
|
||||||
|
|
||||||
if status_value in {'neaktivan', 'zavrsen'} and not work_order:
|
if status_value in {'neaktivan', 'zavrsen'} and not work_order:
|
||||||
raise serializers.ValidationError({
|
raise serializers.ValidationError({
|
||||||
|
|||||||
@@ -100,3 +100,57 @@ class TaskSerializerTests(TestCase):
|
|||||||
)
|
)
|
||||||
assert not serializer.is_valid()
|
assert not serializer.is_valid()
|
||||||
assert 'scheduled_date' in serializer.errors
|
assert 'scheduled_date' in serializer.errors
|
||||||
|
|
||||||
|
def test_can_assign_work_order_from_different_crane(self):
|
||||||
|
"""Task može dobiti putni nalog koji pripada drugoj dizalici (cross-crane dijeljenje naloga)."""
|
||||||
|
other_vehicle = Vehicle.objects.create(
|
||||||
|
registration_number="ZG-TSK-02",
|
||||||
|
asset_type='crane',
|
||||||
|
make='Manitowoc',
|
||||||
|
model='Grove',
|
||||||
|
client=self.client,
|
||||||
|
is_company_vehicle=False,
|
||||||
|
)
|
||||||
|
work_order = WorkOrder.objects.create(vehicle=other_vehicle, creator=self.user, purpose='kontrola')
|
||||||
|
task = Task.objects.create(title="Cross-crane task", status="servis", assigned_to=self.user, vehicle=self.vehicle)
|
||||||
|
|
||||||
|
serializer = TaskSerializer(instance=task, data={"work_order": str(work_order.id)}, partial=True)
|
||||||
|
assert serializer.is_valid(), serializer.errors
|
||||||
|
updated_task = serializer.save()
|
||||||
|
assert updated_task.work_order_id == work_order.id
|
||||||
|
assert updated_task.vehicle_id == self.vehicle.id # task zadržava svoju dizalicu
|
||||||
|
|
||||||
|
def test_can_close_task_with_cross_crane_work_order(self):
|
||||||
|
"""Task se može zatvoriti putnim nalogom koji pripada drugoj dizalici."""
|
||||||
|
other_vehicle = Vehicle.objects.create(
|
||||||
|
registration_number="ZG-TSK-03",
|
||||||
|
asset_type='crane',
|
||||||
|
make='Tadano',
|
||||||
|
model='GR',
|
||||||
|
client=self.client,
|
||||||
|
is_company_vehicle=False,
|
||||||
|
)
|
||||||
|
work_order = WorkOrder.objects.create(vehicle=other_vehicle, creator=self.user, purpose='popravak')
|
||||||
|
task = Task.objects.create(title="Task for close", status="servis", assigned_to=self.user, vehicle=self.vehicle)
|
||||||
|
|
||||||
|
serializer = TaskSerializer(
|
||||||
|
instance=task,
|
||||||
|
data={"status": "zavrsen", "work_order": str(work_order.id)},
|
||||||
|
partial=True
|
||||||
|
)
|
||||||
|
assert serializer.is_valid(), serializer.errors
|
||||||
|
updated_task = serializer.save()
|
||||||
|
assert updated_task.status == 'zavrsen'
|
||||||
|
assert updated_task.work_order_id == work_order.id
|
||||||
|
|
||||||
|
def test_work_order_auto_derives_vehicle_when_task_has_none(self):
|
||||||
|
"""Ako task nema dizalicu a dobiva WO, dizalica se preuzima iz WO (backward compat)."""
|
||||||
|
work_order = WorkOrder.objects.create(vehicle=self.vehicle, creator=self.user, purpose='defektaza')
|
||||||
|
serializer = TaskSerializer(
|
||||||
|
data={"title": "Auto-vehicle task", "status": "aktivan", "work_order": str(work_order.id)},
|
||||||
|
)
|
||||||
|
# vehicle je obavezan pri kreiranju, ali ako task nije instanca ovo bi trebalo failati
|
||||||
|
# Provjeri da vehicle derivation iz WO radi samo ako task već postoji (partial update)
|
||||||
|
task = Task.objects.create(title="Temp task", status="aktivan", assigned_to=self.user, vehicle=self.vehicle)
|
||||||
|
serializer2 = TaskSerializer(instance=task, data={"work_order": str(work_order.id)}, partial=True)
|
||||||
|
assert serializer2.is_valid(), serializer2.errors
|
||||||
218
docs/production-db-local-setup.md
Normal file
218
docs/production-db-local-setup.md
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
# 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
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Korak 2 — Kopiraj dump na lokalni PC
|
||||||
|
|
||||||
|
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 pg_restore `
|
||||||
|
-U TVOJ_USER `
|
||||||
|
-d TVOJA_BAZA `
|
||||||
|
--no-owner `
|
||||||
|
--no-acl `
|
||||||
|
-Fc `
|
||||||
|
/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
|
||||||
|
```
|
||||||
@@ -423,19 +423,44 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
|
|||||||
if (!hydratedSelectedVehicleId) {
|
if (!hydratedSelectedVehicleId) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
return codeSorted.filter((order) => String(order.vehicle) === String(hydratedSelectedVehicleId));
|
// Collect work order IDs that are directly assigned to tasks of the context crane.
|
||||||
}, [workOrders, workOrderScope, hydratedSelectedVehicleId, user?.id, user?.is_serviser, user?.is_team_member, workOrderCodeSortDirection]);
|
// This covers cross-crane shared work orders: a WO created for crane A can be
|
||||||
|
// assigned to a task of crane B — it should still appear in crane B's context view.
|
||||||
|
const contextTaskWorkOrderIds = new Set(
|
||||||
|
tasks
|
||||||
|
.filter((t) => String(t.vehicle || '') === String(hydratedSelectedVehicleId) && t.work_order)
|
||||||
|
.map((t) => String(t.work_order))
|
||||||
|
);
|
||||||
|
return codeSorted.filter(
|
||||||
|
(order) => String(order.vehicle) === String(hydratedSelectedVehicleId)
|
||||||
|
|| contextTaskWorkOrderIds.has(String(order.id))
|
||||||
|
);
|
||||||
|
}, [workOrders, tasks, workOrderScope, hydratedSelectedVehicleId, user?.id, user?.is_serviser, user?.is_team_member, workOrderCodeSortDirection]);
|
||||||
const pageSize = 10;
|
const pageSize = 10;
|
||||||
const totalPages = Math.max(1, Math.ceil(scopedWorkOrders.length / pageSize));
|
const totalPages = Math.max(1, Math.ceil(scopedWorkOrders.length / pageSize));
|
||||||
const latestWorkOrders = useMemo(
|
const latestWorkOrders = useMemo(
|
||||||
() => scopedWorkOrders.slice((currentPage - 1) * pageSize, currentPage * pageSize),
|
() => scopedWorkOrders.slice((currentPage - 1) * pageSize, currentPage * pageSize),
|
||||||
[scopedWorkOrders, currentPage]
|
[scopedWorkOrders, currentPage]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Map: workOrderId → array of unique crane objects (own + cross-crane task links)
|
||||||
|
const workOrderSharedCranes = useMemo(() => {
|
||||||
|
const craneById = new Map(cranes.map((c) => [String(c.id), c]));
|
||||||
|
const map = new Map();
|
||||||
|
tasks.forEach((task) => {
|
||||||
|
if (!task.work_order || !task.vehicle) return;
|
||||||
|
const woId = String(task.work_order);
|
||||||
|
if (!map.has(woId)) map.set(woId, new Map());
|
||||||
|
const craneObj = craneById.get(String(task.vehicle));
|
||||||
|
if (craneObj) map.get(woId).set(String(task.vehicle), craneObj);
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
}, [tasks, cranes]);
|
||||||
const serviceRecordWorkOrders = useMemo(() => {
|
const serviceRecordWorkOrders = useMemo(() => {
|
||||||
const craneId = serviceRecordCrane?.id || contextCrane?.id;
|
// Show ALL open work orders so a task can be linked to any work order regardless of crane.
|
||||||
if (!craneId) return [];
|
// Cross-crane sharing is intentional: a servicer may use one travel order for multiple cranes.
|
||||||
return workOrders
|
return [...workOrders]
|
||||||
.filter((order) => String(order.vehicle) === String(craneId))
|
.filter((order) => String(order.status || '').toLowerCase() !== 'closed')
|
||||||
.sort((a, b) => String(b.date || '').localeCompare(String(a.date || '')));
|
.sort((a, b) => String(b.date || '').localeCompare(String(a.date || '')));
|
||||||
}, [workOrders, serviceRecordCrane, contextCrane]);
|
}, [workOrders, serviceRecordCrane, contextCrane]);
|
||||||
const serviceRecordTasks = useMemo(() => {
|
const serviceRecordTasks = useMemo(() => {
|
||||||
@@ -541,10 +566,11 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
|
|||||||
}, [serviceRecords, selectedTask?.id]);
|
}, [serviceRecords, selectedTask?.id]);
|
||||||
const selectedTaskWorkOrders = useMemo(() => {
|
const selectedTaskWorkOrders = useMemo(() => {
|
||||||
if (!selectedTask) return [];
|
if (!selectedTask) return [];
|
||||||
const byVehicle = selectedTask.vehicle
|
// Only open work orders in the dropdown — closed ones cannot receive new tasks.
|
||||||
? workOrders.filter((order) => String(order.vehicle) === String(selectedTask.vehicle))
|
// Cross-crane sharing allowed: no vehicle filter.
|
||||||
: workOrders;
|
return [...workOrders]
|
||||||
return [...byVehicle].sort((a, b) => String(b.date || '').localeCompare(String(a.date || '')));
|
.filter((order) => String(order.status || '').toLowerCase() !== 'closed')
|
||||||
|
.sort((a, b) => String(b.date || '').localeCompare(String(a.date || '')));
|
||||||
}, [selectedTask, workOrders]);
|
}, [selectedTask, workOrders]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -947,7 +973,7 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
|
|||||||
workOrderScope === 'context'
|
workOrderScope === 'context'
|
||||||
? (!hydratedSelectedVehicleId
|
? (!hydratedSelectedVehicleId
|
||||||
? 'Odaberite dizalicu za prikaz putnih naloga iz servisnog konteksta.'
|
? 'Odaberite dizalicu za prikaz putnih naloga iz servisnog konteksta.'
|
||||||
: 'Nema dostupnih putnih naloga za odabrani servisni kontekst.')
|
: 'Nema dostupnih putnih naloga za odabrani servisni kontekst. Možete prebaciti na prikaz svih naloga.')
|
||||||
: 'Nema dostupnih putnih naloga.'
|
: 'Nema dostupnih putnih naloga.'
|
||||||
}
|
}
|
||||||
renderRow={(workOrder) => (
|
renderRow={(workOrder) => (
|
||||||
@@ -961,10 +987,51 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
|
|||||||
<td
|
<td
|
||||||
className="px-4 py-3"
|
className="px-4 py-3"
|
||||||
>
|
>
|
||||||
{[workOrder.craneInfo?.make, workOrder.craneInfo?.model].filter(Boolean).join(' ') || workOrder.craneLabel || workOrder.craneInfo?.registration_number || `#${workOrder.crane || workOrder.vehicle}`}
|
{(() => {
|
||||||
|
const sharedCranes = workOrderSharedCranes.get(String(workOrder.id));
|
||||||
|
if (sharedCranes && sharedCranes.size > 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
{[...sharedCranes.values()].map((c) => {
|
||||||
|
const craneName = [c.make, c.model].filter(Boolean).join(' ') || c.registration_number || `#${c.id}`;
|
||||||
|
return (
|
||||||
|
<span key={c.id}>
|
||||||
|
{craneName}
|
||||||
|
{c.client_name && (
|
||||||
|
<span className="text-text-muted text-xs ml-1">({c.client_name})</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span>
|
||||||
|
{[workOrder.craneInfo?.make, workOrder.craneInfo?.model].filter(Boolean).join(' ')
|
||||||
|
|| workOrder.craneLabel
|
||||||
|
|| workOrder.craneInfo?.registration_number
|
||||||
|
|| `#${workOrder.crane || workOrder.vehicle}`}
|
||||||
|
{workOrder.craneInfo?.client_name && (
|
||||||
|
<span className="text-text-muted text-xs ml-1">({workOrder.craneInfo.client_name})</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 font-mono text-xs text-text-muted">
|
<td className="px-4 py-3 font-mono text-xs text-text-muted">
|
||||||
{workOrder.craneInfo?.crane_serial_number || '-'}
|
{(() => {
|
||||||
|
const sharedCranes = workOrderSharedCranes.get(String(workOrder.id));
|
||||||
|
if (sharedCranes && sharedCranes.size > 0) {
|
||||||
|
const sns = [...sharedCranes.values()].map((c) => c.crane_serial_number || '-');
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
{sns.map((sn, i) => <span key={i}>{sn}</span>)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return workOrder.craneInfo?.crane_serial_number || '-';
|
||||||
|
})()}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
{formatDate(workOrder.date)}
|
{formatDate(workOrder.date)}
|
||||||
|
|||||||
@@ -13,6 +13,17 @@ const INITIAL_FORM = {
|
|||||||
scheduled_date: '',
|
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 }) {
|
export default function TaskCreateModal({ open, workOrders = [], contextCrane = null, onClose, onSuccess }) {
|
||||||
const [form, setForm] = useState(INITIAL_FORM);
|
const [form, setForm] = useState(INITIAL_FORM);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
@@ -40,8 +51,18 @@ export default function TaskCreateModal({ open, workOrders = [], contextCrane =
|
|||||||
setTemplateId('');
|
setTemplateId('');
|
||||||
setTemplates([]);
|
setTemplates([]);
|
||||||
setTemplateLoading(false);
|
setTemplateLoading(false);
|
||||||
|
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(() => {
|
useEffect(() => {
|
||||||
if (!selectedTemplate) return;
|
if (!selectedTemplate) return;
|
||||||
@@ -254,12 +275,18 @@ export default function TaskCreateModal({ open, workOrders = [], contextCrane =
|
|||||||
className="rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-text-main disabled:opacity-50"
|
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>
|
<option value="">— Bez povezanog naloga —</option>
|
||||||
{availableWorkOrders.map((workOrder) => (
|
{availableWorkOrders.map((workOrder) => {
|
||||||
|
const isToday = String(workOrder.date || '').slice(0, 10) === todayIso();
|
||||||
|
return (
|
||||||
<option key={workOrder.id} value={String(workOrder.id)}>
|
<option key={workOrder.id} value={String(workOrder.id)}>
|
||||||
{formatWorkOrderDisplayCode(workOrder)} • {workOrder.craneLabel || workOrder.craneInfo?.registration_number || '-'} • {formatPurposeLabel(workOrder.purpose) || '-'}
|
{isToday ? '★ ' : ''}{formatWorkOrderDisplayCode(workOrder)} • {formatWODate(workOrder.date)} • {workOrder.craneLabel || workOrder.craneInfo?.registration_number || '-'} • {formatPurposeLabel(workOrder.purpose) || '-'}
|
||||||
</option>
|
</option>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</select>
|
</select>
|
||||||
|
<span className="text-[11px] text-text-muted">
|
||||||
|
Više zadataka može dijeliti isti putni nalog. Danas kreiran nalog označen je s ★.
|
||||||
|
</span>
|
||||||
{templateMode && (
|
{templateMode && (
|
||||||
<span className="text-[11px] text-text-muted">
|
<span className="text-[11px] text-text-muted">
|
||||||
Kod template unosa nalog nije obavezan i backend ga neće automatski povezati.
|
Kod template unosa nalog nije obavezan i backend ga neće automatski povezati.
|
||||||
|
|||||||
@@ -3,9 +3,14 @@ import ModalShell from '../ui/ModalShell';
|
|||||||
import { formatWorkOrderDisplayCode } from '../../lib/displayIds';
|
import { formatWorkOrderDisplayCode } from '../../lib/displayIds';
|
||||||
import { getStatusLabel } from '../../stores/taskStore';
|
import { getStatusLabel } from '../../stores/taskStore';
|
||||||
import { getTaskCraneDisplay, getTaskCraneOwner } from '../../lib/taskCraneDisplay';
|
import { getTaskCraneDisplay, getTaskCraneOwner } from '../../lib/taskCraneDisplay';
|
||||||
|
import { updateVehicle, $vehicles } from '../../stores/fleetDashboardStore';
|
||||||
|
|
||||||
const STATUS_OPTIONS = ['aktivan', 'servis', 'spreman_za_zavrsetak', 'zavrsen', 'neaktivan'];
|
const STATUS_OPTIONS = ['aktivan', 'servis', 'spreman_za_zavrsetak', 'zavrsen', 'neaktivan'];
|
||||||
|
|
||||||
|
function todayIso() {
|
||||||
|
return new Date().toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
function formatDate(value) {
|
function formatDate(value) {
|
||||||
if (!value) return '-';
|
if (!value) return '-';
|
||||||
const parsed = new Date(value);
|
const parsed = new Date(value);
|
||||||
@@ -51,6 +56,10 @@ export default function TaskServiceRecordsModal({
|
|||||||
status: 'aktivan',
|
status: 'aktivan',
|
||||||
scheduled_date: '',
|
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(() => {
|
useEffect(() => {
|
||||||
if (!open || typeof window === 'undefined') return;
|
if (!open || typeof window === 'undefined') return;
|
||||||
@@ -72,17 +81,79 @@ export default function TaskServiceRecordsModal({
|
|||||||
status: String(task.status || 'aktivan'),
|
status: String(task.status || 'aktivan'),
|
||||||
scheduled_date: String(task.scheduled_date || ''),
|
scheduled_date: String(task.scheduled_date || ''),
|
||||||
});
|
});
|
||||||
|
setCraneDataModalOpen(false);
|
||||||
|
setCraneDataError('');
|
||||||
|
setSavingCraneData(false);
|
||||||
}, [task?.id, task?.work_order]);
|
}, [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(
|
const currentWorkOrder = useMemo(
|
||||||
() => workOrders.find((item) => String(item.id) === String(task?.work_order || selectedWorkOrderId)) || null,
|
() => workOrders.find((item) => String(item.id) === String(task?.work_order || selectedWorkOrderId)) || null,
|
||||||
[workOrders, task?.work_order, selectedWorkOrderId]
|
[workOrders, task?.work_order, selectedWorkOrderId]
|
||||||
);
|
);
|
||||||
|
|
||||||
const sortedWorkOrders = useMemo(
|
const sortedWorkOrders = useMemo(() => {
|
||||||
() => [...workOrders].sort((a, b) => String(b.date || '').localeCompare(String(a.date || ''))),
|
const today = todayIso();
|
||||||
[workOrders]
|
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 effectiveWorkOrderId = task?.work_order ? String(task.work_order) : String(selectedWorkOrderId || '');
|
||||||
|
|
||||||
@@ -157,6 +228,7 @@ export default function TaskServiceRecordsModal({
|
|||||||
if (!open || !task) return null;
|
if (!open || !task) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<ModalShell
|
<ModalShell
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
overlayClassName="z-50 overflow-y-auto p-4 pt-2"
|
overlayClassName="z-50 overflow-y-auto p-4 pt-2"
|
||||||
@@ -228,7 +300,19 @@ export default function TaskServiceRecordsModal({
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-xs text-text-muted">Dizalica</dt>
|
<dt className="text-xs text-text-muted">Dizalica</dt>
|
||||||
<dd className="text-text-main">{getTaskCraneDisplay(task)}</dd>
|
<dd className="flex items-center gap-2 text-text-main">
|
||||||
|
<span>{getTaskCraneDisplay(task)}</span>
|
||||||
|
{task?.vehicle && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={openCraneDataModal}
|
||||||
|
className="rounded border border-border-hairline px-1.5 py-0.5 text-[10px] text-text-muted hover:bg-canvas-base"
|
||||||
|
title="Izmijeni radne sate i kilometražu dizalice"
|
||||||
|
>
|
||||||
|
🔧
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-xs text-text-muted">Putni nalog</dt>
|
<dt className="text-xs text-text-muted">Putni nalog</dt>
|
||||||
@@ -355,7 +439,7 @@ export default function TaskServiceRecordsModal({
|
|||||||
{currentWorkOrder && !showWorkOrderEditor ? (
|
{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">
|
<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">
|
<p className="text-sm text-text-main">
|
||||||
{formatWorkOrderDisplayCode(currentWorkOrder)} • {currentWorkOrder.craneLabel || currentWorkOrder.craneInfo?.registration_number || '-'} • {currentWorkOrder.status || '-'}
|
{formatWorkOrderDisplayCode(currentWorkOrder)} • {formatDate(currentWorkOrder.date)} • {currentWorkOrder.craneLabel || currentWorkOrder.craneInfo?.registration_number || '-'} • {currentWorkOrder.status || '-'}
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -374,14 +458,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"
|
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>
|
<option value="">— Odaberi putni nalog —</option>
|
||||||
{sortedWorkOrders.map((workOrder) => (
|
{sortedWorkOrders.map((workOrder) => {
|
||||||
|
const isToday = String(workOrder.date || '').slice(0, 10) === todayIso();
|
||||||
|
return (
|
||||||
<option key={workOrder.id} value={String(workOrder.id)}>
|
<option key={workOrder.id} value={String(workOrder.id)}>
|
||||||
{formatWorkOrderDisplayCode(workOrder)} • {workOrder.craneLabel || workOrder.craneInfo?.registration_number || '-'} • {workOrder.status || '-'}
|
{isToday ? '★ ' : ''}{formatWorkOrderDisplayCode(workOrder)} • {formatDate(workOrder.date)} • {workOrder.craneLabel || workOrder.craneInfo?.registration_number || '-'} • {workOrder.status || '-'}
|
||||||
</option>
|
</option>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</select>
|
</select>
|
||||||
<p className="text-xs text-text-muted">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -448,5 +537,76 @@ export default function TaskServiceRecordsModal({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</ModalShell>
|
</ModalShell>
|
||||||
|
|
||||||
|
{craneDataModalOpen && (
|
||||||
|
<ModalShell open={craneDataModalOpen} onClose={() => setCraneDataModalOpen(false)} title="Izmijeni podatke dizalice">
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -609,13 +609,6 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi
|
|||||||
>
|
>
|
||||||
Dodaj račun
|
Dodaj račun
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={openCraneDataModal}
|
|
||||||
className="rounded-md border border-border-hairline px-3 py-2 text-xs font-medium text-text-main hover:bg-canvas-deep"
|
|
||||||
>
|
|
||||||
🔧 Izmijeni podatke dizalice
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export default function ServiceNotesWidget({
|
|||||||
canManageRecipients = false,
|
canManageRecipients = false,
|
||||||
activeTasks = [],
|
activeTasks = [],
|
||||||
activeWorkOrders = [],
|
activeWorkOrders = [],
|
||||||
servicers = [],
|
teamMembers = [],
|
||||||
onCreate,
|
onCreate,
|
||||||
onClose,
|
onClose,
|
||||||
}) {
|
}) {
|
||||||
@@ -156,7 +156,7 @@ export default function ServiceNotesWidget({
|
|||||||
className="w-full rounded-md border border-border-hairline bg-canvas-base px-2 py-1.5 text-xs text-text-main"
|
className="w-full rounded-md border border-border-hairline bg-canvas-base px-2 py-1.5 text-xs text-text-main"
|
||||||
>
|
>
|
||||||
<option value="self">Notifikacija samo meni</option>
|
<option value="self">Notifikacija samo meni</option>
|
||||||
{canManageRecipients && <option value="member">Notifikacija serviserima</option>}
|
{canManageRecipients && <option value="member">Notifikacija odabranim članovima tima</option>}
|
||||||
{canManageRecipients && <option value="team">Notifikacija svim članovima tima</option>}
|
{canManageRecipients && <option value="team">Notifikacija svim članovima tima</option>}
|
||||||
</select>
|
</select>
|
||||||
{canManageRecipients && audience === 'member' && (
|
{canManageRecipients && audience === 'member' && (
|
||||||
@@ -167,10 +167,10 @@ export default function ServiceNotesWidget({
|
|||||||
const selected = Array.from(event.currentTarget.selectedOptions).map((option) => option.value);
|
const selected = Array.from(event.currentTarget.selectedOptions).map((option) => option.value);
|
||||||
setTargetUsers(selected);
|
setTargetUsers(selected);
|
||||||
}}
|
}}
|
||||||
size={Math.min(6, Math.max(3, servicers.length || 3))}
|
size={Math.min(6, Math.max(3, teamMembers.length || 3))}
|
||||||
className="w-full rounded-md border border-border-hairline bg-canvas-base px-2 py-1.5 text-xs text-text-main"
|
className="w-full rounded-md border border-border-hairline bg-canvas-base px-2 py-1.5 text-xs text-text-main"
|
||||||
>
|
>
|
||||||
{servicers.map((member) => (
|
{teamMembers.map((member) => (
|
||||||
<option key={member.id} value={member.id}>
|
<option key={member.id} value={member.id}>
|
||||||
{member.full_name || member.email}
|
{member.full_name || member.email}
|
||||||
</option>
|
</option>
|
||||||
@@ -179,7 +179,7 @@ export default function ServiceNotesWidget({
|
|||||||
)}
|
)}
|
||||||
{canManageRecipients && audience === 'member' && (
|
{canManageRecipients && audience === 'member' && (
|
||||||
<p className="text-[10px] text-text-muted">
|
<p className="text-[10px] text-text-muted">
|
||||||
Držite Ctrl (ili Cmd) za odabir više servisera.
|
Držite Ctrl (ili Cmd) za odabir više članova tima.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from 'preact/hooks';
|
|||||||
import { useStore } from '@nanostores/preact';
|
import { useStore } from '@nanostores/preact';
|
||||||
import { $tasks } from '../../stores/taskStore';
|
import { $tasks } from '../../stores/taskStore';
|
||||||
import { $user } from '../../stores/authStore';
|
import { $user } from '../../stores/authStore';
|
||||||
import { $workOrdersWithVehicle, fetchServicers } from '../../stores/fleetDashboardStore';
|
import { $workOrdersWithVehicle, fetchTeamMembers } from '../../stores/fleetDashboardStore';
|
||||||
import {
|
import {
|
||||||
$serviceNotes,
|
$serviceNotes,
|
||||||
$serviceNotesLoading,
|
$serviceNotesLoading,
|
||||||
@@ -30,7 +30,7 @@ export default function TaskCalendarPortal() {
|
|||||||
const notes = useStore($serviceNotes);
|
const notes = useStore($serviceNotes);
|
||||||
const notesLoading = useStore($serviceNotesLoading);
|
const notesLoading = useStore($serviceNotesLoading);
|
||||||
const user = useStore($user);
|
const user = useStore($user);
|
||||||
const [servicers, setServicers] = useState([]);
|
const [teamMembers, setTeamMembers] = useState([]);
|
||||||
|
|
||||||
const isServiser = Boolean(user?.is_serviser);
|
const isServiser = Boolean(user?.is_serviser);
|
||||||
const isTeamMember = Boolean(user?.is_team_member);
|
const isTeamMember = Boolean(user?.is_team_member);
|
||||||
@@ -82,18 +82,18 @@ export default function TaskCalendarPortal() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!canManageRecipients) {
|
if (!canManageRecipients) {
|
||||||
setServicers([]);
|
setTeamMembers([]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let active = true;
|
let active = true;
|
||||||
fetchServicers()
|
fetchTeamMembers()
|
||||||
.then((rows) => {
|
.then((rows) => {
|
||||||
if (!active) return;
|
if (!active) return;
|
||||||
setServicers(Array.isArray(rows) ? rows : []);
|
setTeamMembers(Array.isArray(rows) ? rows : []);
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
if (!active) return;
|
if (!active) return;
|
||||||
setServicers([]);
|
setTeamMembers([]);
|
||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
active = false;
|
active = false;
|
||||||
@@ -123,7 +123,7 @@ export default function TaskCalendarPortal() {
|
|||||||
canManageRecipients={canManageRecipients}
|
canManageRecipients={canManageRecipients}
|
||||||
activeTasks={activeTasks}
|
activeTasks={activeTasks}
|
||||||
activeWorkOrders={activeWorkOrders}
|
activeWorkOrders={activeWorkOrders}
|
||||||
servicers={servicers}
|
teamMembers={teamMembers}
|
||||||
onCreate={createServiceNote}
|
onCreate={createServiceNote}
|
||||||
onClose={closeServiceNote}
|
onClose={closeServiceNote}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import Pusher from 'pusher-js';
|
|||||||
import { api } from '../services/apiClient';
|
import { api } from '../services/apiClient';
|
||||||
import { $accessToken, $user } from './authStore';
|
import { $accessToken, $user } from './authStore';
|
||||||
import { showToast } from './toastStore';
|
import { showToast } from './toastStore';
|
||||||
|
import { fetchServiceNotes } from './serviceNotesStore';
|
||||||
|
|
||||||
export const $notifications = atom([]);
|
export const $notifications = atom([]);
|
||||||
export const $isRealtimeConnected = atom(false);
|
export const $isRealtimeConnected = atom(false);
|
||||||
@@ -84,6 +85,9 @@ function attachChannelHandlers(channel) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$notifications.set([next, ...existing]);
|
$notifications.set([next, ...existing]);
|
||||||
|
if (next?.metadata?.entity_type === 'service_note') {
|
||||||
|
fetchServiceNotes().catch(() => {});
|
||||||
|
}
|
||||||
showToast(next.title, next.level === 'warning' || next.level === 'critical' ? 'error' : 'success');
|
showToast(next.title, next.level === 'warning' || next.level === 'critical' ? 'error' : 'success');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export async function fetchServiceNotes() {
|
|||||||
export async function createServiceNote(data) {
|
export async function createServiceNote(data) {
|
||||||
const payload = await api.post('fleet/service-notes/', data ?? {});
|
const payload = await api.post('fleet/service-notes/', data ?? {});
|
||||||
const nextNotes = Array.isArray(payload?.notes) ? payload.notes : null;
|
const nextNotes = Array.isArray(payload?.notes) ? payload.notes : null;
|
||||||
if (nextNotes) {
|
if (nextNotes && nextNotes.length > 0) {
|
||||||
$serviceNotes.set(nextNotes);
|
$serviceNotes.set(nextNotes);
|
||||||
} else {
|
} else {
|
||||||
await fetchServiceNotes();
|
await fetchServiceNotes();
|
||||||
|
|||||||
Reference in New Issue
Block a user