kamau1 commited on
Commit
41d110f
·
1 Parent(s): e5f65c7

feat: location trail for visualization

Browse files
docs/api/projects/field-agent-dashboard.md ADDED
@@ -0,0 +1,143 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Field Agent Project Dashboard
2
+
3
+ ## Endpoint
4
+
5
+ ```http
6
+ GET /api/v1/projects/{project_id}/dashboard
7
+ ```
8
+
9
+ ## Response Structure
10
+
11
+ ```json
12
+ {
13
+ "project_info": {
14
+ "id": "uuid",
15
+ "title": "Atomio Fttx",
16
+ "project_type": "customer_service",
17
+ "status": "active"
18
+ },
19
+ "my_stats": {
20
+ "tickets": {
21
+ "total_assigned": 3,
22
+ "pending": 0,
23
+ "in_progress": 2,
24
+ "completed_this_week": 1
25
+ },
26
+ "expenses": {
27
+ "total_amount": 5000.0,
28
+ "pending_amount": 5000.0,
29
+ "pending_count": 1
30
+ },
31
+ "inventory": {
32
+ "items_on_hand": 0
33
+ },
34
+ "notifications": {
35
+ "unread": 0,
36
+ "total": 0
37
+ }
38
+ },
39
+ "work_queue": {
40
+ "pending_assignments": [...],
41
+ "total_pending": 2,
42
+ "high_priority": 0,
43
+ "due_today": 1
44
+ },
45
+ "assignment_history": {
46
+ "assignments": [
47
+ {
48
+ "assignment_id": "uuid",
49
+ "ticket_id": "uuid",
50
+ "ticket_name": "John Kamau",
51
+ "ticket_type": "installation",
52
+ "service_type": "ftth",
53
+ "action": "completed",
54
+ "assigned_at": "2025-11-28T09:00:00Z",
55
+ "ended_at": "2025-11-28T12:30:00Z",
56
+ "travel_time_minutes": 15,
57
+ "work_time_minutes": 45,
58
+ "total_time_minutes": 60,
59
+ "journey_distance_km": 2.3,
60
+ "has_location_trail": true,
61
+ "reason": null
62
+ }
63
+ ],
64
+ "total": 5,
65
+ "completed": 4,
66
+ "dropped": 1,
67
+ "rejected": 0,
68
+ "period_days": 7
69
+ },
70
+ "cached_at": "2025-12-01T07:00:00Z",
71
+ "cache_expires_in_seconds": 300
72
+ }
73
+ ```
74
+
75
+ ## Assignment History
76
+
77
+ ### What It Shows
78
+
79
+ - **Last 7 days** of completed work (configurable)
80
+ - Assignments with `ended_at` set (completed, dropped, rejected)
81
+ - Sorted by most recent first
82
+
83
+ ### Assignment Actions
84
+
85
+ - `completed` - Successfully finished
86
+ - `dropped` - Agent couldn't complete (reason provided)
87
+ - `rejected` - Agent rejected assignment
88
+
89
+ ### Performance Metrics
90
+
91
+ Each assignment includes:
92
+ - `travel_time_minutes` - Time from acceptance to arrival
93
+ - `work_time_minutes` - Time from arrival to completion
94
+ - `total_time_minutes` - Total assignment duration
95
+ - `journey_distance_km` - Distance traveled (if GPS tracking used)
96
+ - `has_location_trail` - Boolean flag for "View Journey" button
97
+
98
+ ### Frontend Usage
99
+
100
+ ```typescript
101
+ // Display history section
102
+ const history = dashboard.assignment_history;
103
+
104
+ // Show summary
105
+ showSummary({
106
+ total: history.total,
107
+ completed: history.completed,
108
+ dropped: history.dropped,
109
+ period: `Last ${history.period_days} days`
110
+ });
111
+
112
+ // Render list
113
+ history.assignments.forEach(assignment => {
114
+ renderHistoryCard({
115
+ ticketName: assignment.ticket_name,
116
+ action: assignment.action,
117
+ endedAt: assignment.ended_at,
118
+ duration: `${assignment.total_time_minutes} min`,
119
+ distance: assignment.journey_distance_km ? `${assignment.journey_distance_km} km` : null,
120
+ showJourneyButton: assignment.has_location_trail
121
+ });
122
+ });
123
+
124
+ // "View Journey" button
125
+ if (assignment.has_location_trail) {
126
+ onClickViewJourney(() => {
127
+ navigateToMap(assignment.assignment_id);
128
+ });
129
+ }
130
+ ```
131
+
132
+ ## Cache
133
+
134
+ - **TTL**: 5 minutes
135
+ - **Invalidation**: Automatic on data changes
136
+ - **Force refresh**: Add `?force_refresh=true` query param
137
+
138
+ ## Notes
139
+
140
+ - History period is 7 days by default
141
+ - Only shows agent's own assignments
142
+ - Includes performance metrics for completed work
143
+ - Location trail available if GPS tracking was used
docs/api/ticket-assignments/location-trail.md ADDED
@@ -0,0 +1,118 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Location Trail - Frontend Guide
2
+
3
+ ## What We Built
4
+
5
+ Agents can now view their journey trail on a map after completing a ticket. The trail shows GPS breadcrumbs collected during travel.
6
+
7
+ ## How It Works
8
+
9
+ ### 1. Ticket Detail Response
10
+
11
+ Assignment now includes `has_location_trail` flag:
12
+
13
+ ```json
14
+ {
15
+ "current_assignment": {
16
+ "id": "assignment-uuid",
17
+ "has_location_trail": true,
18
+ "journey_distance_km": 2.3,
19
+ "travel_time_minutes": 15
20
+ }
21
+ }
22
+ ```
23
+
24
+ ### 2. Show "View Journey" Button
25
+
26
+ ```typescript
27
+ if (assignment.has_location_trail) {
28
+ // Show "View Journey" button
29
+ // On click → navigate to map page
30
+ }
31
+ ```
32
+
33
+ ### 3. Fetch Location Trail
34
+
35
+ ```http
36
+ GET /api/v1/ticket-assignments/assignments/{assignment_id}/location-trail
37
+ ```
38
+
39
+ **Response:**
40
+ ```json
41
+ {
42
+ "assignment_id": "uuid",
43
+ "ticket_id": "uuid",
44
+ "user_id": "uuid",
45
+ "user_name": "John Doe",
46
+ "journey_started_at": "2024-03-20T09:00:00Z",
47
+ "arrived_at": "2024-03-20T09:15:00Z",
48
+ "journey_start": {
49
+ "lat": -1.2921,
50
+ "lng": 36.8219
51
+ },
52
+ "arrival_point": {
53
+ "lat": -1.2930,
54
+ "lng": 36.8225
55
+ },
56
+ "trail": [
57
+ {
58
+ "lat": -1.2921,
59
+ "lng": 36.8219,
60
+ "timestamp": "2024-03-20T09:00:00Z",
61
+ "accuracy": 10,
62
+ "speed": 0
63
+ },
64
+ {
65
+ "lat": -1.2925,
66
+ "lng": 36.8222,
67
+ "timestamp": "2024-03-20T09:05:00Z",
68
+ "accuracy": 8,
69
+ "speed": 45
70
+ }
71
+ ],
72
+ "total_points": 45,
73
+ "journey_distance_km": 2.3,
74
+ "travel_time_minutes": 15
75
+ }
76
+ ```
77
+
78
+ ## Map Rendering
79
+
80
+ ```typescript
81
+ // Fetch trail
82
+ const trail = await api.get(`/ticket-assignments/assignments/${assignmentId}/location-trail`);
83
+
84
+ // Render on map
85
+ const map = initMap();
86
+
87
+ // Add start marker (green)
88
+ if (trail.journey_start) {
89
+ addMarker(map, trail.journey_start, 'green', 'Start');
90
+ }
91
+
92
+ // Add arrival marker (red)
93
+ if (trail.arrival_point) {
94
+ addMarker(map, trail.arrival_point, 'red', 'Arrival');
95
+ }
96
+
97
+ // Draw route line
98
+ const coordinates = trail.trail.map(point => [point.lat, point.lng]);
99
+ drawPolyline(map, coordinates, 'blue');
100
+
101
+ // Show stats
102
+ showStats({
103
+ distance: `${trail.journey_distance_km} km`,
104
+ time: `${trail.travel_time_minutes} min`,
105
+ points: trail.total_points
106
+ });
107
+ ```
108
+
109
+ ## Authorization
110
+
111
+ - Agents can view their own trails
112
+ - PM/Dispatcher/Admin can view any trail
113
+
114
+ ## Notes
115
+
116
+ - Trail only exists if agent used GPS tracking during journey
117
+ - Empty trail = agent didn't update location during travel
118
+ - Trail data is lazy-loaded (not in ticket detail response)
docs/devlogs/browser/response.json CHANGED
@@ -1,183 +1,67 @@
1
  {
2
- "ticket": {
3
- "id": "0fd3ee15-5e7d-465a-b377-155f9bdb7e70",
4
- "project_id": "0ade6bd1-e492-4e25-b681-59f42058d29a",
5
- "source": "sales_order",
6
- "source_id": "f40b7d15-01f3-4dbc-9fd5-3f9114db5439",
7
- "ticket_name": "John Kamau",
8
- "ticket_type": "installation",
9
- "service_type": "ftth",
10
- "work_description": "Install Premium Fiber 50Mbps for John Kamau",
11
- "status": "completed",
12
- "priority": "normal",
13
- "scheduled_date": "2025-12-01",
14
- "scheduled_time_slot": "Morning 9AM-12PM",
15
- "due_date": "2025-12-01T07:52:17.445636Z",
16
- "sla_target_date": "2025-12-01T07:52:17.445636Z",
17
- "sla_violated": false,
18
- "started_at": "2025-12-01T05:52:03.358706Z",
19
- "completed_at": "2025-12-01T05:54:37.178415Z",
20
- "is_invoiced": false,
21
- "invoiced_at": null,
22
- "project_region_id": "4cd27765-5720-4cc0-872e-bf0da3cd1898",
23
- "work_location_latitude": "-1.2200285",
24
- "work_location_longitude": "36.8770248",
25
- "work_location_verified": true,
26
- "notes": "[COMPLETION] Hhhdg",
27
- "version": 1,
28
- "created_at": "2025-11-28T07:52:17.445636Z",
29
- "updated_at": "2025-12-01T05:54:37.233167Z",
30
- "project_title": null,
31
- "region_name": null,
32
- "customer_name": null,
33
- "is_open": false,
34
- "is_assigned": false,
35
- "is_in_progress": false,
36
- "is_completed": true,
37
- "is_cancelled": false,
38
- "is_active": false,
39
- "is_overdue": false,
40
- "has_region": true,
41
- "has_schedule": true,
42
- "can_be_assigned": false,
43
- "can_be_started": false,
44
- "can_be_completed": false,
45
- "can_be_cancelled": false
46
  },
47
- "available_actions": [],
48
- "current_assignment": {
49
- "id": "d6f25868-5117-4b85-8bb3-44314144ef6e",
50
- "action": "completed",
51
- "status": "CLOSED",
52
- "journey_started": true,
53
- "arrived": true,
54
- "ended": true,
55
- "can_drop": false
56
- },
57
- "team_info": {
58
- "required_size": 1,
59
- "assigned_size": 0,
60
- "is_full": false,
61
- "assigned_agents": []
62
- },
63
- "message": "Ticket TicketStatus.COMPLETED",
64
- "source_data": {
65
- "type": "sales_order",
66
- "id": "f40b7d15-01f3-4dbc-9fd5-3f9114db5439",
67
- "order_number": "ORD-2025-001",
68
- "customer_preferred_package": "Premium Fiber 50Mbps",
69
- "package_price": 5000.0,
70
- "installation_address": "123 Kilimani Road, Apt 4B",
71
- "installation_latitude": -1.286389,
72
- "installation_longitude": 36.817223,
73
- "preferred_visit_date": "2025-12-01",
74
- "status": "processed"
75
- },
76
- "customer": {
77
- "id": "5daadd72-7e1c-454b-8a7a-50e50a28e7ba",
78
- "name": "John Kamau",
79
- "phone": "+254712345678",
80
- "email": "john.kamau@email.com",
81
- "address": null,
82
- "location_latitude": null,
83
- "location_longitude": null
84
- },
85
- "expenses": [],
86
- "images": [
87
- {
88
- "id": "286bc35f-0154-46a7-b9c5-411bc7e4cf3e",
89
- "image_url": "https://res.cloudinary.com/dnhajmziu/image/upload/v1764568434/ticket_0fd3ee15_ticket_photo_airtel_network_airtel_network_20251201_055353_176456838428265210116379744783.jpg",
90
- "image_type": "completion",
91
- "description": "[Airtel network] Completion photo for ticket",
92
- "captured_at": "2025-12-01T05:53:54.723185+00:00",
93
- "uploaded_by_user_id": "43b778b0-2062-4724-abbb-916a4835a9b0",
94
- "created_at": "2025-12-01T05:53:54.723853+00:00"
95
  },
96
- {
97
- "id": "e66c9611-010b-44c0-8210-a96d6bc3a40a",
98
- "image_url": "https://res.cloudinary.com/dnhajmziu/image/upload/v1764568433/ticket_0fd3ee15_ticket_photo_speedtest_speedtest_20251201_055353_176456840453490667282686508717.jpg",
99
- "image_type": "completion",
100
- "description": "[Speedtest] Completion photo for ticket",
101
- "captured_at": "2025-12-01T05:53:53.881645+00:00",
102
- "uploaded_by_user_id": "43b778b0-2062-4724-abbb-916a4835a9b0",
103
- "created_at": "2025-12-01T05:53:53.882301+00:00"
104
  },
105
- {
106
- "id": "2caeb3e0-ba66-4a71-aaa0-19f8fb5a6fc7",
107
- "image_url": "https://res.cloudinary.com/dnhajmziu/image/upload/v1764568432/ticket_0fd3ee15_ticket_photo_odu_outdoor_image_odu_outdoor_image_20251201_055352_176456841641438038017979239093.jpg",
108
- "image_type": "completion",
109
- "description": "[ODU outdoor image] Completion photo for ticket",
110
- "captured_at": "2025-12-01T05:53:53.064280+00:00",
111
- "uploaded_by_user_id": "43b778b0-2062-4724-abbb-916a4835a9b0",
112
- "created_at": "2025-12-01T05:53:53.096941+00:00"
113
- }
114
- ],
115
- "comments": [],
116
- "assignments": [
117
- {
118
- "id": "d6f25868-5117-4b85-8bb3-44314144ef6e",
119
- "user_id": "43b778b0-2062-4724-abbb-916a4835a9b0",
120
- "user_name": "Viyisa Sasa",
121
- "action": "completed",
122
- "status": "CLOSED",
123
- "assigned_at": "2025-12-01T05:50:51.219042+00:00",
124
- "responded_at": "2025-12-01T05:50:51.219044+00:00",
125
- "journey_started_at": "2025-12-01T05:52:03.358665+00:00",
126
- "arrived_at": "2025-12-01T05:52:35.847093+00:00",
127
- "ended_at": "2025-12-01T05:54:37.183538+00:00"
128
- }
129
- ],
130
- "status_history": [
131
- {
132
- "id": "e8de766c-14f9-4425-8c67-a77fa671b16f",
133
- "old_status": "in_progress",
134
- "new_status": "completed",
135
- "changed_at": "2025-12-01T05:54:37.189824+00:00",
136
- "changed_by_user_id": "43b778b0-2062-4724-abbb-916a4835a9b0",
137
- "changed_by_user_name": "Viyisa Sasa",
138
- "assignment_id": "d6f25868-5117-4b85-8bb3-44314144ef6e",
139
- "change_reason": "Ticket completed with all requirements satisfied",
140
- "notes": "Hhhdg",
141
- "location_latitude": null,
142
- "location_longitude": null,
143
- "location_accuracy": null,
144
- "location_verified": false,
145
- "communication_method": "app",
146
- "additional_metadata": {}
147
- },
148
- {
149
- "id": "48ef8f9d-731d-4e74-b2ed-136f3bd1d026",
150
- "old_status": "assigned",
151
- "new_status": "in_progress",
152
- "changed_at": "2025-12-01T05:52:03.358718+00:00",
153
- "changed_by_user_id": "43b778b0-2062-4724-abbb-916a4835a9b0",
154
- "changed_by_user_name": "Viyisa Sasa",
155
- "assignment_id": "d6f25868-5117-4b85-8bb3-44314144ef6e",
156
- "change_reason": "Agent started journey to site",
157
- "notes": null,
158
- "location_latitude": -1.2200188,
159
- "location_longitude": 36.8770193,
160
- "location_accuracy": null,
161
- "location_verified": false,
162
- "communication_method": "app",
163
- "additional_metadata": {}
164
  },
165
- {
166
- "id": "096f3c95-b8fb-4fa8-9602-9f5f0da467d8",
167
- "old_status": "open",
168
- "new_status": "assigned",
169
- "changed_at": "2025-12-01T05:50:51.241567+00:00",
170
- "changed_by_user_id": "43b778b0-2062-4724-abbb-916a4835a9b0",
171
- "changed_by_user_name": "Viyisa Sasa",
172
- "assignment_id": "d6f25868-5117-4b85-8bb3-44314144ef6e",
173
- "change_reason": "Self-assigned by agent",
174
- "notes": null,
175
- "location_latitude": null,
176
- "location_longitude": null,
177
- "location_accuracy": null,
178
- "location_verified": false,
179
- "communication_method": "app",
180
- "additional_metadata": {}
181
  }
182
- ]
183
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  {
2
+ "project_info": {
3
+ "id": "0ade6bd1-e492-4e25-b681-59f42058d29a",
4
+ "title": "Atomio Fttx",
5
+ "project_type": "customer_service",
6
+ "status": "active"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7
  },
8
+ "my_stats": {
9
+ "tickets": {
10
+ "total_assigned": 3,
11
+ "pending": 0,
12
+ "in_progress": 2,
13
+ "completed_this_week": 1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
  },
15
+ "expenses": {
16
+ "total_amount": 5000.0,
17
+ "pending_amount": 5000.0,
18
+ "pending_count": 1
 
 
 
 
19
  },
20
+ "inventory": {
21
+ "items_on_hand": 0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22
  },
23
+ "notifications": {
24
+ "unread": 0,
25
+ "total": 0
 
 
 
 
 
 
 
 
 
 
 
 
 
26
  }
27
+ },
28
+ "work_queue": {
29
+ "pending_assignments": [
30
+ {
31
+ "assignment_id": "b3a83bd0-d287-4cea-a1c8-8bef145c1296",
32
+ "ticket_id": "8f08ad14-df8b-4780-84e7-0d45e133f2a6",
33
+ "ticket_name": "Catherine Njoki",
34
+ "ticket_type": "installation",
35
+ "service_type": "ftth",
36
+ "work_description": "Install Basic 10Mbps for Catherine Njoki",
37
+ "status": "ON_SITE",
38
+ "priority": "normal",
39
+ "due_date": "2025-12-01T07:52:17.604326+00:00",
40
+ "scheduled_date": "2025-12-28",
41
+ "scheduled_time_slot": "Afternoon 1PM-4PM",
42
+ "execution_order": null,
43
+ "assigned_at": "2025-11-28T09:27:41.952753+00:00"
44
+ },
45
+ {
46
+ "assignment_id": "a82a3824-f4f1-4283-a2e3-8c348dbb28ce",
47
+ "ticket_id": "f59b29fc-d0b9-4618-b0d1-889e340da612",
48
+ "ticket_name": "Elizabeth Muthoni",
49
+ "ticket_type": "installation",
50
+ "service_type": "ftth",
51
+ "work_description": "Install Premium Fiber 100Mbps for Elizabeth Muthoni",
52
+ "status": "ON_SITE",
53
+ "priority": "normal",
54
+ "due_date": "2025-11-29T13:24:19.212882+00:00",
55
+ "scheduled_date": "2025-12-11",
56
+ "scheduled_time_slot": null,
57
+ "execution_order": null,
58
+ "assigned_at": "2025-11-30T10:21:10.085152+00:00"
59
+ }
60
+ ],
61
+ "total_pending": 2,
62
+ "high_priority": 0,
63
+ "due_today": 1
64
+ },
65
+ "cached_at": "2025-12-01T07:00:02.307362Z",
66
+ "cache_expires_in_seconds": 300
67
+ }
src/app/api/v1/ticket_assignments.py CHANGED
@@ -654,6 +654,56 @@ def get_assignment(
654
  return service.get_assignment(assignment_id)
655
 
656
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
657
  @router.get(
658
  "/tickets/{ticket_id}/assignments",
659
  summary="Get Ticket Assignments",
 
654
  return service.get_assignment(assignment_id)
655
 
656
 
657
+ @router.get(
658
+ "/assignments/{assignment_id}/location-trail",
659
+ summary="Get Location Trail for Map",
660
+ description="""
661
+ Get GPS location trail for map visualization.
662
+
663
+ **Returns:**
664
+ - Journey start point
665
+ - Arrival point
666
+ - GPS breadcrumb trail (array of coordinates with timestamps)
667
+ - Journey stats (distance, travel time)
668
+
669
+ **Authorization:**
670
+ - Agent can view their own trail
671
+ - PM/Dispatcher/Admin can view any trail
672
+
673
+ **Use Case:**
674
+ - Frontend shows "View Journey" button if `has_location_trail: true`
675
+ - Button opens map page
676
+ - Map page calls this endpoint to get coordinates
677
+ - Renders route on map with start/end markers
678
+
679
+ **Example Response:**
680
+ ```json
681
+ {
682
+ "assignment_id": "uuid",
683
+ "journey_start": {"lat": -1.2921, "lng": 36.8219},
684
+ "arrival_point": {"lat": -1.2930, "lng": 36.8225},
685
+ "trail": [
686
+ {"lat": -1.2921, "lng": 36.8219, "timestamp": "2024-03-20T09:00:00Z"},
687
+ {"lat": -1.2925, "lng": 36.8222, "timestamp": "2024-03-20T09:05:00Z"}
688
+ ],
689
+ "total_points": 45,
690
+ "journey_distance_km": 2.3,
691
+ "travel_time_minutes": 15
692
+ }
693
+ ```
694
+ """,
695
+ tags=["Ticket Assignments - Queries"]
696
+ )
697
+ def get_location_trail(
698
+ assignment_id: UUID,
699
+ db: Session = Depends(get_db),
700
+ current_user: User = Depends(get_current_user)
701
+ ):
702
+ from app.schemas.ticket_assignment import LocationTrailResponse
703
+ service = TicketAssignmentService(db)
704
+ return service.get_location_trail(assignment_id, current_user)
705
+
706
+
707
  @router.get(
708
  "/tickets/{ticket_id}/assignments",
709
  summary="Get Ticket Assignments",
src/app/schemas/ticket_assignment.py CHANGED
@@ -295,6 +295,7 @@ class TicketAssignmentResponse(BaseModel):
295
  arrival_longitude: Optional[Decimal] = None
296
  arrival_verified: bool = False
297
  journey_location_history: List[Dict[str, Any]] = []
 
298
 
299
  # Computed
300
  status: str # PENDING, ACCEPTED, IN_TRANSIT, ON_SITE, CLOSED
@@ -335,6 +336,33 @@ class TicketAssignmentBriefResponse(BaseModel):
335
  from_attributes = True
336
 
337
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
338
  class TicketWithAssignmentsResponse(BaseModel):
339
  """Ticket with assignment info"""
340
  id: UUID
 
295
  arrival_longitude: Optional[Decimal] = None
296
  arrival_verified: bool = False
297
  journey_location_history: List[Dict[str, Any]] = []
298
+ has_location_trail: bool = False # True if journey_location_history has data
299
 
300
  # Computed
301
  status: str # PENDING, ACCEPTED, IN_TRANSIT, ON_SITE, CLOSED
 
336
  from_attributes = True
337
 
338
 
339
+ class LocationTrailResponse(BaseModel):
340
+ """GPS location trail for map visualization"""
341
+ assignment_id: UUID
342
+ ticket_id: UUID
343
+ user_id: UUID
344
+ user_name: str
345
+
346
+ # Journey timeline
347
+ journey_started_at: Optional[datetime] = None
348
+ arrived_at: Optional[datetime] = None
349
+
350
+ # Start and end points
351
+ journey_start: Optional[Dict[str, Any]] = None # {"lat": float, "lng": float}
352
+ arrival_point: Optional[Dict[str, Any]] = None # {"lat": float, "lng": float}
353
+
354
+ # GPS breadcrumb trail
355
+ trail: List[LocationBreadcrumb] = []
356
+
357
+ # Stats
358
+ total_points: int = 0
359
+ journey_distance_km: Optional[float] = None
360
+ travel_time_minutes: Optional[int] = None
361
+
362
+ class Config:
363
+ from_attributes = True
364
+
365
+
366
  class TicketWithAssignmentsResponse(BaseModel):
367
  """Ticket with assignment info"""
368
  id: UUID
src/app/services/dashboard_service.py CHANGED
@@ -125,6 +125,7 @@ class DashboardService:
125
  "notifications": DashboardService._get_notification_stats(db, current_user)
126
  },
127
  "work_queue": DashboardService._get_project_work_queue(db, project_id, current_user, limit=50),
 
128
  "cached_at": datetime.utcnow().isoformat() + "Z",
129
  "cache_expires_in_seconds": 300
130
  }
@@ -856,6 +857,99 @@ class DashboardService:
856
  "due_today": 0
857
  }
858
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
859
  @staticmethod
860
  def _get_inventory_stats(db: Session, project_id: str) -> Dict:
861
  """Get inventory stats for project"""
 
125
  "notifications": DashboardService._get_notification_stats(db, current_user)
126
  },
