Announcements and IT Job Checklist
This commit is contained in:
parent
7d9096963a
commit
3fb6fd5c93
70
lib/models/announcement.dart
Normal file
70
lib/models/announcement.dart
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
import '../utils/app_time.dart';
|
||||||
|
|
||||||
|
class Announcement {
|
||||||
|
Announcement({
|
||||||
|
required this.id,
|
||||||
|
required this.authorId,
|
||||||
|
required this.title,
|
||||||
|
required this.body,
|
||||||
|
required this.visibleRoles,
|
||||||
|
required this.isTemplate,
|
||||||
|
this.templateId,
|
||||||
|
required this.createdAt,
|
||||||
|
required this.updatedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String id;
|
||||||
|
final String authorId;
|
||||||
|
final String title;
|
||||||
|
final String body;
|
||||||
|
final List<String> visibleRoles;
|
||||||
|
final bool isTemplate;
|
||||||
|
final String? templateId;
|
||||||
|
final DateTime createdAt;
|
||||||
|
final DateTime updatedAt;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) =>
|
||||||
|
identical(this, other) ||
|
||||||
|
other is Announcement &&
|
||||||
|
runtimeType == other.runtimeType &&
|
||||||
|
id == other.id &&
|
||||||
|
authorId == other.authorId &&
|
||||||
|
title == other.title &&
|
||||||
|
body == other.body &&
|
||||||
|
isTemplate == other.isTemplate &&
|
||||||
|
templateId == other.templateId &&
|
||||||
|
createdAt == other.createdAt &&
|
||||||
|
updatedAt == other.updatedAt;
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => Object.hash(
|
||||||
|
id,
|
||||||
|
authorId,
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
isTemplate,
|
||||||
|
templateId,
|
||||||
|
createdAt,
|
||||||
|
updatedAt,
|
||||||
|
);
|
||||||
|
|
||||||
|
factory Announcement.fromMap(Map<String, dynamic> map) {
|
||||||
|
final rolesRaw = map['visible_roles'];
|
||||||
|
final roles = rolesRaw is List
|
||||||
|
? rolesRaw.cast<String>()
|
||||||
|
: <String>[];
|
||||||
|
|
||||||
|
return Announcement(
|
||||||
|
id: map['id'] as String,
|
||||||
|
authorId: map['author_id'] as String,
|
||||||
|
title: map['title'] as String? ?? '',
|
||||||
|
body: map['body'] as String? ?? '',
|
||||||
|
visibleRoles: roles,
|
||||||
|
isTemplate: map['is_template'] as bool? ?? false,
|
||||||
|
templateId: map['template_id'] as String?,
|
||||||
|
createdAt: AppTime.parse(map['created_at'] as String),
|
||||||
|
updatedAt: AppTime.parse(map['updated_at'] as String),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
47
lib/models/announcement_comment.dart
Normal file
47
lib/models/announcement_comment.dart
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
import '../utils/app_time.dart';
|
||||||
|
|
||||||
|
class AnnouncementComment {
|
||||||
|
AnnouncementComment({
|
||||||
|
required this.id,
|
||||||
|
required this.announcementId,
|
||||||
|
required this.authorId,
|
||||||
|
required this.body,
|
||||||
|
required this.createdAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String id;
|
||||||
|
final String announcementId;
|
||||||
|
final String authorId;
|
||||||
|
final String body;
|
||||||
|
final DateTime createdAt;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) =>
|
||||||
|
identical(this, other) ||
|
||||||
|
other is AnnouncementComment &&
|
||||||
|
runtimeType == other.runtimeType &&
|
||||||
|
id == other.id &&
|
||||||
|
announcementId == other.announcementId &&
|
||||||
|
authorId == other.authorId &&
|
||||||
|
body == other.body &&
|
||||||
|
createdAt == other.createdAt;
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => Object.hash(
|
||||||
|
id,
|
||||||
|
announcementId,
|
||||||
|
authorId,
|
||||||
|
body,
|
||||||
|
createdAt,
|
||||||
|
);
|
||||||
|
|
||||||
|
factory AnnouncementComment.fromMap(Map<String, dynamic> map) {
|
||||||
|
return AnnouncementComment(
|
||||||
|
id: map['id'] as String,
|
||||||
|
announcementId: map['announcement_id'] as String,
|
||||||
|
authorId: map['author_id'] as String,
|
||||||
|
body: map['body'] as String? ?? '',
|
||||||
|
createdAt: AppTime.parse(map['created_at'] as String),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -10,6 +10,7 @@ class NotificationItem {
|
||||||
this.leaveId,
|
this.leaveId,
|
||||||
this.passSlipId,
|
this.passSlipId,
|
||||||
required this.itServiceRequestId,
|
required this.itServiceRequestId,
|
||||||
|
this.announcementId,
|
||||||
required this.messageId,
|
required this.messageId,
|
||||||
required this.type,
|
required this.type,
|
||||||
required this.createdAt,
|
required this.createdAt,
|
||||||
|
|
@ -24,6 +25,7 @@ class NotificationItem {
|
||||||
final String? leaveId;
|
final String? leaveId;
|
||||||
final String? passSlipId;
|
final String? passSlipId;
|
||||||
final String? itServiceRequestId;
|
final String? itServiceRequestId;
|
||||||
|
final String? announcementId;
|
||||||
final int? messageId;
|
final int? messageId;
|
||||||
final String type;
|
final String type;
|
||||||
final DateTime createdAt;
|
final DateTime createdAt;
|
||||||
|
|
@ -41,6 +43,7 @@ class NotificationItem {
|
||||||
leaveId: map['leave_id'] as String?,
|
leaveId: map['leave_id'] as String?,
|
||||||
passSlipId: map['pass_slip_id'] as String?,
|
passSlipId: map['pass_slip_id'] as String?,
|
||||||
itServiceRequestId: map['it_service_request_id'] as String?,
|
itServiceRequestId: map['it_service_request_id'] as String?,
|
||||||
|
announcementId: map['announcement_id'] as String?,
|
||||||
messageId: map['message_id'] as int?,
|
messageId: map['message_id'] as int?,
|
||||||
type: map['type'] as String? ?? 'mention',
|
type: map['type'] as String? ?? 'mention',
|
||||||
createdAt: AppTime.parse(map['created_at'] as String),
|
createdAt: AppTime.parse(map['created_at'] as String),
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,9 @@ class Task {
|
||||||
this.actionTaken,
|
this.actionTaken,
|
||||||
this.cancellationReason,
|
this.cancellationReason,
|
||||||
this.cancelledAt,
|
this.cancelledAt,
|
||||||
|
this.itJobPrinted = false,
|
||||||
|
this.itJobPrintedAt,
|
||||||
|
this.itJobReceivedById,
|
||||||
});
|
});
|
||||||
|
|
||||||
final String id;
|
final String id;
|
||||||
|
|
@ -59,6 +62,11 @@ class Task {
|
||||||
final String? cancellationReason;
|
final String? cancellationReason;
|
||||||
final DateTime? cancelledAt;
|
final DateTime? cancelledAt;
|
||||||
|
|
||||||
|
/// Whether the printed IT Job has been submitted.
|
||||||
|
final bool itJobPrinted;
|
||||||
|
final DateTime? itJobPrintedAt;
|
||||||
|
final String? itJobReceivedById;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool operator ==(Object other) =>
|
bool operator ==(Object other) =>
|
||||||
identical(this, other) ||
|
identical(this, other) ||
|
||||||
|
|
@ -85,7 +93,10 @@ class Task {
|
||||||
requestCategory == other.requestCategory &&
|
requestCategory == other.requestCategory &&
|
||||||
actionTaken == other.actionTaken &&
|
actionTaken == other.actionTaken &&
|
||||||
cancellationReason == other.cancellationReason &&
|
cancellationReason == other.cancellationReason &&
|
||||||
cancelledAt == other.cancelledAt;
|
cancelledAt == other.cancelledAt &&
|
||||||
|
itJobPrinted == other.itJobPrinted &&
|
||||||
|
itJobPrintedAt == other.itJobPrintedAt &&
|
||||||
|
itJobReceivedById == other.itJobReceivedById;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get hashCode => Object.hash(
|
int get hashCode => Object.hash(
|
||||||
|
|
@ -109,7 +120,8 @@ class Task {
|
||||||
requestTypeOther,
|
requestTypeOther,
|
||||||
requestCategory,
|
requestCategory,
|
||||||
// Object.hash supports max 20 positional args; combine remainder.
|
// Object.hash supports max 20 positional args; combine remainder.
|
||||||
Object.hash(actionTaken, cancellationReason, cancelledAt),
|
Object.hash(actionTaken, cancellationReason, cancelledAt,
|
||||||
|
itJobPrinted, itJobPrintedAt, itJobReceivedById),
|
||||||
);
|
);
|
||||||
|
|
||||||
/// Helper that indicates whether a completed task still has missing
|
/// Helper that indicates whether a completed task still has missing
|
||||||
|
|
@ -154,6 +166,11 @@ class Task {
|
||||||
cancelledAt: map['cancelled_at'] == null
|
cancelledAt: map['cancelled_at'] == null
|
||||||
? null
|
? null
|
||||||
: AppTime.parse(map['cancelled_at'] as String),
|
: AppTime.parse(map['cancelled_at'] as String),
|
||||||
|
itJobPrinted: map['it_job_printed'] as bool? ?? false,
|
||||||
|
itJobPrintedAt: map['it_job_printed_at'] == null
|
||||||
|
? null
|
||||||
|
: AppTime.parse(map['it_job_printed_at'] as String),
|
||||||
|
itJobReceivedById: map['it_job_received_by_id'] as String?,
|
||||||
actionTaken: (() {
|
actionTaken: (() {
|
||||||
final at = map['action_taken'];
|
final at = map['action_taken'];
|
||||||
if (at == null) return null;
|
if (at == null) return null;
|
||||||
|
|
|
||||||
258
lib/providers/announcements_provider.dart
Normal file
258
lib/providers/announcements_provider.dart
Normal file
|
|
@ -0,0 +1,258 @@
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||||
|
|
||||||
|
import '../models/announcement.dart';
|
||||||
|
import '../models/announcement_comment.dart';
|
||||||
|
import 'notifications_provider.dart';
|
||||||
|
import 'profile_provider.dart';
|
||||||
|
import 'supabase_provider.dart';
|
||||||
|
import 'stream_recovery.dart';
|
||||||
|
import 'realtime_controller.dart';
|
||||||
|
import '../utils/app_time.dart';
|
||||||
|
|
||||||
|
/// Thrown when an announcement or comment is saved successfully but
|
||||||
|
/// notification delivery fails. The primary action (insert) has already
|
||||||
|
/// completed — callers should treat this as a non-blocking warning.
|
||||||
|
class AnnouncementNotificationException implements Exception {
|
||||||
|
const AnnouncementNotificationException(this.message);
|
||||||
|
final String message;
|
||||||
|
@override
|
||||||
|
String toString() => message;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Streams all announcements visible to the current user (RLS filtered).
|
||||||
|
final announcementsProvider = StreamProvider<List<Announcement>>((ref) {
|
||||||
|
final userId = ref.watch(currentUserIdProvider);
|
||||||
|
if (userId == null) return const Stream.empty();
|
||||||
|
|
||||||
|
final client = ref.watch(supabaseClientProvider);
|
||||||
|
|
||||||
|
final wrapper = StreamRecoveryWrapper<Announcement>(
|
||||||
|
stream: client
|
||||||
|
.from('announcements')
|
||||||
|
.stream(primaryKey: ['id'])
|
||||||
|
.order('created_at', ascending: false),
|
||||||
|
onPollData: () async {
|
||||||
|
final data = await client
|
||||||
|
.from('announcements')
|
||||||
|
.select()
|
||||||
|
.order('created_at', ascending: false);
|
||||||
|
return data.map(Announcement.fromMap).toList();
|
||||||
|
},
|
||||||
|
fromMap: Announcement.fromMap,
|
||||||
|
channelName: 'announcements',
|
||||||
|
onStatusChanged: ref.read(realtimeControllerProvider).handleChannelStatus,
|
||||||
|
);
|
||||||
|
|
||||||
|
ref.onDispose(wrapper.dispose);
|
||||||
|
return wrapper.stream.map((result) => result.data);
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Streams comments for a specific announcement.
|
||||||
|
final announcementCommentsProvider =
|
||||||
|
StreamProvider.family<List<AnnouncementComment>, String>((
|
||||||
|
ref,
|
||||||
|
announcementId,
|
||||||
|
) {
|
||||||
|
final client = ref.watch(supabaseClientProvider);
|
||||||
|
|
||||||
|
final wrapper = StreamRecoveryWrapper<AnnouncementComment>(
|
||||||
|
stream: client
|
||||||
|
.from('announcement_comments')
|
||||||
|
.stream(primaryKey: ['id'])
|
||||||
|
.eq('announcement_id', announcementId)
|
||||||
|
.order('created_at'),
|
||||||
|
onPollData: () async {
|
||||||
|
final data = await client
|
||||||
|
.from('announcement_comments')
|
||||||
|
.select()
|
||||||
|
.eq('announcement_id', announcementId)
|
||||||
|
.order('created_at');
|
||||||
|
return data.map(AnnouncementComment.fromMap).toList();
|
||||||
|
},
|
||||||
|
fromMap: AnnouncementComment.fromMap,
|
||||||
|
channelName: 'announcement_comments_$announcementId',
|
||||||
|
onStatusChanged: ref.read(realtimeControllerProvider).handleChannelStatus,
|
||||||
|
);
|
||||||
|
|
||||||
|
ref.onDispose(wrapper.dispose);
|
||||||
|
return wrapper.stream.map((result) => result.data);
|
||||||
|
});
|
||||||
|
|
||||||
|
final announcementsControllerProvider =
|
||||||
|
Provider<AnnouncementsController>((ref) {
|
||||||
|
final client = ref.watch(supabaseClientProvider);
|
||||||
|
final notifCtrl = ref.watch(notificationsControllerProvider);
|
||||||
|
return AnnouncementsController(client, notifCtrl);
|
||||||
|
});
|
||||||
|
|
||||||
|
class AnnouncementsController {
|
||||||
|
AnnouncementsController(this._client, this._notifCtrl);
|
||||||
|
|
||||||
|
final SupabaseClient _client;
|
||||||
|
final NotificationsController _notifCtrl;
|
||||||
|
|
||||||
|
/// Create a new announcement and send push notifications to target users.
|
||||||
|
Future<void> createAnnouncement({
|
||||||
|
required String title,
|
||||||
|
required String body,
|
||||||
|
required List<String> visibleRoles,
|
||||||
|
bool isTemplate = false,
|
||||||
|
String? templateId,
|
||||||
|
}) async {
|
||||||
|
final authorId = _client.auth.currentUser?.id;
|
||||||
|
if (authorId == null) return;
|
||||||
|
|
||||||
|
final row = {
|
||||||
|
'author_id': authorId,
|
||||||
|
'title': title,
|
||||||
|
'body': body,
|
||||||
|
'visible_roles': visibleRoles,
|
||||||
|
'is_template': isTemplate,
|
||||||
|
'template_id': templateId,
|
||||||
|
};
|
||||||
|
|
||||||
|
final result = await _client
|
||||||
|
.from('announcements')
|
||||||
|
.insert(row)
|
||||||
|
.select('id')
|
||||||
|
.single();
|
||||||
|
final announcementId = result['id'] as String;
|
||||||
|
|
||||||
|
// Don't send notifications for templates (they are drafts for reuse)
|
||||||
|
if (isTemplate) return;
|
||||||
|
|
||||||
|
// Query users whose role matches visible_roles, excluding the author
|
||||||
|
try {
|
||||||
|
final profiles = await _client
|
||||||
|
.from('profiles')
|
||||||
|
.select('id, role')
|
||||||
|
.inFilter('role', visibleRoles);
|
||||||
|
final userIds = (profiles as List)
|
||||||
|
.map((p) => p['id'] as String)
|
||||||
|
.where((id) => id != authorId)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
if (userIds.isEmpty) return;
|
||||||
|
|
||||||
|
await _notifCtrl.createNotification(
|
||||||
|
userIds: userIds,
|
||||||
|
type: 'announcement',
|
||||||
|
actorId: authorId,
|
||||||
|
fields: {'announcement_id': announcementId},
|
||||||
|
pushTitle: 'New Announcement',
|
||||||
|
pushBody: title.length > 100 ? '${title.substring(0, 100)}...' : title,
|
||||||
|
pushData: {
|
||||||
|
'announcement_id': announcementId,
|
||||||
|
'navigate_to': '/announcements',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('AnnouncementsController: notification error: $e');
|
||||||
|
throw AnnouncementNotificationException(e.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update an existing announcement.
|
||||||
|
Future<void> updateAnnouncement({
|
||||||
|
required String id,
|
||||||
|
required String title,
|
||||||
|
required String body,
|
||||||
|
required List<String> visibleRoles,
|
||||||
|
bool? isTemplate,
|
||||||
|
}) async {
|
||||||
|
final payload = <String, dynamic>{
|
||||||
|
'title': title,
|
||||||
|
'body': body,
|
||||||
|
'visible_roles': visibleRoles,
|
||||||
|
'updated_at': AppTime.nowUtc().toIso8601String(),
|
||||||
|
};
|
||||||
|
if (isTemplate != null) {
|
||||||
|
payload['is_template'] = isTemplate;
|
||||||
|
}
|
||||||
|
await _client.from('announcements').update(payload).eq('id', id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete an announcement.
|
||||||
|
Future<void> deleteAnnouncement(String id) async {
|
||||||
|
await _client.from('announcements').delete().eq('id', id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch a template announcement's data for reposting.
|
||||||
|
Future<Announcement?> fetchTemplate(String templateId) async {
|
||||||
|
final data = await _client
|
||||||
|
.from('announcements')
|
||||||
|
.select()
|
||||||
|
.eq('id', templateId)
|
||||||
|
.maybeSingle();
|
||||||
|
if (data == null) return null;
|
||||||
|
return Announcement.fromMap(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add a comment to an announcement.
|
||||||
|
/// Notifies the announcement author and all previous commenters.
|
||||||
|
Future<void> addComment({
|
||||||
|
required String announcementId,
|
||||||
|
required String body,
|
||||||
|
}) async {
|
||||||
|
final authorId = _client.auth.currentUser?.id;
|
||||||
|
if (authorId == null) return;
|
||||||
|
|
||||||
|
await _client.from('announcement_comments').insert({
|
||||||
|
'announcement_id': announcementId,
|
||||||
|
'author_id': authorId,
|
||||||
|
'body': body,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Notify announcement author + previous commenters
|
||||||
|
try {
|
||||||
|
// Get the announcement author
|
||||||
|
final announcement = await _client
|
||||||
|
.from('announcements')
|
||||||
|
.select('author_id')
|
||||||
|
.eq('id', announcementId)
|
||||||
|
.maybeSingle();
|
||||||
|
if (announcement == null) return;
|
||||||
|
|
||||||
|
final announcementAuthorId = announcement['author_id'] as String;
|
||||||
|
|
||||||
|
// Get all unique commenters on this announcement
|
||||||
|
final comments = await _client
|
||||||
|
.from('announcement_comments')
|
||||||
|
.select('author_id')
|
||||||
|
.eq('announcement_id', announcementId);
|
||||||
|
final commenterIds = (comments as List)
|
||||||
|
.map((c) => c['author_id'] as String)
|
||||||
|
.toSet();
|
||||||
|
|
||||||
|
// Combine author + commenters, exclude self
|
||||||
|
final notifyIds = <String>{announcementAuthorId, ...commenterIds}
|
||||||
|
.where((id) => id != authorId)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
if (notifyIds.isEmpty) return;
|
||||||
|
|
||||||
|
await _notifCtrl.createNotification(
|
||||||
|
userIds: notifyIds,
|
||||||
|
type: 'announcement_comment',
|
||||||
|
actorId: authorId,
|
||||||
|
fields: {'announcement_id': announcementId},
|
||||||
|
pushTitle: 'New Comment',
|
||||||
|
pushBody: body.length > 100 ? '${body.substring(0, 100)}...' : body,
|
||||||
|
pushData: {
|
||||||
|
'announcement_id': announcementId,
|
||||||
|
'navigate_to': '/announcements',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('AnnouncementsController: comment notification error: $e');
|
||||||
|
throw AnnouncementNotificationException(e.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete a comment.
|
||||||
|
Future<void> deleteComment(String id) async {
|
||||||
|
await _client.from('announcement_comments').delete().eq('id', id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -234,6 +234,9 @@ Map<String, dynamic> _buildTaskPayload({
|
||||||
'action_taken': task.actionTaken,
|
'action_taken': task.actionTaken,
|
||||||
'cancellation_reason': task.cancellationReason,
|
'cancellation_reason': task.cancellationReason,
|
||||||
'cancelled_at': task.cancelledAt?.toIso8601String(),
|
'cancelled_at': task.cancelledAt?.toIso8601String(),
|
||||||
|
'it_job_printed': task.itJobPrinted,
|
||||||
|
'it_job_printed_at': task.itJobPrintedAt?.toIso8601String(),
|
||||||
|
'it_job_received_by_id': task.itJobReceivedById,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.toList();
|
.toList();
|
||||||
|
|
@ -1694,6 +1697,25 @@ class TasksController {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Mark a task's printed IT Job as received by [receivedById].
|
||||||
|
///
|
||||||
|
/// Uses a SECURITY DEFINER RPC to bypass RLS UPDATE restrictions
|
||||||
|
/// that would otherwise silently block the dispatcher's update.
|
||||||
|
Future<void> markItJobPrinted(String taskId,
|
||||||
|
{required String receivedById}) async {
|
||||||
|
await _client.rpc('mark_it_job_printed', params: {
|
||||||
|
'p_task_id': taskId,
|
||||||
|
'p_receiver_id': receivedById,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unmark a task's printed IT Job submission.
|
||||||
|
Future<void> markItJobNotPrinted(String taskId) async {
|
||||||
|
await _client.rpc('unmark_it_job_printed', params: {
|
||||||
|
'p_task_id': taskId,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Public DTO used by unit tests to validate selection logic.
|
/// Public DTO used by unit tests to validate selection logic.
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ import '../screens/dashboard/dashboard_screen.dart';
|
||||||
import '../screens/notifications/notifications_screen.dart';
|
import '../screens/notifications/notifications_screen.dart';
|
||||||
import '../screens/profile/profile_screen.dart';
|
import '../screens/profile/profile_screen.dart';
|
||||||
import '../screens/shared/mobile_verification_screen.dart';
|
import '../screens/shared/mobile_verification_screen.dart';
|
||||||
import '../screens/shared/under_development_screen.dart';
|
import '../screens/announcements/announcements_screen.dart';
|
||||||
import '../screens/shared/permissions_screen.dart';
|
import '../screens/shared/permissions_screen.dart';
|
||||||
import '../screens/reports/reports_screen.dart';
|
import '../screens/reports/reports_screen.dart';
|
||||||
import '../screens/tasks/task_detail_screen.dart';
|
import '../screens/tasks/task_detail_screen.dart';
|
||||||
|
|
@ -183,11 +183,7 @@ final appRouterProvider = Provider<GoRouter>((ref) {
|
||||||
path: '/announcements',
|
path: '/announcements',
|
||||||
pageBuilder: (context, state) => M3SharedAxisPage(
|
pageBuilder: (context, state) => M3SharedAxisPage(
|
||||||
key: state.pageKey,
|
key: state.pageKey,
|
||||||
child: const UnderDevelopmentScreen(
|
child: const AnnouncementsScreen(),
|
||||||
title: 'Announcement',
|
|
||||||
subtitle: 'Operational broadcasts are coming soon.',
|
|
||||||
icon: Icons.campaign,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
|
|
|
||||||
258
lib/screens/announcements/announcement_comments_section.dart
Normal file
258
lib/screens/announcements/announcement_comments_section.dart
Normal file
|
|
@ -0,0 +1,258 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../../models/announcement_comment.dart';
|
||||||
|
import '../../providers/announcements_provider.dart';
|
||||||
|
import '../../providers/profile_provider.dart';
|
||||||
|
import '../../utils/app_time.dart';
|
||||||
|
import '../../widgets/profile_avatar.dart';
|
||||||
|
|
||||||
|
/// Inline, collapsible comments section for an announcement card.
|
||||||
|
class AnnouncementCommentsSection extends ConsumerStatefulWidget {
|
||||||
|
const AnnouncementCommentsSection({
|
||||||
|
super.key,
|
||||||
|
required this.announcementId,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String announcementId;
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<AnnouncementCommentsSection> createState() =>
|
||||||
|
_AnnouncementCommentsSectionState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AnnouncementCommentsSectionState
|
||||||
|
extends ConsumerState<AnnouncementCommentsSection> {
|
||||||
|
final _controller = TextEditingController();
|
||||||
|
bool _sending = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_controller.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _submit() async {
|
||||||
|
final text = _controller.text.trim();
|
||||||
|
if (text.isEmpty || _sending) return;
|
||||||
|
|
||||||
|
setState(() => _sending = true);
|
||||||
|
try {
|
||||||
|
await ref.read(announcementsControllerProvider).addComment(
|
||||||
|
announcementId: widget.announcementId,
|
||||||
|
body: text,
|
||||||
|
);
|
||||||
|
_controller.clear();
|
||||||
|
} on AnnouncementNotificationException {
|
||||||
|
// Comment was posted; only push notification delivery failed.
|
||||||
|
_controller.clear();
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Comment posted, but notifications may not have been sent.'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('Failed to post comment: $e')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _sending = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final commentsAsync =
|
||||||
|
ref.watch(announcementCommentsProvider(widget.announcementId));
|
||||||
|
final profiles = ref.watch(profilesProvider).valueOrNull ?? [];
|
||||||
|
final cs = Theme.of(context).colorScheme;
|
||||||
|
final tt = Theme.of(context).textTheme;
|
||||||
|
final currentUserId = ref.watch(currentUserIdProvider);
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Divider(height: 1, color: cs.outlineVariant.withValues(alpha: 0.5)),
|
||||||
|
commentsAsync.when(
|
||||||
|
loading: () => const Padding(
|
||||||
|
padding: EdgeInsets.all(16),
|
||||||
|
child: Center(child: CircularProgressIndicator(strokeWidth: 2)),
|
||||||
|
),
|
||||||
|
error: (e, _) => Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Text('Error loading comments',
|
||||||
|
style: tt.bodySmall?.copyWith(color: cs.error)),
|
||||||
|
),
|
||||||
|
data: (comments) => comments.isEmpty
|
||||||
|
? Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 16, vertical: 12),
|
||||||
|
child: Text(
|
||||||
|
'No comments yet',
|
||||||
|
style: tt.bodySmall
|
||||||
|
?.copyWith(color: cs.onSurfaceVariant),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: ListView.separated(
|
||||||
|
shrinkWrap: true,
|
||||||
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
|
padding:
|
||||||
|
const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
|
itemCount: comments.length,
|
||||||
|
separatorBuilder: (_, _) => const SizedBox(height: 8),
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final comment = comments[index];
|
||||||
|
return _CommentTile(
|
||||||
|
comment: comment,
|
||||||
|
profiles: profiles,
|
||||||
|
currentUserId: currentUserId,
|
||||||
|
onDelete: () async {
|
||||||
|
await ref
|
||||||
|
.read(announcementsControllerProvider)
|
||||||
|
.deleteComment(comment.id);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Input row
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 4, 16, 12),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: TextField(
|
||||||
|
controller: _controller,
|
||||||
|
maxLines: null,
|
||||||
|
minLines: 1,
|
||||||
|
keyboardType: TextInputType.multiline,
|
||||||
|
textInputAction: TextInputAction.newline,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: 'Write a comment...',
|
||||||
|
isDense: true,
|
||||||
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 12, vertical: 10),
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: BorderSide(color: cs.outlineVariant),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
style: tt.bodyMedium,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
IconButton(
|
||||||
|
onPressed: _sending ? null : _submit,
|
||||||
|
icon: _sending
|
||||||
|
? const SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child:
|
||||||
|
CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: Icon(Icons.send, color: cs.primary),
|
||||||
|
tooltip: 'Send',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CommentTile extends StatelessWidget {
|
||||||
|
const _CommentTile({
|
||||||
|
required this.comment,
|
||||||
|
required this.profiles,
|
||||||
|
required this.currentUserId,
|
||||||
|
required this.onDelete,
|
||||||
|
});
|
||||||
|
|
||||||
|
final AnnouncementComment comment;
|
||||||
|
final List profiles;
|
||||||
|
final String? currentUserId;
|
||||||
|
final VoidCallback onDelete;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final cs = Theme.of(context).colorScheme;
|
||||||
|
final tt = Theme.of(context).textTheme;
|
||||||
|
|
||||||
|
// Resolve author name
|
||||||
|
String authorName = 'Unknown';
|
||||||
|
String? avatarUrl;
|
||||||
|
for (final p in profiles) {
|
||||||
|
if (p.id == comment.authorId) {
|
||||||
|
authorName = p.fullName;
|
||||||
|
avatarUrl = p.avatarUrl;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final isOwn = comment.authorId == currentUserId;
|
||||||
|
|
||||||
|
return Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
ProfileAvatar(fullName: authorName, avatarUrl: avatarUrl, radius: 14),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Flexible(
|
||||||
|
child: Text(
|
||||||
|
authorName,
|
||||||
|
style: tt.labelMedium
|
||||||
|
?.copyWith(fontWeight: FontWeight.w600),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Flexible(
|
||||||
|
child: Text(
|
||||||
|
_relativeTime(comment.createdAt),
|
||||||
|
style: tt.labelSmall
|
||||||
|
?.copyWith(color: cs.onSurfaceVariant),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(comment.body, style: tt.bodySmall),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (isOwn)
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(Icons.close, size: 16, color: cs.onSurfaceVariant),
|
||||||
|
onPressed: onDelete,
|
||||||
|
tooltip: 'Delete',
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
constraints: const BoxConstraints(minWidth: 28, minHeight: 28),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _relativeTime(DateTime dt) {
|
||||||
|
final now = AppTime.now();
|
||||||
|
final diff = now.difference(dt);
|
||||||
|
if (diff.inMinutes < 1) return 'just now';
|
||||||
|
if (diff.inMinutes < 60) return '${diff.inMinutes}m ago';
|
||||||
|
if (diff.inHours < 24) return '${diff.inHours}h ago';
|
||||||
|
if (diff.inDays < 7) return '${diff.inDays}d ago';
|
||||||
|
return AppTime.formatDate(dt);
|
||||||
|
}
|
||||||
410
lib/screens/announcements/announcements_screen.dart
Normal file
410
lib/screens/announcements/announcements_screen.dart
Normal file
|
|
@ -0,0 +1,410 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:skeletonizer/skeletonizer.dart';
|
||||||
|
|
||||||
|
import '../../models/announcement.dart';
|
||||||
|
import '../../providers/announcements_provider.dart';
|
||||||
|
import '../../providers/profile_provider.dart';
|
||||||
|
import '../../theme/m3_motion.dart';
|
||||||
|
import '../../utils/app_time.dart';
|
||||||
|
import '../../widgets/app_page_header.dart';
|
||||||
|
import '../../widgets/m3_card.dart';
|
||||||
|
import '../../widgets/profile_avatar.dart';
|
||||||
|
import '../../widgets/responsive_body.dart';
|
||||||
|
import 'announcement_comments_section.dart';
|
||||||
|
import 'create_announcement_dialog.dart';
|
||||||
|
|
||||||
|
class AnnouncementsScreen extends ConsumerStatefulWidget {
|
||||||
|
const AnnouncementsScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<AnnouncementsScreen> createState() =>
|
||||||
|
_AnnouncementsScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AnnouncementsScreenState extends ConsumerState<AnnouncementsScreen> {
|
||||||
|
final Set<String> _expandedComments = {};
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final announcementsAsync = ref.watch(announcementsProvider);
|
||||||
|
final profiles = ref.watch(profilesProvider).valueOrNull ?? [];
|
||||||
|
final currentProfile = ref.watch(currentProfileProvider).valueOrNull;
|
||||||
|
final currentUserId = ref.watch(currentUserIdProvider);
|
||||||
|
final role = currentProfile?.role ?? 'standard';
|
||||||
|
final canCreate = const ['admin', 'dispatcher', 'programmer', 'it_staff']
|
||||||
|
.contains(role);
|
||||||
|
|
||||||
|
final hasValue = announcementsAsync.hasValue;
|
||||||
|
final hasError = announcementsAsync.hasError;
|
||||||
|
final items = announcementsAsync.valueOrNull ?? [];
|
||||||
|
final showSkeleton = !hasValue && !hasError;
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
floatingActionButton: canCreate
|
||||||
|
? M3ExpandedFab(
|
||||||
|
heroTag: 'announcement_fab',
|
||||||
|
onPressed: () =>
|
||||||
|
showCreateAnnouncementDialog(context),
|
||||||
|
icon: const Icon(Icons.add),
|
||||||
|
label: const Text('New Announcement'),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
body: ResponsiveBody(
|
||||||
|
child: Skeletonizer(
|
||||||
|
enabled: showSkeleton,
|
||||||
|
child: CustomScrollView(
|
||||||
|
slivers: [
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: AppPageHeader(title: 'Announcements'),
|
||||||
|
),
|
||||||
|
if (hasError && !hasValue)
|
||||||
|
SliverFillRemaining(
|
||||||
|
child: Center(
|
||||||
|
child: Text(
|
||||||
|
'Failed to load announcements.',
|
||||||
|
style: Theme.of(context)
|
||||||
|
.textTheme
|
||||||
|
.bodyMedium
|
||||||
|
?.copyWith(
|
||||||
|
color: Theme.of(context).colorScheme.error),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else if (!showSkeleton && items.isEmpty)
|
||||||
|
SliverFillRemaining(
|
||||||
|
child: Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.campaign_outlined,
|
||||||
|
size: 64,
|
||||||
|
color: Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.onSurfaceVariant),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text('No announcements yet',
|
||||||
|
style: Theme.of(context)
|
||||||
|
.textTheme
|
||||||
|
.bodyLarge
|
||||||
|
?.copyWith(
|
||||||
|
color: Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.onSurfaceVariant)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
SliverList(
|
||||||
|
delegate: SliverChildBuilderDelegate(
|
||||||
|
(context, index) {
|
||||||
|
if (showSkeleton) {
|
||||||
|
// Placeholder card for shimmer
|
||||||
|
return _buildPlaceholderCard(context);
|
||||||
|
}
|
||||||
|
final announcement = items[index];
|
||||||
|
return _AnnouncementCard(
|
||||||
|
announcement: announcement,
|
||||||
|
profiles: profiles,
|
||||||
|
currentUserId: currentUserId,
|
||||||
|
canCreate: canCreate,
|
||||||
|
isExpanded:
|
||||||
|
_expandedComments.contains(announcement.id),
|
||||||
|
onToggleComments: () {
|
||||||
|
setState(() {
|
||||||
|
if (_expandedComments.contains(announcement.id)) {
|
||||||
|
_expandedComments.remove(announcement.id);
|
||||||
|
} else {
|
||||||
|
_expandedComments.add(announcement.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onEdit: () => showCreateAnnouncementDialog(
|
||||||
|
context,
|
||||||
|
editing: announcement,
|
||||||
|
),
|
||||||
|
onDelete: () async {
|
||||||
|
final confirm = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
title: const Text('Delete Announcement'),
|
||||||
|
content: const Text(
|
||||||
|
'Are you sure you want to delete this announcement?'),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () =>
|
||||||
|
Navigator.of(ctx).pop(false),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () =>
|
||||||
|
Navigator.of(ctx).pop(true),
|
||||||
|
child: const Text('Delete'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (confirm == true) {
|
||||||
|
await ref
|
||||||
|
.read(announcementsControllerProvider)
|
||||||
|
.deleteAnnouncement(announcement.id);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
childCount: showSkeleton ? 5 : items.length,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Bottom padding so FAB doesn't cover last card
|
||||||
|
const SliverPadding(padding: EdgeInsets.only(bottom: 80)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildPlaceholderCard(BuildContext context) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 0, vertical: 6),
|
||||||
|
child: M3Card.elevated(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const CircleAvatar(radius: 18),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 120,
|
||||||
|
height: 14,
|
||||||
|
color: Colors.grey,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Container(
|
||||||
|
width: 60,
|
||||||
|
height: 10,
|
||||||
|
color: Colors.grey,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 200),
|
||||||
|
child: Container(
|
||||||
|
width: double.infinity, height: 16, color: Colors.grey),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Container(
|
||||||
|
width: double.infinity, height: 12, color: Colors.grey),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 240),
|
||||||
|
child: Container(
|
||||||
|
width: double.infinity, height: 12, color: Colors.grey),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AnnouncementCard extends ConsumerWidget {
|
||||||
|
const _AnnouncementCard({
|
||||||
|
required this.announcement,
|
||||||
|
required this.profiles,
|
||||||
|
required this.currentUserId,
|
||||||
|
required this.canCreate,
|
||||||
|
required this.isExpanded,
|
||||||
|
required this.onToggleComments,
|
||||||
|
required this.onEdit,
|
||||||
|
required this.onDelete,
|
||||||
|
});
|
||||||
|
|
||||||
|
final Announcement announcement;
|
||||||
|
final List profiles;
|
||||||
|
final String? currentUserId;
|
||||||
|
final bool canCreate;
|
||||||
|
final bool isExpanded;
|
||||||
|
final VoidCallback onToggleComments;
|
||||||
|
final VoidCallback onEdit;
|
||||||
|
final VoidCallback onDelete;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final cs = Theme.of(context).colorScheme;
|
||||||
|
final tt = Theme.of(context).textTheme;
|
||||||
|
|
||||||
|
// Resolve author
|
||||||
|
String authorName = 'Unknown';
|
||||||
|
String? avatarUrl;
|
||||||
|
for (final p in profiles) {
|
||||||
|
if (p.id == announcement.authorId) {
|
||||||
|
authorName = p.fullName;
|
||||||
|
avatarUrl = p.avatarUrl;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final isOwner = announcement.authorId == currentUserId;
|
||||||
|
|
||||||
|
// Comment count
|
||||||
|
final commentsAsync =
|
||||||
|
ref.watch(announcementCommentsProvider(announcement.id));
|
||||||
|
final commentCount = commentsAsync.valueOrNull?.length ?? 0;
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||||
|
child: M3Card.elevated(
|
||||||
|
child: Column(
|
||||||
|
// mainAxisSize.min prevents the Column from trying to fill infinite
|
||||||
|
// height when rendered inside a SliverList (unbounded vertical axis).
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 16, 8, 0),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
ProfileAvatar(
|
||||||
|
fullName: authorName,
|
||||||
|
avatarUrl: avatarUrl,
|
||||||
|
radius: 20,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
authorName,
|
||||||
|
style: tt.titleSmall
|
||||||
|
?.copyWith(fontWeight: FontWeight.w600),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
_relativeTime(announcement.createdAt),
|
||||||
|
style: tt.labelSmall
|
||||||
|
?.copyWith(color: cs.onSurfaceVariant),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (isOwner)
|
||||||
|
PopupMenuButton<String>(
|
||||||
|
onSelected: (value) {
|
||||||
|
switch (value) {
|
||||||
|
case 'edit':
|
||||||
|
onEdit();
|
||||||
|
case 'delete':
|
||||||
|
onDelete();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
itemBuilder: (context) => [
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: 'edit', child: Text('Edit')),
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: 'delete', child: Text('Delete')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Title + Body
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
|
||||||
|
child: Text(
|
||||||
|
announcement.title,
|
||||||
|
style: tt.titleMedium?.copyWith(fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 6, 16, 0),
|
||||||
|
child: Text(announcement.body, style: tt.bodyMedium),
|
||||||
|
),
|
||||||
|
// Visible roles chips
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 10, 16, 0),
|
||||||
|
child: Wrap(
|
||||||
|
spacing: 6,
|
||||||
|
runSpacing: 4,
|
||||||
|
children: announcement.visibleRoles.map((role) {
|
||||||
|
return Chip(
|
||||||
|
label: Text(
|
||||||
|
_roleLabel(role),
|
||||||
|
style: tt.labelSmall,
|
||||||
|
),
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Comment toggle row
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(8, 4, 8, 0),
|
||||||
|
child: TextButton.icon(
|
||||||
|
onPressed: onToggleComments,
|
||||||
|
icon: Icon(
|
||||||
|
isExpanded
|
||||||
|
? Icons.expand_less
|
||||||
|
: Icons.comment_outlined,
|
||||||
|
size: 18,
|
||||||
|
),
|
||||||
|
label: Text(
|
||||||
|
commentCount > 0
|
||||||
|
? '$commentCount comment${commentCount == 1 ? '' : 's'}'
|
||||||
|
: 'Comment',
|
||||||
|
),
|
||||||
|
style: TextButton.styleFrom(
|
||||||
|
foregroundColor: cs.onSurfaceVariant,
|
||||||
|
textStyle: tt.labelMedium,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Comments section
|
||||||
|
if (isExpanded)
|
||||||
|
AnnouncementCommentsSection(
|
||||||
|
announcementId: announcement.id),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _relativeTime(DateTime dt) {
|
||||||
|
final now = AppTime.now();
|
||||||
|
final diff = now.difference(dt);
|
||||||
|
if (diff.inMinutes < 1) return 'Just now';
|
||||||
|
if (diff.inMinutes < 60) return '${diff.inMinutes}m ago';
|
||||||
|
if (diff.inHours < 24) return '${diff.inHours}h ago';
|
||||||
|
if (diff.inDays < 7) return '${diff.inDays}d ago';
|
||||||
|
return AppTime.formatDate(dt);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _roleLabel(String role) {
|
||||||
|
const labels = {
|
||||||
|
'admin': 'Admin',
|
||||||
|
'dispatcher': 'Dispatcher',
|
||||||
|
'programmer': 'Programmer',
|
||||||
|
'it_staff': 'IT Staff',
|
||||||
|
'standard': 'Standard',
|
||||||
|
};
|
||||||
|
return labels[role] ?? role;
|
||||||
|
}
|
||||||
314
lib/screens/announcements/create_announcement_dialog.dart
Normal file
314
lib/screens/announcements/create_announcement_dialog.dart
Normal file
|
|
@ -0,0 +1,314 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../../models/announcement.dart';
|
||||||
|
import '../../providers/announcements_provider.dart';
|
||||||
|
import '../../theme/m3_motion.dart';
|
||||||
|
import '../../utils/snackbar.dart';
|
||||||
|
import '../../widgets/app_breakpoints.dart';
|
||||||
|
|
||||||
|
/// All user roles available for announcement visibility.
|
||||||
|
const _allRoles = ['admin', 'dispatcher', 'programmer', 'it_staff', 'standard'];
|
||||||
|
|
||||||
|
/// Default visible roles (standard is excluded by default).
|
||||||
|
const _defaultVisibleRoles = ['admin', 'dispatcher', 'programmer', 'it_staff'];
|
||||||
|
|
||||||
|
/// Human-readable labels for each role.
|
||||||
|
const _roleLabels = {
|
||||||
|
'admin': 'Admin',
|
||||||
|
'dispatcher': 'Dispatcher',
|
||||||
|
'programmer': 'Programmer',
|
||||||
|
'it_staff': 'IT Staff',
|
||||||
|
'standard': 'Standard',
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Shows the create/edit announcement dialog.
|
||||||
|
///
|
||||||
|
/// On mobile, uses a full-screen bottom sheet; on desktop, a centered dialog.
|
||||||
|
Future<void> showCreateAnnouncementDialog(
|
||||||
|
BuildContext context, {
|
||||||
|
Announcement? editing,
|
||||||
|
}) async {
|
||||||
|
final width = MediaQuery.sizeOf(context).width;
|
||||||
|
if (width < AppBreakpoints.tablet) {
|
||||||
|
await m3ShowBottomSheet<void>(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
builder: (ctx) => _CreateAnnouncementContent(editing: editing),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await m3ShowDialog<void>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => Dialog(
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 560),
|
||||||
|
child: _CreateAnnouncementContent(editing: editing),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CreateAnnouncementContent extends ConsumerStatefulWidget {
|
||||||
|
const _CreateAnnouncementContent({this.editing});
|
||||||
|
|
||||||
|
final Announcement? editing;
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<_CreateAnnouncementContent> createState() =>
|
||||||
|
_CreateAnnouncementContentState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CreateAnnouncementContentState
|
||||||
|
extends ConsumerState<_CreateAnnouncementContent> {
|
||||||
|
late final TextEditingController _titleCtrl;
|
||||||
|
late final TextEditingController _bodyCtrl;
|
||||||
|
late Set<String> _selectedRoles;
|
||||||
|
bool _isTemplate = false;
|
||||||
|
bool _submitting = false;
|
||||||
|
|
||||||
|
// Template selection
|
||||||
|
String? _selectedTemplateId;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
final source = widget.editing;
|
||||||
|
_titleCtrl = TextEditingController(text: source?.title ?? '');
|
||||||
|
_bodyCtrl = TextEditingController(text: source?.body ?? '');
|
||||||
|
_selectedRoles = source != null
|
||||||
|
? Set<String>.from(source.visibleRoles)
|
||||||
|
: Set<String>.from(_defaultVisibleRoles);
|
||||||
|
_isTemplate = widget.editing?.isTemplate ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_titleCtrl.dispose();
|
||||||
|
_bodyCtrl.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get _canSubmit {
|
||||||
|
final hasContent =
|
||||||
|
_titleCtrl.text.trim().isNotEmpty && _bodyCtrl.text.trim().isNotEmpty;
|
||||||
|
final hasRoles = _selectedRoles.isNotEmpty;
|
||||||
|
return hasContent && hasRoles && !_submitting;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _applyTemplate(Announcement template) {
|
||||||
|
setState(() {
|
||||||
|
_selectedTemplateId = template.id;
|
||||||
|
_titleCtrl.text = template.title;
|
||||||
|
_bodyCtrl.text = template.body;
|
||||||
|
_selectedRoles = Set<String>.from(template.visibleRoles);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _clearTemplate() {
|
||||||
|
setState(() {
|
||||||
|
_selectedTemplateId = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _submit() async {
|
||||||
|
if (!_canSubmit) return;
|
||||||
|
setState(() => _submitting = true);
|
||||||
|
try {
|
||||||
|
final ctrl = ref.read(announcementsControllerProvider);
|
||||||
|
if (widget.editing != null) {
|
||||||
|
await ctrl.updateAnnouncement(
|
||||||
|
id: widget.editing!.id,
|
||||||
|
title: _titleCtrl.text.trim(),
|
||||||
|
body: _bodyCtrl.text.trim(),
|
||||||
|
visibleRoles: _selectedRoles.toList(),
|
||||||
|
isTemplate: _isTemplate,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await ctrl.createAnnouncement(
|
||||||
|
title: _titleCtrl.text.trim(),
|
||||||
|
body: _bodyCtrl.text.trim(),
|
||||||
|
visibleRoles: _selectedRoles.toList(),
|
||||||
|
isTemplate: _isTemplate,
|
||||||
|
templateId: _selectedTemplateId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (mounted) Navigator.of(context).pop();
|
||||||
|
} on AnnouncementNotificationException {
|
||||||
|
// Saved successfully; only push notification delivery failed.
|
||||||
|
if (mounted) {
|
||||||
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
messenger.showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Posted, but some notifications failed to send.'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) showErrorSnackBar(context, 'Failed to save: $e');
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _submitting = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final cs = Theme.of(context).colorScheme;
|
||||||
|
final tt = Theme.of(context).textTheme;
|
||||||
|
final isEditing = widget.editing != null;
|
||||||
|
|
||||||
|
// Get available templates from the stream (filter client-side)
|
||||||
|
final templates = ref
|
||||||
|
.watch(announcementsProvider)
|
||||||
|
.valueOrNull
|
||||||
|
?.where((a) => a.isTemplate)
|
||||||
|
.toList() ?? [];
|
||||||
|
|
||||||
|
return SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
// Dialog title
|
||||||
|
Text(
|
||||||
|
isEditing ? 'Edit Announcement' : 'New Announcement',
|
||||||
|
style: tt.titleLarge?.copyWith(fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
|
||||||
|
// Template selector (only for new announcements)
|
||||||
|
if (!isEditing && templates.isNotEmpty) ...[
|
||||||
|
DropdownButtonFormField<String?>(
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: 'Post from template (optional)',
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
suffixIcon: _selectedTemplateId != null
|
||||||
|
? IconButton(
|
||||||
|
icon: const Icon(Icons.clear),
|
||||||
|
tooltip: 'Clear template',
|
||||||
|
onPressed: _clearTemplate,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
key: ValueKey(_selectedTemplateId),
|
||||||
|
initialValue: _selectedTemplateId,
|
||||||
|
items: [
|
||||||
|
const DropdownMenuItem<String?>(
|
||||||
|
value: null,
|
||||||
|
child: Text('No template'),
|
||||||
|
),
|
||||||
|
...templates.map(
|
||||||
|
(t) => DropdownMenuItem<String?>(
|
||||||
|
value: t.id,
|
||||||
|
child: Text(
|
||||||
|
t.title.isEmpty ? '(Untitled)' : t.title,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
onChanged: (id) {
|
||||||
|
if (id == null) {
|
||||||
|
_clearTemplate();
|
||||||
|
} else {
|
||||||
|
final tmpl = templates.firstWhere((t) => t.id == id);
|
||||||
|
_applyTemplate(tmpl);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
],
|
||||||
|
|
||||||
|
// Title field
|
||||||
|
TextField(
|
||||||
|
controller: _titleCtrl,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Title',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
|
onChanged: (_) => setState(() {}),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Body field
|
||||||
|
TextField(
|
||||||
|
controller: _bodyCtrl,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Body',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
alignLabelWithHint: true,
|
||||||
|
),
|
||||||
|
maxLines: 6,
|
||||||
|
minLines: 3,
|
||||||
|
onChanged: (_) => setState(() {}),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Role visibility
|
||||||
|
Text('Visible to:', style: tt.labelLarge),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 6,
|
||||||
|
children: _allRoles.map((role) {
|
||||||
|
final selected = _selectedRoles.contains(role);
|
||||||
|
return FilterChip(
|
||||||
|
label: Text(_roleLabels[role] ?? role),
|
||||||
|
selected: selected,
|
||||||
|
onSelected: (val) {
|
||||||
|
setState(() {
|
||||||
|
if (val) {
|
||||||
|
_selectedRoles.add(role);
|
||||||
|
} else {
|
||||||
|
_selectedRoles.remove(role);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Template toggle
|
||||||
|
SwitchListTile(
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
title: Text('Save as template', style: tt.bodyMedium),
|
||||||
|
subtitle: Text(
|
||||||
|
'Templates can be selected when creating new announcements.',
|
||||||
|
style: tt.bodySmall?.copyWith(color: cs.onSurfaceVariant),
|
||||||
|
),
|
||||||
|
value: _isTemplate,
|
||||||
|
onChanged: (val) => setState(() => _isTemplate = val),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
|
||||||
|
// Action buttons
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: _canSubmit ? _submit : null,
|
||||||
|
child: _submitting
|
||||||
|
? const SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2, color: Colors.white),
|
||||||
|
)
|
||||||
|
: Text(isEditing ? 'Save' : 'Post'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -115,7 +115,9 @@ class _NotificationsScreenState extends ConsumerState<NotificationsScreen> {
|
||||||
final taskTitle = item.taskId == null
|
final taskTitle = item.taskId == null
|
||||||
? 'Task'
|
? 'Task'
|
||||||
: (taskById[item.taskId]?.title ?? item.taskId!);
|
: (taskById[item.taskId]?.title ?? item.taskId!);
|
||||||
final subtitle = item.taskId != null
|
final subtitle = item.announcementId != null
|
||||||
|
? 'Announcement'
|
||||||
|
: item.taskId != null
|
||||||
? taskTitle
|
? taskTitle
|
||||||
: ticketSubject;
|
: ticketSubject;
|
||||||
|
|
||||||
|
|
@ -162,7 +164,9 @@ class _NotificationsScreenState extends ConsumerState<NotificationsScreen> {
|
||||||
.markRead(item.id);
|
.markRead(item.id);
|
||||||
}
|
}
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
if (taskId != null) {
|
if (item.announcementId != null) {
|
||||||
|
context.go('/announcements');
|
||||||
|
} else if (taskId != null) {
|
||||||
context.go('/tasks/$taskId');
|
context.go('/tasks/$taskId');
|
||||||
} else if (ticketId != null) {
|
} else if (ticketId != null) {
|
||||||
context.go('/tickets/$ticketId');
|
context.go('/tickets/$ticketId');
|
||||||
|
|
@ -195,6 +199,12 @@ class _NotificationsScreenState extends ConsumerState<NotificationsScreen> {
|
||||||
return '$actorName requested a shift swap';
|
return '$actorName requested a shift swap';
|
||||||
case 'swap_update':
|
case 'swap_update':
|
||||||
return '$actorName updated a swap request';
|
return '$actorName updated a swap request';
|
||||||
|
case 'announcement':
|
||||||
|
return '$actorName posted an announcement';
|
||||||
|
case 'announcement_comment':
|
||||||
|
return '$actorName commented on an announcement';
|
||||||
|
case 'it_job_reminder':
|
||||||
|
return 'IT Job submission reminder';
|
||||||
case 'mention':
|
case 'mention':
|
||||||
default:
|
default:
|
||||||
return '$actorName mentioned you';
|
return '$actorName mentioned you';
|
||||||
|
|
@ -211,6 +221,12 @@ class _NotificationsScreenState extends ConsumerState<NotificationsScreen> {
|
||||||
return Icons.swap_horiz;
|
return Icons.swap_horiz;
|
||||||
case 'swap_update':
|
case 'swap_update':
|
||||||
return Icons.update;
|
return Icons.update;
|
||||||
|
case 'announcement':
|
||||||
|
return Icons.campaign;
|
||||||
|
case 'announcement_comment':
|
||||||
|
return Icons.comment_outlined;
|
||||||
|
case 'it_job_reminder':
|
||||||
|
return Icons.print;
|
||||||
case 'mention':
|
case 'mention':
|
||||||
default:
|
default:
|
||||||
return Icons.alternate_email;
|
return Icons.alternate_email;
|
||||||
|
|
|
||||||
597
lib/screens/tasks/it_job_checklist_tab.dart
Normal file
597
lib/screens/tasks/it_job_checklist_tab.dart
Normal file
|
|
@ -0,0 +1,597 @@
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:skeletonizer/skeletonizer.dart';
|
||||||
|
|
||||||
|
import '../../models/task.dart';
|
||||||
|
import '../../models/task_assignment.dart';
|
||||||
|
import '../../providers/notifications_provider.dart';
|
||||||
|
import '../../providers/profile_provider.dart';
|
||||||
|
import '../../providers/tasks_provider.dart';
|
||||||
|
import '../../providers/teams_provider.dart';
|
||||||
|
import '../../utils/app_time.dart';
|
||||||
|
import '../../utils/snackbar.dart';
|
||||||
|
import '../../widgets/m3_card.dart';
|
||||||
|
import '../../widgets/mono_text.dart';
|
||||||
|
import '../../widgets/profile_avatar.dart';
|
||||||
|
|
||||||
|
/// IT Job Checklist tab — visible only to admin/dispatcher.
|
||||||
|
///
|
||||||
|
/// Shows all completed tasks with a checkbox for tracking printed IT Job
|
||||||
|
/// submission status, plus a per-task notification button with 60s cooldown.
|
||||||
|
class ItJobChecklistTab extends ConsumerStatefulWidget {
|
||||||
|
const ItJobChecklistTab({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<ItJobChecklistTab> createState() => _ItJobChecklistTabState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ItJobChecklistTabState extends ConsumerState<ItJobChecklistTab> {
|
||||||
|
String? _selectedTeamId;
|
||||||
|
String? _statusFilter = 'not_submitted';
|
||||||
|
final _searchController = TextEditingController();
|
||||||
|
String _searchQuery = '';
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_searchController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final cs = Theme.of(context).colorScheme;
|
||||||
|
final tt = Theme.of(context).textTheme;
|
||||||
|
|
||||||
|
final tasksAsync = ref.watch(tasksProvider);
|
||||||
|
final assignmentsAsync = ref.watch(taskAssignmentsProvider);
|
||||||
|
final profiles = ref.watch(profilesProvider).valueOrNull ?? [];
|
||||||
|
final teams = ref.watch(teamsProvider).valueOrNull ?? [];
|
||||||
|
final teamMembers = ref.watch(teamMembersProvider).valueOrNull ?? [];
|
||||||
|
|
||||||
|
final allTasks = tasksAsync.valueOrNull ?? [];
|
||||||
|
final allAssignments = assignmentsAsync.valueOrNull ?? [];
|
||||||
|
final showSkeleton = !tasksAsync.hasValue && !tasksAsync.hasError;
|
||||||
|
|
||||||
|
// All completed tasks
|
||||||
|
var filtered = allTasks.where((t) => t.status == 'completed').toList();
|
||||||
|
|
||||||
|
// Search by task # or subject
|
||||||
|
if (_searchQuery.isNotEmpty) {
|
||||||
|
final q = _searchQuery.toLowerCase();
|
||||||
|
filtered = filtered.where((t) {
|
||||||
|
return (t.taskNumber?.toLowerCase().contains(q) ?? false) ||
|
||||||
|
t.title.toLowerCase().contains(q);
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by team
|
||||||
|
if (_selectedTeamId != null) {
|
||||||
|
final memberIds = teamMembers
|
||||||
|
.where((m) => m.teamId == _selectedTeamId)
|
||||||
|
.map((m) => m.userId)
|
||||||
|
.toSet();
|
||||||
|
filtered = filtered.where((t) {
|
||||||
|
final taskAssignees =
|
||||||
|
allAssignments.where((a) => a.taskId == t.id).map((a) => a.userId);
|
||||||
|
return taskAssignees.any(memberIds.contains);
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by submission status
|
||||||
|
if (_statusFilter == 'submitted') {
|
||||||
|
filtered = filtered.where((t) => t.itJobPrinted).toList();
|
||||||
|
} else if (_statusFilter == 'not_submitted') {
|
||||||
|
filtered = filtered.where((t) => !t.itJobPrinted).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by task number ascending (format YYYY-MM-NNNNN — lexicographic works)
|
||||||
|
filtered.sort((a, b) {
|
||||||
|
final ta = a.taskNumber ?? '';
|
||||||
|
final tb = b.taskNumber ?? '';
|
||||||
|
return ta.compareTo(tb);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Stats (always computed over all completed, regardless of filter)
|
||||||
|
final allCompleted = allTasks.where((t) => t.status == 'completed');
|
||||||
|
final submitted = allCompleted.where((t) => t.itJobPrinted).length;
|
||||||
|
final total = allCompleted.length;
|
||||||
|
|
||||||
|
// Do NOT wrap the outer Column in Skeletonizer — Skeletonizer's measurement
|
||||||
|
// pass gives the Column's Expanded child unbounded height constraints,
|
||||||
|
// causing a RenderFlex bottom-overflow. Skeletonizer is applied only to
|
||||||
|
// the list itself (see below inside Expanded).
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
// Stats card
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(0, 12, 0, 4),
|
||||||
|
child: M3Card.filled(
|
||||||
|
color: cs.surfaceContainerHighest,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.print, color: cs.primary, size: 20),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
'IT Job Submission',
|
||||||
|
style: tt.titleSmall
|
||||||
|
?.copyWith(fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
Text(
|
||||||
|
'$submitted / $total submitted',
|
||||||
|
style: tt.labelLarge?.copyWith(
|
||||||
|
color: cs.primary,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
child: LinearProgressIndicator(
|
||||||
|
value: total > 0 ? submitted / total : 0,
|
||||||
|
minHeight: 6,
|
||||||
|
backgroundColor: cs.surfaceContainerLow,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Filters
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
|
child: Wrap(
|
||||||
|
spacing: 12,
|
||||||
|
runSpacing: 8,
|
||||||
|
crossAxisAlignment: WrapCrossAlignment.center,
|
||||||
|
children: [
|
||||||
|
// Search by task # or subject
|
||||||
|
SizedBox(
|
||||||
|
width: 240,
|
||||||
|
child: TextField(
|
||||||
|
controller: _searchController,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: 'Search task # or subject',
|
||||||
|
isDense: true,
|
||||||
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 12, vertical: 8),
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
prefixIcon:
|
||||||
|
const Icon(Icons.search, size: 18),
|
||||||
|
suffixIcon: _searchQuery.isNotEmpty
|
||||||
|
? IconButton(
|
||||||
|
icon: const Icon(Icons.clear, size: 16),
|
||||||
|
onPressed: () {
|
||||||
|
_searchController.clear();
|
||||||
|
setState(() => _searchQuery = '');
|
||||||
|
},
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
onChanged: (v) =>
|
||||||
|
setState(() => _searchQuery = v.trim()),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Team filter
|
||||||
|
SizedBox(
|
||||||
|
width: 180,
|
||||||
|
child: DropdownButtonFormField<String>(
|
||||||
|
key: ValueKey(_selectedTeamId),
|
||||||
|
initialValue: _selectedTeamId,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Team',
|
||||||
|
isDense: true,
|
||||||
|
contentPadding:
|
||||||
|
EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
isExpanded: true,
|
||||||
|
items: [
|
||||||
|
const DropdownMenuItem<String>(
|
||||||
|
value: null,
|
||||||
|
child: Text('All Teams'),
|
||||||
|
),
|
||||||
|
...teams.map((t) => DropdownMenuItem<String>(
|
||||||
|
value: t.id,
|
||||||
|
child: Text(t.name,
|
||||||
|
overflow: TextOverflow.ellipsis),
|
||||||
|
)),
|
||||||
|
],
|
||||||
|
onChanged: (v) => setState(() => _selectedTeamId = v),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Status filter
|
||||||
|
SizedBox(
|
||||||
|
width: 180,
|
||||||
|
child: DropdownButtonFormField<String>(
|
||||||
|
key: ValueKey(_statusFilter),
|
||||||
|
initialValue: _statusFilter,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Status',
|
||||||
|
isDense: true,
|
||||||
|
contentPadding:
|
||||||
|
EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
isExpanded: true,
|
||||||
|
items: const [
|
||||||
|
DropdownMenuItem<String>(
|
||||||
|
value: null, child: Text('All')),
|
||||||
|
DropdownMenuItem(
|
||||||
|
value: 'submitted', child: Text('Submitted')),
|
||||||
|
DropdownMenuItem(
|
||||||
|
value: 'not_submitted',
|
||||||
|
child: Text('Not Submitted')),
|
||||||
|
],
|
||||||
|
onChanged: (v) => setState(() => _statusFilter = v),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Clear button
|
||||||
|
if (_selectedTeamId != null ||
|
||||||
|
_statusFilter != 'not_submitted' ||
|
||||||
|
_searchQuery.isNotEmpty)
|
||||||
|
TextButton.icon(
|
||||||
|
onPressed: () {
|
||||||
|
_searchController.clear();
|
||||||
|
setState(() {
|
||||||
|
_selectedTeamId = null;
|
||||||
|
_statusFilter = 'not_submitted';
|
||||||
|
_searchQuery = '';
|
||||||
|
});
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.clear, size: 16),
|
||||||
|
label: const Text('Clear'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Task list — Skeletonizer lives inside Expanded so it always
|
||||||
|
// receives bounded height constraints (avoids bottom-overflow).
|
||||||
|
Expanded(
|
||||||
|
child: showSkeleton
|
||||||
|
? Skeletonizer(
|
||||||
|
enabled: true,
|
||||||
|
child: ListView.separated(
|
||||||
|
padding: const EdgeInsets.only(bottom: 80),
|
||||||
|
itemCount: 5,
|
||||||
|
separatorBuilder: (_, _) => const SizedBox(height: 4),
|
||||||
|
itemBuilder: (_, _) => _buildSkeletonTile(context),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: filtered.isEmpty
|
||||||
|
? Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.checklist,
|
||||||
|
size: 48, color: cs.onSurfaceVariant),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'No completed tasks',
|
||||||
|
style: tt.bodyMedium
|
||||||
|
?.copyWith(color: cs.onSurfaceVariant),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: ListView.separated(
|
||||||
|
padding: const EdgeInsets.only(bottom: 80),
|
||||||
|
itemCount: filtered.length,
|
||||||
|
separatorBuilder: (_, _) => const SizedBox(height: 4),
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final task = filtered[index];
|
||||||
|
final assignees = allAssignments
|
||||||
|
.where((a) => a.taskId == task.id)
|
||||||
|
.toList();
|
||||||
|
return _ItJobTile(
|
||||||
|
key: ValueKey(task.id),
|
||||||
|
task: task,
|
||||||
|
assignees: assignees,
|
||||||
|
profiles: profiles,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSkeletonTile(BuildContext context) {
|
||||||
|
return M3Card.outlined(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Container(width: 60, height: 14, color: Colors.grey),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Container(height: 14, color: Colors.grey),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Container(width: 24, height: 24, color: Colors.grey),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Per-task tile — stateful for optimistic checkbox + 60s bell cooldown
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class _ItJobTile extends ConsumerStatefulWidget {
|
||||||
|
const _ItJobTile({
|
||||||
|
super.key,
|
||||||
|
required this.task,
|
||||||
|
required this.assignees,
|
||||||
|
required this.profiles,
|
||||||
|
});
|
||||||
|
|
||||||
|
final Task task;
|
||||||
|
final List<TaskAssignment> assignees;
|
||||||
|
final List profiles;
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<_ItJobTile> createState() => _ItJobTileState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ItJobTileState extends ConsumerState<_ItJobTile> {
|
||||||
|
static const _cooldownSeconds = 60;
|
||||||
|
|
||||||
|
bool? _optimisticChecked; // null = follow task.itJobPrinted
|
||||||
|
DateTime? _reminderSentAt;
|
||||||
|
Timer? _cooldownTimer;
|
||||||
|
int _secondsRemaining = 0;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_cooldownTimer?.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get _isChecked => _optimisticChecked ?? widget.task.itJobPrinted;
|
||||||
|
|
||||||
|
bool get _inCooldown => _secondsRemaining > 0;
|
||||||
|
|
||||||
|
void _startCooldown() {
|
||||||
|
_reminderSentAt = DateTime.now();
|
||||||
|
_secondsRemaining = _cooldownSeconds;
|
||||||
|
_cooldownTimer?.cancel();
|
||||||
|
_cooldownTimer = Timer.periodic(const Duration(seconds: 1), (_) {
|
||||||
|
if (!mounted) return;
|
||||||
|
final elapsed =
|
||||||
|
DateTime.now().difference(_reminderSentAt!).inSeconds;
|
||||||
|
final remaining = (_cooldownSeconds - elapsed).clamp(0, _cooldownSeconds);
|
||||||
|
setState(() => _secondsRemaining = remaining);
|
||||||
|
if (remaining == 0) {
|
||||||
|
_cooldownTimer?.cancel();
|
||||||
|
_cooldownTimer = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _toggleChecked(bool? val) async {
|
||||||
|
if (val == null) return;
|
||||||
|
setState(() => _optimisticChecked = val);
|
||||||
|
try {
|
||||||
|
final ctrl = ref.read(tasksControllerProvider);
|
||||||
|
if (val) {
|
||||||
|
final currentUserId = ref.read(currentUserIdProvider) ?? '';
|
||||||
|
await ctrl.markItJobPrinted(widget.task.id,
|
||||||
|
receivedById: currentUserId);
|
||||||
|
} else {
|
||||||
|
await ctrl.markItJobNotPrinted(widget.task.id);
|
||||||
|
}
|
||||||
|
// Keep optimistic state — do NOT clear it here.
|
||||||
|
// The realtime stream may arrive with a slight delay; clearing here
|
||||||
|
// causes a visible revert flash before the stream catches up.
|
||||||
|
} catch (e) {
|
||||||
|
// Revert optimistic state on failure only
|
||||||
|
if (mounted) {
|
||||||
|
setState(() => _optimisticChecked = !val);
|
||||||
|
showErrorSnackBar(context, 'Failed to update: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _sendReminder() async {
|
||||||
|
final userIds = widget.assignees.map((a) => a.userId).toList();
|
||||||
|
if (userIds.isEmpty) {
|
||||||
|
showErrorSnackBar(context, 'No assigned staff');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
final currentUserId = ref.read(currentUserIdProvider);
|
||||||
|
await ref.read(notificationsControllerProvider).createNotification(
|
||||||
|
userIds: userIds,
|
||||||
|
type: 'it_job_reminder',
|
||||||
|
actorId: currentUserId ?? '',
|
||||||
|
fields: {'task_id': widget.task.id},
|
||||||
|
pushTitle: 'IT Job Reminder',
|
||||||
|
pushBody:
|
||||||
|
'Please submit printed IT Job for Task #${widget.task.taskNumber ?? widget.task.id.substring(0, 8)}',
|
||||||
|
pushData: {
|
||||||
|
'task_id': widget.task.id,
|
||||||
|
'navigate_to': '/tasks/${widget.task.id}',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (mounted) {
|
||||||
|
setState(_startCooldown);
|
||||||
|
showSuccessSnackBar(context, 'Reminder sent');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) showErrorSnackBar(context, 'Failed to send: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final cs = Theme.of(context).colorScheme;
|
||||||
|
final tt = Theme.of(context).textTheme;
|
||||||
|
|
||||||
|
// Resolve received-by profile
|
||||||
|
String? receivedByName;
|
||||||
|
if (widget.task.itJobReceivedById != null) {
|
||||||
|
for (final p in widget.profiles) {
|
||||||
|
if (p.id == widget.task.itJobReceivedById) {
|
||||||
|
receivedByName = p.fullName as String?;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
receivedByName ??= 'Unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
return M3Card.outlined(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
// Task number — 150 px fixed; MonoText clips long numbers.
|
||||||
|
SizedBox(
|
||||||
|
width: 150,
|
||||||
|
child: MonoText(
|
||||||
|
widget.task.taskNumber ?? '-',
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
// Subject + assignees + received-by — Expanded so it never
|
||||||
|
// causes the Row to overflow on any screen width.
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
widget.task.title,
|
||||||
|
style: tt.bodyMedium,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
if (widget.assignees.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Wrap(
|
||||||
|
spacing: 4,
|
||||||
|
runSpacing: 2,
|
||||||
|
children: widget.assignees.map((a) {
|
||||||
|
String name = 'Unknown';
|
||||||
|
String? avatarUrl;
|
||||||
|
for (final p in widget.profiles) {
|
||||||
|
if (p.id == a.userId) {
|
||||||
|
name = p.fullName as String;
|
||||||
|
avatarUrl = p.avatarUrl as String?;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return InputChip(
|
||||||
|
avatar: ProfileAvatar(
|
||||||
|
fullName: name,
|
||||||
|
avatarUrl: avatarUrl,
|
||||||
|
radius: 12,
|
||||||
|
),
|
||||||
|
label: Text(name, style: tt.labelSmall),
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
materialTapTargetSize:
|
||||||
|
MaterialTapTargetSize.shrinkWrap,
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
onPressed: () {},
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
// Received-by info shown inline — avoids a fixed-width
|
||||||
|
// column that overflows on narrow/mobile screens.
|
||||||
|
if (_isChecked) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.check_circle_outline,
|
||||||
|
size: 14, color: cs.primary),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Flexible(
|
||||||
|
child: Text(
|
||||||
|
receivedByName != null
|
||||||
|
? widget.task.itJobPrintedAt != null
|
||||||
|
? '$receivedByName · ${AppTime.formatDate(widget.task.itJobPrintedAt!)} ${AppTime.formatTime(widget.task.itJobPrintedAt!)}'
|
||||||
|
: receivedByName
|
||||||
|
: 'Submitted',
|
||||||
|
style:
|
||||||
|
tt.labelSmall?.copyWith(color: cs.primary),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Compact checkbox — shrinkWrap removes the default 48 px touch
|
||||||
|
// target padding so it doesn't push the Row wider on mobile.
|
||||||
|
Checkbox(
|
||||||
|
value: _isChecked,
|
||||||
|
onChanged: _toggleChecked,
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||||
|
),
|
||||||
|
// Bell / cooldown button
|
||||||
|
SizedBox(
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
child: _inCooldown
|
||||||
|
? Tooltip(
|
||||||
|
message: '$_secondsRemaining s',
|
||||||
|
child: Stack(
|
||||||
|
alignment: Alignment.center,
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
value: _secondsRemaining / _cooldownSeconds,
|
||||||
|
strokeWidth: 2.5,
|
||||||
|
color: cs.primary,
|
||||||
|
backgroundColor:
|
||||||
|
cs.primary.withValues(alpha: 0.15),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'$_secondsRemaining',
|
||||||
|
style: tt.labelSmall?.copyWith(
|
||||||
|
fontSize: 9,
|
||||||
|
color: cs.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: IconButton(
|
||||||
|
tooltip: 'Remind assigned staff',
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
onPressed: _sendReminder,
|
||||||
|
icon: Icon(
|
||||||
|
Icons.notifications_active_outlined,
|
||||||
|
color: cs.primary,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -33,6 +33,7 @@ import '../../widgets/app_state_view.dart';
|
||||||
import '../../utils/subject_suggestions.dart';
|
import '../../utils/subject_suggestions.dart';
|
||||||
import '../../widgets/gemini_button.dart';
|
import '../../widgets/gemini_button.dart';
|
||||||
import '../../widgets/gemini_animated_text_field.dart';
|
import '../../widgets/gemini_animated_text_field.dart';
|
||||||
|
import 'it_job_checklist_tab.dart';
|
||||||
|
|
||||||
// request metadata options used in task creation/editing dialogs
|
// request metadata options used in task creation/editing dialogs
|
||||||
const List<String> _requestTypeOptions = [
|
const List<String> _requestTypeOptions = [
|
||||||
|
|
@ -57,14 +58,16 @@ class TasksListScreen extends ConsumerStatefulWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
class _TasksListScreenState extends ConsumerState<TasksListScreen>
|
class _TasksListScreenState extends ConsumerState<TasksListScreen>
|
||||||
with SingleTickerProviderStateMixin {
|
with TickerProviderStateMixin {
|
||||||
final TextEditingController _subjectController = TextEditingController();
|
final TextEditingController _subjectController = TextEditingController();
|
||||||
final TextEditingController _taskNumberController = TextEditingController();
|
final TextEditingController _taskNumberController = TextEditingController();
|
||||||
String? _selectedOfficeId;
|
String? _selectedOfficeId;
|
||||||
String? _selectedStatus;
|
String? _selectedStatus;
|
||||||
String? _selectedAssigneeId;
|
String? _selectedAssigneeId;
|
||||||
DateTimeRange? _selectedDateRange;
|
DateTimeRange? _selectedDateRange;
|
||||||
late final TabController _tabController;
|
late TabController _tabController;
|
||||||
|
bool _showItJobTab = false;
|
||||||
|
bool _tabInited = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
|
@ -78,13 +81,31 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen>
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_tabController = TabController(length: 2, vsync: this);
|
_tabController = TabController(length: 2, vsync: this);
|
||||||
// Rebuild when tab changes so filter header can show/hide the
|
|
||||||
// "Assigned staff" dropdown for the All Tasks tab.
|
|
||||||
_tabController.addListener(() {
|
_tabController.addListener(() {
|
||||||
if (mounted) setState(() {});
|
if (mounted) setState(() {});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _ensureTabController(bool shouldShowItJobTab) {
|
||||||
|
if (_tabInited && _showItJobTab == shouldShowItJobTab) return;
|
||||||
|
final oldIndex = _tabController.index;
|
||||||
|
_tabController.removeListener(_onTabChange);
|
||||||
|
_tabController.dispose();
|
||||||
|
_tabController = TabController(
|
||||||
|
length: shouldShowItJobTab ? 3 : 2,
|
||||||
|
vsync: this,
|
||||||
|
initialIndex: oldIndex.clamp(0, shouldShowItJobTab ? 2 : 1),
|
||||||
|
);
|
||||||
|
_tabController.addListener(_onTabChange);
|
||||||
|
_tabInited = true;
|
||||||
|
_showItJobTab = shouldShowItJobTab;
|
||||||
|
setState(() {});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onTabChange() {
|
||||||
|
if (mounted) setState(() {});
|
||||||
|
}
|
||||||
|
|
||||||
bool get _hasTaskFilters {
|
bool get _hasTaskFilters {
|
||||||
return _subjectController.text.trim().isNotEmpty ||
|
return _subjectController.text.trim().isNotEmpty ||
|
||||||
_taskNumberController.text.trim().isNotEmpty ||
|
_taskNumberController.text.trim().isNotEmpty ||
|
||||||
|
|
@ -129,6 +150,13 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen>
|
||||||
profile.role == 'it_staff'),
|
profile.role == 'it_staff'),
|
||||||
orElse: () => false,
|
orElse: () => false,
|
||||||
);
|
);
|
||||||
|
final role = profileAsync.valueOrNull?.role ?? '';
|
||||||
|
final shouldShowItJobTab = role == 'admin' || role == 'dispatcher';
|
||||||
|
if (!_tabInited || _showItJobTab != shouldShowItJobTab) {
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (mounted) _ensureTabController(shouldShowItJobTab);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
final ticketById = <String, Ticket>{
|
final ticketById = <String, Ticket>{
|
||||||
for (final ticket in ticketsAsync.valueOrNull ?? <Ticket>[])
|
for (final ticket in ticketsAsync.valueOrNull ?? <Ticket>[])
|
||||||
|
|
@ -521,9 +549,11 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen>
|
||||||
children: [
|
children: [
|
||||||
TabBar(
|
TabBar(
|
||||||
controller: _tabController,
|
controller: _tabController,
|
||||||
tabs: const [
|
tabs: [
|
||||||
Tab(text: 'My Tasks'),
|
const Tab(text: 'My Tasks'),
|
||||||
Tab(text: 'All Tasks'),
|
const Tab(text: 'All Tasks'),
|
||||||
|
if (_showItJobTab)
|
||||||
|
const Tab(text: 'IT Job Checklist'),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
|
|
@ -552,6 +582,8 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen>
|
||||||
: 'Tasks created for your team will appear here.',
|
: 'Tasks created for your team will appear here.',
|
||||||
)
|
)
|
||||||
: makeList(filteredTasks),
|
: makeList(filteredTasks),
|
||||||
|
if (_showItJobTab)
|
||||||
|
const ItJobChecklistTab(),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -564,7 +596,20 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen>
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (canCreate)
|
if (_showItJobTab && _tabController.index == 2)
|
||||||
|
Positioned(
|
||||||
|
right: 16,
|
||||||
|
bottom: 16,
|
||||||
|
child: SafeArea(
|
||||||
|
child: M3ExpandedFab(
|
||||||
|
heroTag: 'notify_all_fab',
|
||||||
|
onPressed: () => _notifyAllPending(ref),
|
||||||
|
icon: const Icon(Icons.notifications_active),
|
||||||
|
label: const Text('Notify All Pending'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else if (canCreate)
|
||||||
Positioned(
|
Positioned(
|
||||||
right: 16,
|
right: 16,
|
||||||
bottom: 16,
|
bottom: 16,
|
||||||
|
|
@ -581,6 +626,56 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _notifyAllPending(WidgetRef ref) async {
|
||||||
|
final tasks = ref.read(tasksProvider).valueOrNull ?? [];
|
||||||
|
final assignments = ref.read(taskAssignmentsProvider).valueOrNull ?? [];
|
||||||
|
|
||||||
|
// All completed tasks with IT Job not yet submitted
|
||||||
|
final pending = tasks
|
||||||
|
.where((t) => t.status == 'completed' && !t.itJobPrinted)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
if (pending.isEmpty) {
|
||||||
|
if (mounted) showSuccessSnackBar(context, 'No pending IT Jobs to notify');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// One notification per unique assignee (not per task)
|
||||||
|
final userIds = <String>{};
|
||||||
|
for (final task in pending) {
|
||||||
|
for (final a in assignments.where((a) => a.taskId == task.id)) {
|
||||||
|
userIds.add(a.userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userIds.isEmpty) {
|
||||||
|
if (mounted) showErrorSnackBar(context, 'No assigned staff found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final currentUserId = ref.read(currentUserIdProvider);
|
||||||
|
// Single generic notification per user — no task_id to avoid flooding
|
||||||
|
await ref.read(notificationsControllerProvider).createNotification(
|
||||||
|
userIds: userIds.toList(),
|
||||||
|
type: 'it_job_reminder',
|
||||||
|
actorId: currentUserId ?? '',
|
||||||
|
pushTitle: 'IT Job Reminder',
|
||||||
|
pushBody:
|
||||||
|
'You have pending IT Job submissions. Please submit your printed copies to the dispatcher.',
|
||||||
|
pushData: {'navigate_to': '/tasks'},
|
||||||
|
);
|
||||||
|
if (mounted) {
|
||||||
|
showSuccessSnackBar(
|
||||||
|
context,
|
||||||
|
'Reminder sent to ${userIds.length} staff member(s)',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) showErrorSnackBar(context, 'Failed to send: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _showCreateTaskDialog(
|
Future<void> _showCreateTaskDialog(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
WidgetRef ref,
|
WidgetRef ref,
|
||||||
|
|
|
||||||
|
|
@ -285,7 +285,10 @@ class _NotificationBell extends ConsumerWidget {
|
||||||
clipBehavior: Clip.none,
|
clipBehavior: Clip.none,
|
||||||
children: [
|
children: [
|
||||||
const Icon(Icons.notifications_outlined),
|
const Icon(Icons.notifications_outlined),
|
||||||
AnimatedSwitcher(
|
Positioned(
|
||||||
|
right: -3,
|
||||||
|
top: -3,
|
||||||
|
child: AnimatedSwitcher(
|
||||||
duration: const Duration(milliseconds: 200),
|
duration: const Duration(milliseconds: 200),
|
||||||
switchInCurve: Curves.easeOutBack,
|
switchInCurve: Curves.easeOutBack,
|
||||||
switchOutCurve: Curves.easeInCubic,
|
switchOutCurve: Curves.easeInCubic,
|
||||||
|
|
@ -294,11 +297,8 @@ class _NotificationBell extends ConsumerWidget {
|
||||||
child: child,
|
child: child,
|
||||||
),
|
),
|
||||||
child: unreadCount > 0
|
child: unreadCount > 0
|
||||||
? Positioned(
|
? Container(
|
||||||
key: const ValueKey('badge'),
|
key: const ValueKey('badge'),
|
||||||
right: -3,
|
|
||||||
top: -3,
|
|
||||||
child: Container(
|
|
||||||
width: 10,
|
width: 10,
|
||||||
height: 10,
|
height: 10,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
|
|
@ -309,10 +309,10 @@ class _NotificationBell extends ConsumerWidget {
|
||||||
width: 1.5,
|
width: 1.5,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
)
|
)
|
||||||
: const SizedBox.shrink(key: ValueKey('no-badge')),
|
: const SizedBox.shrink(key: ValueKey('no-badge')),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
140
supabase/migrations/20260322100000_create_announcements.sql
Normal file
140
supabase/migrations/20260322100000_create_announcements.sql
Normal file
|
|
@ -0,0 +1,140 @@
|
||||||
|
-- ============================================================
|
||||||
|
-- Announcements + Comments tables
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
-- 1. announcements table
|
||||||
|
CREATE TABLE IF NOT EXISTS public.announcements (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
author_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||||
|
title text NOT NULL,
|
||||||
|
body text NOT NULL,
|
||||||
|
visible_roles text[] NOT NULL DEFAULT ARRAY['admin','dispatcher','programmer','it_staff']::text[],
|
||||||
|
is_template boolean NOT NULL DEFAULT false,
|
||||||
|
template_id uuid REFERENCES public.announcements(id) ON DELETE SET NULL,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE public.announcements ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE public.announcements REPLICA IDENTITY FULL;
|
||||||
|
|
||||||
|
-- RLS: SELECT — user can see if their role is in visible_roles OR they are the author
|
||||||
|
CREATE POLICY "Announcements: select" ON public.announcements
|
||||||
|
FOR SELECT
|
||||||
|
USING (
|
||||||
|
author_id = auth.uid()
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1 FROM public.profiles p
|
||||||
|
WHERE p.id = auth.uid()
|
||||||
|
AND p.role::text = ANY(visible_roles)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- RLS: INSERT — admin, dispatcher, programmer, it_staff only
|
||||||
|
CREATE POLICY "Announcements: insert" ON public.announcements
|
||||||
|
FOR INSERT
|
||||||
|
WITH CHECK (
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM public.profiles p
|
||||||
|
WHERE p.id = auth.uid()
|
||||||
|
AND p.role IN ('admin', 'dispatcher', 'programmer', 'it_staff')
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- RLS: UPDATE — only the author or admin
|
||||||
|
CREATE POLICY "Announcements: update" ON public.announcements
|
||||||
|
FOR UPDATE
|
||||||
|
USING (
|
||||||
|
author_id = auth.uid()
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1 FROM public.profiles p
|
||||||
|
WHERE p.id = auth.uid() AND p.role = 'admin'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- RLS: DELETE — only the author or admin
|
||||||
|
CREATE POLICY "Announcements: delete" ON public.announcements
|
||||||
|
FOR DELETE
|
||||||
|
USING (
|
||||||
|
author_id = auth.uid()
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1 FROM public.profiles p
|
||||||
|
WHERE p.id = auth.uid() AND p.role = 'admin'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Index for efficient queries
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_announcements_created_at ON public.announcements (created_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_announcements_visible_roles ON public.announcements USING GIN (visible_roles);
|
||||||
|
|
||||||
|
-- 2. announcement_comments table
|
||||||
|
CREATE TABLE IF NOT EXISTS public.announcement_comments (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
announcement_id uuid NOT NULL REFERENCES public.announcements(id) ON DELETE CASCADE,
|
||||||
|
author_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||||
|
body text NOT NULL,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE public.announcement_comments ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE public.announcement_comments REPLICA IDENTITY FULL;
|
||||||
|
|
||||||
|
-- RLS: SELECT — can read comments if user can see the parent announcement
|
||||||
|
CREATE POLICY "Announcement comments: select" ON public.announcement_comments
|
||||||
|
FOR SELECT
|
||||||
|
USING (
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM public.announcements a
|
||||||
|
WHERE a.id = announcement_id
|
||||||
|
AND (
|
||||||
|
a.author_id = auth.uid()
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1 FROM public.profiles p
|
||||||
|
WHERE p.id = auth.uid()
|
||||||
|
AND p.role::text = ANY(a.visible_roles)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- RLS: INSERT — any authenticated user who can see the announcement
|
||||||
|
CREATE POLICY "Announcement comments: insert" ON public.announcement_comments
|
||||||
|
FOR INSERT
|
||||||
|
WITH CHECK (
|
||||||
|
author_id = auth.uid()
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1 FROM public.announcements a
|
||||||
|
WHERE a.id = announcement_id
|
||||||
|
AND (
|
||||||
|
a.author_id = auth.uid()
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1 FROM public.profiles p
|
||||||
|
WHERE p.id = auth.uid()
|
||||||
|
AND p.role::text = ANY(a.visible_roles)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- RLS: DELETE — only the comment author or admin
|
||||||
|
CREATE POLICY "Announcement comments: delete" ON public.announcement_comments
|
||||||
|
FOR DELETE
|
||||||
|
USING (
|
||||||
|
author_id = auth.uid()
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1 FROM public.profiles p
|
||||||
|
WHERE p.id = auth.uid() AND p.role = 'admin'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Index for efficient comment lookups
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_announcement_comments_announcement_id
|
||||||
|
ON public.announcement_comments (announcement_id, created_at);
|
||||||
|
|
||||||
|
-- 3. Extend notifications table with announcement_id FK
|
||||||
|
ALTER TABLE public.notifications
|
||||||
|
ADD COLUMN IF NOT EXISTS announcement_id uuid REFERENCES public.announcements(id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
-- 4. Enable realtime for both tables
|
||||||
|
ALTER PUBLICATION supabase_realtime ADD TABLE public.announcements;
|
||||||
|
ALTER PUBLICATION supabase_realtime ADD TABLE public.announcement_comments;
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
-- ============================================================
|
||||||
|
-- Add IT Job printed tracking columns to tasks table
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
ALTER TABLE public.tasks
|
||||||
|
ADD COLUMN IF NOT EXISTS it_job_printed boolean NOT NULL DEFAULT false;
|
||||||
|
|
||||||
|
ALTER TABLE public.tasks
|
||||||
|
ADD COLUMN IF NOT EXISTS it_job_printed_at timestamptz;
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
-- ============================================================
|
||||||
|
-- Track who (dispatcher/admin) marked the IT Job as received
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
ALTER TABLE public.tasks
|
||||||
|
ADD COLUMN IF NOT EXISTS it_job_received_by_id uuid
|
||||||
|
REFERENCES auth.users(id) ON DELETE SET NULL;
|
||||||
58
supabase/migrations/20260322130000_it_job_printed_rpc.sql
Normal file
58
supabase/migrations/20260322130000_it_job_printed_rpc.sql
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
-- ============================================================
|
||||||
|
-- SECURITY DEFINER RPCs for toggling IT Job printed status.
|
||||||
|
-- The tasks table RLS UPDATE policies restrict who can update
|
||||||
|
-- rows, but dispatchers/admins need to set it_job_printed
|
||||||
|
-- regardless of task ownership. SECURITY DEFINER bypasses RLS
|
||||||
|
-- while still validating the caller's role.
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.mark_it_job_printed(
|
||||||
|
p_task_id uuid,
|
||||||
|
p_receiver_id uuid
|
||||||
|
)
|
||||||
|
RETURNS void
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path = public
|
||||||
|
AS $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM profiles
|
||||||
|
WHERE id = auth.uid()
|
||||||
|
AND role IN ('admin', 'dispatcher', 'programmer')
|
||||||
|
) THEN
|
||||||
|
RAISE EXCEPTION 'Only admin or dispatcher can mark IT Job as received';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
UPDATE tasks
|
||||||
|
SET it_job_printed = true,
|
||||||
|
it_job_printed_at = now(),
|
||||||
|
it_job_received_by_id = p_receiver_id
|
||||||
|
WHERE id = p_task_id;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.unmark_it_job_printed(
|
||||||
|
p_task_id uuid
|
||||||
|
)
|
||||||
|
RETURNS void
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path = public
|
||||||
|
AS $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM profiles
|
||||||
|
WHERE id = auth.uid()
|
||||||
|
AND role IN ('admin', 'dispatcher', 'programmer')
|
||||||
|
) THEN
|
||||||
|
RAISE EXCEPTION 'Only admin or dispatcher can unmark IT Job as received';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
UPDATE tasks
|
||||||
|
SET it_job_printed = false,
|
||||||
|
it_job_printed_at = null,
|
||||||
|
it_job_received_by_id = null
|
||||||
|
WHERE id = p_task_id;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
13
supabase/migrations/20260322140000_fix_it_job_rpc_grants.sql
Normal file
13
supabase/migrations/20260322140000_fix_it_job_rpc_grants.sql
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
-- ============================================================
|
||||||
|
-- Grant EXECUTE on IT Job RPCs to the authenticated role and
|
||||||
|
-- enable REPLICA IDENTITY FULL on tasks for realtime updates.
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
GRANT EXECUTE ON FUNCTION public.mark_it_job_printed(uuid, uuid) TO authenticated;
|
||||||
|
GRANT EXECUTE ON FUNCTION public.unmark_it_job_printed(uuid) TO authenticated;
|
||||||
|
|
||||||
|
-- Enable REPLICA IDENTITY FULL on the tasks table so that
|
||||||
|
-- Supabase Realtime UPDATE events carry the full new row data,
|
||||||
|
-- including the newly added it_job_printed / it_job_printed_at /
|
||||||
|
-- it_job_received_by_id columns.
|
||||||
|
ALTER TABLE public.tasks REPLICA IDENTITY FULL;
|
||||||
Loading…
Reference in New Issue
Block a user