From 3fb6fd5c9319511f8e2730b0a06a9b34d1b120a3 Mon Sep 17 00:00:00 2001 From: Marc Rejohn Castillano Date: Sat, 21 Mar 2026 18:51:04 +0800 Subject: [PATCH] Announcements and IT Job Checklist --- lib/models/announcement.dart | 70 ++ lib/models/announcement_comment.dart | 47 ++ lib/models/notification_item.dart | 3 + lib/models/task.dart | 21 +- lib/providers/announcements_provider.dart | 258 ++++++++ lib/providers/tasks_provider.dart | 22 + lib/routing/app_router.dart | 8 +- .../announcement_comments_section.dart | 258 ++++++++ .../announcements/announcements_screen.dart | 410 ++++++++++++ .../create_announcement_dialog.dart | 314 +++++++++ .../notifications/notifications_screen.dart | 24 +- lib/screens/tasks/it_job_checklist_tab.dart | 597 ++++++++++++++++++ lib/screens/tasks/tasks_list_screen.dart | 111 +++- lib/widgets/app_shell.dart | 34 +- .../20260322100000_create_announcements.sql | 140 ++++ ...0322110000_add_it_job_printed_to_tasks.sql | 9 + ...120000_add_it_job_received_by_to_tasks.sql | 7 + .../20260322130000_it_job_printed_rpc.sql | 58 ++ .../20260322140000_fix_it_job_rpc_grants.sql | 13 + 19 files changed, 2367 insertions(+), 37 deletions(-) create mode 100644 lib/models/announcement.dart create mode 100644 lib/models/announcement_comment.dart create mode 100644 lib/providers/announcements_provider.dart create mode 100644 lib/screens/announcements/announcement_comments_section.dart create mode 100644 lib/screens/announcements/announcements_screen.dart create mode 100644 lib/screens/announcements/create_announcement_dialog.dart create mode 100644 lib/screens/tasks/it_job_checklist_tab.dart create mode 100644 supabase/migrations/20260322100000_create_announcements.sql create mode 100644 supabase/migrations/20260322110000_add_it_job_printed_to_tasks.sql create mode 100644 supabase/migrations/20260322120000_add_it_job_received_by_to_tasks.sql create mode 100644 supabase/migrations/20260322130000_it_job_printed_rpc.sql create mode 100644 supabase/migrations/20260322140000_fix_it_job_rpc_grants.sql diff --git a/lib/models/announcement.dart b/lib/models/announcement.dart new file mode 100644 index 00000000..40c1be52 --- /dev/null +++ b/lib/models/announcement.dart @@ -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 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 map) { + final rolesRaw = map['visible_roles']; + final roles = rolesRaw is List + ? rolesRaw.cast() + : []; + + 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), + ); + } +} diff --git a/lib/models/announcement_comment.dart b/lib/models/announcement_comment.dart new file mode 100644 index 00000000..87342c80 --- /dev/null +++ b/lib/models/announcement_comment.dart @@ -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 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), + ); + } +} diff --git a/lib/models/notification_item.dart b/lib/models/notification_item.dart index 7e266ad1..76c973c0 100644 --- a/lib/models/notification_item.dart +++ b/lib/models/notification_item.dart @@ -10,6 +10,7 @@ class NotificationItem { this.leaveId, this.passSlipId, required this.itServiceRequestId, + this.announcementId, required this.messageId, required this.type, required this.createdAt, @@ -24,6 +25,7 @@ class NotificationItem { final String? leaveId; final String? passSlipId; final String? itServiceRequestId; + final String? announcementId; final int? messageId; final String type; final DateTime createdAt; @@ -41,6 +43,7 @@ class NotificationItem { leaveId: map['leave_id'] as String?, passSlipId: map['pass_slip_id'] as String?, itServiceRequestId: map['it_service_request_id'] as String?, + announcementId: map['announcement_id'] as String?, messageId: map['message_id'] as int?, type: map['type'] as String? ?? 'mention', createdAt: AppTime.parse(map['created_at'] as String), diff --git a/lib/models/task.dart b/lib/models/task.dart index bc6a04a0..39c368f0 100644 --- a/lib/models/task.dart +++ b/lib/models/task.dart @@ -27,6 +27,9 @@ class Task { this.actionTaken, this.cancellationReason, this.cancelledAt, + this.itJobPrinted = false, + this.itJobPrintedAt, + this.itJobReceivedById, }); final String id; @@ -59,6 +62,11 @@ class Task { final String? cancellationReason; final DateTime? cancelledAt; + /// Whether the printed IT Job has been submitted. + final bool itJobPrinted; + final DateTime? itJobPrintedAt; + final String? itJobReceivedById; + @override bool operator ==(Object other) => identical(this, other) || @@ -85,7 +93,10 @@ class Task { requestCategory == other.requestCategory && actionTaken == other.actionTaken && cancellationReason == other.cancellationReason && - cancelledAt == other.cancelledAt; + cancelledAt == other.cancelledAt && + itJobPrinted == other.itJobPrinted && + itJobPrintedAt == other.itJobPrintedAt && + itJobReceivedById == other.itJobReceivedById; @override int get hashCode => Object.hash( @@ -109,7 +120,8 @@ class Task { requestTypeOther, requestCategory, // 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 @@ -154,6 +166,11 @@ class Task { cancelledAt: map['cancelled_at'] == null ? null : 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: (() { final at = map['action_taken']; if (at == null) return null; diff --git a/lib/providers/announcements_provider.dart b/lib/providers/announcements_provider.dart new file mode 100644 index 00000000..7039d3ec --- /dev/null +++ b/lib/providers/announcements_provider.dart @@ -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>((ref) { + final userId = ref.watch(currentUserIdProvider); + if (userId == null) return const Stream.empty(); + + final client = ref.watch(supabaseClientProvider); + + final wrapper = StreamRecoveryWrapper( + 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, String>(( + ref, + announcementId, +) { + final client = ref.watch(supabaseClientProvider); + + final wrapper = StreamRecoveryWrapper( + 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((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 createAnnouncement({ + required String title, + required String body, + required List 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 updateAnnouncement({ + required String id, + required String title, + required String body, + required List visibleRoles, + bool? isTemplate, + }) async { + final payload = { + '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 deleteAnnouncement(String id) async { + await _client.from('announcements').delete().eq('id', id); + } + + /// Fetch a template announcement's data for reposting. + Future 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 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 = {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 deleteComment(String id) async { + await _client.from('announcement_comments').delete().eq('id', id); + } +} diff --git a/lib/providers/tasks_provider.dart b/lib/providers/tasks_provider.dart index d92b5907..8b2f1d0d 100644 --- a/lib/providers/tasks_provider.dart +++ b/lib/providers/tasks_provider.dart @@ -234,6 +234,9 @@ Map _buildTaskPayload({ 'action_taken': task.actionTaken, 'cancellation_reason': task.cancellationReason, '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(); @@ -1694,6 +1697,25 @@ class TasksController { 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 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 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. diff --git a/lib/routing/app_router.dart b/lib/routing/app_router.dart index 978fa6f1..f4eb8e94 100644 --- a/lib/routing/app_router.dart +++ b/lib/routing/app_router.dart @@ -16,7 +16,7 @@ import '../screens/dashboard/dashboard_screen.dart'; import '../screens/notifications/notifications_screen.dart'; import '../screens/profile/profile_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/reports/reports_screen.dart'; import '../screens/tasks/task_detail_screen.dart'; @@ -183,11 +183,7 @@ final appRouterProvider = Provider((ref) { path: '/announcements', pageBuilder: (context, state) => M3SharedAxisPage( key: state.pageKey, - child: const UnderDevelopmentScreen( - title: 'Announcement', - subtitle: 'Operational broadcasts are coming soon.', - icon: Icons.campaign, - ), + child: const AnnouncementsScreen(), ), ), GoRoute( diff --git a/lib/screens/announcements/announcement_comments_section.dart b/lib/screens/announcements/announcement_comments_section.dart new file mode 100644 index 00000000..074f3bbe --- /dev/null +++ b/lib/screens/announcements/announcement_comments_section.dart @@ -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 createState() => + _AnnouncementCommentsSectionState(); +} + +class _AnnouncementCommentsSectionState + extends ConsumerState { + final _controller = TextEditingController(); + bool _sending = false; + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + Future _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); +} diff --git a/lib/screens/announcements/announcements_screen.dart b/lib/screens/announcements/announcements_screen.dart new file mode 100644 index 00000000..63c99970 --- /dev/null +++ b/lib/screens/announcements/announcements_screen.dart @@ -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 createState() => + _AnnouncementsScreenState(); +} + +class _AnnouncementsScreenState extends ConsumerState { + final Set _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( + 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( + 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; +} diff --git a/lib/screens/announcements/create_announcement_dialog.dart b/lib/screens/announcements/create_announcement_dialog.dart new file mode 100644 index 00000000..ce21f487 --- /dev/null +++ b/lib/screens/announcements/create_announcement_dialog.dart @@ -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 showCreateAnnouncementDialog( + BuildContext context, { + Announcement? editing, +}) async { + final width = MediaQuery.sizeOf(context).width; + if (width < AppBreakpoints.tablet) { + await m3ShowBottomSheet( + context: context, + isScrollControlled: true, + builder: (ctx) => _CreateAnnouncementContent(editing: editing), + ); + } else { + await m3ShowDialog( + 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 _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.from(source.visibleRoles) + : Set.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.from(template.visibleRoles); + }); + } + + void _clearTemplate() { + setState(() { + _selectedTemplateId = null; + }); + } + + Future _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( + 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( + value: null, + child: Text('No template'), + ), + ...templates.map( + (t) => DropdownMenuItem( + 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'), + ), + ], + ), + ], + ), + ); + } +} diff --git a/lib/screens/notifications/notifications_screen.dart b/lib/screens/notifications/notifications_screen.dart index 4820d9a2..615c88df 100644 --- a/lib/screens/notifications/notifications_screen.dart +++ b/lib/screens/notifications/notifications_screen.dart @@ -115,9 +115,11 @@ class _NotificationsScreenState extends ConsumerState { final taskTitle = item.taskId == null ? 'Task' : (taskById[item.taskId]?.title ?? item.taskId!); - final subtitle = item.taskId != null - ? taskTitle - : ticketSubject; + final subtitle = item.announcementId != null + ? 'Announcement' + : item.taskId != null + ? taskTitle + : ticketSubject; final title = _notificationTitle(item.type, actorName); final icon = _notificationIcon(item.type); @@ -162,7 +164,9 @@ class _NotificationsScreenState extends ConsumerState { .markRead(item.id); } if (!context.mounted) return; - if (taskId != null) { + if (item.announcementId != null) { + context.go('/announcements'); + } else if (taskId != null) { context.go('/tasks/$taskId'); } else if (ticketId != null) { context.go('/tickets/$ticketId'); @@ -195,6 +199,12 @@ class _NotificationsScreenState extends ConsumerState { return '$actorName requested a shift swap'; case 'swap_update': 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': default: return '$actorName mentioned you'; @@ -211,6 +221,12 @@ class _NotificationsScreenState extends ConsumerState { return Icons.swap_horiz; case 'swap_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': default: return Icons.alternate_email; diff --git a/lib/screens/tasks/it_job_checklist_tab.dart b/lib/screens/tasks/it_job_checklist_tab.dart new file mode 100644 index 00000000..6017631c --- /dev/null +++ b/lib/screens/tasks/it_job_checklist_tab.dart @@ -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 createState() => _ItJobChecklistTabState(); +} + +class _ItJobChecklistTabState extends ConsumerState { + 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( + 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( + value: null, + child: Text('All Teams'), + ), + ...teams.map((t) => DropdownMenuItem( + value: t.id, + child: Text(t.name, + overflow: TextOverflow.ellipsis), + )), + ], + onChanged: (v) => setState(() => _selectedTeamId = v), + ), + ), + // Status filter + SizedBox( + width: 180, + child: DropdownButtonFormField( + 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( + 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 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 _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 _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, + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/screens/tasks/tasks_list_screen.dart b/lib/screens/tasks/tasks_list_screen.dart index 6bbd4831..43cd2950 100644 --- a/lib/screens/tasks/tasks_list_screen.dart +++ b/lib/screens/tasks/tasks_list_screen.dart @@ -33,6 +33,7 @@ import '../../widgets/app_state_view.dart'; import '../../utils/subject_suggestions.dart'; import '../../widgets/gemini_button.dart'; import '../../widgets/gemini_animated_text_field.dart'; +import 'it_job_checklist_tab.dart'; // request metadata options used in task creation/editing dialogs const List _requestTypeOptions = [ @@ -57,14 +58,16 @@ class TasksListScreen extends ConsumerStatefulWidget { } class _TasksListScreenState extends ConsumerState - with SingleTickerProviderStateMixin { + with TickerProviderStateMixin { final TextEditingController _subjectController = TextEditingController(); final TextEditingController _taskNumberController = TextEditingController(); String? _selectedOfficeId; String? _selectedStatus; String? _selectedAssigneeId; DateTimeRange? _selectedDateRange; - late final TabController _tabController; + late TabController _tabController; + bool _showItJobTab = false; + bool _tabInited = false; @override void dispose() { @@ -78,13 +81,31 @@ class _TasksListScreenState extends ConsumerState void initState() { super.initState(); _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(() { 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 { return _subjectController.text.trim().isNotEmpty || _taskNumberController.text.trim().isNotEmpty || @@ -129,6 +150,13 @@ class _TasksListScreenState extends ConsumerState profile.role == 'it_staff'), 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 = { for (final ticket in ticketsAsync.valueOrNull ?? []) @@ -521,9 +549,11 @@ class _TasksListScreenState extends ConsumerState children: [ TabBar( controller: _tabController, - tabs: const [ - Tab(text: 'My Tasks'), - Tab(text: 'All Tasks'), + tabs: [ + const Tab(text: 'My Tasks'), + const Tab(text: 'All Tasks'), + if (_showItJobTab) + const Tab(text: 'IT Job Checklist'), ], ), Expanded( @@ -552,6 +582,8 @@ class _TasksListScreenState extends ConsumerState : 'Tasks created for your team will appear here.', ) : makeList(filteredTasks), + if (_showItJobTab) + const ItJobChecklistTab(), ], ), ), @@ -564,7 +596,20 @@ class _TasksListScreenState extends ConsumerState ), ), ), - 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( right: 16, bottom: 16, @@ -581,6 +626,56 @@ class _TasksListScreenState extends ConsumerState ); } + Future _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 = {}; + 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 _showCreateTaskDialog( BuildContext context, WidgetRef ref, diff --git a/lib/widgets/app_shell.dart b/lib/widgets/app_shell.dart index 8fb075f5..42f97f43 100644 --- a/lib/widgets/app_shell.dart +++ b/lib/widgets/app_shell.dart @@ -285,20 +285,20 @@ class _NotificationBell extends ConsumerWidget { clipBehavior: Clip.none, children: [ const Icon(Icons.notifications_outlined), - AnimatedSwitcher( - duration: const Duration(milliseconds: 200), - switchInCurve: Curves.easeOutBack, - switchOutCurve: Curves.easeInCubic, - transitionBuilder: (child, animation) => ScaleTransition( - scale: animation, - child: child, - ), - child: unreadCount > 0 - ? Positioned( - key: const ValueKey('badge'), - right: -3, - top: -3, - child: Container( + Positioned( + right: -3, + top: -3, + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + switchInCurve: Curves.easeOutBack, + switchOutCurve: Curves.easeInCubic, + transitionBuilder: (child, animation) => ScaleTransition( + scale: animation, + child: child, + ), + child: unreadCount > 0 + ? Container( + key: const ValueKey('badge'), width: 10, height: 10, decoration: BoxDecoration( @@ -309,9 +309,9 @@ class _NotificationBell extends ConsumerWidget { width: 1.5, ), ), - ), - ) - : const SizedBox.shrink(key: ValueKey('no-badge')), + ) + : const SizedBox.shrink(key: ValueKey('no-badge')), + ), ), ], ), diff --git a/supabase/migrations/20260322100000_create_announcements.sql b/supabase/migrations/20260322100000_create_announcements.sql new file mode 100644 index 00000000..78ab43ca --- /dev/null +++ b/supabase/migrations/20260322100000_create_announcements.sql @@ -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; diff --git a/supabase/migrations/20260322110000_add_it_job_printed_to_tasks.sql b/supabase/migrations/20260322110000_add_it_job_printed_to_tasks.sql new file mode 100644 index 00000000..d93ce746 --- /dev/null +++ b/supabase/migrations/20260322110000_add_it_job_printed_to_tasks.sql @@ -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; diff --git a/supabase/migrations/20260322120000_add_it_job_received_by_to_tasks.sql b/supabase/migrations/20260322120000_add_it_job_received_by_to_tasks.sql new file mode 100644 index 00000000..e1ba50fc --- /dev/null +++ b/supabase/migrations/20260322120000_add_it_job_received_by_to_tasks.sql @@ -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; diff --git a/supabase/migrations/20260322130000_it_job_printed_rpc.sql b/supabase/migrations/20260322130000_it_job_printed_rpc.sql new file mode 100644 index 00000000..9e004d99 --- /dev/null +++ b/supabase/migrations/20260322130000_it_job_printed_rpc.sql @@ -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; +$$; diff --git a/supabase/migrations/20260322140000_fix_it_job_rpc_grants.sql b/supabase/migrations/20260322140000_fix_it_job_rpc_grants.sql new file mode 100644 index 00000000..4456cab0 --- /dev/null +++ b/supabase/migrations/20260322140000_fix_it_job_rpc_grants.sql @@ -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;