127
  "work_queue": DashboardService._get_project_work_queue(db, project_id, current_user, limit=50),
128
+ "assignment_history": DashboardService._get_assignment_history(db, project_id, current_user, days=7),
129
  "cached_at": datetime.utcnow().isoformat() + "Z",
130
  "cache_expires_in_seconds": 300
131
  }
 
857
  "due_today": 0
858
  }
859
 
860
+ @staticmethod
861
+ def _get_assignment_history(db: Session, project_id: str, current_user: User, days: int = 7) -> Dict:
862
+ """
863
+ Get field agent's completed assignment history for specific project.
864
+
865
+ Returns assignments that have ended (completed, dropped, rejected) within the last N days.
866
+ Default is 7 days (weekly history).
867
+
868
+ Args:
869
+ db: Database session
870
+ project_id: Project ID
871
+ current_user: Current user (field agent)
872
+ days: Number of days to look back (default: 7)
873
+ """
874
+ try:
875
+ from app.models.ticket_assignment import TicketAssignment
876
+
877
+ # Calculate date threshold
878
+ date_threshold = datetime.utcnow() - timedelta(days=days)
879
+
880
+ # Query completed assignments (ended_at is set)
881
+ assignments = db.query(TicketAssignment).options(
882
+ joinedload(TicketAssignment.ticket)
883
+ ).join(
884
+ Ticket
885
+ ).filter(
886
+ Ticket.project_id == project_id,
887
+ TicketAssignment.user_id == current_user.id,
888
+ TicketAssignment.ended_at.isnot(None), # Assignment ended
889
+ TicketAssignment.ended_at >= date_threshold, # Within last N days
890
+ TicketAssignment.deleted_at.is_(None)
891
+ ).order_by(
892
+ TicketAssignment.ended_at.desc() # Most recent first
893
+ ).all()
894
+
895
+ # Build history items
896
+ history_items = []
897
+ completed_count = 0
898
+ dropped_count = 0
899
+ rejected_count = 0
900
+
901
+ for assignment in assignments:
902
+ ticket = assignment.ticket
903
+
904
+ # Count by action
905
+ if assignment.action == "completed":
906
+ completed_count += 1
907
+ elif assignment.action == "dropped":
908
+ dropped_count += 1
909
+ elif assignment.action == "rejected":
910
+ rejected_count += 1
911
+
912
+ item = {
913
+ "assignment_id": str(assignment.id),
914
+ "ticket_id": str(ticket.id) if ticket else None,
915
+ "ticket_name": ticket.ticket_name if ticket else "Unknown Ticket",
916
+ "ticket_type": ticket.ticket_type if ticket else None,
917
+ "service_type": ticket.service_type if ticket else None,
918
+ "action": assignment.action, # completed, dropped, rejected
919
+ "assigned_at": assignment.assigned_at.isoformat() if assignment.assigned_at else None,
920
+ "ended_at": assignment.ended_at.isoformat() if assignment.ended_at else None,
921
+ "travel_time_minutes": assignment.travel_time_minutes,
922
+ "work_time_minutes": assignment.work_time_minutes,
923
+ "total_time_minutes": assignment.total_time_minutes,
924
+ "journey_distance_km": assignment.journey_distance_km,
925
+ "has_location_trail": (
926
+ isinstance(assignment.journey_location_history, list) and
927
+ len(assignment.journey_location_history) > 0
928
+ ),
929
+ "reason": assignment.reason # For dropped/rejected
930
+ }
931
+
932
+ history_items.append(item)
933
+
934
+ return {
935
+ "assignments": history_items,
936
+ "total": len(assignments),
937
+ "completed": completed_count,
938
+ "dropped": dropped_count,
939
+ "rejected": rejected_count,
940
+ "period_days": days
941
+ }
942
+ except Exception as e:
943
+ logger.error(f"Error getting assignment history: {e}")
944
+ return {
945
+ "assignments": [],
946
+ "total": 0,
947
+ "completed": 0,
948
+ "dropped": 0,
949
+ "rejected": 0,
950
+ "period_days": days
951
+ }
952
+
953
  @staticmethod
