624 lines
24 KiB
Dart
624 lines
24 KiB
Dart
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 to admin/dispatcher and IT staff.
|
|
///
|
|
/// Admin/dispatcher view: shows all completed tasks with a checkbox for
|
|
/// tracking printed IT Job submission status, plus a per-task notification
|
|
/// button with 60s cooldown.
|
|
///
|
|
/// IT staff view: shows only tasks assigned to the current user, read-only,
|
|
/// so they can track which IT Jobs still need a physical copy submitted.
|
|
class ItJobChecklistTab extends ConsumerStatefulWidget {
|
|
const ItJobChecklistTab({super.key, this.isAdminView = true});
|
|
|
|
/// When true, shows admin controls (checkbox, bell, stats, team filter).
|
|
/// When false (IT staff), shows only the current user's tasks, read-only.
|
|
final bool isAdminView;
|
|
|
|
@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;
|
|
final currentUserId = widget.isAdminView
|
|
? null
|
|
: ref.watch(currentUserIdProvider);
|
|
|
|
// All completed tasks
|
|
var filtered = allTasks.where((t) => t.status == 'completed').toList();
|
|
|
|
// IT staff: restrict to tasks assigned to the current user
|
|
if (!widget.isAdminView && currentUserId != null) {
|
|
filtered = filtered.where((t) {
|
|
return allAssignments
|
|
.any((a) => a.taskId == t.id && a.userId == currentUserId);
|
|
}).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 (admin/dispatcher only)
|
|
if (widget.isAdminView && _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 (admin/dispatcher only — computed over all completed tasks)
|
|
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 — admin/dispatcher only
|
|
if (widget.isAdminView)
|
|
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 — admin/dispatcher only
|
|
if (widget.isAdminView)
|
|
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 ((widget.isAdminView && _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,
|
|
isAdminView: widget.isAdminView,
|
|
);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
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,
|
|
this.isAdminView = true,
|
|
});
|
|
|
|
final Task task;
|
|
final List<TaskAssignment> assignees;
|
|
final List profiles;
|
|
final bool isAdminView;
|
|
|
|
@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');
|
|
}
|
|
}
|
|
}
|
|
|
|
void _sendReminder() {
|
|
final userIds = widget.assignees.map((a) => a.userId).toList();
|
|
if (userIds.isEmpty) {
|
|
showErrorSnackBar(context, 'No assigned staff');
|
|
return;
|
|
}
|
|
|
|
// Start cooldown and give immediate feedback — notification fires async.
|
|
setState(_startCooldown);
|
|
showSuccessSnackBar(context, 'Reminder sent');
|
|
|
|
final currentUserId = ref.read(currentUserIdProvider);
|
|
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}',
|
|
},
|
|
)
|
|
.ignore();
|
|
}
|
|
|
|
@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 + bell — admin/dispatcher only
|
|
if (widget.isAdminView) ...[
|
|
// 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(
|
|
color: cs.primary,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
)
|
|
: IconButton(
|
|
tooltip: 'Remind assigned staff',
|
|
padding: EdgeInsets.zero,
|
|
onPressed: _sendReminder,
|
|
icon: Icon(
|
|
Icons.notifications_active_outlined,
|
|
color: cs.primary,
|
|
size: 20,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|