Files
tasq/lib/providers/programmer_day_sheets_provider.dart
T
2026-09-28 14:00:29 +08:00

389 lines
13 KiB
Dart

import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../models/programmer_day_sheet.model.dart';
import '../models/programmer_day_sheet_event.model.dart';
import '../utils/app_time.dart';
import '../utils/programmer_daily_time.dart';
import 'notifications_provider.dart';
import 'profile_provider.dart';
import 'supabase_provider.dart';
// ---------------------------------------------------------------------------
// Shared types
// ---------------------------------------------------------------------------
/// Callback type for sending push notifications.
typedef PushFn = Future<void> Function({
required List<String> userIds,
required String title,
required String body,
Map<String, dynamic>? data,
});
// ---------------------------------------------------------------------------
// Error types
// ---------------------------------------------------------------------------
enum DaySheetError {
alreadyReviewed,
alreadySubmitted,
notAuthorized,
remarksRequired,
bodyRequired,
workDateLocked,
invalidSnapshot,
unknown,
}
class DaySheetActionException implements Exception {
const DaySheetActionException(this.code);
final DaySheetError code;
String get message {
switch (code) {
case DaySheetError.alreadyReviewed:
return 'This sheet was already reviewed.';
case DaySheetError.alreadySubmitted:
return 'This day sheet was already submitted.';
case DaySheetError.notAuthorized:
return 'You are not allowed to do that.';
case DaySheetError.remarksRequired:
return 'Remarks are required to disapprove.';
case DaySheetError.bodyRequired:
return 'Please write a justification.';
case DaySheetError.workDateLocked:
return 'That day is locked — you can only add entries to pending or disapproved days.';
case DaySheetError.invalidSnapshot:
return 'Could not approve: the sheet data was invalid.';
case DaySheetError.unknown:
return 'Something went wrong. Please try again.';
}
}
@override
String toString() => 'DaySheetActionException(${code.name}): $message';
}
/// Maps an RPC error to a [DaySheetError] by substring-matching the lowercased
/// error string against the known database error codes.
DaySheetError mapDaySheetError(Object error) {
final s = error.toString().toLowerCase();
if (s.contains('not_authorized')) return DaySheetError.notAuthorized;
if (s.contains('already_reviewed')) return DaySheetError.alreadyReviewed;
if (s.contains('already_submitted')) return DaySheetError.alreadySubmitted;
if (s.contains('remarks_required')) return DaySheetError.remarksRequired;
if (s.contains('body_required')) return DaySheetError.bodyRequired;
if (s.contains('work_date_locked')) return DaySheetError.workDateLocked;
if (s.contains('invalid_snapshot')) return DaySheetError.invalidSnapshot;
return DaySheetError.unknown;
}
// ---------------------------------------------------------------------------
// Result type
// ---------------------------------------------------------------------------
class BulkApproveResult {
const BulkApproveResult({
required this.approvedIds,
required this.skippedIds,
});
final List<String> approvedIds;
final List<String> skippedIds;
}
// ---------------------------------------------------------------------------
// Controller
// ---------------------------------------------------------------------------
class DaySheetsController {
DaySheetsController(this._client, {required PushFn push}) : _push = push;
final dynamic _client;
final PushFn _push;
// -------------------------------------------------------------------------
// Submit
// -------------------------------------------------------------------------
Future<String> submit(DateTime dayKey) async {
try {
final dateStr = formatDayKey(dayKey);
final result = await _client.rpc(
'submit_day_sheet',
params: {'p_date': dateStr},
) as Map<String, dynamic>;
final sheetId = result['sheet_id'] as String;
final workDate = parseDayKey(result['work_date'] as String);
final programmerName = result['programmer_name'] as String;
final notifyUserIds =
List<String>.from(result['notify_user_ids'] as List? ?? []);
if (notifyUserIds.isNotEmpty) {
try {
await _push(
userIds: notifyUserIds,
title: 'Day sheet submitted',
body:
'$programmerName submitted their day sheet for ${AppTime.formatDate(workDate)}',
data: {
'type': 'day_sheet_submitted',
'navigate_to': '/programmer-tasks/day-sheets/$sheetId',
},
);
} catch (e) {
debugPrint('DaySheetsController.submit push error: $e');
}
}
return sheetId;
} catch (e) {
if (e is DaySheetActionException) rethrow;
throw DaySheetActionException(mapDaySheetError(e));
}
}
// -------------------------------------------------------------------------
// Disapprove
// -------------------------------------------------------------------------
Future<void> disapprove({
required String sheetId,
required String remarks,
required List<String> flaggedTaskIds,
}) async {
if (remarks.trim().isEmpty) {
throw DaySheetActionException(DaySheetError.remarksRequired);
}
try {
final result = await _client.rpc(
'disapprove_day_sheet',
params: {
'p_sheet': sheetId,
'p_remarks': remarks.trim(),
'p_flagged_task_ids': flaggedTaskIds,
},
);
if (result is Map<String, dynamic>) {
final programmerId = result['programmer_id'] as String?;
final rawDate = result['work_date'] as String?;
if (programmerId != null && rawDate != null) {
final workDate = parseDayKey(rawDate);
final truncated = remarks.trim().length > 80
? '${remarks.trim().substring(0, 80)}…'
: remarks.trim();
try {
await _push(
userIds: [programmerId],
title: 'Day sheet disapproved',
body:
'Your day sheet for ${AppTime.formatDate(workDate)} needs changes: $truncated',
data: {
'type': 'day_sheet_disapproved',
'navigate_to': '/programmer-tasks/day-sheets/$sheetId',
},
);
} catch (e) {
debugPrint('DaySheetsController.disapprove push error: $e');
}
}
}
} catch (e) {
if (e is DaySheetActionException) rethrow;
throw DaySheetActionException(mapDaySheetError(e));
}
}
// -------------------------------------------------------------------------
// Justify
// -------------------------------------------------------------------------
Future<void> justify({
required String sheetId,
required String body,
}) async {
if (body.trim().isEmpty) {
throw DaySheetActionException(DaySheetError.bodyRequired);
}
try {
final result = await _client.rpc(
'justify_day_sheet',
params: {
'p_sheet': sheetId,
'p_body': body.trim(),
},
);
if (result is Map<String, dynamic>) {
final notifyUserIds =
List<String>.from(result['notify_user_ids'] as List? ?? []);
final programmerName = result['programmer_name'] as String?;
final rawDate = result['work_date'] as String?;
if (notifyUserIds.isNotEmpty &&
programmerName != null &&
rawDate != null) {
final workDate = parseDayKey(rawDate);
try {
await _push(
userIds: notifyUserIds,
title: 'Day sheet resubmitted',
body:
'$programmerName justified their day sheet for ${AppTime.formatDate(workDate)}',
data: {
'type': 'day_sheet_resubmitted',
'navigate_to': '/programmer-tasks/day-sheets/$sheetId',
},
);
} catch (e) {
debugPrint('DaySheetsController.justify push error: $e');
}
}
}
} catch (e) {
if (e is DaySheetActionException) rethrow;
throw DaySheetActionException(mapDaySheetError(e));
}
}
// -------------------------------------------------------------------------
// Approve (bulk)
// -------------------------------------------------------------------------
Future<BulkApproveResult> approve(
List<({String sheetId, DaySheetSnapshot snapshot})> items,
) async {
try {
final params = items
.map((e) => {
'sheet_id': e.sheetId,
'snapshot': e.snapshot.toMap(),
})
.toList();
final result = await _client.rpc(
'approve_day_sheets',
params: {'p_items': params},
) as Map<String, dynamic>;
final approved = List<Map<String, dynamic>>.from(
(result['approved'] as List? ?? []).cast<Map<String, dynamic>>(),
);
final skippedIds =
List<String>.from(result['skipped_ids'] as List? ?? []);
for (final item in approved) {
final approvedSheetId = item['sheet_id'] as String;
final programmerId = item['programmer_id'] as String?;
final rawDate = item['work_date'] as String?;
if (programmerId != null && rawDate != null) {
final workDate = parseDayKey(rawDate);
try {
await _push(
userIds: [programmerId],
title: 'Day sheet approved',
body:
'Your day sheet for ${AppTime.formatDate(workDate)} was approved',
data: {
'type': 'day_sheet_approved',
'navigate_to':
'/programmer-tasks/day-sheets/$approvedSheetId',
},
);
} catch (e) {
debugPrint('DaySheetsController.approve push error: $e');
}
}
}
return BulkApproveResult(
approvedIds: approved.map((e) => e['sheet_id'] as String).toList(),
skippedIds: skippedIds,
);
} catch (e) {
if (e is DaySheetActionException) rethrow;
throw DaySheetActionException(mapDaySheetError(e));
}
}
}
// ---------------------------------------------------------------------------
// Riverpod providers
// ---------------------------------------------------------------------------
final daySheetsControllerProvider = Provider<DaySheetsController>((ref) {
final client = ref.watch(supabaseClientProvider);
final notifications = ref.read(notificationsControllerProvider);
return DaySheetsController(
client,
push: notifications.sendPush,
);
});
// ---------------------------------------------------------------------------
// Stream providers
// ---------------------------------------------------------------------------
/// Day sheets for the currently authenticated programmer, newest first.
final myDaySheetsProvider =
StreamProvider<List<ProgrammerDaySheet>>((ref) {
final userId = ref.watch(currentUserIdProvider);
if (userId == null) return const Stream.empty();
final client = ref.watch(supabaseClientProvider);
return client
.from('programmer_day_sheets')
.stream(primaryKey: ['id'])
.eq('programmer_id', userId)
.order('work_date', ascending: false)
.map((rows) => rows.map(ProgrammerDaySheet.fromMap).toList());
});
/// Day sheets for a specific user (admin view), newest first.
final daySheetsForUserProvider =
StreamProvider.family<List<ProgrammerDaySheet>, String>((ref, userId) {
final client = ref.watch(supabaseClientProvider);
return client
.from('programmer_day_sheets')
.stream(primaryKey: ['id'])
.eq('programmer_id', userId)
.order('work_date', ascending: false)
.map((rows) => rows.map(ProgrammerDaySheet.fromMap).toList());
});
/// All day sheets visible to the current user (RLS-scoped), newest first.
final allDaySheetsProvider =
StreamProvider<List<ProgrammerDaySheet>>((ref) {
final client = ref.watch(supabaseClientProvider);
return client
.from('programmer_day_sheets')
.stream(primaryKey: ['id'])
.order('work_date', ascending: false)
.map((rows) => rows.map(ProgrammerDaySheet.fromMap).toList());
});
/// A single day sheet by its ID, or null if not found.
final daySheetByIdProvider =
StreamProvider.family<ProgrammerDaySheet?, String>((ref, id) {
final client = ref.watch(supabaseClientProvider);
return client
.from('programmer_day_sheets')
.stream(primaryKey: ['id'])
.eq('id', id)
.map((rows) => rows.isEmpty ? null : ProgrammerDaySheet.fromMap(rows.first));
});
/// Events for a day sheet, ordered by creation time ascending.
final daySheetEventsProvider =
StreamProvider.family<List<ProgrammerDaySheetEvent>, String>((ref, sheetId) {
final client = ref.watch(supabaseClientProvider);
return client
.from('programmer_day_sheet_events')
.stream(primaryKey: ['id'])
.eq('sheet_id', sheetId)
.order('created_at', ascending: true)
.map((rows) => rows.map(ProgrammerDaySheetEvent.fromMap).toList());
});