744 lines
23 KiB
Dart
744 lines
23 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';
|
||
import 'stream_recovery.dart';
|
||
import 'realtime_controller.dart';
|
||
|
||
final officesProvider = StreamProvider<List<Office>>((ref) {
|
||
final client = ref.watch(supabaseClientProvider);
|
||
|
||
final wrapper = StreamRecoveryWrapper<Office>(
|
||
stream: client.from('offices').stream(primaryKey: ['id']).order('name'),
|
||
onPollData: () async {
|
||
final data = await client.from('offices').select().order('name');
|
||
return data.map(Office.fromMap).toList();
|
||
},
|
||
fromMap: Office.fromMap,
|
||
channelName: 'offices',
|
||
onStatusChanged: ref.read(realtimeControllerProvider).handleChannelStatus,
|
||
);
|
||
|
||
ref.onDispose(wrapper.dispose);
|
||
return wrapper.stream.map((result) => result.data);
|
||
});
|
||
|
||
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,
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Builds the isolate payload from a list of [Ticket] objects and the current
|
||
/// query/access context. Extracted so the initial REST seed and the realtime
|
||
/// stream listener can share the same logic without duplication.
|
||
Map<String, dynamic> _buildTicketPayload({
|
||
required List<Ticket> tickets,
|
||
required bool isGlobal,
|
||
required List<String> allowedOfficeIds,
|
||
required TicketQuery query,
|
||
}) {
|
||
final rowsList = tickets
|
||
.map(
|
||
(ticket) => <String, dynamic>{
|
||
'id': ticket.id,
|
||
'subject': ticket.subject,
|
||
'description': ticket.description,
|
||
'status': ticket.status,
|
||
'office_id': ticket.officeId,
|
||
'creator_id': ticket.creatorId,
|
||
'created_at': ticket.createdAt.toIso8601String(),
|
||
'responded_at': ticket.respondedAt?.toIso8601String(),
|
||
'promoted_at': ticket.promotedAt?.toIso8601String(),
|
||
'closed_at': ticket.closedAt?.toIso8601String(),
|
||
},
|
||
)
|
||
.toList();
|
||
|
||
return {
|
||
'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 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 == 'programmer' ||
|
||
profile.role == 'dispatcher' ||
|
||
profile.role == 'it_staff';
|
||
|
||
final allowedOfficeIds =
|
||
assignmentsAsync.valueOrNull
|
||
?.where((a) => a.userId == profile.id)
|
||
.map((a) => a.officeId)
|
||
.toList() ??
|
||
<String>[];
|
||
|
||
// Wrap realtime stream with recovery logic
|
||
final wrapper = StreamRecoveryWrapper<Ticket>(
|
||
stream: client.from('tickets').stream(primaryKey: ['id']),
|
||
onPollData: () async {
|
||
final data = await client.from('tickets').select();
|
||
return data.cast<Map<String, dynamic>>().map(Ticket.fromMap).toList();
|
||
},
|
||
fromMap: Ticket.fromMap,
|
||
channelName: 'tickets',
|
||
onStatusChanged: ref.read(realtimeControllerProvider).handleChannelStatus,
|
||
);
|
||
|
||
ref.onDispose(wrapper.dispose);
|
||
|
||
var lastResultHash = '';
|
||
Timer? debounceTimer;
|
||
// broadcast() so Riverpod and any other listener can both receive events.
|
||
final controller = StreamController<List<Ticket>>.broadcast();
|
||
|
||
void emitDebounced(List<Ticket> tickets) {
|
||
debounceTimer?.cancel();
|
||
debounceTimer = Timer(const Duration(milliseconds: 150), () {
|
||
if (!controller.isClosed) controller.add(tickets);
|
||
});
|
||
}
|
||
|
||
ref.onDispose(() {
|
||
debounceTimer?.cancel();
|
||
controller.close();
|
||
});
|
||
|
||
// ── Immediate REST seed ───────────────────────────────────────────────────
|
||
// Fire a one-shot HTTP fetch right now so the UI can render before the
|
||
// WebSocket realtime channel is fully established. This eliminates the
|
||
// loading delay on web (WebSocket ~200-500 ms) and the initial flash on
|
||
// mobile. The realtime stream takes over afterwards; the hash check below
|
||
// prevents a duplicate rebuild if both arrive with identical data.
|
||
unawaited(
|
||
Future(() async {
|
||
try {
|
||
final data = await client.from('tickets').select();
|
||
final raw = data
|
||
.cast<Map<String, dynamic>>()
|
||
.map(Ticket.fromMap)
|
||
.toList();
|
||
final payload = _buildTicketPayload(
|
||
tickets: raw,
|
||
isGlobal: isGlobal,
|
||
allowedOfficeIds: allowedOfficeIds,
|
||
query: query,
|
||
);
|
||
final processed = await compute(_processTicketsInIsolate, payload);
|
||
final tickets = (processed as List<dynamic>)
|
||
.cast<Map<String, dynamic>>()
|
||
.map(Ticket.fromMap)
|
||
.toList();
|
||
final hash = tickets.fold('', (h, t) => '$h${t.id}');
|
||
if (!controller.isClosed && hash != lastResultHash) {
|
||
lastResultHash = hash;
|
||
controller.add(tickets); // emit immediately – no debounce
|
||
}
|
||
} catch (e) {
|
||
debugPrint('[ticketsProvider] initial seed error: $e');
|
||
}
|
||
}),
|
||
);
|
||
|
||
// ── Realtime stream ───────────────────────────────────────────────────────
|
||
// Processes every realtime event through the same isolate. Debounced so
|
||
// rapid consecutive events (e.g. bulk inserts) don't cause repeated renders.
|
||
final wrapperSub = wrapper.stream
|
||
.asyncMap((result) async {
|
||
final payload = _buildTicketPayload(
|
||
tickets: result.data,
|
||
isGlobal: isGlobal,
|
||
allowedOfficeIds: allowedOfficeIds,
|
||
query: query,
|
||
);
|
||
final processed = await compute(_processTicketsInIsolate, payload);
|
||
return (processed as List<dynamic>)
|
||
.cast<Map<String, dynamic>>()
|
||
.map(Ticket.fromMap)
|
||
.toList();
|
||
})
|
||
.listen(
|
||
(tickets) {
|
||
final hash = tickets.fold('', (h, t) => '$h${t.id}');
|
||
if (hash != lastResultHash) {
|
||
lastResultHash = hash;
|
||
emitDebounced(tickets);
|
||
}
|
||
},
|
||
onError: (Object e) {
|
||
debugPrint('[ticketsProvider] stream error: $e');
|
||
// Don't forward errors — the wrapper handles recovery internally.
|
||
},
|
||
);
|
||
|
||
ref.onDispose(wrapperSub.cancel);
|
||
|
||
return controller.stream;
|
||
});
|
||
|
||
// 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();
|
||
}
|
||
|
||
// Parse created_at timestamp from map. `created_at` may be ISO strings or timestamps.
|
||
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;
|
||
}
|
||
|
||
int statusRank(String s) {
|
||
switch (s) {
|
||
case 'pending':
|
||
return 0;
|
||
case 'promoted':
|
||
return 1;
|
||
case 'closed':
|
||
return 2;
|
||
default:
|
||
return 3;
|
||
}
|
||
}
|
||
|
||
// Sort by status rank first (pending → promoted → closed),
|
||
// then by created_at descending (newest first)
|
||
list.sort((a, b) {
|
||
final ra = statusRank((a['status'] as String?) ?? '');
|
||
final rb = statusRank((b['status'] as String?) ?? '');
|
||
final rcmp = ra.compareTo(rb);
|
||
if (rcmp != 0) return rcmp;
|
||
|
||
// Same status: sort by created_at descending (newest first)
|
||
return 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(),
|
||
);
|
||
|
||
/// Derived provider that selects a single [Ticket] by ID from the tickets list.
|
||
///
|
||
/// Because [Ticket] implements `==`, this provider only notifies watchers when
|
||
/// the specific ticket's data actually changes — not when unrelated tickets in
|
||
/// the list are updated. Use this in detail screens to avoid full-list rebuilds.
|
||
final ticketByIdProvider = Provider.family<Ticket?, String>((ref, ticketId) {
|
||
return ref
|
||
.watch(ticketsProvider)
|
||
.valueOrNull
|
||
?.where((t) => t.id == ticketId)
|
||
.firstOrNull;
|
||
});
|
||
|
||
final ticketMessagesProvider =
|
||
StreamProvider.family<List<TicketMessage>, String>((ref, ticketId) {
|
||
final client = ref.watch(supabaseClientProvider);
|
||
|
||
final wrapper = StreamRecoveryWrapper<TicketMessage>(
|
||
stream: client
|
||
.from('ticket_messages')
|
||
.stream(primaryKey: ['id'])
|
||
.eq('ticket_id', ticketId)
|
||
.order('created_at', ascending: false),
|
||
onPollData: () async {
|
||
final data = await client
|
||
.from('ticket_messages')
|
||
.select()
|
||
.eq('ticket_id', ticketId)
|
||
.order('created_at', ascending: false);
|
||
return data.map(TicketMessage.fromMap).toList();
|
||
},
|
||
fromMap: TicketMessage.fromMap,
|
||
channelName: 'ticket_messages:$ticketId',
|
||
onStatusChanged: ref
|
||
.read(realtimeControllerProvider)
|
||
.handleChannelStatus,
|
||
);
|
||
|
||
ref.onDispose(wrapper.dispose);
|
||
return wrapper.stream.map((result) => result.data);
|
||
});
|
||
|
||
final ticketMessagesAllProvider = StreamProvider<List<TicketMessage>>((ref) {
|
||
final client = ref.watch(supabaseClientProvider);
|
||
|
||
final wrapper = StreamRecoveryWrapper<TicketMessage>(
|
||
stream: client
|
||
.from('ticket_messages')
|
||
.stream(primaryKey: ['id'])
|
||
.order('created_at', ascending: false),
|
||
onPollData: () async {
|
||
final data = await client
|
||
.from('ticket_messages')
|
||
.select()
|
||
.order('created_at', ascending: false);
|
||
return data.map(TicketMessage.fromMap).toList();
|
||
},
|
||
fromMap: TicketMessage.fromMap,
|
||
channelName: 'ticket_messages_all',
|
||
onStatusChanged: ref.read(realtimeControllerProvider).handleChannelStatus,
|
||
);
|
||
|
||
ref.onDispose(wrapper.dispose);
|
||
return wrapper.stream.map((result) => result.data);
|
||
});
|
||
|
||
final taskMessagesProvider = StreamProvider.family<List<TicketMessage>, String>(
|
||
(ref, taskId) {
|
||
final client = ref.watch(supabaseClientProvider);
|
||
|
||
final wrapper = StreamRecoveryWrapper<TicketMessage>(
|
||
stream: client
|
||
.from('ticket_messages')
|
||
.stream(primaryKey: ['id'])
|
||
.eq('task_id', taskId)
|
||
.order('created_at', ascending: false),
|
||
onPollData: () async {
|
||
final data = await client
|
||
.from('ticket_messages')
|
||
.select()
|
||
.eq('task_id', taskId)
|
||
.order('created_at', ascending: false);
|
||
return data.map(TicketMessage.fromMap).toList();
|
||
},
|
||
fromMap: TicketMessage.fromMap,
|
||
channelName: 'task_messages:$taskId',
|
||
onStatusChanged: ref.read(realtimeControllerProvider).handleChannelStatus,
|
||
);
|
||
|
||
ref.onDispose(wrapper.dispose);
|
||
return wrapper.stream.map((result) => result.data);
|
||
},
|
||
);
|
||
|
||
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);
|
||
}
|
||
}
|