954
  def _get_inventory_stats(db: Session, project_id: str) -> Dict:
955
  """Get inventory stats for project"""
src/app/services/ticket_assignment_service.py CHANGED
@@ -976,6 +976,81 @@ class TicketAssignmentService:
976
  assignment = self._get_assignment_or_404(assignment_id)
977
  return self._to_response(assignment)
978
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
979
  def get_ticket_assignments(self, ticket_id: UUID) -> dict:
980
  """Get all assignments for ticket (current + past)"""
981
  assignments = self.db.query(TicketAssignment).options(
@@ -1242,6 +1317,12 @@ class TicketAssignmentService:
1242
  phone=assignment.assigned_by.phone
1243
  )
1244
 
 
 
 
 
 
 
1245
  return TicketAssignmentResponse(
1246
  id=assignment.id,
1247
  ticket_id=assignment.ticket_id,
@@ -1262,6 +1343,7 @@ class TicketAssignmentService:
1262
  arrival_longitude=assignment.arrival_longitude,
1263
  arrival_verified=assignment.arrival_verified,
1264
  journey_location_history=assignment.journey_location_history,
 
1265
  status=assignment.status,
1266
  is_active=assignment.is_active,
1267
  travel_time_minutes=assignment.travel_time_minutes,
 
976
  assignment = self._get_assignment_or_404(assignment_id)
977
  return self._to_response(assignment)
978
 
979
+ def get_location_trail(self, assignment_id: UUID, current_user: User):
980
+ """
981
+ Get GPS location trail for map visualization.
982
+
983
+ Authorization:
984
+ - Agent can view their own trail
985
+ - PM/Dispatcher/Admin can view any trail
986
+ """
987
+ from app.schemas.ticket_assignment import LocationTrailResponse, LocationBreadcrumb
988
+
989
+ assignment = self.db.query(TicketAssignment).options(
990
+ joinedload(TicketAssignment.user)
991
+ ).filter(
992
+ TicketAssignment.id == assignment_id,
993
+ TicketAssignment.deleted_at.is_(None)
994
+ ).first()
995
+
996
+ if not assignment:
997
+ raise HTTPException(
998
+ status_code=status.HTTP_404_NOT_FOUND,
999
+ detail="Assignment not found"
1000
+ )
1001
+
1002
+ # Authorization check
1003
+ is_own_assignment = assignment.user_id == current_user.id
1004
+ is_authorized_role = current_user.role in [
1005
+ AppRole.PLATFORM_ADMIN,
1006
+ AppRole.PROJECT_MANAGER,
1007
+ AppRole.DISPATCHER
1008
+ ]
1009
+
1010
+ if not (is_own_assignment or is_authorized_role):
1011
+ raise HTTPException(
1012
+ status_code=status.HTTP_403_FORBIDDEN,
1013
+ detail="Not authorized to view this location trail"
1014
+ )
1015
+
1016
+ # Build journey start point
1017
+ journey_start = None
1018
+ if assignment.journey_start_latitude and assignment.journey_start_longitude:
1019
+ journey_start = {
1020
+ "lat": float(assignment.journey_start_latitude),
1021
+ "lng": float(assignment.journey_start_longitude)
1022
+ }
1023
+
1024
+ # Build arrival point
1025
+ arrival_point = None
1026
+ if assignment.arrival_latitude and assignment.arrival_longitude:
1027
+ arrival_point = {
1028
+ "lat": float(assignment.arrival_latitude),
1029
+ "lng": float(assignment.arrival_longitude)
1030
+ }
1031
+
1032
+ # Convert trail to LocationBreadcrumb objects
1033
+ trail = []
1034
+ if isinstance(assignment.journey_location_history, list):
1035
+ for point in assignment.journey_location_history:
1036
+ if isinstance(point, dict) and "lat" in point and "lng" in point:
1037
+ trail.append(LocationBreadcrumb(**point))
1038
+
1039
+ return LocationTrailResponse(
1040
+ assignment_id=assignment.id,
1041
+ ticket_id=assignment.ticket_id,
1042
+ user_id=assignment.user_id,
1043
+ user_name=assignment.user.full_name if assignment.user else "Unknown",
1044
+ journey_started_at=assignment.journey_started_at,
1045
+ arrived_at=assignment.arrived_at,
1046
+ journey_start=journey_start,
1047
+ arrival_point=arrival_point,
1048
+ trail=trail,
1049
+ total_points=len(trail),
1050
+ journey_distance_km=assignment.journey_distance_km,
1051
+ travel_time_minutes=assignment.travel_time_minutes
1052
+ )
1053
+
1054
  def get_ticket_assignments(self, ticket_id: UUID) -> dict:
1055
  """Get all assignments for ticket (current + past)"""
1056
  assignments = self.db.query(TicketAssignment).options(
 
1317
  phone=assignment.assigned_by.phone
1318
  )
1319
 
1320
+ # Check if location trail exists
1321
+ has_location_trail = (
1322
+ isinstance(assignment.journey_location_history, list) and
1323
+ len(assignment.journey_location_history) > 0
1324
+ )
1325
+
1326
  return TicketAssignmentResponse(
1327
  id=assignment.id,
1328
  ticket_id=assignment.ticket_id,
 
1343
  arrival_longitude=assignment.arrival_longitude,
1344
  arrival_verified=assignment.arrival_verified,
1345
  journey_location_history=assignment.journey_location_history,
1346
+ has_location_trail=has_location_trail,
1347
  status=assignment.status,
1348
  is_active=assignment.is_active,
1349
  travel_time_minutes=assignment.travel_time_minutes,