cc8d8ab8bf
Co-Authored-By: claude-flow <ruv@ruv.net>
389 lines
13 KiB
Dart
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());
|
|
});
|