541 lines
16 KiB
Dart
541 lines
16 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:supabase_flutter/supabase_flutter.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/foundation.dart';
|
|
|
|
import '../models/office.dart';
|
|
import '../models/ticket.dart';
|
|
import '../models/ticket_message.dart';
|
|
import 'profile_provider.dart';
|
|
import 'supabase_provider.dart';
|
|
import 'user_offices_provider.dart';
|
|
import 'tasks_provider.dart';
|
|
|
|
final officesProvider = StreamProvider<List<Office>>((ref) {
|
|
final client = ref.watch(supabaseClientProvider);
|
|
return client
|
|
.from('offices')
|
|
.stream(primaryKey: ['id'])
|
|
.order('name')
|
|
.map((rows) => rows.map(Office.fromMap).toList());
|
|
});
|
|
|
|
final officesOnceProvider = FutureProvider<List<Office>>((ref) async {
|
|
final client = ref.watch(supabaseClientProvider);
|
|
final rows = await client.from('offices').select().order('name');
|
|
return (rows as List<dynamic>)
|
|
.map((row) => Office.fromMap(row as Map<String, dynamic>))
|
|
.toList();
|
|
});
|
|
|
|
/// Office query parameters for server-side pagination.
|
|
class OfficeQuery {
|
|
/// Creates office query parameters.
|
|
const OfficeQuery({this.offset = 0, this.limit = 50, this.searchQuery = ''});
|
|
|
|
/// Offset for pagination.
|
|
final int offset;
|
|
|
|
/// Number of items per page (default: 50).
|
|
final int limit;
|
|
|
|
/// Full text search query.
|
|
final String searchQuery;
|
|
|
|
OfficeQuery copyWith({int? offset, int? limit, String? searchQuery}) {
|
|
return OfficeQuery(
|
|
offset: offset ?? this.offset,
|
|
limit: limit ?? this.limit,
|
|
searchQuery: searchQuery ?? this.searchQuery,
|
|
);
|
|
}
|
|
}
|
|
|
|
final officesQueryProvider = StateProvider<OfficeQuery>(
|
|
(ref) => const OfficeQuery(),
|
|
);
|
|
|
|
final officesControllerProvider = Provider<OfficesController>((ref) {
|
|
final client = ref.watch(supabaseClientProvider);
|
|
return OfficesController(client);
|
|
});
|
|
|
|
/// Ticket query parameters for server-side pagination and filtering.
|
|
class TicketQuery {
|
|
/// Creates ticket query parameters.
|
|
const TicketQuery({
|
|
this.offset = 0,
|
|
this.limit = 50,
|
|
this.searchQuery = '',
|
|
this.officeId,
|
|
this.status,
|
|
this.dateRange,
|
|
});
|
|
|
|
/// Offset for pagination.
|
|
final int offset;
|
|
|
|
/// Number of items per page (default: 50).
|
|
final int limit;
|
|
|
|
/// Full text search query.
|
|
final String searchQuery;
|
|
|
|
/// Filter by office ID.
|
|
final String? officeId;
|
|
|
|
/// Filter by status.
|
|
final String? status;
|
|
|
|
/// Filter by date range.
|
|
/// Filter by date range.
|
|
final DateTimeRange? dateRange;
|
|
|
|
TicketQuery copyWith({
|
|
int? offset,
|
|
int? limit,
|
|
String? searchQuery,
|
|
String? officeId,
|
|
String? status,
|
|
DateTimeRange? dateRange,
|
|
}) {
|
|
return TicketQuery(
|
|
offset: offset ?? this.offset,
|
|
limit: limit ?? this.limit,
|
|
searchQuery: searchQuery ?? this.searchQuery,
|
|
officeId: officeId ?? this.officeId,
|
|
status: status ?? this.status,
|
|
dateRange: dateRange ?? this.dateRange,
|
|
);
|
|
}
|
|
}
|
|
|
|
final ticketsProvider = StreamProvider<List<Ticket>>((ref) {
|
|
final client = ref.watch(supabaseClientProvider);
|
|
final profileAsync = ref.watch(currentProfileProvider);
|
|
final assignmentsAsync = ref.watch(userOfficesProvider);
|
|
final query = ref.watch(ticketsQueryProvider);
|
|
|
|
final profile = profileAsync.valueOrNull;
|
|
if (profile == null) {
|
|
return Stream.value(const <Ticket>[]);
|
|
}
|
|
|
|
final isGlobal =
|
|
profile.role == 'admin' ||
|
|
profile.role == 'dispatcher' ||
|
|
profile.role == 'it_staff';
|
|
|
|
// Use stream for realtime updates. Offload expensive client-side
|
|
// filtering/sorting/pagination to a background isolate via `compute`
|
|
// so UI navigation and builds remain smooth.
|
|
final baseStream = client.from('tickets').stream(primaryKey: ['id']);
|
|
|
|
return baseStream.asyncMap((rows) async {
|
|
// rows is List<dynamic> of maps coming from Supabase
|
|
final rowsList = (rows as List<dynamic>).cast<Map<String, dynamic>>();
|
|
|
|
// Prepare lightweight serializable args for background processing
|
|
final allowedOfficeIds =
|
|
assignmentsAsync.valueOrNull
|
|
?.where((assignment) => assignment.userId == profile.id)
|
|
.map((assignment) => assignment.officeId)
|
|
.toList() ??
|
|
<String>[];
|
|
|
|
final payload = <String, dynamic>{
|
|
'rows': rowsList,
|
|
'isGlobal': isGlobal,
|
|
'allowedOfficeIds': allowedOfficeIds,
|
|
'offset': query.offset,
|
|
'limit': query.limit,
|
|
'searchQuery': query.searchQuery,
|
|
'officeId': query.officeId,
|
|
'status': query.status,
|
|
'dateStart': query.dateRange?.start.millisecondsSinceEpoch,
|
|
'dateEnd': query.dateRange?.end.millisecondsSinceEpoch,
|
|
};
|
|
|
|
final processed = await compute(_processTicketsInIsolate, payload);
|
|
|
|
// `processed` is List<Map<String,dynamic>> — convert to Ticket objects
|
|
final tickets = (processed as List<dynamic>)
|
|
.cast<Map<String, dynamic>>()
|
|
.map(Ticket.fromMap)
|
|
.toList();
|
|
|
|
debugPrint('[ticketsProvider] processed ${tickets.length} tickets');
|
|
return tickets;
|
|
});
|
|
});
|
|
|
|
// Runs inside a background isolate. Accepts a serializable payload and
|
|
// returns a list of ticket maps after filtering/sorting/pagination.
|
|
List<Map<String, dynamic>> _processTicketsInIsolate(
|
|
Map<String, dynamic> payload,
|
|
) {
|
|
final rows = (payload['rows'] as List).cast<Map<String, dynamic>>();
|
|
var list = List<Map<String, dynamic>>.from(rows);
|
|
|
|
final isGlobal = payload['isGlobal'] as bool? ?? false;
|
|
final allowedOfficeIds =
|
|
(payload['allowedOfficeIds'] as List?)?.cast<String>().toSet() ??
|
|
<String>{};
|
|
|
|
if (!isGlobal) {
|
|
if (allowedOfficeIds.isEmpty) {
|
|
return <Map<String, dynamic>>[];
|
|
}
|
|
list = list
|
|
.where((t) => allowedOfficeIds.contains(t['office_id']))
|
|
.toList();
|
|
}
|
|
|
|
final officeId = payload['officeId'] as String?;
|
|
if (officeId != null) {
|
|
list = list.where((t) => t['office_id'] == officeId).toList();
|
|
}
|
|
final status = payload['status'] as String?;
|
|
if (status != null) {
|
|
list = list.where((t) => t['status'] == status).toList();
|
|
}
|
|
|
|
final searchQuery = (payload['searchQuery'] as String?) ?? '';
|
|
if (searchQuery.isNotEmpty) {
|
|
final q = searchQuery.toLowerCase();
|
|
list = list.where((t) {
|
|
final subj = (t['subject'] as String?)?.toLowerCase() ?? '';
|
|
final desc = (t['description'] as String?)?.toLowerCase() ?? '';
|
|
return subj.contains(q) || desc.contains(q);
|
|
}).toList();
|
|
}
|
|
|
|
// Sort newest first. `created_at` may be ISO strings or timestamps;
|
|
// handle strings and numeric values.
|
|
int parseCreatedAt(Map<String, dynamic> m) {
|
|
final v = m['created_at'];
|
|
if (v == null) return 0;
|
|
if (v is int) return v;
|
|
if (v is double) return v.toInt();
|
|
if (v is String) {
|
|
try {
|
|
return DateTime.parse(v).millisecondsSinceEpoch;
|
|
} catch (_) {
|
|
return 0;
|
|
}
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
list.sort((a, b) => parseCreatedAt(b).compareTo(parseCreatedAt(a)));
|
|
|
|
final start = (payload['offset'] as int?) ?? 0;
|
|
final limit = (payload['limit'] as int?) ?? 50;
|
|
final end = (start + limit).clamp(0, list.length);
|
|
if (start >= list.length) return <Map<String, dynamic>>[];
|
|
return list.sublist(start, end);
|
|
}
|
|
|
|
/// Provider for ticket query parameters.
|
|
final ticketsQueryProvider = StateProvider<TicketQuery>(
|
|
(ref) => const TicketQuery(),
|
|
);
|
|
|
|
final ticketMessagesProvider =
|
|
StreamProvider.family<List<TicketMessage>, String>((ref, ticketId) {
|
|
final client = ref.watch(supabaseClientProvider);
|
|
return client
|
|
.from('ticket_messages')
|
|
.stream(primaryKey: ['id'])
|
|
.eq('ticket_id', ticketId)
|
|
.order('created_at', ascending: false)
|
|
.map((rows) => rows.map(TicketMessage.fromMap).toList());
|
|
});
|
|
|
|
final ticketMessagesAllProvider = StreamProvider<List<TicketMessage>>((ref) {
|
|
final client = ref.watch(supabaseClientProvider);
|
|
return client
|
|
.from('ticket_messages')
|
|
.stream(primaryKey: ['id'])
|
|
.order('created_at', ascending: false)
|
|
.map((rows) => rows.map(TicketMessage.fromMap).toList());
|
|
});
|
|
|
|
final taskMessagesProvider = StreamProvider.family<List<TicketMessage>, String>(
|
|
(ref, taskId) {
|
|
final client = ref.watch(supabaseClientProvider);
|
|
return client
|
|
.from('ticket_messages')
|
|
.stream(primaryKey: ['id'])
|
|
.eq('task_id', taskId)
|
|
.order('created_at', ascending: false)
|
|
.map((rows) => rows.map(TicketMessage.fromMap).toList());
|
|
},
|
|
);
|
|
|
|
final ticketsControllerProvider = Provider<TicketsController>((ref) {
|
|
final client = ref.watch(supabaseClientProvider);
|
|
return TicketsController(client);
|
|
});
|
|
|
|
class TicketsController {
|
|
TicketsController(this._client);
|
|
|
|
final SupabaseClient _client;
|
|
|
|
Future<void> createTicket({
|
|
required String subject,
|
|
required String description,
|
|
required String officeId,
|
|
}) async {
|
|
final actorId = _client.auth.currentUser?.id;
|
|
final data = await _client
|
|
.from('tickets')
|
|
.insert({
|
|
'subject': subject,
|
|
'description': description,
|
|
'office_id': officeId,
|
|
'creator_id': _client.auth.currentUser?.id,
|
|
})
|
|
.select('id')
|
|
.single();
|
|
final ticketId = data['id'] as String?;
|
|
if (ticketId == null) return;
|
|
unawaited(_notifyCreated(ticketId: ticketId, actorId: actorId));
|
|
}
|
|
|
|
Future<void> _notifyCreated({
|
|
required String ticketId,
|
|
required String? actorId,
|
|
}) async {
|
|
try {
|
|
final recipients = await _fetchRoleUserIds(
|
|
roles: const ['dispatcher', 'it_staff'],
|
|
excludeUserId: actorId,
|
|
);
|
|
if (recipients.isEmpty) return;
|
|
final rows = recipients
|
|
.map(
|
|
(userId) => {
|
|
'user_id': userId,
|
|
'actor_id': actorId,
|
|
'ticket_id': ticketId,
|
|
'type': 'created',
|
|
},
|
|
)
|
|
.toList();
|
|
await _client.from('notifications').insert(rows);
|
|
// Send FCM pushes for ticket creation
|
|
try {
|
|
String actorName = 'Someone';
|
|
if (actorId != null && actorId.isNotEmpty) {
|
|
try {
|
|
final p = await _client
|
|
.from('profiles')
|
|
.select('full_name,display_name,name')
|
|
.eq('id', actorId)
|
|
.maybeSingle();
|
|
if (p != null) {
|
|
if (p['full_name'] != null) {
|
|
actorName = p['full_name'].toString();
|
|
} else if (p['display_name'] != null) {
|
|
actorName = p['display_name'].toString();
|
|
} else if (p['name'] != null) {
|
|
actorName = p['name'].toString();
|
|
}
|
|
}
|
|
} catch (_) {}
|
|
}
|
|
|
|
String? ticketNumber;
|
|
try {
|
|
final t = await _client
|
|
.from('tickets')
|
|
.select('ticket_number')
|
|
.eq('id', ticketId)
|
|
.maybeSingle();
|
|
if (t != null && t['ticket_number'] != null) {
|
|
ticketNumber = t['ticket_number'].toString();
|
|
}
|
|
} catch (_) {}
|
|
|
|
final title = '$actorName created a new ticket';
|
|
final body = ticketNumber != null
|
|
? '$actorName created ticket #$ticketNumber'
|
|
: '$actorName created a new ticket';
|
|
|
|
await _client.functions.invoke(
|
|
'send_fcm',
|
|
body: {
|
|
'user_ids': recipients,
|
|
'title': title,
|
|
'body': body,
|
|
'data': {
|
|
'ticket_id': ticketId,
|
|
'ticket_number': ?ticketNumber,
|
|
'type': 'created',
|
|
},
|
|
},
|
|
);
|
|
} catch (e) {
|
|
// non-fatal
|
|
debugPrint('ticket notifyCreated push error: $e');
|
|
}
|
|
} catch (_) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
Future<List<String>> _fetchRoleUserIds({
|
|
required List<String> roles,
|
|
required String? excludeUserId,
|
|
}) async {
|
|
try {
|
|
final data = await _client
|
|
.from('profiles')
|
|
.select('id, role')
|
|
.inFilter('role', roles);
|
|
final rows = data as List<dynamic>;
|
|
final ids = rows
|
|
.map((row) => row['id'] as String?)
|
|
.whereType<String>()
|
|
.where((id) => id.isNotEmpty && id != excludeUserId)
|
|
.toList();
|
|
return ids;
|
|
} catch (_) {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
Future<TicketMessage> sendTicketMessage({
|
|
required String ticketId,
|
|
required String content,
|
|
}) async {
|
|
final data = await _client
|
|
.from('ticket_messages')
|
|
.insert({
|
|
'ticket_id': ticketId,
|
|
'content': content,
|
|
'sender_id': _client.auth.currentUser?.id,
|
|
})
|
|
.select()
|
|
.single();
|
|
return TicketMessage.fromMap(data);
|
|
}
|
|
|
|
Future<TicketMessage> sendTaskMessage({
|
|
required String taskId,
|
|
required String? ticketId,
|
|
required String content,
|
|
}) async {
|
|
final payload = <String, dynamic>{
|
|
'task_id': taskId,
|
|
'content': content,
|
|
'sender_id': _client.auth.currentUser?.id,
|
|
};
|
|
if (ticketId != null) {
|
|
payload['ticket_id'] = ticketId;
|
|
}
|
|
final data = await _client
|
|
.from('ticket_messages')
|
|
.insert(payload)
|
|
.select()
|
|
.single();
|
|
return TicketMessage.fromMap(data);
|
|
}
|
|
|
|
Future<void> updateTicketStatus({
|
|
required String ticketId,
|
|
required String status,
|
|
}) async {
|
|
await _client.from('tickets').update({'status': status}).eq('id', ticketId);
|
|
|
|
// If ticket is promoted, create a linked Task (only once) — the
|
|
// TasksController.createTask already runs auto-assignment on creation.
|
|
if (status == 'promoted') {
|
|
try {
|
|
final existing = await _client
|
|
.from('tasks')
|
|
.select('id')
|
|
.eq('ticket_id', ticketId)
|
|
.maybeSingle();
|
|
if (existing != null) return;
|
|
|
|
final ticketRow = await _client
|
|
.from('tickets')
|
|
.select('subject, description, office_id')
|
|
.eq('id', ticketId)
|
|
.maybeSingle();
|
|
|
|
final title = (ticketRow?['subject'] as String?) ?? 'Task from ticket';
|
|
final description = (ticketRow?['description'] as String?) ?? '';
|
|
final officeId = ticketRow?['office_id'] as String?;
|
|
|
|
final tasksCtrl = TasksController(_client);
|
|
await tasksCtrl.createTask(
|
|
title: title,
|
|
description: description,
|
|
officeId: officeId,
|
|
ticketId: ticketId,
|
|
);
|
|
} catch (_) {
|
|
// best-effort — don't fail the ticket status update
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Update editable ticket fields such as subject, description, and office.
|
|
Future<void> updateTicket({
|
|
required String ticketId,
|
|
String? subject,
|
|
String? description,
|
|
String? officeId,
|
|
}) async {
|
|
final payload = <String, dynamic>{};
|
|
if (subject != null) payload['subject'] = subject;
|
|
if (description != null) payload['description'] = description;
|
|
if (officeId != null) payload['office_id'] = officeId;
|
|
if (payload.isEmpty) return;
|
|
|
|
await _client.from('tickets').update(payload).eq('id', ticketId);
|
|
|
|
// record an activity row for edit operations (best-effort)
|
|
try {
|
|
final actorId = _client.auth.currentUser?.id;
|
|
await _client.from('ticket_messages').insert({
|
|
'ticket_id': ticketId,
|
|
'sender_id': actorId,
|
|
'content': 'Ticket updated',
|
|
});
|
|
} catch (_) {}
|
|
}
|
|
}
|
|
|
|
class OfficesController {
|
|
OfficesController(this._client);
|
|
|
|
final SupabaseClient _client;
|
|
|
|
Future<void> createOffice({required String name, String? serviceId}) async {
|
|
final payload = {'name': name};
|
|
if (serviceId != null) payload['service_id'] = serviceId;
|
|
await _client.from('offices').insert(payload);
|
|
}
|
|
|
|
Future<void> updateOffice({
|
|
required String id,
|
|
required String name,
|
|
String? serviceId,
|
|
}) async {
|
|
final payload = {'name': name};
|
|
if (serviceId != null) payload['service_id'] = serviceId;
|
|
await _client.from('offices').update(payload).eq('id', id);
|
|
}
|
|
|
|
Future<void> deleteOffice({required String id}) async {
|
|
await _client.from('offices').delete().eq('id', id);
|
|
}
|
|
}
|