Compare commits
43 Commits
7716460363
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 8220482465 | |||
| 88848138d9 | |||
| 2946945ce3 | |||
| 4f60e23003 | |||
| 68817a70f0 | |||
| 9f2c14dc6b | |||
| 1101fb0a97 | |||
| 167e208ffa | |||
| 87014d1656 | |||
| f56a3057d4 | |||
| 8209719928 | |||
| 2585cce36d | |||
| 5af0ce62d9 | |||
| 54ce528b02 | |||
| 1fe9653bdf | |||
| 91fb023ef2 | |||
| be16a3cd0d | |||
| cc8d8ab8bf | |||
| 20e95ffe37 | |||
| e0dd3f6368 | |||
| e2ff0140ef | |||
| e24083356f | |||
| 7beb8e8713 | |||
| 791653f45c | |||
| 935ee670e7 | |||
| a7fd1e74f1 | |||
| 0c99ef270d | |||
| 9a40ffec81 | |||
| 29bcbcaa4d | |||
| f8c3422715 | |||
| cdfeb954fd | |||
| ab82af119b | |||
| 12842bfa12 | |||
| f9b8831c71 | |||
| f071cc722d | |||
| 6beb92d3c6 | |||
| dd6730f62d | |||
| 7a319b6e16 | |||
| b307f66f2c | |||
| 627645056b | |||
| e7706058b7 | |||
| 4685430406 | |||
| 6bde8630d8 |
@@ -0,0 +1,909 @@
|
||||
# Programmer Oversight Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add daily Day-Sheet approvals (with remarks ↔ justification loop), a live admin Monitor, a PDF Accomplishment Report, and a gamified Mine dashboard to the Programmer Tasks module.
|
||||
|
||||
**Architecture:** Pure Dart utilities compute per-day worked time (clipped to Manila midnights, credited to the assignee at the time) and build "day rows"; every view (dashboard, monitor, day sheet, report) renders those rows. Day-sheet state lives in Supabase (`programmer_day_sheets` + append-only `programmer_day_sheet_events`), changed only through SECURITY DEFINER RPCs and triggers; a pg_cron job auto-submits past drafts. Approval stores a JSON snapshot so approved days are fixed records.
|
||||
|
||||
**Tech Stack:** Flutter 3.47.5 / Dart 3.13, Riverpod 2.6, supabase_flutter 2.x (Postgres, RLS, realtime, pg_cron, Deno edge functions), `pdf` 3.12 + `printing` 5.14 + `pdfrx` 2.6, flutter_quill 11.6.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-09-27-programmer-oversight-design.md`
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Run Flutter on the host: `$FLUTTER <args>` means `flatpak-spawn --host sh -c 'cd /home/redz/tasq && ~/flutter/bin/flutter <args>'`.
|
||||
- Never run `dart format` on existing files (the repo is not formatted; it causes diff churn). New files may be formatted individually.
|
||||
- Stage files explicitly (`git add <paths>`). The working tree has unrelated modified files (`.claude/`, `.swarm/`, `ruvector.db`, `.mcp.json`) — never `git add -A` / `git add .`. Every commit message ends with `Co-Authored-By: claude-flow <ruv@ruv.net>`.
|
||||
- Supabase DDL and edge-function deploys are done by the user. Do not call `mcp__supabase__apply_migration` or `deploy_edge_function`; read-only checks via `mcp__supabase__execute_sql` inside `begin; … rollback;` are fine.
|
||||
- Time zone is Asia/Manila via `AppTime`. **Day keys** are `DateTime.utc(y, m, d)` of the Manila calendar date, produced only by `manilaDay()` / `parseDayKey()` (Task 1). DB `date` values are `yyyy-MM-dd` strings via `formatDayKey()`. `ReportDateRange` uses local `DateTime(y, m, d)` with an **exclusive** `end`; convert with `DateTime.utc(r.start.year, r.start.month, r.start.day)`.
|
||||
- "Admin" = `profiles.role == 'admin'` via `isStrictAdminProvider` (Task 10) and SQL `role = 'admin'`. Never use `isAdminProvider` (it includes programmers) for approval or monitor gating.
|
||||
- Status color only via `AppStatusColors` / `StatusKind` (`success, warning, danger, info, neutral`) / `StatusPill.kind`. Section titles `AppSectionHeader(title, trailing:)`; KPI tiles `AppMetricCard(title:, value:, icon:, trailing:)`; codes/durations `TechChip`; cards `M3Card`. No hardcoded status hex.
|
||||
- Every new file < 500 lines.
|
||||
- Day sheets and work logs are online-only. When `isOnlineProvider` is false, day-sheet actions are disabled and a banner reads: `You're offline — day sheet actions need a connection.`
|
||||
- Tests live in `test/`, call `setUpAll(AppTime.initialize)`, and follow the fake-client pattern of `test/programmer_tasks_controller_test.dart` (controller constructed with `fake as dynamic`). Widget tests wrap in `ProviderScope` + `MaterialApp(theme: AppTheme.light())`.
|
||||
- Known unrelated failures when running the full suite: `theme_overhaul_test`, `profile_screen_test`, `user_management_screen_test`, and time-of-day-dependent cases in `dashboard_metrics_provider_test`.
|
||||
|
||||
## Review Focus
|
||||
|
||||
1. **Device time zone ≠ Manila** (browser set to UTC): an event at `2026-09-26T16:30Z` belongs to Manila day 2026-09-27, and a day sheet's time never leaks across Manila midnight. → Task 1 test `manilaDay ignores device time zone`.
|
||||
2. **Legacy plain-text work-log descriptions** (stored before Quill, not Delta JSON): shown verbatim in rows, sheet, and PDF — never blank. → Task 2 test `legacy plain text notes pass through`.
|
||||
3. **Offline-replayed task with no `started` log but `started_at` set**: its time is still counted from `started_at`. → Task 1 test `falls back to task.startedAt`.
|
||||
4. **Concurrent review** — two admins act on one sheet, or a bulk approval includes a sheet someone already reviewed: single actions fail with "This sheet was already reviewed."; bulk approves the rest and reports the skipped ones. → Task 14 tests + Task 12 SQL checklist items 5 and 8.
|
||||
5. **Report period with zero approved days**: the PDF still renders the letterhead, the line `No approved accomplishments in this period.`, and the excluded-days footnote. → Task 21 test `empty period`, Task 22 test.
|
||||
|
||||
---
|
||||
|
||||
# Part A — Slice 1: Mine dashboard (no schema change)
|
||||
|
||||
### Task 1: Day keys and per-day time split
|
||||
|
||||
**Files:**
|
||||
- Create: `lib/utils/programmer_daily_time.dart`
|
||||
- Test: `test/programmer_daily_time_test.dart`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `ProgrammerTask` (`lib/models/programmer_task.model.dart`), `ProgrammerTaskActivityLog` (`actionType`, `meta`, `createdAt`), `AppTime.fromComponents`.
|
||||
- Produces:
|
||||
```dart
|
||||
DateTime manilaDay(DateTime instant); // DateTime.utc(y,m,d) of the Manila date
|
||||
DateTime manilaDayStart(DateTime dayKey); // Manila midnight instant (TZDateTime)
|
||||
DateTime parseDayKey(String yyyyMmDd); // '2026-09-27' -> DateTime.utc(2026,9,27)
|
||||
String formatDayKey(DateTime dayKey); // -> '2026-09-27'
|
||||
typedef WorkedInterval = ({String assigneeId, DateTime start, DateTime end});
|
||||
List<WorkedInterval> workedIntervals({required ProgrammerTask task,
|
||||
required List<ProgrammerTaskActivityLog> logs, required DateTime now});
|
||||
/// assigneeId -> dayKey -> seconds (adjustments applied, each bucket >= 0)
|
||||
Map<String, Map<DateTime, int>> dailySecondsByAssignee({required ProgrammerTask task,
|
||||
required List<ProgrammerTaskActivityLog> logs, required DateTime now});
|
||||
```
|
||||
|
||||
- [ ] **Step 1: Write the failing tests.** Build fixtures with a helper `_ev(String action, int d, int h, [int m = 0, Map<String, dynamic>? meta])` creating a log at Manila `2026-09-d h:m` (via `AppTime.fromComponents`), and a helper `_task({status, creatorId, assigneeId, startedAt})`.
|
||||
|
||||
```dart
|
||||
test('manilaDay ignores device time zone', () {
|
||||
expect(manilaDay(DateTime.parse('2026-09-26T16:30:00Z')), DateTime.utc(2026, 9, 27));
|
||||
expect(manilaDay(DateTime.parse('2026-09-26T15:59:00Z')), DateTime.utc(2026, 9, 26));
|
||||
});
|
||||
test('day key round-trip', () {
|
||||
expect(formatDayKey(parseDayKey('2026-09-27')), '2026-09-27');
|
||||
});
|
||||
test('single day with a pause', () {
|
||||
// started 09:00, paused 11:00, resumed 13:00, completed 15:00 on Sep 27; creator=assignee='a'
|
||||
expect(dailySecondsByAssignee(task: t, logs: logs, now: now), {'a': {DateTime.utc(2026, 9, 27): 4 * 3600}});
|
||||
});
|
||||
test('running across Manila midnight splits by day', () {
|
||||
// started Sep 26 22:00, status in_progress, now = Sep 27 02:00
|
||||
expect(result['a'], {DateTime.utc(2026, 9, 26): 7200, DateTime.utc(2026, 9, 27): 7200});
|
||||
});
|
||||
test('reassignment credits each assignee for their own span', () {
|
||||
// creator 'a', current assignee 'b'; started 09:00, reassigned {assignee_id:'b'} 10:00, completed 12:00
|
||||
expect(result['a']![DateTime.utc(2026, 9, 27)], 3600);
|
||||
expect(result['b']![DateTime.utc(2026, 9, 27)], 7200);
|
||||
});
|
||||
test('initial assignee comes from the first assigned event', () {
|
||||
// creator 'boss', assigned {assignee_id:'a'}, started 09:00, completed 10:00
|
||||
expect(result.keys, ['a']);
|
||||
});
|
||||
test('adjustment subtracts on the day created and clamps at zero', () {
|
||||
// started 09:00, adjustment {seconds:1800} 10:00, completed 11:00 -> 5400
|
||||
// second case: adjustment {seconds: 99999} -> 0
|
||||
});
|
||||
test('falls back to task.startedAt when there is no started log', () {
|
||||
// no 'started' log, task.startedAt = Sep 27 09:00, completed 10:00 -> 3600
|
||||
});
|
||||
test('a paused in-progress task stops accruing at the pause', () {
|
||||
// started 09:00, paused 10:00, status in_progress, now 15:00 -> 3600
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify failure.** Run: `$FLUTTER test test/programmer_daily_time_test.dart` — Expected: FAIL (file/functions not defined).
|
||||
|
||||
- [ ] **Step 3: Implement.** Rules the tests don't fully pin:
|
||||
- Walk logs in **ascending** `createdAt` order (input may be any order).
|
||||
- Initial assignee = meta `assignee_id` of the first `assigned` event; else, if any `reassigned` event exists, `task.creatorId`; else `task.assigneeId`.
|
||||
- `started`/`resumed` open a segment (if none open); `paused`/`completed`/`cancelled` close it; `reassigned` closes the open segment for the old assignee and, if it was open, reopens it for `meta['assignee_id']`.
|
||||
- If no `started` event exists and `task.startedAt != null`, treat `startedAt` as a synthetic `started`.
|
||||
- An open segment at the end closes at `now` only when `task.status == in_progress`; otherwise at `completedAt ?? cancelledAt ?? now`.
|
||||
- Split each interval at Manila midnights using `manilaDayStart(nextDayKey)`; adjustments subtract `meta['seconds']` from the bucket of the assignee-at-that-time on `manilaDay(event.createdAt)`; clamp each bucket to ≥ 0.
|
||||
|
||||
- [ ] **Step 4: Run to verify pass.** Run: `$FLUTTER test test/programmer_daily_time_test.dart` — Expected: all PASS.
|
||||
|
||||
- [ ] **Step 5: Commit.**
|
||||
```bash
|
||||
git add lib/utils/programmer_daily_time.dart test/programmer_daily_time_test.dart
|
||||
git commit -m "feat(programmer): per-day worked time split with assignee attribution
|
||||
|
||||
Co-Authored-By: claude-flow <ruv@ruv.net>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Day rows, Quill plain text, work-log `workDate`
|
||||
|
||||
**Files:**
|
||||
- Create: `lib/utils/programmer_day_rows.dart`
|
||||
- Modify: `lib/widgets/quill_field.dart` (add a top-level function), `lib/models/programmer_task_work_log.model.dart` (add `workDate`)
|
||||
- Test: `test/programmer_day_rows_test.dart`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Task 1 (`manilaDay`, `parseDayKey`, `dailySecondsByAssignee`).
|
||||
- Produces:
|
||||
```dart
|
||||
// quill_field.dart
|
||||
String deltaJsonToPlainText(String? stored); // Delta JSON -> trimmed text; non-JSON -> stored.trim(); null -> ''
|
||||
// programmer_task_work_log.model.dart
|
||||
final DateTime workDate; // ctor param `DateTime? workDate` -> `workDate ?? manilaDay(createdAt)`;
|
||||
// fromMap: map['work_date'] != null ? parseDayKey(...) : manilaDay(createdAt)
|
||||
// programmer_day_rows.dart
|
||||
class DaySheetRow {
|
||||
final String taskId, title, category, kind; // kind: 'assignee' | 'helper'
|
||||
final String? projectName, helpedName;
|
||||
final int seconds;
|
||||
final List<String> notes;
|
||||
Map<String, dynamic> toJson(); // keys: task_id,title,category,project_name,kind,helped_name,seconds,notes
|
||||
factory DaySheetRow.fromJson(Map<String, dynamic> json);
|
||||
}
|
||||
List<DaySheetRow> buildDayRows({required String userId, required DateTime day,
|
||||
required List<ProgrammerTask> tasks,
|
||||
required Map<String, List<ProgrammerTaskActivityLog>> logsByTask,
|
||||
required List<ProgrammerTaskWorkLog> workLogs,
|
||||
required Map<String, String> projectNames, required Map<String, String> profileNames,
|
||||
required DateTime now});
|
||||
int totalSeconds(List<DaySheetRow> rows);
|
||||
```
|
||||
|
||||
- [ ] **Step 1: Write the failing tests.**
|
||||
```dart
|
||||
test('assignee row: that day seconds + that day notes only', ...); // seconds from Task 1 split; a note from Sep 26 excluded
|
||||
test('helper row: minutes*60 and helpedName', () {
|
||||
// work log by 'h' on task assigned to 'a', minutes 45, Sep 27
|
||||
expect(row.kind, 'helper'); expect(row.seconds, 2700); expect(row.helpedName, 'Ana Cruz');
|
||||
});
|
||||
test('notes-only row when the assignee logged but ran no timer', () { expect(row.seconds, 0); });
|
||||
test('legacy plain text notes pass through', () {
|
||||
expect(deltaJsonToPlainText('fixed bug'), 'fixed bug');
|
||||
expect(deltaJsonToPlainText('[{"insert":"Hello\\n"}]'), 'Hello');
|
||||
expect(deltaJsonToPlainText(null), '');
|
||||
});
|
||||
test('rows sort: assignee by seconds desc, then helpers by seconds desc', ...);
|
||||
test('DaySheetRow json round-trip', () { expect(DaySheetRow.fromJson(r.toJson()).toJson(), r.toJson()); });
|
||||
test('workDate falls back to the Manila day of createdAt', () {
|
||||
expect(ProgrammerTaskWorkLog.fromMap({...base, 'created_at': '2026-09-26T16:30:00Z'}).workDate, DateTime.utc(2026, 9, 27));
|
||||
expect(ProgrammerTaskWorkLog.fromMap({...base, 'work_date': '2026-09-25'}).workDate, DateTime.utc(2026, 9, 25));
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify failure.** Run: `$FLUTTER test test/programmer_day_rows_test.dart` — Expected: FAIL.
|
||||
|
||||
- [ ] **Step 3: Implement.** Row rules: an **assignee** row exists for a task when `dailySecondsByAssignee(...)[userId]?[day] > 0` or `userId` authored a `minutes == null` work log on it with `workDate == day`. A **helper** row exists for a task when `userId != task.assigneeId` and `userId` authored work logs with `minutes > 0` and `workDate == day`; `seconds = sum(minutes) * 60`, `helpedName = profileNames[task.assigneeId]`. `notes` = `deltaJsonToPlainText` of that user's work logs on that task that day, oldest first, empty strings dropped. Tasks missing from `tasks` are skipped.
|
||||
|
||||
- [ ] **Step 4: Run to verify pass.** Run: `$FLUTTER test test/programmer_day_rows_test.dart test/programmer_task_ledger_test.dart` — Expected: PASS (ledger test proves the model change is backward compatible).
|
||||
|
||||
- [ ] **Step 5: Commit** `lib/utils/programmer_day_rows.dart lib/widgets/quill_field.dart lib/models/programmer_task_work_log.model.dart test/programmer_day_rows_test.dart` — message `feat(programmer): day rows builder + work-log workDate`.
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Day activity data, run states, clock ticker
|
||||
|
||||
**Files:**
|
||||
- Create: `lib/providers/programmer_day_data_provider.dart`
|
||||
- Test: `test/programmer_day_data_test.dart`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Task 1, Task 2 (`ProgrammerTaskWorkLog.fromMap`), `programmerTasksProvider`, `supabaseClientProvider`.
|
||||
- Produces:
|
||||
```dart
|
||||
enum ProgrammerRunState { running, paused, notStarted, completed, cancelled }
|
||||
typedef ProgrammerRunInfo = ({ProgrammerRunState state, DateTime? since});
|
||||
Map<String, ProgrammerRunInfo> computeRunStates(List<ProgrammerTask> tasks,
|
||||
Map<String, List<ProgrammerTaskActivityLog>> logsByTask); // pure
|
||||
class DayActivityData {
|
||||
final DateTime day; final List<ProgrammerTask> tasks;
|
||||
final Map<String, List<ProgrammerTaskActivityLog>> logsByTask;
|
||||
final List<ProgrammerTaskWorkLog> workLogs;
|
||||
}
|
||||
Future<DayActivityData> fetchDayActivityData(dynamic client,
|
||||
{required DateTime day, required List<ProgrammerTask> tasks, required DateTime now});
|
||||
final clockTickerProvider = StreamProvider<DateTime>(...); // AppTime.now() immediately, then every 30 s
|
||||
final todayKeyProvider = Provider<DateTime>(...); // manilaDay(ticker value ?? AppTime.now())
|
||||
final programmerActivitySignalProvider = StreamProvider<int>(...); // +1 per realtime INSERT on either log table
|
||||
final programmerDayDataProvider = FutureProvider.family<DayActivityData, DateTime>(...); // key = day key
|
||||
final programmerRunStatesProvider = Provider<Map<String, ProgrammerRunInfo>>(...); // from today's day data
|
||||
```
|
||||
|
||||
- [ ] **Step 1: Write the failing tests.** Define a local `_FakeClient` in the test following the controller-test pattern, supporting `select`, `eq`, `inFilter`, `gte`, `lt`, `order`.
|
||||
```dart
|
||||
test('computeRunStates: queued -> notStarted; latest paused -> paused since pause time; resumed -> running; completed', ...);
|
||||
test('fetchDayActivityData includes in-progress tasks and tasks completed that day', ...);
|
||||
test('fetchDayActivityData excludes a task completed two days earlier', ...);
|
||||
test('fetchDayActivityData includes a task referenced only by a helper work log that day', ...);
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify failure.** Run: `$FLUTTER test test/programmer_day_data_test.dart` — Expected: FAIL.
|
||||
|
||||
- [ ] **Step 3: Implement.** Candidate tasks for a day = `status == in_progress`, or `startedAt != null && startedAt < dayEnd && (completedAt ?? cancelledAt ?? now) >= dayStart`. Work logs: `programmer_task_work_logs` with `created_at >= dayStart.toUtc()` and `< dayEnd.toUtc()` (Task 15 switches this to `work_date`); their `task_id`s join the candidates. Activity logs: one `select … inFilter('task_id', ids) order created_at desc`, grouped by task. Run state: `in_progress` whose latest of `started/paused/resumed` is `paused` → `paused` (`since` = that event); otherwise `running` (`since` = latest started/resumed, else `startedAt`). The signal provider subscribes one channel `programmer_activity_signal` with two `onPostgresChanges(event: PostgresChangeEvent.insert, …)` handlers and removes it on dispose. `programmerDayDataProvider` watches `programmerTasksProvider`, `programmerActivitySignalProvider`, `supabaseClientProvider`.
|
||||
|
||||
- [ ] **Step 4: Run to verify pass.** Run: `$FLUTTER test test/programmer_day_data_test.dart` — Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Commit** — `feat(programmer): day activity data + run-state provider`.
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Day-sheet model, stats, streak, badges (pure)
|
||||
|
||||
**Files:**
|
||||
- Create: `lib/models/programmer_day_sheet.model.dart`, `lib/utils/programmer_stats.dart`
|
||||
- Test: `test/programmer_stats_test.dart`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Task 1 (`parseDayKey`, `manilaDay`), Task 2 (`DaySheetRow`).
|
||||
- Produces:
|
||||
```dart
|
||||
class DaySheetStatus { static const draft = 'draft', pending = 'pending',
|
||||
disapproved = 'disapproved', approved = 'approved';
|
||||
static String label(String s); } // 'Draft' | 'Pending' | 'Disapproved' | 'Approved'
|
||||
class DaySheetSnapshot { final int totalSeconds; final List<DaySheetRow> rows;
|
||||
Map<String, dynamic> toJson(); // {'v':1,'total_seconds':..,'rows':[..]}
|
||||
factory DaySheetSnapshot.fromJson(Map<String, dynamic> j);
|
||||
factory DaySheetSnapshot.fromRows(List<DaySheetRow> rows); }
|
||||
class ProgrammerDaySheet { final String id, programmerId, status; final DateTime workDate; // day key
|
||||
final DateTime? submittedAt, reviewedAt; final bool autoSubmitted; final String? reviewedBy;
|
||||
final int resubmissions; final DaySheetSnapshot? approvedSnapshot;
|
||||
final DateTime createdAt, updatedAt;
|
||||
factory ProgrammerDaySheet.fromMap(Map<String, dynamic> m);
|
||||
bool get isFirstPassApproved; } // status == approved && resubmissions == 0
|
||||
class ProgrammerStats { final Duration todayFocus, helpGivenThisMonth, approvedTotal, helpGivenTotal;
|
||||
final int completedThisWeek, completedLastWeek, currentStreak, bestStreak, completedTotal, bugFixCompleted;
|
||||
final double? firstPassRate; final bool hasCleanWeek; }
|
||||
ProgrammerStats computeProgrammerStats({required String userId, required DateTime now,
|
||||
required List<ProgrammerTask> tasks, required List<ProgrammerDaySheet> sheets,
|
||||
required Set<DateTime> leaveDays, required Duration todayFocus});
|
||||
({int current, int best}) computeStreak({required List<ProgrammerDaySheet> sheets,
|
||||
required Set<DateTime> leaveDays, required DateTime today});
|
||||
class BadgeProgress { final String id, title, description; final IconData icon;
|
||||
final int current, target; final bool earned; }
|
||||
List<BadgeProgress> computeBadges(ProgrammerStats stats, {required bool includeSheetBadges});
|
||||
```
|
||||
|
||||
- [ ] **Step 1: Write the failing tests.** (2026-09-27 is a Sunday; Mon 21 … Fri 25, Mon 28.)
|
||||
```dart
|
||||
test('fromMap parses work_date and snapshot', ...);
|
||||
test('streak counts weekdays back from today, skipping weekends', () {
|
||||
// today Mon 28 (no sheet); Wed 23, Thu 24, Fri 25 pending/approved
|
||||
expect(computeStreak(sheets: s, leaveDays: {}, today: DateTime.utc(2026, 9, 28)).current, 3);
|
||||
});
|
||||
test('a disapproved day breaks the streak', () { /* Fri 25 disapproved -> current 0 */ });
|
||||
test('approved leave days are neutral', () { /* Thu 24 in leaveDays, no sheet; Wed 23 + Fri 25 approved -> 2 */ });
|
||||
test('best streak finds the longest earlier run', () { /* earlier run of 4 -> best 4 */ });
|
||||
test('first-pass rate over 30 days', () {
|
||||
// approved(r=0), approved(r=0), approved(r=1), disapproved -> 0.5 ; none reviewed -> null
|
||||
});
|
||||
test('completedThisWeek uses a Monday-start week', ...);
|
||||
test('tiered badge shows highest earned tier and next target', () {
|
||||
final b = computeBadges(statsWith(completedTotal: 12), includeSheetBadges: false)
|
||||
.firstWhere((b) => b.id == 'finisher');
|
||||
expect(b.earned, isTrue); expect(b.title, 'Finisher · 10'); expect(b.target, 50);
|
||||
});
|
||||
test('includeSheetBadges false hides sheet-based badges', () {
|
||||
expect(ids, isNot(containsAll(['deep_work', 'on_a_roll', 'team_player', 'clean_week'])));
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify failure.** Run: `$FLUTTER test test/programmer_stats_test.dart` — Expected: FAIL.
|
||||
|
||||
- [ ] **Step 3: Implement.** Pinned rules:
|
||||
- **Streak:** a day qualifies if its sheet is `pending` or `approved`. Walk back from `today`: a non-qualifying `today` is skipped (not a break); Sat/Sun and `leaveDays` are skipped; a qualifying day counts; any other day stops. **Best** = longest run scanning from the earliest sheet day to today with the same neutral rules.
|
||||
- **Weeks** start Monday. `completedThisWeek` = tasks with `assigneeId == userId`, `status == completed`, `completedAt` in this week.
|
||||
- **firstPassRate:** sheets with `workDate` in the last 30 days (today−29 … today); reviewed = approved ∨ disapproved ∨ `resubmissions > 0`; null when none reviewed.
|
||||
- **Approved sums** from `approvedSnapshot`: `approvedTotal` = Σ totalSeconds; `helpGivenTotal` / `helpGivenThisMonth` = Σ seconds of `kind == 'helper'` rows (month of `workDate`).
|
||||
- **hasCleanWeek:** some Mon–Fri where every weekday is in `leaveDays` or `isFirstPassApproved`, with ≥ 1 approved.
|
||||
- **Badges** (tiers ascending; `target` = lowest unreached tier or top tier when all reached; `earned` = current ≥ first tier; tiered earned title = `'<Title> · <highest reached tier>'`):
|
||||
|
||||
| id | title | tiers | current | sheet-based | icon | description |
|
||||
|---|---|---|---|---|---|---|
|
||||
| first_finish | First Finish | 1 | completedTotal | no | `Icons.flag_outlined` | Complete your first task |
|
||||
| finisher | Finisher | 10/50/100 | completedTotal | no | `Icons.task_alt` | Complete 10 / 50 / 100 tasks |
|
||||
| bug_squasher | Bug Squasher | 10 | bugFixCompleted | no | `Icons.bug_report_outlined` | Complete 10 Bug Fix tasks |
|
||||
| deep_work | Deep Work | 100/500 | approvedTotal.inHours | yes | `Icons.timer_outlined` | Log 100 / 500 approved hours |
|
||||
| on_a_roll | On a Roll | 5/10/20 | bestStreak | yes | `Icons.local_fire_department_outlined` | Reach a 5 / 10 / 20-day streak |
|
||||
| team_player | Team Player | 10 | helpGivenTotal.inHours | yes | `Icons.handshake_outlined` | Give 10 hours of help to teammates |
|
||||
| clean_week | Clean Week | 1 | hasCleanWeek ? 1 : 0 | yes | `Icons.verified_outlined` | A Mon–Fri week with every day approved first time |
|
||||
|
||||
- [ ] **Step 4: Run to verify pass.** Run: `$FLUTTER test test/programmer_stats_test.dart` — Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Commit** — `feat(programmer): day-sheet model + stats, streak and badges`.
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Shared task card with run-state pill; shared complete action
|
||||
|
||||
**Files:**
|
||||
- Create: `lib/screens/programmer_tasks/widgets/programmer_task_card.dart`
|
||||
- Modify: `lib/screens/programmer_tasks/programmer_tasks_list_screen.dart` (delete `_TaskCard`, use the new card), `lib/screens/programmer_tasks/programmer_task_actions.dart` (receive moved function), `lib/screens/programmer_tasks/programmer_task_detail_screen.dart` (`_complete` → call moved function)
|
||||
- Test: `test/programmer_task_card_test.dart`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Task 3 (`programmerRunStatesProvider`, `ProgrammerRunState`).
|
||||
- Produces:
|
||||
```dart
|
||||
class ProgrammerTaskCard extends ConsumerWidget {
|
||||
const ProgrammerTaskCard({super.key, required this.task, this.assignee, required this.isMine, this.readOnly = false});
|
||||
}
|
||||
Future<void> completeProgrammerTaskWithConfirm(BuildContext context, WidgetRef ref, ProgrammerTask task); // body moved verbatim from detail `_complete`
|
||||
```
|
||||
|
||||
- [ ] **Step 1: Write the failing test.** Override `programmerRunStatesProvider` with `{'t1': (state: ProgrammerRunState.paused, since: …)}`.
|
||||
```dart
|
||||
testWidgets('paused pill shows for any assignee; Resume only for mine and not read-only', (tester) async {
|
||||
// isMine: false -> find.text('Paused') findsOneWidget, find.text('Resume') findsNothing
|
||||
// isMine: true -> find.text('Resume') findsOneWidget
|
||||
// isMine: true, readOnly: true -> find.text('Resume') findsNothing
|
||||
});
|
||||
```
|
||||
- [ ] **Step 2: Run to verify failure.** `$FLUTTER test test/programmer_task_card_test.dart` — FAIL.
|
||||
- [ ] **Step 3: Implement** — same layout as the old `_TaskCard`; pill = `StatusPill.kind(StatusKind.warning, label: 'Paused')` when run state is paused, else `programmerTaskStatusKind(task.status)` + `ProgrammerTaskStatus.label`. Start/Resume shown only when `isMine && !readOnly`.
|
||||
- [ ] **Step 4: Verify.** `$FLUTTER test test/programmer_task_card_test.dart test/programmer_tasks_controller_test.dart` — PASS; `$FLUTTER analyze lib` — no new issues.
|
||||
- [ ] **Step 5: Commit** — `refactor(programmer): shared task card driven by run states`.
|
||||
|
||||
---
|
||||
|
||||
### Task 6: ProgrammerDashboard — hero, sections, Mine tab
|
||||
|
||||
**Files:**
|
||||
- Create: `lib/screens/programmer_tasks/dashboard/programmer_dashboard.dart`, `dashboard/now_working_card.dart`, `dashboard/task_sections.dart`
|
||||
- Modify: `lib/screens/programmer_tasks/programmer_tasks_list_screen.dart` (Mine tab body → `ProgrammerDashboard(userId: me)`)
|
||||
- Test: `test/programmer_dashboard_sections_test.dart`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Tasks 1, 3, 5 (`dailySecondsByAssignee`, `programmerDayDataProvider`, `programmerRunStatesProvider`, `todayKeyProvider`, `clockTickerProvider`, `ProgrammerTaskCard`, `completeProgrammerTaskWithConfirm`), `startProgrammerTaskWithPausePrompt`, `programmerTasksControllerProvider.pauseTask`.
|
||||
- Produces:
|
||||
```dart
|
||||
class ProgrammerDashboard extends ConsumerWidget { const ProgrammerDashboard({super.key, required this.userId, this.readOnly = false}); }
|
||||
typedef TaskSectionGroups = ({List<ProgrammerTask> running, List<ProgrammerTask> paused,
|
||||
List<ProgrammerTask> notStarted, List<ProgrammerTask> completed, List<ProgrammerTask> cancelled});
|
||||
TaskSectionGroups groupTasksForDashboard(List<ProgrammerTask> tasks,
|
||||
Map<String, ProgrammerRunInfo> runStates, String userId); // only tasks assigned to userId
|
||||
class TaskSections extends StatelessWidget { const TaskSections({super.key, required this.groups, required this.profileById, required this.readOnly}); }
|
||||
class NowWorkingCard extends ConsumerWidget { const NowWorkingCard({super.key, required this.running, required this.nextUp, required this.todayOnTask, required this.totalOnTask, required this.readOnly}); }
|
||||
```
|
||||
|
||||
- [ ] **Step 1: Write the failing tests.**
|
||||
```dart
|
||||
test('groupTasksForDashboard splits by run state and excludes other assignees', ...);
|
||||
test('completed sorted by completedAt desc, cancelled by cancelledAt desc', ...);
|
||||
testWidgets('Completed and Cancelled start collapsed', (t) async {
|
||||
// 12 completed: header 'Completed' visible, first completed title not visible
|
||||
// tap header -> 10 titles visible + text 'Show all (12)'
|
||||
});
|
||||
testWidgets('empty sections are hidden', ...); // no 'Cancelled' header when none
|
||||
```
|
||||
- [ ] **Step 2: Run to verify failure.** `$FLUTTER test test/programmer_dashboard_sections_test.dart` — FAIL.
|
||||
- [ ] **Step 3: Implement.** Section headers `AppSectionHeader('<Name>', trailing: Text('<count>'))` with names `In progress`, `Paused`, `Not started`, `Completed`, `Cancelled`. When all groups are empty show the existing empty view (`No tasks assigned to you`). Hero: running task → title, `TechChip` category, `Today <h m> · Total <h m>` (`formatDurationHm`; today = `dailySecondsByAssignee(...)[userId][today]`, total = sum over all days for `userId`), buttons **Pause** (`pauseTask(taskId:)`) and **Complete** (`completeProgrammerTaskWithConfirm`). Nothing running → text `Nothing running` and, if `nextUp != null`, **Start** via `startProgrammerTaskWithPausePrompt`. `readOnly` hides all buttons. Live values re-render on `clockTickerProvider`.
|
||||
- [ ] **Step 4: Verify.** `$FLUTTER test test/programmer_dashboard_sections_test.dart` — PASS; `$FLUTTER analyze lib` — clean.
|
||||
- [ ] **Step 5: Commit** — `feat(programmer): Mine dashboard hero + task sections`.
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Staff presence helpers (leave / pass slip)
|
||||
|
||||
**Files:**
|
||||
- Create: `lib/utils/staff_presence.dart`
|
||||
- Modify: `lib/screens/dashboard/dashboard_screen.dart:371-386` (replace the two inline indexing loops with calls)
|
||||
- Test: `test/staff_presence_test.dart`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces:
|
||||
```dart
|
||||
Map<String, LeaveOfAbsence> activeLeaveByUser(List<LeaveOfAbsence> leaves, DateTime now); // status approved && start <= now < end
|
||||
Map<String, PassSlip> activePassSlipByUser(List<PassSlip> slips); // slip.isActive
|
||||
Set<DateTime> approvedLeaveDays(List<LeaveOfAbsence> leaves, String userId); // Manila day keys covered
|
||||
```
|
||||
- [ ] **Step 1: Failing tests** — approved leave spanning `now` included, pending excluded; active pass slip included; `approvedLeaveDays` for a leave Sep 24 08:00 → Sep 25 17:00 == `{utc(2026,9,24), utc(2026,9,25)}`.
|
||||
- [ ] **Step 2:** `$FLUTTER test test/staff_presence_test.dart` — FAIL.
|
||||
- [ ] **Step 3: Implement** and swap the dashboard loops (behavior unchanged).
|
||||
- [ ] **Step 4:** `$FLUTTER test test/staff_presence_test.dart test/dashboard_metrics_provider_test.dart` — PASS (ignore the known time-of-day cases); `$FLUTTER analyze lib` — clean.
|
||||
- [ ] **Step 5: Commit** — `refactor: extract staff presence helpers`.
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Stat tiles and badges strip
|
||||
|
||||
**Files:**
|
||||
- Create: `lib/screens/programmer_tasks/dashboard/stat_tiles.dart`, `dashboard/badges_strip.dart`
|
||||
- Modify: `dashboard/programmer_dashboard.dart` (insert tiles + badges between hero and sections)
|
||||
- Test: `test/programmer_stat_tiles_test.dart`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Task 4 (`computeProgrammerStats`, `computeBadges`), Task 7 (`approvedLeaveDays`), `leavesProvider`, `profilesProvider`.
|
||||
- Produces:
|
||||
```dart
|
||||
class StatTiles extends StatelessWidget { const StatTiles({super.key, required this.stats, required this.showSheetStats}); }
|
||||
class BadgesStrip extends StatelessWidget { const BadgesStrip({super.key, required this.badges}); }
|
||||
```
|
||||
- [ ] **Step 1: Failing tests.**
|
||||
```dart
|
||||
testWidgets('sheet stats hidden when showSheetStats is false', ...); // find.text('STREAK') findsNothing
|
||||
testWidgets('streak tile shows days and best', ...); // '6 days', 'Best 9'
|
||||
testWidgets('locked badge shows progress', ...); // '7/10'
|
||||
```
|
||||
- [ ] **Step 2:** `$FLUTTER test test/programmer_stat_tiles_test.dart` — FAIL.
|
||||
- [ ] **Step 3: Implement.** Tiles (`AppMetricCard`, `Wrap`), exact copy:
|
||||
- `TODAY'S FOCUS` — value `formatDurationHm(todayFocus)`, trailing: a 40 px `CircularProgressIndicator(value: min(1, focus / 8h))` with `/ 8h` label (pattern `reports/widgets/conversion_rate_card.dart:43-55`).
|
||||
- `COMPLETED THIS WEEK` — value count; trailing `↑N vs last week` / `↓N vs last week` / `Same as last week`.
|
||||
- `STREAK` — icon `Icons.local_fire_department_outlined`, value `N days` (`1 day`), trailing `Best M`.
|
||||
- `FIRST-PASS APPROVAL` — value `NN%` or `—`, trailing `Last 30 days`.
|
||||
- `HELP GIVEN` — value `formatDurationHm`, trailing `This month`.
|
||||
- `showSheetStats` = the dashboard user's role is `programmer`; when false only the first two tiles render and `computeBadges(includeSheetBadges: false)`.
|
||||
- Badges: horizontal list of chips; earned = `AppStatusColors` success container; locked = neutral + `current/target`; tap → `AlertDialog` with title + description.
|
||||
- Dashboard feeds `sheets: const <ProgrammerDaySheet>[]` until Task 20 wires `daySheetsForUserProvider`; `todayFocus` = `totalSeconds(buildDayRows(userId, today, …))`.
|
||||
- [ ] **Step 4:** `$FLUTTER test test/programmer_stat_tiles_test.dart` — PASS; `$FLUTTER analyze lib` — clean.
|
||||
- [ ] **Step 5: Commit** — `feat(programmer): dashboard stat tiles + badges`.
|
||||
|
||||
**Slice 1 checkpoint:** `$FLUTTER test` (only known failures) and `$FLUTTER build web` succeed.
|
||||
|
||||
---
|
||||
|
||||
# Part B — Slice 2: Admin Monitor
|
||||
|
||||
### Task 9: Monitor view-model (pure)
|
||||
|
||||
**Files:**
|
||||
- Create: `lib/screens/programmer_tasks/monitor/monitor_model.dart`
|
||||
- Test: `test/programmer_monitor_model_test.dart`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Tasks 2, 3, 7.
|
||||
- Produces:
|
||||
```dart
|
||||
enum MonitorState { working, paused, idle, onLeave, passSlip }
|
||||
class HelpEdge { final String helperId, assigneeId, taskId, taskTitle; final int minutes; final DateTime at; }
|
||||
List<HelpEdge> buildHelpEdges({required List<ProgrammerTaskWorkLog> workLogs,
|
||||
required Map<String, ProgrammerTask> taskById, required DateTime day}); // author != assignee, minutes > 0, workDate == day; newest first
|
||||
class ProgrammerMonitorEntry { final Profile programmer; final MonitorState state;
|
||||
final ProgrammerTask? currentTask; final Duration currentTaskToday, todayTotal;
|
||||
final int inProgress, paused, notStarted; final String? sheetStatus;
|
||||
final List<HelpEdge> helped, helpedBy; }
|
||||
List<ProgrammerMonitorEntry> buildMonitorEntries({required List<Profile> programmers,
|
||||
required DayActivityData today, required Map<String, ProgrammerRunInfo> runStates,
|
||||
required Map<String, LeaveOfAbsence> leaves, required Map<String, PassSlip> passSlips,
|
||||
required Map<String, String> sheetStatusByUser, required Map<String, String> projectNames,
|
||||
required Map<String, String> profileNames, required DateTime now});
|
||||
```
|
||||
- [ ] **Step 1: Failing tests** — leave overrides a running task (`onLeave`); pass slip → `passSlip`; running → `working` with that `currentTask`; only paused → `paused` with the most recent `since`; none → `idle`; sort order working, paused, idle, passSlip, onLeave, then name; `helped`/`helpedBy` edges; `todayTotal` equals `totalSeconds(buildDayRows(...))`.
|
||||
- [ ] **Step 2:** `$FLUTTER test test/programmer_monitor_model_test.dart` — FAIL.
|
||||
- [ ] **Step 3: Implement** (counts: `inProgress` = running tasks, `paused`, `notStarted` = queued, all for tasks assigned to that programmer).
|
||||
- [ ] **Step 4:** same command — PASS.
|
||||
- [ ] **Step 5: Commit** — `feat(programmer): monitor view-model`.
|
||||
|
||||
---
|
||||
|
||||
### Task 10: Strict admin, role tabs, Monitor tab, drill-down
|
||||
|
||||
**Files:**
|
||||
- Modify: `lib/providers/profile_provider.dart` (add provider), `lib/screens/programmer_tasks/programmer_tasks_list_screen.dart` (role tabs + `initialTab`), `lib/routing/app_router.dart` (query `tab`, route `programmers/:userId` placed before `:id`)
|
||||
- Create: `lib/screens/programmer_tasks/monitor/monitor_tab.dart`, `monitor/programmer_monitor_card.dart`, `monitor/help_panel.dart`, `lib/screens/programmer_tasks/programmer_drill_down_screen.dart`, `lib/screens/programmer_tasks/programmer_tasks_tabs.dart`
|
||||
- Test: `test/programmer_tabs_test.dart`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Tasks 6, 7, 9.
|
||||
- Produces:
|
||||
```dart
|
||||
final isStrictAdminProvider = Provider<bool>(...); // currentProfileProvider role == 'admin'
|
||||
enum ProgrammerTasksTab { monitor, approvals, mine, all, daySheets }
|
||||
List<ProgrammerTasksTab> programmerTasksTabsFor({required bool isStrictAdmin});
|
||||
ProgrammerTasksTab? parseProgrammerTasksTab(String? q); // 'monitor'|'approvals'|'mine'|'all'|'day-sheets'
|
||||
String programmerTasksTabLabel(ProgrammerTasksTab t); // 'Monitor'|'Approvals'|'Mine'|'All'|'Day Sheets'
|
||||
class ProgrammerTasksListScreen { const ProgrammerTasksListScreen({super.key, this.initialTab}); final String? initialTab; }
|
||||
class ProgrammerDrillDownScreen extends ConsumerWidget { const ProgrammerDrillDownScreen({super.key, required this.userId}); } // route /programmer-tasks/programmers/:userId
|
||||
class ProgrammerMonitorCard extends StatelessWidget { const ProgrammerMonitorCard({super.key, required this.entry, required this.onTap}); }
|
||||
```
|
||||
- [ ] **Step 1: Failing tests.**
|
||||
```dart
|
||||
test('tabs in this slice', () {
|
||||
expect(programmerTasksTabsFor(isStrictAdmin: true), [ProgrammerTasksTab.monitor, ProgrammerTasksTab.mine, ProgrammerTasksTab.all]);
|
||||
expect(programmerTasksTabsFor(isStrictAdmin: false), [ProgrammerTasksTab.mine, ProgrammerTasksTab.all]);
|
||||
});
|
||||
test('parse tab query', () { expect(parseProgrammerTasksTab('day-sheets'), ProgrammerTasksTab.daySheets); expect(parseProgrammerTasksTab('x'), isNull); });
|
||||
testWidgets('monitor card pill labels', ...); // onLeave -> 'On leave', passSlip -> 'Pass slip', working -> 'Working'
|
||||
```
|
||||
- [ ] **Step 2:** `$FLUTTER test test/programmer_tabs_test.dart` — FAIL.
|
||||
- [ ] **Step 3: Implement.**
|
||||
- Pills: working → success `Working`; paused → warning `Paused`; idle → neutral `Idle`; onLeave → info `On leave`; passSlip → info `Pass slip`.
|
||||
- Monitor tab: `AppStatusSummaryRow` with `Working`, `Paused`, `Idle`, `Away` (leave + pass slip) counts; grid columns by width (< 700 → 1, < 1100 → 2, else 3). Card body per spec §7 (current task + `running <h m> today`, `Today <h m>`, counts, help chips `Helped → <name> · <task> · <m>m` and `Helped by ← <name> · <m>m`). Tap → `context.go('/programmer-tasks/programmers/<id>')`.
|
||||
- Help panel: `AppSectionHeader("Who's helping whom")`, day stepper (← date →, cannot pass today), rows `<helper> → <assignee> · <task> · <m>m · <time>`, from `programmerDayDataProvider(day)`; empty → `No help logged this day.`
|
||||
- Drill-down: AppBar title = programmer name; body `ProgrammerDashboard(userId, readOnly: true)`. Non-admins see `AppEmptyView(icon: Icons.lock_outline, title: 'Admins only')`.
|
||||
- The list screen builds its `TabController` from `programmerTasksTabsFor`, selects `parseProgrammerTasksTab(initialTab)` if present in the list; router passes `state.uri.queryParameters['tab']`.
|
||||
- [ ] **Step 4:** `$FLUTTER test test/programmer_tabs_test.dart` — PASS; `$FLUTTER analyze lib` — clean.
|
||||
- [ ] **Step 5: Commit** — `feat(programmer): admin monitor tab + drill-down`.
|
||||
|
||||
**Slice 2 checkpoint:** full `$FLUTTER test` + `$FLUTTER build web`; QA harness as admin shows the Monitor with Working/Paused/Idle.
|
||||
|
||||
---
|
||||
|
||||
# Part C — Slice 3: Day Sheets
|
||||
|
||||
### Task 11: Migration — schema, triggers, RLS
|
||||
|
||||
**Files:**
|
||||
- Create: `supabase/migrations/20260927150000_add_programmer_day_sheets.sql` (the spec's single migration is split into this file + Task 12's file for reviewability)
|
||||
|
||||
**Interfaces:**
|
||||
- Produces (DB): `manila_today() returns date`; tables `programmer_day_sheets`, `programmer_day_sheet_events` exactly as spec §5.2 (checks included; events body non-empty for `disapproved`/`justified`); `programmer_task_work_logs.work_date`; `notifications.day_sheet_id`; `ensure_day_sheet(p_programmer uuid, p_date date) returns uuid`; error code `work_date_locked`.
|
||||
|
||||
- [ ] **Step 1: Write the SQL.** Every function `set search_path = public`. Pinned behaviors:
|
||||
1. `manila_today()` = `(now() at time zone 'Asia/Manila')::date`, `stable`.
|
||||
2. Tables + `unique(programmer_id, work_date)`, index `(status, work_date)`; `updated_at` trigger reusing `set_programmer_tasks_updated_at()`.
|
||||
3. `work_date`: add nullable → backfill `(created_at at time zone 'Asia/Manila')::date` → `set default manila_today()` → `set not null`; index `(author_id, work_date)`.
|
||||
4. `ensure_day_sheet` (SECURITY DEFINER; `revoke execute … from public, anon, authenticated`): returns null unless the profile's role is `programmer`; inserts `draft` on conflict do nothing; if the row is `approved` and `p_date = manila_today()` → set `pending` and insert event `kind='amended'`, `actor_id null`, `body 'New work was logged after approval.'`.
|
||||
5. BEFORE INSERT on work logs (SECURITY DEFINER): `work_date := coalesce(work_date, manila_today())`; future date → `raise exception 'work_date_locked'`; past date allowed only when the author's sheet for that date has status `pending` or `disapproved`, else `raise exception 'work_date_locked'`.
|
||||
6. AFTER INSERT on work logs → `ensure_day_sheet(new.author_id, new.work_date)`.
|
||||
7. AFTER INSERT on `programmer_task_activity_logs` when `action_type in ('started','paused','resumed','completed','cancelled','adjustment')` → `ensure_day_sheet(<task's assignee_id>, (new.created_at at time zone 'Asia/Manila')::date)` if assignee not null.
|
||||
8. RLS on both tables. Sheets SELECT: `programmer_id = auth.uid() or exists (select 1 from profiles p where p.id = auth.uid() and p.role = 'admin')`. Events SELECT: `exists (select 1 from programmer_day_sheets s where s.id = sheet_id)`. No INSERT/UPDATE/DELETE policies.
|
||||
9. `alter table notifications add column if not exists day_sheet_id uuid references programmer_day_sheets(id) on delete cascade` + index.
|
||||
10. `alter publication supabase_realtime add table programmer_day_sheets, programmer_day_sheet_events;`
|
||||
- [ ] **Step 2: Self-check.** Re-read against spec §5.2–5.4; confirm every trigger function that writes sheets is SECURITY DEFINER.
|
||||
- [ ] **Step 3: Commit** — `feat(db): programmer day sheets schema, triggers, RLS`. (Applied together with Task 12.)
|
||||
|
||||
---
|
||||
|
||||
### Task 12: Migration — RPCs, auto-submit cron, digest queue; apply + SQL checklist
|
||||
|
||||
**Files:**
|
||||
- Create: `supabase/migrations/20260927150100_programmer_day_sheet_rpcs.sql`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Task 11.
|
||||
- Produces (all SECURITY DEFINER, `set search_path = public`, `grant execute … to authenticated` except `auto_submit_day_sheets`):
|
||||
```sql
|
||||
submit_day_sheet(p_date date) returns jsonb
|
||||
disapprove_day_sheet(p_sheet uuid, p_remarks text, p_flagged_task_ids uuid[] default '{}') returns jsonb
|
||||
justify_day_sheet(p_sheet uuid, p_body text) returns jsonb
|
||||
approve_day_sheets(p_items jsonb) returns jsonb -- [{sheet_id, snapshot}]
|
||||
auto_submit_day_sheets() returns void -- revoked from public, anon, authenticated
|
||||
```
|
||||
Single-sheet RPCs return `{sheet_id, work_date, programmer_id, programmer_name, notify_user_ids}`; approve returns `{approved: [{sheet_id, programmer_id, work_date}], skipped_ids: [...]}`.
|
||||
Error codes (`raise exception '<code>'`): `not_authorized`, `already_reviewed`, `already_submitted`, `remarks_required`, `body_required`, `invalid_snapshot`, `future_date`.
|
||||
|
||||
- [ ] **Step 1: Write the SQL.** Pinned behaviors (lock the sheet row `for update` before status checks):
|
||||
- **submit:** caller role `programmer`; `p_date > manila_today()` → `future_date`; upsert draft row for (caller, p_date); status ≠ `draft` → `already_submitted`; set `pending`, `submitted_at = now()`, `auto_submitted = false`; event `submitted`; notify **every** `role = 'admin'` profile with `type 'day_sheet_submitted'`, `actor_id = caller`, `day_sheet_id`.
|
||||
- **disapprove:** caller admin else `not_authorized`; `btrim(p_remarks) = ''` → `remarks_required`; status ≠ `pending` → `already_reviewed`; set `disapproved`, `reviewed_by`, `reviewed_at`; event `disapproved` with body + flags; notify the programmer `day_sheet_disapproved`.
|
||||
- **justify:** caller = owner else `not_authorized`; blank body → `body_required`; status ≠ `disapproved` → `already_reviewed`; set `pending`, `resubmissions + 1`, `submitted_at = now()`; event `justified`; notify `reviewed_by` (if not null) `day_sheet_justified`.
|
||||
- **approve:** caller admin; each item's `snapshot` must be an object with a `rows` array else `invalid_snapshot`; status in (`pending`,`disapproved`) → set `approved`, `reviewed_by`, `reviewed_at`, `approved_snapshot`, event `approved`, notify programmer `day_sheet_approved`, append to `approved`; otherwise append to `skipped_ids` (no error).
|
||||
- **auto_submit:** (1) for each `in_progress` task whose latest of started/paused/resumed is not `paused`, with a `programmer` assignee: `ensure_day_sheet(assignee, manila_today() - 1)`; if that sheet is `approved` → `pending` + event `amended` body `'Timer was still running at the end of the day.'`. (2) every `draft` with `work_date < manila_today()` → `pending`, `submitted_at = now()`, `auto_submitted = true`, event `auto_submitted`. (3) if any sheet is `pending`: for each admin insert `notifications(user_id, actor_id null, type 'day_sheet_digest')` and `scheduled_notifications(user_id, notify_type 'day_sheet_digest', scheduled_for ((manila_today() + time '08:00') at time zone 'Asia/Manila'), epoch to_char(manila_today(), 'YYYYMMDD')::int) on conflict do nothing`.
|
||||
- Replace `chk_at_least_one_ref` on `scheduled_notifications`, keeping its current five references and adding `or notify_type = 'day_sheet_digest'`.
|
||||
- Cron in a `DO $$ … EXCEPTION WHEN others THEN RAISE NOTICE … $$` block: unschedule `programmer_day_sheet_autosubmit` (ignore errors), then `cron.schedule('programmer_day_sheet_autosubmit', '5 16 * * *', 'SELECT public.auto_submit_day_sheets();')` (16:05 UTC = 00:05 Manila).
|
||||
- [ ] **Step 2: Commit** — `feat(db): day sheet RPCs, auto-submit cron, digest queue`.
|
||||
- [ ] **Step 3: Ask the user to apply** `20260927150000_…sql` then `20260927150100_…sql` (dashboard SQL editor or `supabase db push`). Wait for confirmation.
|
||||
- [ ] **Step 4: Run the SQL checklist** with `mcp__supabase__execute_sql`, each item in `begin; … rollback;`, impersonating via `set local role authenticated; select set_config('request.jwt.claims', json_build_object('sub','<uuid>','role','authenticated')::text, true);`. Programmer = `pao@mail.com`'s profile id; admin = any `role = 'admin'` id. Setup rows for past dates are inserted as `postgres` before switching role.
|
||||
|
||||
| # | Action | Expected |
|
||||
|---|---|---|
|
||||
| 1 | programmer `submit_day_sheet(manila_today())` | returns jsonb; sheet `pending` |
|
||||
| 2 | repeat 1 | error `already_submitted` |
|
||||
| 3 | programmer `disapprove_day_sheet(id,'x')` | error `not_authorized` |
|
||||
| 4 | admin disapprove with `' '`, then `'Missing notes'` | `remarks_required`; then `disapproved` + a `day_sheet_disapproved` notification for the programmer |
|
||||
| 5 | admin disapprove again | `already_reviewed` |
|
||||
| 6 | programmer inserts a work log with `work_date = manila_today()-1` where no sheet exists; then on a `disapproved` yesterday sheet | `work_date_locked`; then succeeds |
|
||||
| 7 | programmer justify `''`, then `'Added notes'` | `body_required`; then `pending`, `resubmissions = 1` |
|
||||
| 8 | admin `approve_day_sheets` with a valid snapshot, then again | id in `approved`; then id in `skipped_ids` |
|
||||
| 9 | today's sheet approved; programmer inserts a work log today | sheet `pending` + `amended` event |
|
||||
| 10 | postgres: draft sheet dated yesterday; `select auto_submit_day_sheets()` twice | `pending`, `auto_submitted = true`; one `day_sheet_digest` scheduled row per admin (no duplicate) |
|
||||
| 11 | a second programmer selects `programmer_day_sheets` | only own rows |
|
||||
| 12 | `mcp__supabase__get_advisors(type: 'security')` | no new ERROR-level lint on the new tables/functions |
|
||||
|
||||
---
|
||||
|
||||
### Task 13: Edge function — digest push
|
||||
|
||||
**Files:**
|
||||
- Modify: `supabase/functions/process_scheduled_notifications/index.ts` (new `case 'day_sheet_digest'` in the `notify_type` switch)
|
||||
|
||||
- [ ] **Step 1: Implement.** Count `programmer_day_sheets` with `status = 'pending'` (`select('id', { count: 'exact', head: true })`). If 0 → mark the row processed and skip sending. Else `title = 'Day sheets awaiting review'`, `body = \`${n} day sheet${n === 1 ? ' is' : 's are'} awaiting your review.\``, `data.navigate_to = '/programmer-tasks?tab=approvals'`.
|
||||
- [ ] **Step 2: Commit** — `feat(edge): day sheet digest push`.
|
||||
- [ ] **Step 3: Ask the user to redeploy** `process_scheduled_notifications`. Verify: insert (as postgres, not rolled back, then delete) a `scheduled_notifications` row for an admin with `notify_type 'day_sheet_digest'`, `scheduled_for now()`, `epoch 1`; within ~1 minute `processed = true` and `last_error is null`.
|
||||
|
||||
---
|
||||
|
||||
### Task 14: Day-sheet event model, providers, controller
|
||||
|
||||
**Files:**
|
||||
- Create: `lib/models/programmer_day_sheet_event.model.dart`, `lib/providers/programmer_day_sheets_provider.dart`
|
||||
- Test: `test/programmer_day_sheets_controller_test.dart`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Task 4 (`ProgrammerDaySheet`, `DaySheetSnapshot`), Task 1 (`formatDayKey`, `parseDayKey`), `NotificationsController.sendPush`.
|
||||
- Produces:
|
||||
```dart
|
||||
class ProgrammerDaySheetEvent { final String id, sheetId, kind; final String? actorId, body;
|
||||
final List<String> flaggedTaskIds; final DateTime createdAt; factory ProgrammerDaySheetEvent.fromMap(Map<String, dynamic> m); }
|
||||
enum DaySheetError { alreadyReviewed, alreadySubmitted, notAuthorized, remarksRequired, bodyRequired, workDateLocked, invalidSnapshot, unknown }
|
||||
class DaySheetActionException implements Exception { final DaySheetError code; String get message; }
|
||||
DaySheetError mapDaySheetError(Object error); // substring match on the codes from Tasks 11–12
|
||||
typedef PushFn = Future<void> Function({required List<String> userIds, required String title, required String body, Map<String, dynamic>? data});
|
||||
class BulkApproveResult { final List<String> approvedIds, skippedIds; }
|
||||
class DaySheetsController {
|
||||
DaySheetsController(dynamic client, {required PushFn push});
|
||||
Future<String> submit(DateTime dayKey);
|
||||
Future<void> disapprove({required String sheetId, required String remarks, required List<String> flaggedTaskIds});
|
||||
Future<void> justify({required String sheetId, required String body});
|
||||
Future<BulkApproveResult> approve(List<({String sheetId, DaySheetSnapshot snapshot})> items);
|
||||
}
|
||||
final daySheetsControllerProvider = Provider<DaySheetsController>(...);
|
||||
final myDaySheetsProvider = StreamProvider<List<ProgrammerDaySheet>>(...); // programmer_id = me, work_date desc
|
||||
final daySheetsForUserProvider = StreamProvider.family<List<ProgrammerDaySheet>, String>(...);
|
||||
final allDaySheetsProvider = StreamProvider<List<ProgrammerDaySheet>>(...); // RLS-scoped, work_date desc
|
||||
final daySheetByIdProvider = StreamProvider.family<ProgrammerDaySheet?, String>(...);
|
||||
final daySheetEventsProvider = StreamProvider.family<List<ProgrammerDaySheetEvent>, String>(...); // created_at asc
|
||||
```
|
||||
Error messages (exact): alreadyReviewed `This sheet was already reviewed.` · alreadySubmitted `This day sheet was already submitted.` · notAuthorized `You are not allowed to do that.` · remarksRequired `Remarks are required to disapprove.` · bodyRequired `Please write a justification.` · workDateLocked `That day is locked — you can only add entries to pending or disapproved days.` · invalidSnapshot `Could not approve: the sheet data was invalid.` · unknown `Something went wrong. Please try again.`
|
||||
Push copy (dates via `AppTime.formatDate`, e.g. `Sep 28, 2026`; `data` always includes `type` and `navigate_to: '/programmer-tasks/day-sheets/<id>'`):
|
||||
- submitted → `Day sheet submitted` / `<name> submitted their day sheet for <date>`
|
||||
- disapproved → `Day sheet disapproved` / `Your day sheet for <date> needs changes: <remarks, max 80 chars + '…'>`
|
||||
- justified → `Day sheet resubmitted` / `<name> justified their day sheet for <date>`
|
||||
- approved → `Day sheet approved` / `Your day sheet for <date> was approved`
|
||||
|
||||
- [ ] **Step 1: Failing tests** (local fake client with `rpc(String name, {Map<String, dynamic>? params})`; a recording `PushFn`):
|
||||
```dart
|
||||
test('submit calls submit_day_sheet with p_date and pushes admins', () async {
|
||||
// rpc returns {'sheet_id':'s1','work_date':'2026-09-28','programmer_name':'Paola Ross','notify_user_ids':['adm']}
|
||||
expect(fake.calls.single, ('submit_day_sheet', {'p_date': '2026-09-28'}));
|
||||
expect(push.calls.single.title, 'Day sheet submitted');
|
||||
expect(push.calls.single.body, 'Paola Ross submitted their day sheet for Sep 28, 2026');
|
||||
});
|
||||
test('blank remarks fail fast without an rpc call', ...); // throws DaySheetActionException(remarksRequired); fake.calls isEmpty
|
||||
test('already_reviewed maps to a friendly message', ...); // message 'This sheet was already reviewed.'
|
||||
test('approve reports approved and skipped ids and pushes per approved sheet', ...);
|
||||
test('a failing push does not fail the action', ...);
|
||||
```
|
||||
- [ ] **Step 2:** `$FLUTTER test test/programmer_day_sheets_controller_test.dart` — FAIL.
|
||||
- [ ] **Step 3: Implement.** Controller trims inputs and validates remarks/body before calling the RPC; wraps RPC errors with `mapDaySheetError`; push errors are caught and `debugPrint`ed. Provider wires `push` to `ref.read(notificationsControllerProvider).sendPush`.
|
||||
- [ ] **Step 4:** same command — PASS.
|
||||
- [ ] **Step 5: Commit** — `feat(programmer): day sheet providers + controller`.
|
||||
|
||||
---
|
||||
|
||||
### Task 15: Dated work logs (corrections); day data by `work_date`
|
||||
|
||||
**Files:**
|
||||
- Modify: `lib/providers/programmer_task_work_logs_provider.dart`, `lib/screens/programmer_tasks/widgets/work_log_section.dart`, `lib/providers/programmer_day_data_provider.dart`
|
||||
- Test: `test/programmer_tasks_controller_test.dart` (extend the `addWorkLog` group), `test/programmer_day_data_test.dart` (update fake filtering)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Task 14 (`DaySheetActionException`, `mapDaySheetError`).
|
||||
- Produces:
|
||||
```dart
|
||||
Future<void> addWorkLog({required String taskId, required String description, int? minutes, DateTime? workDate});
|
||||
// sends 'work_date': formatDayKey(workDate) only when workDate != null; 'work_date_locked' -> DaySheetActionException(workDateLocked)
|
||||
const WorkLogSection({super.key, required this.task, required this.isAssignee, this.workDate});
|
||||
```
|
||||
- [ ] **Step 1: Failing tests** — `addWorkLog(workDate: utc(2026,9,25))` inserts `work_date: '2026-09-25'`; without `workDate` the key is absent; `fetchDayActivityData` now selects work logs with `eq('work_date', '2026-09-27')`.
|
||||
- [ ] **Step 2:** `$FLUTTER test test/programmer_tasks_controller_test.dart test/programmer_day_data_test.dart` — FAIL.
|
||||
- [ ] **Step 3: Implement.** `WorkLogSection` with `workDate != null`: header `AppSectionHeader('Correction for <date>')`, and skip the pause / deduct prompts after saving.
|
||||
- [ ] **Step 4:** same command — PASS.
|
||||
- [ ] **Step 5: Commit** — `feat(programmer): dated work-log corrections`.
|
||||
|
||||
---
|
||||
|
||||
### Task 16: `ReportDateFilter.controlled`
|
||||
|
||||
**Files:**
|
||||
- Modify: `lib/screens/reports/report_date_filter.dart`
|
||||
- Test: `test/report_date_filter_test.dart`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces:
|
||||
```dart
|
||||
const ReportDateFilter({super.key}); // unchanged: binds reportDateRangeProvider
|
||||
const ReportDateFilter.controlled({super.key, required ReportDateRange value, required ValueChanged<ReportDateRange> onChanged});
|
||||
ReportDateRange thisMonthRange(DateTime now); // start = DateTime(y,m,1), end = DateTime(y,m+1,1), label 'This Month'
|
||||
```
|
||||
- [ ] **Step 1: Failing test** — pump `ReportDateFilter.controlled(value: thisMonthRange(now), onChanged: captured.add)`; tap `Change`, tap preset `Yesterday` → `captured.single.label == 'Yesterday'` and `container.read(reportDateRangeProvider)` still has label `Last 30 Days`.
|
||||
- [ ] **Step 2:** `$FLUTTER test test/report_date_filter_test.dart` — FAIL.
|
||||
- [ ] **Step 3: Implement.** Both constructors share the build; the dialog's `current`/`onApply` come from `value`/`onChanged` when controlled, otherwise from the provider.
|
||||
- [ ] **Step 4:** same command — PASS.
|
||||
- [ ] **Step 5: Commit** — `feat(reports): controlled ReportDateFilter`.
|
||||
|
||||
---
|
||||
|
||||
### Task 17: Day-sheet detail screen, dialogs, route
|
||||
|
||||
**Files:**
|
||||
- Create: `lib/screens/programmer_tasks/day_sheets/day_sheet_detail_screen.dart`, `day_sheets/day_sheet_dialogs.dart`, `day_sheets/day_sheet_rows_table.dart`, `day_sheets/day_sheet_thread.dart`
|
||||
- Modify: `lib/routing/app_router.dart` (route `day-sheets/:id` under `/programmer-tasks`, before `:id`)
|
||||
- Test: `test/day_sheet_dialogs_test.dart`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Tasks 2, 3, 14, 15.
|
||||
- Produces:
|
||||
```dart
|
||||
class DaySheetDetailScreen extends ConsumerStatefulWidget { const DaySheetDetailScreen({super.key, required this.sheetId}); }
|
||||
Future<String?> showDisapproveDialog(BuildContext context, {required List<DaySheetRow> flaggedRows});
|
||||
Future<String?> showJustifyDialog(BuildContext context);
|
||||
class DaySheetRowsTable extends StatelessWidget { const DaySheetRowsTable({super.key, required this.rows,
|
||||
required this.flaggedTaskIds, this.selectedTaskIds, this.onSelectionChanged, this.onAddCorrection}); }
|
||||
class DaySheetThread extends StatelessWidget { const DaySheetThread({super.key, required this.events, required this.profileNames}); }
|
||||
List<DaySheetRow> rowsForSheet(ProgrammerDaySheet sheet, List<DaySheetRow> liveRows); // approved -> snapshot rows, else liveRows
|
||||
```
|
||||
- [ ] **Step 1: Failing tests.**
|
||||
```dart
|
||||
testWidgets('disapprove dialog requires remarks', (t) async {
|
||||
// tap 'Disapprove' with empty field -> 'Remarks are required' visible, dialog stays
|
||||
// enter 'Missing notes' -> dialog returns 'Missing notes'
|
||||
});
|
||||
testWidgets('justify dialog requires a justification', ...); // 'Please write a justification'
|
||||
test('rowsForSheet uses the snapshot once approved', ...);
|
||||
```
|
||||
- [ ] **Step 2:** `$FLUTTER test test/day_sheet_dialogs_test.dart` — FAIL.
|
||||
- [ ] **Step 3: Implement** per spec §6.3:
|
||||
- Header: date, programmer, `StatusPill.kind` (draft→neutral, pending→info, disapproved→warning, approved→success), total, `Round N` (N = resubmissions + 1) when `resubmissions > 0`.
|
||||
- Flags = `flaggedTaskIds` of the latest `disapproved` event; shown while status ≠ approved.
|
||||
- Thread labels: submitted `Submitted` · auto_submitted `Auto-submitted at end of day` · disapproved `Disapproved` · justified `Justified` · approved `Approved` · amended `Reopened — new work after approval`.
|
||||
- Owner when status is `disapproved` (or `pending` for a past day): each row shows an **Add correction** icon → dialog with `WorkLogSection(task, isAssignee: task.assigneeId == me, workDate: sheet.workDate)`. Owner when `disapproved`: **Justify & resubmit** → `showJustifyDialog` → `controller.justify`.
|
||||
- Admin when `pending`/`disapproved`: row checkboxes → **Disapprove** → `showDisapproveDialog(flaggedRows)` → `controller.disapprove`; **Approve** → if the sheet day is today and `programmerRunStatesProvider` has a running task assigned to that programmer, confirm `<name> has a timer running. Work logged after approval will reopen this sheet. Approve anyway?` → `controller.approve([(sheetId: sheet.id, snapshot: DaySheetSnapshot.fromRows(rows))])`.
|
||||
- `DaySheetActionException.message` → SnackBar; offline banner per Global Constraints.
|
||||
- [ ] **Step 4:** `$FLUTTER test test/day_sheet_dialogs_test.dart` — PASS; `$FLUTTER analyze lib` — clean.
|
||||
- [ ] **Step 5: Commit** — `feat(programmer): day sheet detail screen`.
|
||||
|
||||
---
|
||||
|
||||
### Task 18: Day Sheets tab, Approvals tab, tab wiring
|
||||
|
||||
**Files:**
|
||||
- Create: `lib/screens/programmer_tasks/day_sheets/day_sheets_tab.dart`, `day_sheets/approvals_tab.dart`, `day_sheets/day_sheet_snapshot_builder.dart`
|
||||
- Modify: `lib/screens/programmer_tasks/programmer_tasks_tabs.dart` (`programmerTasksTabsFor`), `programmer_tasks_list_screen.dart` (tab bodies)
|
||||
- Test: `test/day_sheet_tabs_test.dart`; update `test/programmer_tabs_test.dart`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Tasks 14, 16, 17.
|
||||
- Produces:
|
||||
```dart
|
||||
Future<DaySheetSnapshot> buildSnapshotForSheet(WidgetRef ref, ProgrammerDaySheet sheet);
|
||||
Map<DateTime, List<ProgrammerDaySheet>> groupSheetsByDate(List<ProgrammerDaySheet> sheets); // keys descending
|
||||
({int pending, int awaiting, int approved}) approvalCounts(List<ProgrammerDaySheet> sheets); // awaiting = disapproved
|
||||
```
|
||||
Tabs now: admin `[monitor, approvals, mine, all]`, programmer `[mine, all, daySheets]`.
|
||||
- [ ] **Step 1: Failing tests** — updated `programmerTasksTabsFor` expectations; `groupSheetsByDate` order; `approvalCounts`; widget: `ApprovalsTab` with overridden `allDaySheetsProvider` (3 pending) → select two cards → button text `Approve selected (2)`.
|
||||
- [ ] **Step 2:** `$FLUTTER test test/day_sheet_tabs_test.dart test/programmer_tabs_test.dart` — FAIL.
|
||||
- [ ] **Step 3: Implement** per spec §6.2 / §6.4:
|
||||
- Day Sheets tab: **Today** card (live `buildDayRows` for me, total, status chip — missing sheet shows `Draft`), **Submit day** enabled when status is draft/missing and rows are non-empty → SnackBar `Day submitted`; **Needs your attention** (my `disapproved` sheets); history with `AppStatusSummaryRow` filters All / Pending / Disapproved / Approved; tapping → `/programmer-tasks/day-sheets/<id>`.
|
||||
- Approvals tab: summary chips `Pending`, `Awaiting justification`, `Approved` (Pending selected by default); programmer dropdown (`All programmers` + names); `ReportDateFilter.controlled` (default `thisMonthRange`); cards grouped by date with name, `TechChip(formatDurationHm(total))`, `<n> tasks`, badge `Auto` if `autoSubmitted` else `Early`, `Round N`; checkboxes on pending cards; **Approve selected (n)** → `buildSnapshotForSheet` each → `approve` → SnackBar `Approved <a>` plus `, <s> already reviewed` when skipped > 0.
|
||||
- [ ] **Step 4:** same command — PASS; `$FLUTTER analyze lib` — clean.
|
||||
- [ ] **Step 5: Commit** — `feat(programmer): day sheets + approvals tabs`.
|
||||
|
||||
---
|
||||
|
||||
### Task 19: Notifications for day sheets
|
||||
|
||||
**Files:**
|
||||
- Modify: `lib/models/notification_item.model.dart` (add `final String? daySheetId;` ← `day_sheet_id`), Brick generated files (regenerated), `lib/screens/notifications/notifications_screen.dart`, `lib/services/notification_bridge.dart`
|
||||
- Test: `test/notification_titles_test.dart`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces (moved out of the screen's State so they are testable; existing cases unchanged):
|
||||
```dart
|
||||
String notificationTitle(String type, String actorName);
|
||||
IconData notificationIcon(String type);
|
||||
```
|
||||
New cases: `day_sheet_submitted` → `'$actorName submitted a day sheet'` / `Icons.assignment_turned_in_outlined`; `day_sheet_disapproved` → `'Day sheet disapproved'` / `Icons.report_outlined`; `day_sheet_justified` → `'$actorName justified a day sheet'` / `Icons.reply_outlined`; `day_sheet_approved` → `'Day sheet approved'` / `Icons.verified_outlined`; `day_sheet_digest` → `'Day sheets awaiting review'` / `Icons.inbox_outlined`. Banner labels: `Day sheet submitted`, `Day sheet disapproved`, `Day sheet justified`, `Day sheet approved`, `Day sheets awaiting review`. Tap: `daySheetId != null` → `/programmer-tasks/day-sheets/<id>`; `day_sheet_digest` → `/programmer-tasks?tab=approvals`.
|
||||
- [ ] **Step 1: Failing tests** for the five titles/icons and one existing case (`'assignment'` → `'X assigned you'`) to guard the move.
|
||||
- [ ] **Step 2:** `$FLUTTER test test/notification_titles_test.dart` — FAIL.
|
||||
- [ ] **Step 3: Implement**, then regenerate Brick: `$FLUTTER pub run build_runner build --delete-conflicting-outputs`; commit the regenerated adapter, `schema.g.dart`, and new `lib/brick/db/*.migration.dart`.
|
||||
- [ ] **Step 4:** `$FLUTTER test test/notification_titles_test.dart test/notification_bridge_test.dart` — PASS; `$FLUTTER analyze lib` — clean.
|
||||
- [ ] **Step 5: Commit** — `feat(notifications): day sheet notification types`.
|
||||
|
||||
---
|
||||
|
||||
### Task 20: Wire sheet data into dashboard, monitor, drill-down
|
||||
|
||||
**Files:**
|
||||
- Modify: `dashboard/programmer_dashboard.dart` (`sheets` ← `daySheetsForUserProvider(userId)`), `monitor/monitor_tab.dart` (`sheetStatusByUser` from `allDaySheetsProvider` for today + summary chip `Sheets pending`), `programmer_drill_down_screen.dart` (`AppSectionHeader('Recent day sheets')` + last 10 sheets → detail route)
|
||||
- Test: `test/programmer_drill_down_test.dart`
|
||||
|
||||
- [ ] **Step 1: Failing test** — drill-down with overridden `daySheetsForUserProvider('u1')` (2 sheets) and admin profile shows `Recent day sheets` and both dates.
|
||||
- [ ] **Step 2:** `$FLUTTER test test/programmer_drill_down_test.dart` — FAIL.
|
||||
- [ ] **Step 3: Implement.**
|
||||
- [ ] **Step 4:** same command — PASS; `$FLUTTER analyze lib` — clean.
|
||||
- [ ] **Step 5: Commit** — `feat(programmer): sheet-driven stats, monitor status, drill-down history`.
|
||||
|
||||
**Slice 3 checkpoint:** full `$FLUTTER test`, `$FLUTTER build web`; QA harness: programmer submits early → admin disapproves with a flag → programmer adds a correction + justifies → admin approves; notifications render and route.
|
||||
|
||||
---
|
||||
|
||||
# Part D — Slice 4: Accomplishment Report
|
||||
|
||||
### Task 21: Report data builder (pure)
|
||||
|
||||
**Files:**
|
||||
- Create: `lib/screens/programmer_tasks/report/accomplishment_report_data.dart`
|
||||
- Test: `test/accomplishment_report_data_test.dart`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Tasks 2, 4.
|
||||
- Produces:
|
||||
```dart
|
||||
class ReportDay { final DateTime day; final String? statusTag; final List<DaySheetRow> rows; } // statusTag null when approved; 'Pending' | 'Disapproved'
|
||||
class AccomplishmentReportData {
|
||||
final String programmerName, positionLabel; // positionLabel: 'Programmer' | 'Administrator'
|
||||
final DateTime periodStart, periodEndInclusive; // day keys
|
||||
final List<ReportDay> days; // ascending
|
||||
final int excludedPending, excludedDisapproved;
|
||||
final int daysReported, totalSeconds, tasksCompleted, helpSeconds;
|
||||
final Map<String, int> secondsByCategory;
|
||||
final String? approverName; }
|
||||
AccomplishmentReportData buildAccomplishmentReportData({required Profile programmer,
|
||||
required ReportDateRange range, required bool includeUnapproved,
|
||||
required List<ProgrammerDaySheet> sheets, required Map<DateTime, List<DaySheetRow>> liveRowsByDay,
|
||||
required List<ProgrammerTask> tasks, required Map<String, String> profileNames});
|
||||
```
|
||||
- [ ] **Step 1: Failing tests.**
|
||||
```dart
|
||||
test('approved-only uses snapshots and counts excluded days', ...); // 2 approved, 1 pending, 1 disapproved -> days 2, excludedPending 1, excludedDisapproved 1
|
||||
test('include unapproved tags days and uses live rows', ...); // statusTag 'Pending' / 'Disapproved'; draft counts as 'Pending'
|
||||
test('period end is inclusive', ...); // range Sep 1 -> Oct 1 (exclusive) -> periodEndInclusive utc(2026,9,30)
|
||||
test('approver name only when one reviewer approved every included day', ...);
|
||||
test('tasksCompleted counts tasks completed in range for the programmer', ...);
|
||||
test('empty period', () { expect(data.days, isEmpty); expect(data.daysReported, 0); });
|
||||
```
|
||||
- [ ] **Step 2:** `$FLUTTER test test/accomplishment_report_data_test.dart` — FAIL.
|
||||
- [ ] **Step 3: Implement.** Only sheets with `workDate` in `[start, end)` and days with ≥ 1 row are included. `helpSeconds` / `secondsByCategory` sum over included rows (helper rows count toward help and their category).
|
||||
- [ ] **Step 4:** same command — PASS.
|
||||
- [ ] **Step 5: Commit** — `feat(report): accomplishment report data`.
|
||||
|
||||
---
|
||||
|
||||
### Task 22: Shared PDF helpers + report PDF
|
||||
|
||||
**Files:**
|
||||
- Create: `lib/utils/pdf/pdf_letterhead.dart`, `lib/widgets/pdf_preview_dialog.dart`, `lib/screens/programmer_tasks/report/accomplishment_report_pdf.dart`
|
||||
- Test: `test/accomplishment_report_pdf_test.dart`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Task 21.
|
||||
- Produces:
|
||||
```dart
|
||||
class PdfBrand { final pw.Font regular, bold; final pw.ImageProvider logo;
|
||||
static Future<PdfBrand> load(); // assets/fonts/Roboto-*.ttf + assets/crmc_logo.png
|
||||
pw.Widget letterhead(); } // same four lines + 60x60 logo as dtr_pdf.dart `_header`
|
||||
Future<void> showPdfPreviewDialog(BuildContext context, {required String title,
|
||||
required String filename, required Future<Uint8List> Function() buildBytes}); // modeled on _DtrPdfDialog: pdfrx PdfViewer.data, Print (Printing.layoutPdf), Download (Printing.sharePdf)
|
||||
Future<Uint8List> buildAccomplishmentReportPdf(AccomplishmentReportData data);
|
||||
```
|
||||
- [ ] **Step 1: Failing tests** (`TestWidgetsFlutterBinding.ensureInitialized()`): a populated report builds non-empty bytes starting with `%PDF`; an empty report builds too (Review Focus 5).
|
||||
- [ ] **Step 2:** `$FLUTTER test test/accomplishment_report_pdf_test.dart` — FAIL.
|
||||
- [ ] **Step 3: Implement** layout per spec §9. Exact copy: title `ACCOMPLISHMENT REPORT`; info labels `Name:`, `Position:`, `Period:` (`MMMM d, yyyy – MMMM d, yyyy`, inclusive end); table headers `DATE`, `TASK / PROJECT`, `ACCOMPLISHMENT`, `TIME`; notes joined with `\n`, `(no notes)` when empty; helper rows `Helped <name> — <task>`; status tag in brackets after the date (`[Pending]`); empty → `No approved accomplishments in this period.`; footnote `<n> day(s) in this period are not included (<p> pending, <d> disapproved).` (only when approved-only and n > 0); summary labels `Days reported`, `Total time`, `Tasks completed`, `Help given`, `Time by category`; signatures `Prepared by:` (programmer name + position) and `Approved by:` (approver name or a blank line). A4 `MultiPage`, margin 32, footer `Page x of y`.
|
||||
- [ ] **Step 4:** same command — PASS.
|
||||
- [ ] **Step 5: Commit** — `feat(report): accomplishment report PDF + shared PDF helpers`.
|
||||
|
||||
---
|
||||
|
||||
### Task 23: Generate dialog + entry points
|
||||
|
||||
**Files:**
|
||||
- Create: `lib/screens/programmer_tasks/report/accomplishment_report_dialog.dart`
|
||||
- Modify: `day_sheets/day_sheets_tab.dart` (app-bar action), `day_sheets/approvals_tab.dart` (action), `programmer_drill_down_screen.dart` (button)
|
||||
- Test: `test/accomplishment_report_dialog_test.dart`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Tasks 16, 21, 22, `fetchDayActivityData`, `buildDayRows`.
|
||||
- Produces:
|
||||
```dart
|
||||
Future<void> showAccomplishmentReportDialog(BuildContext context, WidgetRef ref, {String? programmerId});
|
||||
Future<AccomplishmentReportData> loadAccomplishmentReport(WidgetRef ref,
|
||||
{required Profile programmer, required ReportDateRange range, required bool includeUnapproved});
|
||||
```
|
||||
- [ ] **Step 1: Failing tests** — as programmer: no programmer picker, title `Accomplishment Report`, toggle `Include unapproved days (marked)` off by default; as admin with no `programmerId`: picker lists only `role == 'programmer'` profiles and **Generate** is disabled until one is chosen.
|
||||
- [ ] **Step 2:** `$FLUTTER test test/accomplishment_report_dialog_test.dart` — FAIL.
|
||||
- [ ] **Step 3: Implement.** Loader selects sheets for the programmer with `work_date` in range; for unapproved days (only when the toggle is on) builds live rows via `fetchDayActivityData` + `buildDayRows`; then `showPdfPreviewDialog(title: 'Accomplishment Report – <name>', filename: 'Accomplishment Report - <name>.pdf')`. Entry labels: `Accomplishment Report` (Day Sheets / Approvals app bars), `Generate Accomplishment Report` (drill-down).
|
||||
- [ ] **Step 4:** same command — PASS; `$FLUTTER analyze lib` — clean.
|
||||
- [ ] **Step 5: Commit** — `feat(report): accomplishment report dialog + entry points`.
|
||||
|
||||
---
|
||||
|
||||
### Task 24: Final verification
|
||||
|
||||
- [ ] **Step 1:** `$FLUTTER analyze lib` — no issues.
|
||||
- [ ] **Step 2:** `$FLUTTER test` — only the known unrelated failures.
|
||||
- [ ] **Step 3:** `$FLUTTER build web` — succeeds.
|
||||
- [ ] **Step 4: QA harness** (`~/.cache/tasq-qa/`, fresh `--session` per rebuild; programmer `pao@mail.com`, then an admin; light and dark): log work → Mine dashboard hero/tiles/sections → submit early → admin Monitor shows Working/Paused/On leave and help edges → admin flags + disapproves → programmer adds a correction + justifies → admin approves (single and bulk) → both generate the PDF (approved-only and marked) → notifications render and route. Save screenshots to `~/.cache/tasq-qa/shots/`.
|
||||
- [ ] **Step 5:** Report results to the user; fix regressions via superpowers:systematic-debugging before claiming completion.
|
||||
@@ -0,0 +1,448 @@
|
||||
# Programmer Oversight — Design Spec
|
||||
|
||||
**Date:** 2026-09-27 · **Branch:** `feat/programmer-tasks` · **Status:** Draft for review
|
||||
|
||||
Adds an oversight layer to the Programmer Tasks module: daily **Day Sheet** approvals with a
|
||||
remarks ↔ justification loop, a live **admin Monitor**, a PDF **Accomplishment Report**,
|
||||
and a gamified **Mine dashboard**.
|
||||
|
||||
## 1. Background
|
||||
|
||||
The Programmer Tasks module records programmers' dev work: tasks with a pause-aware timer,
|
||||
per-contributor work logs (assignee progress notes + helper-entered minutes), comments, and
|
||||
projects. Today:
|
||||
|
||||
- The admin cannot see who is working on what right now, or who is helping whom.
|
||||
- Nobody reviews or approves programmers' daily work.
|
||||
- There is no Accomplishment Report.
|
||||
- The "Mine" tab is a flat list of cards.
|
||||
|
||||
## 2. Goals
|
||||
|
||||
1. Admin sees each programmer's live state (working / paused / idle / away), current task,
|
||||
today's time, and help given/received.
|
||||
2. Each programmer's work is reviewed per day: approve, or disapprove with remarks; the
|
||||
programmer justifies (and may append corrections) until approved.
|
||||
3. Programmers generate their own Accomplishment Report (PDF, date-filtered); the admin
|
||||
generates one for any programmer.
|
||||
4. The Mine tab becomes a motivating dashboard with stats, a streak, badges, and tasks
|
||||
separated into In progress / Paused / Not started / Completed / Cancelled.
|
||||
|
||||
### Non-goals
|
||||
|
||||
- Live "help sessions" (help is derived from work logs after the fact).
|
||||
- XP/levels and team leaderboards.
|
||||
- Editing or deleting existing work-log entries (they stay append-only).
|
||||
- Migrating the existing DTR / task PDFs to the new shared PDF helpers.
|
||||
|
||||
## 3. Decisions
|
||||
|
||||
| Topic | Decision |
|
||||
|---|---|
|
||||
| Approval unit | **Day Sheet** — one per programmer per day; admin can **flag task rows** in it |
|
||||
| Submission | **Auto-submit at day end**, plus **manual early submit** |
|
||||
| Same-day approval | **Allowed**; later work that day reopens the sheet as "amended after approval" |
|
||||
| On disapprove | Programmer **justifies** and **may append corrective entries**; originals are immutable |
|
||||
| Late entries | Back-dated work logs allowed on **pending or disapproved** days, never on approved days; no backfilling days that have no sheet |
|
||||
| Helping | **Derived from work logs** (helper = work-log author ≠ task assignee) |
|
||||
| Report scope | **Toggle when generating**: approved days only (default) or include unapproved days (marked) |
|
||||
| Report layout | **By day**: Date · Task/Project · Accomplishment · Time, then summary and signatures |
|
||||
| Gamification | **Stat tiles + streak + badges** |
|
||||
| Notifications | **In-app + push** |
|
||||
| Monitor states | Working / Paused / Idle / **On leave** / **Pass slip** |
|
||||
| Tabs | Programmer: **Mine · All · Day Sheets** — Admin: **Monitor · Approvals · Mine · All** |
|
||||
| Core approach | Day-sheet table + append-only review thread + SECURITY DEFINER RPCs + pg_cron auto-submit + approval snapshot |
|
||||
|
||||
**"Admin" means `profiles.role = 'admin'` only.** The existing `isAdminProvider`
|
||||
(`lib/providers/profile_provider.dart:305`) also returns true for programmers, and the
|
||||
module's RLS treats both roles alike. All approval and monitor gating uses a new
|
||||
`isStrictAdminProvider` on the client and `role = 'admin'` in SQL.
|
||||
|
||||
## 4. Existing code this builds on
|
||||
|
||||
| Fact | Where |
|
||||
|---|---|
|
||||
| Task status is `queued \| in_progress \| completed \| cancelled`. **Paused** = `in_progress` whose latest lifecycle activity-log event is `paused`. **Not started** = `queued`. | `lib/models/programmer_task.model.dart:39-47` |
|
||||
| Work logs are append-only; the day is known only from `created_at`; assignee rows have `minutes = null`, helper rows have minutes. | `supabase/migrations/20260927140000_add_programmer_projects_worklogs_comments.sql:90-111` |
|
||||
| Duration core `computeEffectiveDuration` | `lib/utils/task_duration.dart:38-75` |
|
||||
| Ledger `computeTaskLedger` credits **all** clock time to the *current* assignee — the per-day split must not do this. | `lib/utils/programmer_task_ledger.dart:46-78` |
|
||||
| `ReportDateFilter` is hard-wired to `reportDateRangeProvider`; `ReportDateRange.end` is exclusive. | `lib/screens/reports/report_date_filter.dart`, `lib/providers/reports_provider.dart:13-51` |
|
||||
| Per-person PDF with letterhead + preview dialog (period label is off by one day at :211). | `lib/screens/attendance/dtr_pdf.dart` |
|
||||
| DTO + pure PDF builder pattern | `lib/screens/attendance/work_log_pdf.dart` |
|
||||
| Notification `type` is free text; render + tap switch; push via `send_fcm`. | `lib/screens/notifications/notifications_screen.dart:63-140`, `lib/services/notification_bridge.dart:97,152`, `lib/providers/notifications_provider.dart:376` |
|
||||
| Scheduled push: pg_cron → `scheduled_notifications` (`schedule_id` nullable) → edge function `notify_type` switch. | `supabase/functions/process_scheduled_notifications/index.ts` |
|
||||
| SECURITY DEFINER RPC that writes status + notifications atomically | `respond_shift_swap`, `supabase/migrations/20260322190000_pm_oncall_companion_swap.sql` |
|
||||
| Per-user "on leave / pass slip today" logic | `lib/screens/dashboard/dashboard_screen.dart:371-433` |
|
||||
|
||||
## 5. Day Sheets
|
||||
|
||||
### 5.1 What a Day Sheet contains
|
||||
|
||||
One **row per task** the programmer worked on that day:
|
||||
|
||||
- task title, category, project;
|
||||
- **time that day**:
|
||||
- *Assignee rows*: timer intervals (started/resumed → paused/completed/cancelled)
|
||||
clipped to Manila day boundaries, credited to **whoever was assignee at that moment**
|
||||
(walk `created` / `assigned` / `reassigned` events). A later reassignment never changes
|
||||
a past day. `adjustment` events count against the day they were created.
|
||||
- *Helper rows*: the helper's entered minutes, labelled "Helped <assignee>".
|
||||
- **notes**: that day's work-log entries for the task.
|
||||
|
||||
The admin flags **rows** when disapproving.
|
||||
|
||||
### 5.2 Schema
|
||||
|
||||
New migration `supabase/migrations/20260927150000_add_programmer_day_sheets.sql`.
|
||||
|
||||
**`programmer_day_sheets`**
|
||||
|
||||
| Column | Type | Notes |
|
||||
|---|---|---|
|
||||
| `id` | uuid pk | |
|
||||
| `programmer_id` | uuid → profiles | |
|
||||
| `work_date` | date | Manila calendar day |
|
||||
| `status` | text | check `draft \| pending \| disapproved \| approved`, default `draft` |
|
||||
| `submitted_at` | timestamptz | |
|
||||
| `auto_submitted` | bool | default false |
|
||||
| `reviewed_by` | uuid → profiles | last admin to approve/disapprove |
|
||||
| `reviewed_at` | timestamptz | |
|
||||
| `resubmissions` | int | default 0; incremented by justify |
|
||||
| `approved_snapshot` | jsonb | rows as approved (see 5.5) |
|
||||
| `created_at`, `updated_at` | timestamptz | |
|
||||
|
||||
Unique `(programmer_id, work_date)`.
|
||||
|
||||
**`programmer_day_sheet_events`** — append-only review thread
|
||||
|
||||
| Column | Type | Notes |
|
||||
|---|---|---|
|
||||
| `id` | uuid pk | |
|
||||
| `sheet_id` | uuid → sheets, on delete cascade | |
|
||||
| `actor_id` | uuid null | null = system |
|
||||
| `kind` | text | check `submitted \| auto_submitted \| disapproved \| justified \| approved \| amended` |
|
||||
| `body` | text | remarks / justification; check non-empty for `disapproved` and `justified` |
|
||||
| `flagged_task_ids` | uuid[] | default `'{}'` |
|
||||
| `created_at` | timestamptz | |
|
||||
|
||||
**`programmer_task_work_logs.work_date`** — `date not null default (now() at time zone
|
||||
'Asia/Manila')::date`. Existing rows are backfilled from `created_at`. A BEFORE INSERT
|
||||
guard trigger allows:
|
||||
|
||||
- `work_date` = today (Manila); or
|
||||
- a past `work_date` only if the author's sheet for that date is `pending` or
|
||||
`disapproved`.
|
||||
|
||||
Anything else raises an error.
|
||||
|
||||
**`notifications.day_sheet_id`** — `uuid null references programmer_day_sheets on delete
|
||||
cascade`.
|
||||
|
||||
**RLS:** sheets and events are readable by the owner and by `role = 'admin'`. There are no
|
||||
client INSERT/UPDATE/DELETE policies; every write goes through the RPCs or triggers below.
|
||||
Both tables are added to the `supabase_realtime` publication.
|
||||
|
||||
### 5.3 Status transitions
|
||||
|
||||
Each transition is a SECURITY DEFINER RPC with `set search_path = public`, explicit
|
||||
`auth.uid()`, role, and current-status checks. Each one atomically updates the sheet,
|
||||
inserts a thread event, and inserts notification rows, then returns the recipient user ids
|
||||
so the client can call `send_fcm`. A stale status raises `already_reviewed`.
|
||||
|
||||
| RPC | Who | From → To | Rule |
|
||||
|---|---|---|---|
|
||||
| `submit_day_sheet(p_date)` | owner | draft → pending | Any time, including early. Upserts the row. |
|
||||
| `disapprove_day_sheet(p_sheet, p_remarks, p_flagged_task_ids)` | admin | pending → disapproved | Remarks required. |
|
||||
| `justify_day_sheet(p_sheet, p_body)` | owner | disapproved → pending | Justification required; `resubmissions += 1`. |
|
||||
| `approve_day_sheets(p_items)` — `[{sheet_id, snapshot}]` | admin | pending or disapproved → approved | Same-day allowed. Bulk-capable. Stores the snapshot. |
|
||||
|
||||
```
|
||||
draft ──submit / auto──▶ pending ──approve──▶ approved
|
||||
│ ▲ │
|
||||
disapprove justify │ new work on the same day
|
||||
▼ │ │ (system "amended")
|
||||
disapproved ◀────────────┘ (back to pending)
|
||||
```
|
||||
|
||||
### 5.4 Automatic behaviour
|
||||
|
||||
- **Draft creation.** Triggers upsert a `draft` sheet when a work log is inserted (for the
|
||||
author, on `work_date`) and when a lifecycle activity-log event is inserted (for the
|
||||
task's assignee, on the event's Manila date). Only for `role = 'programmer'`.
|
||||
- **Amended after approval.** A work log or lifecycle event landing on an **approved
|
||||
sheet for today** sets it back to `pending` and adds a system `amended` event. This is not
|
||||
counted as a disapproval. The Approve button warns the admin when that programmer has a
|
||||
timer running.
|
||||
- **Nightly auto-submit** — `auto_submit_day_sheets()` via pg_cron at 00:05 Manila:
|
||||
1. For assignees of tasks still running, upsert yesterday's sheet (reopening it as
|
||||
`amended` if it was approved).
|
||||
2. Flip every `draft` sheet with `work_date < today` to `pending`
|
||||
(`auto_submitted = true`, `auto_submitted` event).
|
||||
3. If anything is pending, insert one `scheduled_notifications` row per admin with
|
||||
`notify_type = 'day_sheet_digest'`, scheduled for 08:00 Manila.
|
||||
|
||||
The cron registration uses the same `DO $$ … EXCEPTION` guard as existing cron
|
||||
migrations.
|
||||
|
||||
### 5.5 Approval snapshot
|
||||
|
||||
On approval the admin client sends the rows it displayed:
|
||||
|
||||
```json
|
||||
{
|
||||
"v": 1,
|
||||
"total_seconds": 27900,
|
||||
"rows": [
|
||||
{
|
||||
"task_id": "…", "title": "…", "category": "Bug Fix", "project_name": "HIS",
|
||||
"kind": "assignee", "helped_name": null,
|
||||
"seconds": 11400, "notes": ["Fixed session timeout…"]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Approved days are thereby fixed records: the report and stats read the snapshot, so later
|
||||
renames or reassignments do not change what was approved.
|
||||
|
||||
## 6. Approval UX
|
||||
|
||||
### 6.1 Tabs
|
||||
|
||||
`programmer_tasks_list_screen.dart` shows tabs by role:
|
||||
|
||||
- **Programmer:** Mine · All · Day Sheets
|
||||
- **Admin:** Monitor · Approvals · Mine · All
|
||||
|
||||
### 6.2 Day Sheets tab (programmer)
|
||||
|
||||
- **Today card** — today's rows (live), total time, Draft/Submitted chip, **Submit day**.
|
||||
- **Needs your attention** — disapproved sheets, pinned, warning style.
|
||||
- **History** — sheets by date with status filter chips (`AppStatusSummaryRow`).
|
||||
- App bar: **Accomplishment Report**.
|
||||
|
||||
### 6.3 Sheet detail — `/programmer-tasks/day-sheets/:id` (both roles)
|
||||
|
||||
- Header: date, programmer, status, total time, "Round N" when resubmitted.
|
||||
- Rows table: task · time · notes. Flagged rows are highlighted (`AppStatusColors`
|
||||
warning + flag icon).
|
||||
- Thread timeline, styled like `widgets/activity_timeline.dart`.
|
||||
- **Programmer, when disapproved:**
|
||||
- **Add correction** — the existing work-log dialog with `workDate` preset to that day,
|
||||
flagged tasks listed first.
|
||||
- Justification field (cannot be empty; modeled on the checkout-justification dialog,
|
||||
`attendance_screen.dart:1462-1510`).
|
||||
- **Resubmit**.
|
||||
- **Admin, when pending or disapproved:**
|
||||
- Row checkboxes for flagging.
|
||||
- **Disapprove** — dialog lists the flagged rows; remarks required.
|
||||
- **Approve** — warns if a timer is running. The admin client builds the snapshot.
|
||||
|
||||
### 6.4 Approvals tab (admin)
|
||||
|
||||
- Summary chips: Pending · Awaiting justification · Approved.
|
||||
- Programmer filter and the shared date filter.
|
||||
- Cards grouped by date: programmer, total time, task count, Early/Auto badge, Round N.
|
||||
- Multi-select → **Approve selected**. Disapproval stays per sheet because it needs remarks.
|
||||
|
||||
## 7. Admin Monitor
|
||||
|
||||
- **Summary row:** Working · Paused · Idle · On leave/Pass slip · Sheets pending.
|
||||
- **One card per programmer** (grid: 1 column on mobile, 2–3 on desktop):
|
||||
- State pill: **Working** (success) / **Paused** (warning) / **Idle** (neutral) /
|
||||
**On leave** or **Pass slip** (info; overrides the others).
|
||||
- Current task: title, category/project `TechChip`s, live "running 1h 12m today".
|
||||
- Today's total; in-progress / paused / not-started counts; today's sheet status.
|
||||
- Help chips: "Helped → Ben · task · 45m", "Helped by ← Ana · 30m".
|
||||
- **Drill-down** (tap a card): `ProgrammerDashboard(userId, readOnly: true)`, their recent
|
||||
Day Sheets, and **Generate Accomplishment Report** with the programmer preselected.
|
||||
- **Who's helping whom** panel: helper → assignee · task · minutes · time, with a day
|
||||
stepper (defaults to today). The working/paused state is always live.
|
||||
|
||||
**Data**
|
||||
|
||||
- New `programmerRunStatesProvider`: realtime stream of lifecycle activity logs for all
|
||||
`in_progress` task ids → latest lifecycle event per task → Working vs Paused. It also
|
||||
replaces the Mine tab's `myRunningProgrammerTaskIdProvider` inference, which currently
|
||||
marks every in-progress task as paused when offline.
|
||||
- Today's time: the per-day split (5.1).
|
||||
- Help: work logs with `work_date = day` whose author ≠ task assignee.
|
||||
- Leave / pass slip: extract the per-user "today" logic from
|
||||
`dashboard_screen.dart:371-433` into `lib/utils/staff_presence.dart`, used by both the
|
||||
dashboard and the Monitor.
|
||||
- Admin-only. Programmers keep the existing All tab and get no live view of teammates.
|
||||
|
||||
## 8. Mine dashboard
|
||||
|
||||
A single widget, `ProgrammerDashboard(userId, {readOnly})`, used for the Mine tab and for
|
||||
the Monitor drill-down.
|
||||
|
||||
1. **Now working hero** — the running task with a live timer (today + total) and
|
||||
Pause/Complete. If nothing is running: "Nothing running" and **Start** on the top
|
||||
not-started task (via `startProgrammerTaskWithPausePrompt`).
|
||||
2. **Stat tiles** (`AppMetricCard`):
|
||||
- **Today's focus** — ring against an 8h target (pattern:
|
||||
`reports/widgets/conversion_rate_card.dart:43-55`).
|
||||
- **Completed this week**, with change vs last week.
|
||||
- **Streak** — consecutive weekdays whose sheet is pending or approved. Weekends and
|
||||
approved-leave days are skipped (neither count nor break). A disapproved day breaks
|
||||
the streak until justified. Today counts once submitted. Shows best streak too.
|
||||
- **First-pass approval rate** (last 30 days) — approved sheets with no `disapproved`
|
||||
event ÷ reviewed sheets.
|
||||
- **Help given** this month.
|
||||
- Streak and approval rate are hidden for admins (admins have no sheets).
|
||||
3. **Badges** — computed from data each time (no table). Locked badges are greyed out with
|
||||
progress (e.g. 7/10).
|
||||
|
||||
| Badge | Unlocks at |
|
||||
|---|---|
|
||||
| First Finish | first completed task |
|
||||
| Finisher | 10 / 50 / 100 completed tasks |
|
||||
| Bug Squasher | 10 completed Bug Fix tasks |
|
||||
| Deep Work | 100 / 500 approved hours |
|
||||
| On a Roll | best streak 5 / 10 / 20 |
|
||||
| Team Player | 10h help given |
|
||||
| Clean Week | a Mon–Fri week with every day approved first time |
|
||||
|
||||
4. **Task sections** (`AppSectionHeader` + count): In progress · Paused · Not started ·
|
||||
Completed (collapsed; last 10 + Show all) · Cancelled (collapsed). The list's private
|
||||
`_TaskCard` is extracted to `widgets/programmer_task_card.dart`.
|
||||
|
||||
**Data:** hour-based stats and badges sum approved snapshots; "today" figures come from the
|
||||
live per-day split; counts come from `programmerTasksProvider` filtered to the user.
|
||||
Calculations live in pure functions (`computeProgrammerStats`, `computeBadges`).
|
||||
|
||||
## 9. Accomplishment Report (PDF)
|
||||
|
||||
**Entry points:** Day Sheets tab app bar (programmer, self); Approvals tab app bar and
|
||||
Monitor drill-down (admin).
|
||||
|
||||
**Generate dialog**
|
||||
|
||||
- Programmer picker — admin only; searchable single-select of `role = 'programmer'`
|
||||
from `profilesProvider`.
|
||||
- Date filter — `ReportDateFilter.controlled`, default "This Month".
|
||||
- Toggle — "Include unapproved days (marked)", off by default.
|
||||
- **Generate** → preview dialog with Print and Download.
|
||||
|
||||
**Date filter reuse:** add `ReportDateFilter.controlled({value, onChanged})` to
|
||||
`lib/screens/reports/report_date_filter.dart`. `_DateFilterDialog` returns the chosen
|
||||
`ReportDateRange` instead of writing the provider. The default constructor keeps binding
|
||||
`reportDateRangeProvider`, so the Reports screen is unchanged.
|
||||
|
||||
**Layout** (A4 MultiPage, Roboto, CRMC letterhead):
|
||||
|
||||
1. Info box — name, position, period (**inclusive** end date: `end − 1 day`).
|
||||
2. By-day table — Date | Task/Project | Accomplishment | Time.
|
||||
- Accomplishment = that day's work-log notes (Quill delta → plain text); "(no notes)"
|
||||
when a row has time but no notes.
|
||||
- Help rows read "Helped Ana — <task>".
|
||||
- When unapproved days are included, they carry a Pending/Disapproved tag.
|
||||
3. Footnote (approved-only mode) — "N days in the period are not included (x pending,
|
||||
y disapproved)".
|
||||
4. Summary — days reported, total time, tasks completed in the period, help given, time by
|
||||
category.
|
||||
5. Signatures — "Prepared by" the programmer; "Approved by" the reviewing admin's name if
|
||||
one admin approved every included day, otherwise a blank line.
|
||||
|
||||
**Data:** approved days come from `approved_snapshot`; unapproved days (when included) are
|
||||
computed live. `buildAccomplishmentReportData(...)` (pure) produces a DTO;
|
||||
`buildAccomplishmentReportPdf(dto)` only draws.
|
||||
|
||||
**Shared helpers (used by new code only):** `lib/utils/pdf/pdf_letterhead.dart` (fonts,
|
||||
logo, letterhead) and `lib/widgets/pdf_preview_dialog.dart` (pdfrx preview,
|
||||
`Printing.layoutPdf`, `Printing.sharePdf`, modeled on `_DtrPdfDialog`).
|
||||
|
||||
## 10. Notifications
|
||||
|
||||
| Type | Recipient | Trigger |
|
||||
|---|---|---|
|
||||
| `day_sheet_submitted` | all admins | programmer submits early |
|
||||
| `day_sheet_digest` | each admin, 08:00 | nightly auto-submit left sheets pending; opens Approvals |
|
||||
| `day_sheet_disapproved` | programmer | admin disapproves (includes remarks) |
|
||||
| `day_sheet_justified` | the reviewing admin | programmer justifies and resubmits |
|
||||
| `day_sheet_approved` | programmer | admin approves (single or bulk) |
|
||||
|
||||
RPCs insert the notification rows and return recipients; the client sends push through
|
||||
`NotificationsController.sendPush`. The digest goes through `scheduled_notifications` with
|
||||
a new case in `process_scheduled_notifications`. New render cases and tap routing go into
|
||||
`notifications_screen.dart` and `notification_bridge.dart`, and `day_sheet_id` is added to
|
||||
`notification_item.model.dart`.
|
||||
|
||||
## 11. Error handling
|
||||
|
||||
- **Stale status** (e.g. two admins act at once) → "This sheet was already reviewed" and
|
||||
the view refreshes.
|
||||
- **Empty remarks/justification** → blocked in the dialog and by the DB check.
|
||||
- **Disallowed back-dated entry** → the guard trigger's error is shown as a clear message.
|
||||
- **Push failure** → non-blocking; logged, as in existing flows.
|
||||
- **Offline** → Day Sheets are online-only (like work logs); actions are disabled with a
|
||||
banner.
|
||||
|
||||
## 12. Files
|
||||
|
||||
New (each under 500 lines):
|
||||
|
||||
- `supabase/migrations/20260927150000_add_programmer_day_sheets.sql`
|
||||
- `lib/models/programmer_day_sheet.model.dart`,
|
||||
`lib/models/programmer_day_sheet_event.model.dart` (plain online models)
|
||||
- `lib/providers/programmer_day_sheets_provider.dart` (streams, RPC controller, push)
|
||||
- `lib/providers/programmer_run_states_provider.dart`
|
||||
- `lib/utils/programmer_daily_time.dart` (per-day split with assignee attribution)
|
||||
- `lib/utils/programmer_stats.dart` (stats, streak, badges)
|
||||
- `lib/utils/staff_presence.dart`
|
||||
- `lib/utils/pdf/pdf_letterhead.dart`, `lib/widgets/pdf_preview_dialog.dart`
|
||||
- `lib/screens/programmer_tasks/dashboard/` — dashboard, hero, stat tiles, badges, sections
|
||||
- `lib/screens/programmer_tasks/monitor/` — monitor tab, programmer card, help panel
|
||||
- `lib/screens/programmer_tasks/day_sheets/` — Day Sheets tab, Approvals tab, detail
|
||||
screen, disapprove/justify dialogs
|
||||
- `lib/screens/programmer_tasks/report/` — generate dialog, report data, report PDF
|
||||
- `lib/screens/programmer_tasks/widgets/programmer_task_card.dart`
|
||||
|
||||
Modified:
|
||||
|
||||
- `lib/screens/programmer_tasks/programmer_tasks_list_screen.dart` — role tabs, card extract
|
||||
- `lib/models/programmer_task_work_log.model.dart`,
|
||||
`lib/providers/programmer_task_work_logs_provider.dart`,
|
||||
`lib/screens/programmer_tasks/widgets/work_log_section.dart` — `workDate`
|
||||
- `lib/screens/reports/report_date_filter.dart` — controlled constructor
|
||||
- `lib/providers/profile_provider.dart` — `isStrictAdminProvider`
|
||||
- `lib/routing/app_router.dart` — day-sheet detail route
|
||||
- `lib/screens/notifications/notifications_screen.dart`,
|
||||
`lib/services/notification_bridge.dart`, `lib/models/notification_item.model.dart`
|
||||
- `supabase/functions/process_scheduled_notifications/index.ts` — digest case
|
||||
- `lib/screens/dashboard/dashboard_screen.dart` — use `staff_presence.dart`
|
||||
|
||||
## 13. Delivery slices
|
||||
|
||||
Each slice ships independently on `feat/programmer-tasks`.
|
||||
|
||||
1. **Mine dashboard** — run-state provider, per-day split util, dashboard. No schema
|
||||
change; fixes the offline "everything paused" bug. Approval-based stats appear once
|
||||
slice 3 lands.
|
||||
2. **Admin Monitor** — plus the `staff_presence.dart` extraction.
|
||||
3. **Day Sheets** — migration, RPCs, cron, Day Sheets/Approvals tabs, detail screen,
|
||||
notifications. **The user applies the migration and redeploys
|
||||
`process_scheduled_notifications`** (the Supabase migration tool is blocked in this
|
||||
environment).
|
||||
4. **Accomplishment Report** — date-filter refactor, PDF helpers, report.
|
||||
|
||||
## 14. Testing and verification
|
||||
|
||||
- `flutter analyze lib` clean; `flutter test` passes apart from the three known,
|
||||
unrelated failures (theme_overhaul, profile_screen, user_management).
|
||||
- **Unit (pure Dart, TDD):** per-day split (midnight crossing, reassignment attribution,
|
||||
pauses, adjustments); stats and streak (weekends, leave, disapproved days); badges;
|
||||
report data (approved-only vs marked, snapshot vs live, footnote, inclusive period).
|
||||
- **Controller (mock-first, pattern of `test/programmer_tasks_controller_test.dart`):**
|
||||
RPC calls, push invocation, stale-status error mapping.
|
||||
- **Widget:** Disapprove dialog requires remarks; dashboard sections; tabs by role.
|
||||
- **SQL checklist** after the migration is applied (rolled-back transactions with
|
||||
`request.jwt.claims` set per role): every transition allowed/denied by role and status;
|
||||
back-dated work-log guard; amended-after-approval; `auto_submit_day_sheets()` called
|
||||
directly; `get_advisors` security check.
|
||||
- **End-to-end** via the QA harness (programmer, then admin; light and dark): log work →
|
||||
submit early → admin flags and disapproves → programmer adds a correction and justifies
|
||||
→ admin approves → both generate the PDF (approved-only and marked) → Monitor shows
|
||||
Working/Paused/On leave and help edges → notifications render and route.
|
||||
- `flutter build web` succeeds.
|
||||
@@ -23,6 +23,9 @@ Future<NotificationItem> _$NotificationItemFromSupabase(
|
||||
? null
|
||||
: data['announcement_id'] as String?,
|
||||
messageId: data['message_id'] == null ? null : data['message_id'] as int?,
|
||||
daySheetId: data['day_sheet_id'] == null
|
||||
? null
|
||||
: data['day_sheet_id'] as String?,
|
||||
type: data['type'] as String,
|
||||
createdAt: DateTime.parse(data['created_at'] as String),
|
||||
readAt: data['read_at'] == null
|
||||
@@ -49,6 +52,7 @@ Future<Map<String, dynamic>> _$NotificationItemToSupabase(
|
||||
'it_service_request_id': instance.itServiceRequestId,
|
||||
'announcement_id': instance.announcementId,
|
||||
'message_id': instance.messageId,
|
||||
'day_sheet_id': instance.daySheetId,
|
||||
'type': instance.type,
|
||||
'created_at': instance.createdAt.toIso8601String(),
|
||||
'read_at': instance.readAt?.toIso8601String(),
|
||||
@@ -77,6 +81,9 @@ Future<NotificationItem> _$NotificationItemFromSqlite(
|
||||
? null
|
||||
: data['announcement_id'] as String?,
|
||||
messageId: data['message_id'] == null ? null : data['message_id'] as int?,
|
||||
daySheetId: data['day_sheet_id'] == null
|
||||
? null
|
||||
: data['day_sheet_id'] as String?,
|
||||
type: data['type'] as String,
|
||||
createdAt: DateTime.parse(data['created_at'] as String),
|
||||
readAt: data['read_at'] == null
|
||||
@@ -103,6 +110,7 @@ Future<Map<String, dynamic>> _$NotificationItemToSqlite(
|
||||
'it_service_request_id': instance.itServiceRequestId,
|
||||
'announcement_id': instance.announcementId,
|
||||
'message_id': instance.messageId,
|
||||
'day_sheet_id': instance.daySheetId,
|
||||
'type': instance.type,
|
||||
'created_at': instance.createdAt.toIso8601String(),
|
||||
'read_at': instance.readAt?.toIso8601String(),
|
||||
@@ -160,6 +168,10 @@ class NotificationItemAdapter
|
||||
association: false,
|
||||
columnName: 'message_id',
|
||||
),
|
||||
'daySheetId': const RuntimeSupabaseColumnDefinition(
|
||||
association: false,
|
||||
columnName: 'day_sheet_id',
|
||||
),
|
||||
'type': const RuntimeSupabaseColumnDefinition(
|
||||
association: false,
|
||||
columnName: 'type',
|
||||
@@ -245,6 +257,12 @@ class NotificationItemAdapter
|
||||
iterable: false,
|
||||
type: int,
|
||||
),
|
||||
'daySheetId': const RuntimeSqliteColumnDefinition(
|
||||
association: false,
|
||||
columnName: 'day_sheet_id',
|
||||
iterable: false,
|
||||
type: String,
|
||||
),
|
||||
'type': const RuntimeSqliteColumnDefinition(
|
||||
association: false,
|
||||
columnName: 'type',
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
// GENERATED CODE DO NOT EDIT
|
||||
part of '../brick.g.dart';
|
||||
|
||||
Future<ProgrammerProject> _$ProgrammerProjectFromSupabase(
|
||||
Map<String, dynamic> data, {
|
||||
required SupabaseProvider provider,
|
||||
OfflineFirstWithSupabaseRepository? repository,
|
||||
}) async {
|
||||
return ProgrammerProject(
|
||||
id: data['id'] as String,
|
||||
name: data['name'] as String,
|
||||
description: data['description'] == null
|
||||
? null
|
||||
: data['description'] as String?,
|
||||
status: data['status'] as String,
|
||||
creatorId: data['creator_id'] == null
|
||||
? null
|
||||
: data['creator_id'] as String?,
|
||||
createdAt: DateTime.parse(data['created_at'] as String),
|
||||
updatedAt: DateTime.parse(data['updated_at'] as String),
|
||||
);
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> _$ProgrammerProjectToSupabase(
|
||||
ProgrammerProject instance, {
|
||||
required SupabaseProvider provider,
|
||||
OfflineFirstWithSupabaseRepository? repository,
|
||||
}) async {
|
||||
return {
|
||||
'id': instance.id,
|
||||
'name': instance.name,
|
||||
'description': instance.description,
|
||||
'status': instance.status,
|
||||
'creator_id': instance.creatorId,
|
||||
'created_at': instance.createdAt.toIso8601String(),
|
||||
'updated_at': instance.updatedAt.toIso8601String(),
|
||||
'is_archived': instance.isArchived,
|
||||
};
|
||||
}
|
||||
|
||||
Future<ProgrammerProject> _$ProgrammerProjectFromSqlite(
|
||||
Map<String, dynamic> data, {
|
||||
required SqliteProvider provider,
|
||||
OfflineFirstWithSupabaseRepository? repository,
|
||||
}) async {
|
||||
return ProgrammerProject(
|
||||
id: data['id'] as String,
|
||||
name: data['name'] as String,
|
||||
description: data['description'] == null
|
||||
? null
|
||||
: data['description'] as String?,
|
||||
status: data['status'] as String,
|
||||
creatorId: data['creator_id'] == null
|
||||
? null
|
||||
: data['creator_id'] as String?,
|
||||
createdAt: DateTime.parse(data['created_at'] as String),
|
||||
updatedAt: DateTime.parse(data['updated_at'] as String),
|
||||
)..primaryKey = data['_brick_id'] as int;
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> _$ProgrammerProjectToSqlite(
|
||||
ProgrammerProject instance, {
|
||||
required SqliteProvider provider,
|
||||
OfflineFirstWithSupabaseRepository? repository,
|
||||
}) async {
|
||||
return {
|
||||
'id': instance.id,
|
||||
'name': instance.name,
|
||||
'description': instance.description,
|
||||
'status': instance.status,
|
||||
'creator_id': instance.creatorId,
|
||||
'created_at': instance.createdAt.toIso8601String(),
|
||||
'updated_at': instance.updatedAt.toIso8601String(),
|
||||
'is_archived': instance.isArchived ? 1 : 0,
|
||||
};
|
||||
}
|
||||
|
||||
/// Construct a [ProgrammerProject]
|
||||
class ProgrammerProjectAdapter
|
||||
extends OfflineFirstWithSupabaseAdapter<ProgrammerProject> {
|
||||
ProgrammerProjectAdapter();
|
||||
|
||||
@override
|
||||
final supabaseTableName = 'programmer_projects';
|
||||
@override
|
||||
final defaultToNull = true;
|
||||
@override
|
||||
final fieldsToSupabaseColumns = {
|
||||
'id': const RuntimeSupabaseColumnDefinition(
|
||||
association: false,
|
||||
columnName: 'id',
|
||||
),
|
||||
'name': const RuntimeSupabaseColumnDefinition(
|
||||
association: false,
|
||||
columnName: 'name',
|
||||
),
|
||||
'description': const RuntimeSupabaseColumnDefinition(
|
||||
association: false,
|
||||
columnName: 'description',
|
||||
),
|
||||
'status': const RuntimeSupabaseColumnDefinition(
|
||||
association: false,
|
||||
columnName: 'status',
|
||||
),
|
||||
'creatorId': const RuntimeSupabaseColumnDefinition(
|
||||
association: false,
|
||||
columnName: 'creator_id',
|
||||
),
|
||||
'createdAt': const RuntimeSupabaseColumnDefinition(
|
||||
association: false,
|
||||
columnName: 'created_at',
|
||||
),
|
||||
'updatedAt': const RuntimeSupabaseColumnDefinition(
|
||||
association: false,
|
||||
columnName: 'updated_at',
|
||||
),
|
||||
'isArchived': const RuntimeSupabaseColumnDefinition(
|
||||
association: false,
|
||||
columnName: 'is_archived',
|
||||
),
|
||||
};
|
||||
@override
|
||||
final ignoreDuplicates = false;
|
||||
@override
|
||||
final uniqueFields = {};
|
||||
@override
|
||||
final Map<String, RuntimeSqliteColumnDefinition> fieldsToSqliteColumns = {
|
||||
'primaryKey': const RuntimeSqliteColumnDefinition(
|
||||
association: false,
|
||||
columnName: '_brick_id',
|
||||
iterable: false,
|
||||
type: int,
|
||||
),
|
||||
'id': const RuntimeSqliteColumnDefinition(
|
||||
association: false,
|
||||
columnName: 'id',
|
||||
iterable: false,
|
||||
type: String,
|
||||
),
|
||||
'name': const RuntimeSqliteColumnDefinition(
|
||||
association: false,
|
||||
columnName: 'name',
|
||||
iterable: false,
|
||||
type: String,
|
||||
),
|
||||
'description': const RuntimeSqliteColumnDefinition(
|
||||
association: false,
|
||||
columnName: 'description',
|
||||
iterable: false,
|
||||
type: String,
|
||||
),
|
||||
'status': const RuntimeSqliteColumnDefinition(
|
||||
association: false,
|
||||
columnName: 'status',
|
||||
iterable: false,
|
||||
type: String,
|
||||
),
|
||||
'creatorId': const RuntimeSqliteColumnDefinition(
|
||||
association: false,
|
||||
columnName: 'creator_id',
|
||||
iterable: false,
|
||||
type: String,
|
||||
),
|
||||
'createdAt': const RuntimeSqliteColumnDefinition(
|
||||
association: false,
|
||||
columnName: 'created_at',
|
||||
iterable: false,
|
||||
type: DateTime,
|
||||
),
|
||||
'updatedAt': const RuntimeSqliteColumnDefinition(
|
||||
association: false,
|
||||
columnName: 'updated_at',
|
||||
iterable: false,
|
||||
type: DateTime,
|
||||
),
|
||||
'isArchived': const RuntimeSqliteColumnDefinition(
|
||||
association: false,
|
||||
columnName: 'is_archived',
|
||||
iterable: false,
|
||||
type: bool,
|
||||
),
|
||||
};
|
||||
@override
|
||||
Future<int?> primaryKeyByUniqueColumns(
|
||||
ProgrammerProject instance,
|
||||
DatabaseExecutor executor,
|
||||
) async => instance.primaryKey;
|
||||
@override
|
||||
final String tableName = 'ProgrammerProject';
|
||||
|
||||
@override
|
||||
Future<ProgrammerProject> fromSupabase(
|
||||
Map<String, dynamic> input, {
|
||||
required provider,
|
||||
covariant OfflineFirstWithSupabaseRepository? repository,
|
||||
}) async => await _$ProgrammerProjectFromSupabase(
|
||||
input,
|
||||
provider: provider,
|
||||
repository: repository,
|
||||
);
|
||||
@override
|
||||
Future<Map<String, dynamic>> toSupabase(
|
||||
ProgrammerProject input, {
|
||||
required provider,
|
||||
covariant OfflineFirstWithSupabaseRepository? repository,
|
||||
}) async => await _$ProgrammerProjectToSupabase(
|
||||
input,
|
||||
provider: provider,
|
||||
repository: repository,
|
||||
);
|
||||
@override
|
||||
Future<ProgrammerProject> fromSqlite(
|
||||
Map<String, dynamic> input, {
|
||||
required provider,
|
||||
covariant OfflineFirstWithSupabaseRepository? repository,
|
||||
}) async => await _$ProgrammerProjectFromSqlite(
|
||||
input,
|
||||
provider: provider,
|
||||
repository: repository,
|
||||
);
|
||||
@override
|
||||
Future<Map<String, dynamic>> toSqlite(
|
||||
ProgrammerProject input, {
|
||||
required provider,
|
||||
covariant OfflineFirstWithSupabaseRepository? repository,
|
||||
}) async => await _$ProgrammerProjectToSqlite(
|
||||
input,
|
||||
provider: provider,
|
||||
repository: repository,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
// GENERATED CODE DO NOT EDIT
|
||||
part of '../brick.g.dart';
|
||||
|
||||
Future<ProgrammerTaskActivityLog> _$ProgrammerTaskActivityLogFromSupabase(
|
||||
Map<String, dynamic> data, {
|
||||
required SupabaseProvider provider,
|
||||
OfflineFirstWithSupabaseRepository? repository,
|
||||
}) async {
|
||||
return ProgrammerTaskActivityLog(
|
||||
id: data['id'] as String,
|
||||
taskId: data['task_id'] as String,
|
||||
actorId: data['actor_id'] == null ? null : data['actor_id'] as String?,
|
||||
actionType: data['action_type'] as String,
|
||||
meta: data['meta'] == null ? null : data['meta'],
|
||||
createdAt: DateTime.parse(data['created_at'] as String),
|
||||
);
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> _$ProgrammerTaskActivityLogToSupabase(
|
||||
ProgrammerTaskActivityLog instance, {
|
||||
required SupabaseProvider provider,
|
||||
OfflineFirstWithSupabaseRepository? repository,
|
||||
}) async {
|
||||
return {
|
||||
'id': instance.id,
|
||||
'task_id': instance.taskId,
|
||||
'actor_id': instance.actorId,
|
||||
'action_type': instance.actionType,
|
||||
'meta': instance.meta,
|
||||
'created_at': instance.createdAt.toIso8601String(),
|
||||
};
|
||||
}
|
||||
|
||||
Future<ProgrammerTaskActivityLog> _$ProgrammerTaskActivityLogFromSqlite(
|
||||
Map<String, dynamic> data, {
|
||||
required SqliteProvider provider,
|
||||
OfflineFirstWithSupabaseRepository? repository,
|
||||
}) async {
|
||||
return ProgrammerTaskActivityLog(
|
||||
id: data['id'] as String,
|
||||
taskId: data['task_id'] as String,
|
||||
actorId: data['actor_id'] == null ? null : data['actor_id'] as String?,
|
||||
actionType: data['action_type'] as String,
|
||||
meta: data['meta'] == null ? null : jsonDecode(data['meta']),
|
||||
createdAt: DateTime.parse(data['created_at'] as String),
|
||||
)..primaryKey = data['_brick_id'] as int;
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> _$ProgrammerTaskActivityLogToSqlite(
|
||||
ProgrammerTaskActivityLog instance, {
|
||||
required SqliteProvider provider,
|
||||
OfflineFirstWithSupabaseRepository? repository,
|
||||
}) async {
|
||||
return {
|
||||
'id': instance.id,
|
||||
'task_id': instance.taskId,
|
||||
'actor_id': instance.actorId,
|
||||
'action_type': instance.actionType,
|
||||
'meta': instance.meta != null ? jsonEncode(instance.meta) : null,
|
||||
'created_at': instance.createdAt.toIso8601String(),
|
||||
};
|
||||
}
|
||||
|
||||
/// Construct a [ProgrammerTaskActivityLog]
|
||||
class ProgrammerTaskActivityLogAdapter
|
||||
extends OfflineFirstWithSupabaseAdapter<ProgrammerTaskActivityLog> {
|
||||
ProgrammerTaskActivityLogAdapter();
|
||||
|
||||
@override
|
||||
final supabaseTableName = 'programmer_task_activity_logs';
|
||||
@override
|
||||
final defaultToNull = true;
|
||||
@override
|
||||
final fieldsToSupabaseColumns = {
|
||||
'id': const RuntimeSupabaseColumnDefinition(
|
||||
association: false,
|
||||
columnName: 'id',
|
||||
),
|
||||
'taskId': const RuntimeSupabaseColumnDefinition(
|
||||
association: false,
|
||||
columnName: 'task_id',
|
||||
),
|
||||
'actorId': const RuntimeSupabaseColumnDefinition(
|
||||
association: false,
|
||||
columnName: 'actor_id',
|
||||
),
|
||||
'actionType': const RuntimeSupabaseColumnDefinition(
|
||||
association: false,
|
||||
columnName: 'action_type',
|
||||
),
|
||||
'meta': const RuntimeSupabaseColumnDefinition(
|
||||
association: false,
|
||||
columnName: 'meta',
|
||||
),
|
||||
'createdAt': const RuntimeSupabaseColumnDefinition(
|
||||
association: false,
|
||||
columnName: 'created_at',
|
||||
),
|
||||
};
|
||||
@override
|
||||
final ignoreDuplicates = false;
|
||||
@override
|
||||
final uniqueFields = {};
|
||||
@override
|
||||
final Map<String, RuntimeSqliteColumnDefinition> fieldsToSqliteColumns = {
|
||||
'primaryKey': const RuntimeSqliteColumnDefinition(
|
||||
association: false,
|
||||
columnName: '_brick_id',
|
||||
iterable: false,
|
||||
type: int,
|
||||
),
|
||||
'id': const RuntimeSqliteColumnDefinition(
|
||||
association: false,
|
||||
columnName: 'id',
|
||||
iterable: false,
|
||||
type: String,
|
||||
),
|
||||
'taskId': const RuntimeSqliteColumnDefinition(
|
||||
association: false,
|
||||
columnName: 'task_id',
|
||||
iterable: false,
|
||||
type: String,
|
||||
),
|
||||
'actorId': const RuntimeSqliteColumnDefinition(
|
||||
association: false,
|
||||
columnName: 'actor_id',
|
||||
iterable: false,
|
||||
type: String,
|
||||
),
|
||||
'actionType': const RuntimeSqliteColumnDefinition(
|
||||
association: false,
|
||||
columnName: 'action_type',
|
||||
iterable: false,
|
||||
type: String,
|
||||
),
|
||||
'meta': const RuntimeSqliteColumnDefinition(
|
||||
association: false,
|
||||
columnName: 'meta',
|
||||
iterable: false,
|
||||
type: Map,
|
||||
),
|
||||
'createdAt': const RuntimeSqliteColumnDefinition(
|
||||
association: false,
|
||||
columnName: 'created_at',
|
||||
iterable: false,
|
||||
type: DateTime,
|
||||
),
|
||||
};
|
||||
@override
|
||||
Future<int?> primaryKeyByUniqueColumns(
|
||||
ProgrammerTaskActivityLog instance,
|
||||
DatabaseExecutor executor,
|
||||
) async => instance.primaryKey;
|
||||
@override
|
||||
final String tableName = 'ProgrammerTaskActivityLog';
|
||||
|
||||
@override
|
||||
Future<ProgrammerTaskActivityLog> fromSupabase(
|
||||
Map<String, dynamic> input, {
|
||||
required provider,
|
||||
covariant OfflineFirstWithSupabaseRepository? repository,
|
||||
}) async => await _$ProgrammerTaskActivityLogFromSupabase(
|
||||
input,
|
||||
provider: provider,
|
||||
repository: repository,
|
||||
);
|
||||
@override
|
||||
Future<Map<String, dynamic>> toSupabase(
|
||||
ProgrammerTaskActivityLog input, {
|
||||
required provider,
|
||||
covariant OfflineFirstWithSupabaseRepository? repository,
|
||||
}) async => await _$ProgrammerTaskActivityLogToSupabase(
|
||||
input,
|
||||
provider: provider,
|
||||
repository: repository,
|
||||
);
|
||||
@override
|
||||
Future<ProgrammerTaskActivityLog> fromSqlite(
|
||||
Map<String, dynamic> input, {
|
||||
required provider,
|
||||
covariant OfflineFirstWithSupabaseRepository? repository,
|
||||
}) async => await _$ProgrammerTaskActivityLogFromSqlite(
|
||||
input,
|
||||
provider: provider,
|
||||
repository: repository,
|
||||
);
|
||||
@override
|
||||
Future<Map<String, dynamic>> toSqlite(
|
||||
ProgrammerTaskActivityLog input, {
|
||||
required provider,
|
||||
covariant OfflineFirstWithSupabaseRepository? repository,
|
||||
}) async => await _$ProgrammerTaskActivityLogToSqlite(
|
||||
input,
|
||||
provider: provider,
|
||||
repository: repository,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
// GENERATED CODE DO NOT EDIT
|
||||
part of '../brick.g.dart';
|
||||
|
||||
Future<ProgrammerTask> _$ProgrammerTaskFromSupabase(
|
||||
Map<String, dynamic> data, {
|
||||
required SupabaseProvider provider,
|
||||
OfflineFirstWithSupabaseRepository? repository,
|
||||
}) async {
|
||||
return ProgrammerTask(
|
||||
id: data['id'] as String,
|
||||
title: data['title'] as String,
|
||||
description: data['description'] == null
|
||||
? null
|
||||
: data['description'] as String?,
|
||||
category: data['category'] as String,
|
||||
status: data['status'] as String,
|
||||
priority: data['priority'] as int,
|
||||
assigneeId: data['assignee_id'] == null
|
||||
? null
|
||||
: data['assignee_id'] as String?,
|
||||
creatorId: data['creator_id'] == null
|
||||
? null
|
||||
: data['creator_id'] as String?,
|
||||
projectId: data['project_id'] == null
|
||||
? null
|
||||
: data['project_id'] as String?,
|
||||
createdAt: DateTime.parse(data['created_at'] as String),
|
||||
startedAt: data['started_at'] == null
|
||||
? null
|
||||
: data['started_at'] == null
|
||||
? null
|
||||
: DateTime.tryParse(data['started_at'] as String),
|
||||
completedAt: data['completed_at'] == null
|
||||
? null
|
||||
: data['completed_at'] == null
|
||||
? null
|
||||
: DateTime.tryParse(data['completed_at'] as String),
|
||||
cancelledAt: data['cancelled_at'] == null
|
||||
? null
|
||||
: data['cancelled_at'] == null
|
||||
? null
|
||||
: DateTime.tryParse(data['cancelled_at'] as String),
|
||||
cancellationReason: data['cancellation_reason'] == null
|
||||
? null
|
||||
: data['cancellation_reason'] as String?,
|
||||
updatedAt: DateTime.parse(data['updated_at'] as String),
|
||||
);
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> _$ProgrammerTaskToSupabase(
|
||||
ProgrammerTask instance, {
|
||||
required SupabaseProvider provider,
|
||||
OfflineFirstWithSupabaseRepository? repository,
|
||||
}) async {
|
||||
return {
|
||||
'id': instance.id,
|
||||
'title': instance.title,
|
||||
'description': instance.description,
|
||||
'category': instance.category,
|
||||
'status': instance.status,
|
||||
'priority': instance.priority,
|
||||
'assignee_id': instance.assigneeId,
|
||||
'creator_id': instance.creatorId,
|
||||
'project_id': instance.projectId,
|
||||
'created_at': instance.createdAt.toIso8601String(),
|
||||
'started_at': instance.startedAt?.toIso8601String(),
|
||||
'completed_at': instance.completedAt?.toIso8601String(),
|
||||
'cancelled_at': instance.cancelledAt?.toIso8601String(),
|
||||
'cancellation_reason': instance.cancellationReason,
|
||||
'updated_at': instance.updatedAt.toIso8601String(),
|
||||
'is_terminal': instance.isTerminal,
|
||||
};
|
||||
}
|
||||
|
||||
Future<ProgrammerTask> _$ProgrammerTaskFromSqlite(
|
||||
Map<String, dynamic> data, {
|
||||
required SqliteProvider provider,
|
||||
OfflineFirstWithSupabaseRepository? repository,
|
||||
}) async {
|
||||
return ProgrammerTask(
|
||||
id: data['id'] as String,
|
||||
title: data['title'] as String,
|
||||
description: data['description'] == null
|
||||
? null
|
||||
: data['description'] as String?,
|
||||
category: data['category'] as String,
|
||||
status: data['status'] as String,
|
||||
priority: data['priority'] as int,
|
||||
assigneeId: data['assignee_id'] == null
|
||||
? null
|
||||
: data['assignee_id'] as String?,
|
||||
creatorId: data['creator_id'] == null
|
||||
? null
|
||||
: data['creator_id'] as String?,
|
||||
projectId: data['project_id'] == null
|
||||
? null
|
||||
: data['project_id'] as String?,
|
||||
createdAt: DateTime.parse(data['created_at'] as String),
|
||||
startedAt: data['started_at'] == null
|
||||
? null
|
||||
: data['started_at'] == null
|
||||
? null
|
||||
: DateTime.tryParse(data['started_at'] as String),
|
||||
completedAt: data['completed_at'] == null
|
||||
? null
|
||||
: data['completed_at'] == null
|
||||
? null
|
||||
: DateTime.tryParse(data['completed_at'] as String),
|
||||
cancelledAt: data['cancelled_at'] == null
|
||||
? null
|
||||
: data['cancelled_at'] == null
|
||||
? null
|
||||
: DateTime.tryParse(data['cancelled_at'] as String),
|
||||
cancellationReason: data['cancellation_reason'] == null
|
||||
? null
|
||||
: data['cancellation_reason'] as String?,
|
||||
updatedAt: DateTime.parse(data['updated_at'] as String),
|
||||
)..primaryKey = data['_brick_id'] as int;
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> _$ProgrammerTaskToSqlite(
|
||||
ProgrammerTask instance, {
|
||||
required SqliteProvider provider,
|
||||
OfflineFirstWithSupabaseRepository? repository,
|
||||
}) async {
|
||||
return {
|
||||
'id': instance.id,
|
||||
'title': instance.title,
|
||||
'description': instance.description,
|
||||
'category': instance.category,
|
||||
'status': instance.status,
|
||||
'priority': instance.priority,
|
||||
'assignee_id': instance.assigneeId,
|
||||
'creator_id': instance.creatorId,
|
||||
'project_id': instance.projectId,
|
||||
'created_at': instance.createdAt.toIso8601String(),
|
||||
'started_at': instance.startedAt?.toIso8601String(),
|
||||
'completed_at': instance.completedAt?.toIso8601String(),
|
||||
'cancelled_at': instance.cancelledAt?.toIso8601String(),
|
||||
'cancellation_reason': instance.cancellationReason,
|
||||
'updated_at': instance.updatedAt.toIso8601String(),
|
||||
'is_terminal': instance.isTerminal ? 1 : 0,
|
||||
};
|
||||
}
|
||||
|
||||
/// Construct a [ProgrammerTask]
|
||||
class ProgrammerTaskAdapter
|
||||
extends OfflineFirstWithSupabaseAdapter<ProgrammerTask> {
|
||||
ProgrammerTaskAdapter();
|
||||
|
||||
@override
|
||||
final supabaseTableName = 'programmer_tasks';
|
||||
@override
|
||||
final defaultToNull = true;
|
||||
@override
|
||||
final fieldsToSupabaseColumns = {
|
||||
'id': const RuntimeSupabaseColumnDefinition(
|
||||
association: false,
|
||||
columnName: 'id',
|
||||
),
|
||||
'title': const RuntimeSupabaseColumnDefinition(
|
||||
association: false,
|
||||
columnName: 'title',
|
||||
),
|
||||
'description': const RuntimeSupabaseColumnDefinition(
|
||||
association: false,
|
||||
columnName: 'description',
|
||||
),
|
||||
'category': const RuntimeSupabaseColumnDefinition(
|
||||
association: false,
|
||||
columnName: 'category',
|
||||
),
|
||||
'status': const RuntimeSupabaseColumnDefinition(
|
||||
association: false,
|
||||
columnName: 'status',
|
||||
),
|
||||
'priority': const RuntimeSupabaseColumnDefinition(
|
||||
association: false,
|
||||
columnName: 'priority',
|
||||
),
|
||||
'assigneeId': const RuntimeSupabaseColumnDefinition(
|
||||
association: false,
|
||||
columnName: 'assignee_id',
|
||||
),
|
||||
'creatorId': const RuntimeSupabaseColumnDefinition(
|
||||
association: false,
|
||||
columnName: 'creator_id',
|
||||
),
|
||||
'projectId': const RuntimeSupabaseColumnDefinition(
|
||||
association: false,
|
||||
columnName: 'project_id',
|
||||
),
|
||||
'createdAt': const RuntimeSupabaseColumnDefinition(
|
||||
association: false,
|
||||
columnName: 'created_at',
|
||||
),
|
||||
'startedAt': const RuntimeSupabaseColumnDefinition(
|
||||
association: false,
|
||||
columnName: 'started_at',
|
||||
),
|
||||
'completedAt': const RuntimeSupabaseColumnDefinition(
|
||||
association: false,
|
||||
columnName: 'completed_at',
|
||||
),
|
||||
'cancelledAt': const RuntimeSupabaseColumnDefinition(
|
||||
association: false,
|
||||
columnName: 'cancelled_at',
|
||||
),
|
||||
'cancellationReason': const RuntimeSupabaseColumnDefinition(
|
||||
association: false,
|
||||
columnName: 'cancellation_reason',
|
||||
),
|
||||
'updatedAt': const RuntimeSupabaseColumnDefinition(
|
||||
association: false,
|
||||
columnName: 'updated_at',
|
||||
),
|
||||
'isTerminal': const RuntimeSupabaseColumnDefinition(
|
||||
association: false,
|
||||
columnName: 'is_terminal',
|
||||
),
|
||||
};
|
||||
@override
|
||||
final ignoreDuplicates = false;
|
||||
@override
|
||||
final uniqueFields = {};
|
||||
@override
|
||||
final Map<String, RuntimeSqliteColumnDefinition> fieldsToSqliteColumns = {
|
||||
'primaryKey': const RuntimeSqliteColumnDefinition(
|
||||
association: false,
|
||||
columnName: '_brick_id',
|
||||
iterable: false,
|
||||
type: int,
|
||||
),
|
||||
'id': const RuntimeSqliteColumnDefinition(
|
||||
association: false,
|
||||
columnName: 'id',
|
||||
iterable: false,
|
||||
type: String,
|
||||
),
|
||||
'title': const RuntimeSqliteColumnDefinition(
|
||||
association: false,
|
||||
columnName: 'title',
|
||||
iterable: false,
|
||||
type: String,
|
||||
),
|
||||
'description': const RuntimeSqliteColumnDefinition(
|
||||
association: false,
|
||||
columnName: 'description',
|
||||
iterable: false,
|
||||
type: String,
|
||||
),
|
||||
'category': const RuntimeSqliteColumnDefinition(
|
||||
association: false,
|
||||
columnName: 'category',
|
||||
iterable: false,
|
||||
type: String,
|
||||
),
|
||||
'status': const RuntimeSqliteColumnDefinition(
|
||||
association: false,
|
||||
columnName: 'status',
|
||||
iterable: false,
|
||||
type: String,
|
||||
),
|
||||
'priority': const RuntimeSqliteColumnDefinition(
|
||||
association: false,
|
||||
columnName: 'priority',
|
||||
iterable: false,
|
||||
type: int,
|
||||
),
|
||||
'assigneeId': const RuntimeSqliteColumnDefinition(
|
||||
association: false,
|
||||
columnName: 'assignee_id',
|
||||
iterable: false,
|
||||
type: String,
|
||||
),
|
||||
'creatorId': const RuntimeSqliteColumnDefinition(
|
||||
association: false,
|
||||
columnName: 'creator_id',
|
||||
iterable: false,
|
||||
type: String,
|
||||
),
|
||||
'projectId': const RuntimeSqliteColumnDefinition(
|
||||
association: false,
|
||||
columnName: 'project_id',
|
||||
iterable: false,
|
||||
type: String,
|
||||
),
|
||||
'createdAt': const RuntimeSqliteColumnDefinition(
|
||||
association: false,
|
||||
columnName: 'created_at',
|
||||
iterable: false,
|
||||
type: DateTime,
|
||||
),
|
||||
'startedAt': const RuntimeSqliteColumnDefinition(
|
||||
association: false,
|
||||
columnName: 'started_at',
|
||||
iterable: false,
|
||||
type: DateTime,
|
||||
),
|
||||
'completedAt': const RuntimeSqliteColumnDefinition(
|
||||
association: false,
|
||||
columnName: 'completed_at',
|
||||
iterable: false,
|
||||
type: DateTime,
|
||||
),
|
||||
'cancelledAt': const RuntimeSqliteColumnDefinition(
|
||||
association: false,
|
||||
columnName: 'cancelled_at',
|
||||
iterable: false,
|
||||
type: DateTime,
|
||||
),
|
||||
'cancellationReason': const RuntimeSqliteColumnDefinition(
|
||||
association: false,
|
||||
columnName: 'cancellation_reason',
|
||||
iterable: false,
|
||||
type: String,
|
||||
),
|
||||
'updatedAt': const RuntimeSqliteColumnDefinition(
|
||||
association: false,
|
||||
columnName: 'updated_at',
|
||||
iterable: false,
|
||||
type: DateTime,
|
||||
),
|
||||
'isTerminal': const RuntimeSqliteColumnDefinition(
|
||||
association: false,
|
||||
columnName: 'is_terminal',
|
||||
iterable: false,
|
||||
type: bool,
|
||||
),
|
||||
};
|
||||
@override
|
||||
Future<int?> primaryKeyByUniqueColumns(
|
||||
ProgrammerTask instance,
|
||||
DatabaseExecutor executor,
|
||||
) async => instance.primaryKey;
|
||||
@override
|
||||
final String tableName = 'ProgrammerTask';
|
||||
|
||||
@override
|
||||
Future<ProgrammerTask> fromSupabase(
|
||||
Map<String, dynamic> input, {
|
||||
required provider,
|
||||
covariant OfflineFirstWithSupabaseRepository? repository,
|
||||
}) async => await _$ProgrammerTaskFromSupabase(
|
||||
input,
|
||||
provider: provider,
|
||||
repository: repository,
|
||||
);
|
||||
@override
|
||||
Future<Map<String, dynamic>> toSupabase(
|
||||
ProgrammerTask input, {
|
||||
required provider,
|
||||
covariant OfflineFirstWithSupabaseRepository? repository,
|
||||
}) async => await _$ProgrammerTaskToSupabase(
|
||||
input,
|
||||
provider: provider,
|
||||
repository: repository,
|
||||
);
|
||||
@override
|
||||
Future<ProgrammerTask> fromSqlite(
|
||||
Map<String, dynamic> input, {
|
||||
required provider,
|
||||
covariant OfflineFirstWithSupabaseRepository? repository,
|
||||
}) async => await _$ProgrammerTaskFromSqlite(
|
||||
input,
|
||||
provider: provider,
|
||||
repository: repository,
|
||||
);
|
||||
@override
|
||||
Future<Map<String, dynamic>> toSqlite(
|
||||
ProgrammerTask input, {
|
||||
required provider,
|
||||
covariant OfflineFirstWithSupabaseRepository? repository,
|
||||
}) async => await _$ProgrammerTaskToSqlite(
|
||||
input,
|
||||
provider: provider,
|
||||
repository: repository,
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,12 @@ import 'package:brick_supabase/brick_supabase.dart';
|
||||
// ignore: unused_import, unused_shown_name, unnecessary_import
|
||||
import '../utils/app_time.dart';
|
||||
// ignore: unused_import, unused_shown_name, unnecessary_import
|
||||
import 'package:tasq/utils/programmer_daily_time.dart';
|
||||
// ignore: unused_import, unused_shown_name, unnecessary_import
|
||||
import 'package:tasq/utils/programmer_day_rows.dart';
|
||||
// ignore: unused_import, unused_shown_name, unnecessary_import
|
||||
import '../utils/programmer_daily_time.dart';
|
||||
// ignore: unused_import, unused_shown_name, unnecessary_import
|
||||
import 'package:tasq/models/team_member.dart';// GENERATED CODE DO NOT EDIT
|
||||
// ignore: unused_import
|
||||
import 'dart:convert';
|
||||
@@ -35,6 +41,9 @@ import '../models/notification_item.model.dart';
|
||||
import '../models/office.model.dart';
|
||||
import '../models/pass_slip.model.dart';
|
||||
import '../models/profile.model.dart';
|
||||
import '../models/programmer_project.model.dart';
|
||||
import '../models/programmer_task.model.dart';
|
||||
import '../models/programmer_task_activity_log.model.dart';
|
||||
import '../models/service.model.dart';
|
||||
import '../models/swap_request.model.dart';
|
||||
import '../models/task.model.dart';
|
||||
@@ -56,6 +65,9 @@ part 'adapters/notification_item_adapter.g.dart';
|
||||
part 'adapters/office_adapter.g.dart';
|
||||
part 'adapters/pass_slip_adapter.g.dart';
|
||||
part 'adapters/profile_adapter.g.dart';
|
||||
part 'adapters/programmer_project_adapter.g.dart';
|
||||
part 'adapters/programmer_task_adapter.g.dart';
|
||||
part 'adapters/programmer_task_activity_log_adapter.g.dart';
|
||||
part 'adapters/service_adapter.g.dart';
|
||||
part 'adapters/swap_request_adapter.g.dart';
|
||||
part 'adapters/task_adapter.g.dart';
|
||||
@@ -79,6 +91,9 @@ final Map<Type, SupabaseAdapter<SupabaseModel>> supabaseMappings = {
|
||||
Office: OfficeAdapter(),
|
||||
PassSlip: PassSlipAdapter(),
|
||||
Profile: ProfileAdapter(),
|
||||
ProgrammerProject: ProgrammerProjectAdapter(),
|
||||
ProgrammerTask: ProgrammerTaskAdapter(),
|
||||
ProgrammerTaskActivityLog: ProgrammerTaskActivityLogAdapter(),
|
||||
Service: ServiceAdapter(),
|
||||
SwapRequest: SwapRequestAdapter(),
|
||||
Task: TaskAdapter(),
|
||||
@@ -104,6 +119,9 @@ final Map<Type, SqliteAdapter<SqliteModel>> sqliteMappings = {
|
||||
Office: OfficeAdapter(),
|
||||
PassSlip: PassSlipAdapter(),
|
||||
Profile: ProfileAdapter(),
|
||||
ProgrammerProject: ProgrammerProjectAdapter(),
|
||||
ProgrammerTask: ProgrammerTaskAdapter(),
|
||||
ProgrammerTaskActivityLog: ProgrammerTaskActivityLogAdapter(),
|
||||
Service: ServiceAdapter(),
|
||||
SwapRequest: SwapRequestAdapter(),
|
||||
Task: TaskAdapter(),
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
// GENERATED CODE EDIT WITH CAUTION
|
||||
// THIS FILE **WILL NOT** BE REGENERATED
|
||||
// This file should be version controlled and can be manually edited.
|
||||
part of 'schema.g.dart';
|
||||
|
||||
// While migrations are intelligently created, the difference between some commands, such as
|
||||
// DropTable vs. RenameTable, cannot be determined. For this reason, please review migrations after
|
||||
// they are created to ensure the correct inference was made.
|
||||
|
||||
// The migration version must **always** mirror the file name
|
||||
|
||||
const List<MigrationCommand> _migration_20260926162926_up = [
|
||||
InsertTable('ProgrammerTask'),
|
||||
InsertTable('ProgrammerTaskActivityLog'),
|
||||
InsertColumn('id', Column.varchar, onTable: 'ProgrammerTask'),
|
||||
InsertColumn('title', Column.varchar, onTable: 'ProgrammerTask'),
|
||||
InsertColumn('description', Column.varchar, onTable: 'ProgrammerTask'),
|
||||
InsertColumn('category', Column.varchar, onTable: 'ProgrammerTask'),
|
||||
InsertColumn('status', Column.varchar, onTable: 'ProgrammerTask'),
|
||||
InsertColumn('priority', Column.integer, onTable: 'ProgrammerTask'),
|
||||
InsertColumn('assignee_id', Column.varchar, onTable: 'ProgrammerTask'),
|
||||
InsertColumn('creator_id', Column.varchar, onTable: 'ProgrammerTask'),
|
||||
InsertColumn('created_at', Column.datetime, onTable: 'ProgrammerTask'),
|
||||
InsertColumn('started_at', Column.datetime, onTable: 'ProgrammerTask'),
|
||||
InsertColumn('completed_at', Column.datetime, onTable: 'ProgrammerTask'),
|
||||
InsertColumn('cancelled_at', Column.datetime, onTable: 'ProgrammerTask'),
|
||||
InsertColumn('cancellation_reason', Column.varchar, onTable: 'ProgrammerTask'),
|
||||
InsertColumn('updated_at', Column.datetime, onTable: 'ProgrammerTask'),
|
||||
InsertColumn('is_terminal', Column.boolean, onTable: 'ProgrammerTask'),
|
||||
InsertColumn('id', Column.varchar, onTable: 'ProgrammerTaskActivityLog'),
|
||||
InsertColumn('task_id', Column.varchar, onTable: 'ProgrammerTaskActivityLog'),
|
||||
InsertColumn('actor_id', Column.varchar, onTable: 'ProgrammerTaskActivityLog'),
|
||||
InsertColumn('action_type', Column.varchar, onTable: 'ProgrammerTaskActivityLog'),
|
||||
InsertColumn('meta', Column.varchar, onTable: 'ProgrammerTaskActivityLog'),
|
||||
InsertColumn('created_at', Column.datetime, onTable: 'ProgrammerTaskActivityLog')
|
||||
];
|
||||
|
||||
const List<MigrationCommand> _migration_20260926162926_down = [
|
||||
DropTable('ProgrammerTask'),
|
||||
DropTable('ProgrammerTaskActivityLog'),
|
||||
DropColumn('id', onTable: 'ProgrammerTask'),
|
||||
DropColumn('title', onTable: 'ProgrammerTask'),
|
||||
DropColumn('description', onTable: 'ProgrammerTask'),
|
||||
DropColumn('category', onTable: 'ProgrammerTask'),
|
||||
DropColumn('status', onTable: 'ProgrammerTask'),
|
||||
DropColumn('priority', onTable: 'ProgrammerTask'),
|
||||
DropColumn('assignee_id', onTable: 'ProgrammerTask'),
|
||||
DropColumn('creator_id', onTable: 'ProgrammerTask'),
|
||||
DropColumn('created_at', onTable: 'ProgrammerTask'),
|
||||
DropColumn('started_at', onTable: 'ProgrammerTask'),
|
||||
DropColumn('completed_at', onTable: 'ProgrammerTask'),
|
||||
DropColumn('cancelled_at', onTable: 'ProgrammerTask'),
|
||||
DropColumn('cancellation_reason', onTable: 'ProgrammerTask'),
|
||||
DropColumn('updated_at', onTable: 'ProgrammerTask'),
|
||||
DropColumn('is_terminal', onTable: 'ProgrammerTask'),
|
||||
DropColumn('id', onTable: 'ProgrammerTaskActivityLog'),
|
||||
DropColumn('task_id', onTable: 'ProgrammerTaskActivityLog'),
|
||||
DropColumn('actor_id', onTable: 'ProgrammerTaskActivityLog'),
|
||||
DropColumn('action_type', onTable: 'ProgrammerTaskActivityLog'),
|
||||
DropColumn('meta', onTable: 'ProgrammerTaskActivityLog'),
|
||||
DropColumn('created_at', onTable: 'ProgrammerTaskActivityLog')
|
||||
];
|
||||
|
||||
//
|
||||
// DO NOT EDIT BELOW THIS LINE
|
||||
//
|
||||
|
||||
@Migratable(
|
||||
version: '20260926162926',
|
||||
up: _migration_20260926162926_up,
|
||||
down: _migration_20260926162926_down,
|
||||
)
|
||||
class Migration20260926162926 extends Migration {
|
||||
const Migration20260926162926()
|
||||
: super(
|
||||
version: 20260926162926,
|
||||
up: _migration_20260926162926_up,
|
||||
down: _migration_20260926162926_down,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// GENERATED CODE EDIT WITH CAUTION
|
||||
// THIS FILE **WILL NOT** BE REGENERATED
|
||||
// This file should be version controlled and can be manually edited.
|
||||
part of 'schema.g.dart';
|
||||
|
||||
// While migrations are intelligently created, the difference between some commands, such as
|
||||
// DropTable vs. RenameTable, cannot be determined. For this reason, please review migrations after
|
||||
// they are created to ensure the correct inference was made.
|
||||
|
||||
// The migration version must **always** mirror the file name
|
||||
|
||||
const List<MigrationCommand> _migration_20260927030348_up = [
|
||||
InsertTable('ProgrammerProject'),
|
||||
InsertColumn('id', Column.varchar, onTable: 'ProgrammerProject'),
|
||||
InsertColumn('name', Column.varchar, onTable: 'ProgrammerProject'),
|
||||
InsertColumn('description', Column.varchar, onTable: 'ProgrammerProject'),
|
||||
InsertColumn('status', Column.varchar, onTable: 'ProgrammerProject'),
|
||||
InsertColumn('creator_id', Column.varchar, onTable: 'ProgrammerProject'),
|
||||
InsertColumn('created_at', Column.datetime, onTable: 'ProgrammerProject'),
|
||||
InsertColumn('updated_at', Column.datetime, onTable: 'ProgrammerProject'),
|
||||
InsertColumn('is_archived', Column.boolean, onTable: 'ProgrammerProject'),
|
||||
InsertColumn('project_id', Column.varchar, onTable: 'ProgrammerTask')
|
||||
];
|
||||
|
||||
const List<MigrationCommand> _migration_20260927030348_down = [
|
||||
DropTable('ProgrammerProject'),
|
||||
DropColumn('id', onTable: 'ProgrammerProject'),
|
||||
DropColumn('name', onTable: 'ProgrammerProject'),
|
||||
DropColumn('description', onTable: 'ProgrammerProject'),
|
||||
DropColumn('status', onTable: 'ProgrammerProject'),
|
||||
DropColumn('creator_id', onTable: 'ProgrammerProject'),
|
||||
DropColumn('created_at', onTable: 'ProgrammerProject'),
|
||||
DropColumn('updated_at', onTable: 'ProgrammerProject'),
|
||||
DropColumn('is_archived', onTable: 'ProgrammerProject'),
|
||||
DropColumn('project_id', onTable: 'ProgrammerTask')
|
||||
];
|
||||
|
||||
//
|
||||
// DO NOT EDIT BELOW THIS LINE
|
||||
//
|
||||
|
||||
@Migratable(
|
||||
version: '20260927030348',
|
||||
up: _migration_20260927030348_up,
|
||||
down: _migration_20260927030348_down,
|
||||
)
|
||||
class Migration20260927030348 extends Migration {
|
||||
const Migration20260927030348()
|
||||
: super(
|
||||
version: 20260927030348,
|
||||
up: _migration_20260927030348_up,
|
||||
down: _migration_20260927030348_down,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
// GENERATED CODE EDIT WITH CAUTION
|
||||
// THIS FILE **WILL NOT** BE REGENERATED
|
||||
// This file should be version controlled and can be manually edited.
|
||||
part of 'schema.g.dart';
|
||||
|
||||
// While migrations are intelligently created, the difference between some commands, such as
|
||||
// DropTable vs. RenameTable, cannot be determined. For this reason, please review migrations after
|
||||
// they are created to ensure the correct inference was made.
|
||||
|
||||
// The migration version must **always** mirror the file name
|
||||
|
||||
const List<MigrationCommand> _migration_20260928065256_up = [
|
||||
InsertColumn('day_sheet_id', Column.varchar, onTable: 'NotificationItem')
|
||||
];
|
||||
|
||||
const List<MigrationCommand> _migration_20260928065256_down = [
|
||||
DropColumn('day_sheet_id', onTable: 'NotificationItem')
|
||||
];
|
||||
|
||||
//
|
||||
// DO NOT EDIT BELOW THIS LINE
|
||||
//
|
||||
|
||||
@Migratable(
|
||||
version: '20260928065256',
|
||||
up: _migration_20260928065256_up,
|
||||
down: _migration_20260928065256_down,
|
||||
)
|
||||
class Migration20260928065256 extends Migration {
|
||||
const Migration20260928065256()
|
||||
: super(
|
||||
version: 20260928065256,
|
||||
up: _migration_20260928065256_up,
|
||||
down: _migration_20260928065256_down,
|
||||
);
|
||||
}
|
||||
@@ -1,18 +1,24 @@
|
||||
// GENERATED CODE DO NOT EDIT
|
||||
// This file should be version controlled
|
||||
import 'package:brick_sqlite/db.dart';
|
||||
part '20260928065256.migration.dart';
|
||||
part '20260411041808.migration.dart';
|
||||
part '20260411042152.migration.dart';
|
||||
part '20260926162926.migration.dart';
|
||||
part '20260927030348.migration.dart';
|
||||
|
||||
/// All intelligently-generated migrations from all `@Migratable` classes on disk
|
||||
final migrations = <Migration>{
|
||||
const Migration20260928065256(),
|
||||
const Migration20260411041808(),
|
||||
const Migration20260411042152(),
|
||||
const Migration20260926162926(),
|
||||
const Migration20260927030348(),
|
||||
};
|
||||
|
||||
/// A consumable database structure including the latest generated migration.
|
||||
final schema = Schema(
|
||||
20260411042152,
|
||||
20260927030348,
|
||||
generatorVersion: 1,
|
||||
tables: <SchemaTable>{
|
||||
SchemaTable(
|
||||
@@ -267,6 +273,7 @@ final schema = Schema(
|
||||
SchemaColumn('it_service_request_id', Column.varchar),
|
||||
SchemaColumn('announcement_id', Column.varchar),
|
||||
SchemaColumn('message_id', Column.integer),
|
||||
SchemaColumn('day_sheet_id', Column.varchar),
|
||||
SchemaColumn('type', Column.varchar),
|
||||
SchemaColumn('created_at', Column.datetime),
|
||||
SchemaColumn('read_at', Column.datetime),
|
||||
@@ -334,6 +341,75 @@ final schema = Schema(
|
||||
},
|
||||
indices: <SchemaIndex>{},
|
||||
),
|
||||
SchemaTable(
|
||||
'ProgrammerProject',
|
||||
columns: <SchemaColumn>{
|
||||
SchemaColumn(
|
||||
'_brick_id',
|
||||
Column.integer,
|
||||
autoincrement: true,
|
||||
nullable: false,
|
||||
isPrimaryKey: true,
|
||||
),
|
||||
SchemaColumn('id', Column.varchar),
|
||||
SchemaColumn('name', Column.varchar),
|
||||
SchemaColumn('description', Column.varchar),
|
||||
SchemaColumn('status', Column.varchar),
|
||||
SchemaColumn('creator_id', Column.varchar),
|
||||
SchemaColumn('created_at', Column.datetime),
|
||||
SchemaColumn('updated_at', Column.datetime),
|
||||
SchemaColumn('is_archived', Column.boolean),
|
||||
},
|
||||
indices: <SchemaIndex>{},
|
||||
),
|
||||
SchemaTable(
|
||||
'ProgrammerTask',
|
||||
columns: <SchemaColumn>{
|
||||
SchemaColumn(
|
||||
'_brick_id',
|
||||
Column.integer,
|
||||
autoincrement: true,
|
||||
nullable: false,
|
||||
isPrimaryKey: true,
|
||||
),
|
||||
SchemaColumn('id', Column.varchar),
|
||||
SchemaColumn('title', Column.varchar),
|
||||
SchemaColumn('description', Column.varchar),
|
||||
SchemaColumn('category', Column.varchar),
|
||||
SchemaColumn('status', Column.varchar),
|
||||
SchemaColumn('priority', Column.integer),
|
||||
SchemaColumn('assignee_id', Column.varchar),
|
||||
SchemaColumn('creator_id', Column.varchar),
|
||||
SchemaColumn('project_id', Column.varchar),
|
||||
SchemaColumn('created_at', Column.datetime),
|
||||
SchemaColumn('started_at', Column.datetime),
|
||||
SchemaColumn('completed_at', Column.datetime),
|
||||
SchemaColumn('cancelled_at', Column.datetime),
|
||||
SchemaColumn('cancellation_reason', Column.varchar),
|
||||
SchemaColumn('updated_at', Column.datetime),
|
||||
SchemaColumn('is_terminal', Column.boolean),
|
||||
},
|
||||
indices: <SchemaIndex>{},
|
||||
),
|
||||
SchemaTable(
|
||||
'ProgrammerTaskActivityLog',
|
||||
columns: <SchemaColumn>{
|
||||
SchemaColumn(
|
||||
'_brick_id',
|
||||
Column.integer,
|
||||
autoincrement: true,
|
||||
nullable: false,
|
||||
isPrimaryKey: true,
|
||||
),
|
||||
SchemaColumn('id', Column.varchar),
|
||||
SchemaColumn('task_id', Column.varchar),
|
||||
SchemaColumn('actor_id', Column.varchar),
|
||||
SchemaColumn('action_type', Column.varchar),
|
||||
SchemaColumn('meta', Column.varchar),
|
||||
SchemaColumn('created_at', Column.datetime),
|
||||
},
|
||||
indices: <SchemaIndex>{},
|
||||
),
|
||||
SchemaTable(
|
||||
'Service',
|
||||
columns: <SchemaColumn>{
|
||||
|
||||
@@ -19,6 +19,8 @@ class NotificationItem extends OfflineFirstWithSupabaseModel {
|
||||
final String? itServiceRequestId;
|
||||
final String? announcementId;
|
||||
final int? messageId;
|
||||
@Sqlite(name: 'day_sheet_id')
|
||||
final String? daySheetId;
|
||||
final String type;
|
||||
final DateTime createdAt;
|
||||
final DateTime? readAt;
|
||||
@@ -38,6 +40,7 @@ class NotificationItem extends OfflineFirstWithSupabaseModel {
|
||||
required this.itServiceRequestId,
|
||||
this.announcementId,
|
||||
required this.messageId,
|
||||
this.daySheetId,
|
||||
required this.type,
|
||||
required this.createdAt,
|
||||
required this.readAt,
|
||||
@@ -55,6 +58,7 @@ class NotificationItem extends OfflineFirstWithSupabaseModel {
|
||||
itServiceRequestId: map['it_service_request_id'] as String?,
|
||||
announcementId: map['announcement_id'] as String?,
|
||||
messageId: map['message_id'] as int?,
|
||||
daySheetId: map['day_sheet_id'] as String?,
|
||||
type: map['type'] as String? ?? 'mention',
|
||||
createdAt: AppTime.parse(map['created_at'] as String),
|
||||
readAt: map['read_at'] == null
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import 'package:tasq/utils/programmer_daily_time.dart';
|
||||
import 'package:tasq/utils/programmer_day_rows.dart';
|
||||
|
||||
/// Status constants for a programmer day sheet.
|
||||
class DaySheetStatus {
|
||||
static const String draft = 'draft';
|
||||
static const String pending = 'pending';
|
||||
static const String disapproved = 'disapproved';
|
||||
static const String approved = 'approved';
|
||||
|
||||
static String label(String s) {
|
||||
switch (s) {
|
||||
case draft:
|
||||
return 'Draft';
|
||||
case pending:
|
||||
return 'Pending';
|
||||
case disapproved:
|
||||
return 'Disapproved';
|
||||
case approved:
|
||||
return 'Approved';
|
||||
default:
|
||||
return s;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Immutable snapshot of a day sheet at approval time.
|
||||
class DaySheetSnapshot {
|
||||
final int totalSeconds;
|
||||
final List<DaySheetRow> rows;
|
||||
|
||||
const DaySheetSnapshot({required this.totalSeconds, required this.rows});
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'v': 1,
|
||||
'total_seconds': totalSeconds,
|
||||
'rows': rows.map((r) => r.toJson()).toList(),
|
||||
};
|
||||
|
||||
/// Alias for [toJson]; used by the approval RPC payload.
|
||||
Map<String, dynamic> toMap() => toJson();
|
||||
|
||||
factory DaySheetSnapshot.fromJson(Map<String, dynamic> j) {
|
||||
final rowsList = (j['rows'] as List? ?? [])
|
||||
.map((r) => DaySheetRow.fromJson(r as Map<String, dynamic>))
|
||||
.toList();
|
||||
return DaySheetSnapshot(
|
||||
totalSeconds: (j['total_seconds'] as num).toInt(),
|
||||
rows: rowsList,
|
||||
);
|
||||
}
|
||||
|
||||
factory DaySheetSnapshot.fromRows(List<DaySheetRow> rows) {
|
||||
final total = rows.fold(0, (sum, r) => sum + r.seconds);
|
||||
return DaySheetSnapshot(totalSeconds: total, rows: rows);
|
||||
}
|
||||
}
|
||||
|
||||
/// A programmer's daily time-sheet submission for one work day.
|
||||
class ProgrammerDaySheet {
|
||||
final String id;
|
||||
final String programmerId;
|
||||
final String status;
|
||||
final DateTime workDate;
|
||||
final DateTime? submittedAt;
|
||||
final DateTime? reviewedAt;
|
||||
final bool autoSubmitted;
|
||||
final String? reviewedBy;
|
||||
final int resubmissions;
|
||||
final DaySheetSnapshot? approvedSnapshot;
|
||||
final DateTime createdAt;
|
||||
final DateTime updatedAt;
|
||||
|
||||
const ProgrammerDaySheet({
|
||||
required this.id,
|
||||
required this.programmerId,
|
||||
required this.status,
|
||||
required this.workDate,
|
||||
this.submittedAt,
|
||||
this.reviewedAt,
|
||||
this.autoSubmitted = false,
|
||||
this.reviewedBy,
|
||||
this.resubmissions = 0,
|
||||
this.approvedSnapshot,
|
||||
required this.createdAt,
|
||||
required this.updatedAt,
|
||||
});
|
||||
|
||||
/// True when approved on the first submission (no resubmissions needed).
|
||||
bool get isFirstPassApproved =>
|
||||
status == DaySheetStatus.approved && resubmissions == 0;
|
||||
|
||||
factory ProgrammerDaySheet.fromMap(Map<String, dynamic> m) {
|
||||
return ProgrammerDaySheet(
|
||||
id: m['id'] as String,
|
||||
programmerId: m['programmer_id'] as String,
|
||||
status: m['status'] as String? ?? DaySheetStatus.draft,
|
||||
workDate: parseDayKey(m['work_date'] as String),
|
||||
submittedAt: m['submitted_at'] != null
|
||||
? DateTime.parse(m['submitted_at'] as String)
|
||||
: null,
|
||||
reviewedAt: m['reviewed_at'] != null
|
||||
? DateTime.parse(m['reviewed_at'] as String)
|
||||
: null,
|
||||
autoSubmitted: m['auto_submitted'] as bool? ?? false,
|
||||
reviewedBy: m['reviewed_by'] as String?,
|
||||
resubmissions: (m['resubmissions'] as num?)?.toInt() ?? 0,
|
||||
approvedSnapshot: m['approved_snapshot'] != null
|
||||
? DaySheetSnapshot.fromJson(
|
||||
m['approved_snapshot'] as Map<String, dynamic>)
|
||||
: null,
|
||||
createdAt: DateTime.parse(m['created_at'] as String),
|
||||
updatedAt: DateTime.parse(m['updated_at'] as String),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/// A single audit-trail event on a programmer day sheet.
|
||||
class ProgrammerDaySheetEvent {
|
||||
final String id;
|
||||
final String sheetId;
|
||||
final String kind;
|
||||
final String? actorId;
|
||||
final String? body;
|
||||
final List<String> flaggedTaskIds;
|
||||
final DateTime createdAt;
|
||||
|
||||
const ProgrammerDaySheetEvent({
|
||||
required this.id,
|
||||
required this.sheetId,
|
||||
required this.kind,
|
||||
this.actorId,
|
||||
this.body,
|
||||
required this.flaggedTaskIds,
|
||||
required this.createdAt,
|
||||
});
|
||||
|
||||
factory ProgrammerDaySheetEvent.fromMap(Map<String, dynamic> m) {
|
||||
return ProgrammerDaySheetEvent(
|
||||
id: m['id'] as String,
|
||||
sheetId: m['sheet_id'] as String,
|
||||
kind: m['kind'] as String,
|
||||
actorId: m['actor_id'] as String?,
|
||||
body: m['body'] as String?,
|
||||
flaggedTaskIds: List<String>.from(m['flagged_task_ids'] as List? ?? []),
|
||||
createdAt: DateTime.parse(m['created_at'] as String),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import 'package:brick_offline_first_with_supabase/brick_offline_first_with_supabase.dart';
|
||||
import 'package:brick_sqlite/brick_sqlite.dart';
|
||||
import 'package:brick_supabase/brick_supabase.dart';
|
||||
|
||||
import '../utils/app_time.dart';
|
||||
|
||||
/// Status of a programmer project.
|
||||
class ProgrammerProjectStatus {
|
||||
static const active = 'active';
|
||||
static const archived = 'archived';
|
||||
|
||||
static const all = [active, archived];
|
||||
|
||||
static String label(String status) {
|
||||
switch (status) {
|
||||
case active:
|
||||
return 'Active';
|
||||
case archived:
|
||||
return 'Archived';
|
||||
default:
|
||||
return status;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ConnectOfflineFirstWithSupabase(
|
||||
supabaseConfig: SupabaseSerializable(tableName: 'programmer_projects'),
|
||||
)
|
||||
class ProgrammerProject extends OfflineFirstWithSupabaseModel {
|
||||
final String id;
|
||||
final String name;
|
||||
final String? description;
|
||||
final String status;
|
||||
final String? creatorId;
|
||||
final DateTime createdAt;
|
||||
final DateTime updatedAt;
|
||||
|
||||
ProgrammerProject({
|
||||
required this.id,
|
||||
required this.name,
|
||||
this.description,
|
||||
required this.status,
|
||||
this.creatorId,
|
||||
required this.createdAt,
|
||||
required this.updatedAt,
|
||||
});
|
||||
|
||||
bool get isArchived => status == ProgrammerProjectStatus.archived;
|
||||
|
||||
@Supabase(ignore: true)
|
||||
@Sqlite(ignore: true)
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is ProgrammerProject &&
|
||||
runtimeType == other.runtimeType &&
|
||||
id == other.id &&
|
||||
name == other.name &&
|
||||
status == other.status &&
|
||||
updatedAt == other.updatedAt;
|
||||
|
||||
@Supabase(ignore: true)
|
||||
@Sqlite(ignore: true)
|
||||
@override
|
||||
int get hashCode => Object.hash(id, name, status, updatedAt);
|
||||
|
||||
factory ProgrammerProject.fromMap(Map<String, dynamic> map) {
|
||||
return ProgrammerProject(
|
||||
id: map['id'].toString(),
|
||||
name: map['name'] as String? ?? '',
|
||||
description: map['description'] as String?,
|
||||
status: map['status'] as String? ?? ProgrammerProjectStatus.active,
|
||||
creatorId: map['creator_id']?.toString(),
|
||||
createdAt: AppTime.parse(map['created_at'] as String),
|
||||
updatedAt: map['updated_at'] == null
|
||||
? AppTime.parse(map['created_at'] as String)
|
||||
: AppTime.parse(map['updated_at'] as String),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import 'package:brick_offline_first_with_supabase/brick_offline_first_with_supabase.dart';
|
||||
import 'package:brick_sqlite/brick_sqlite.dart';
|
||||
import 'package:brick_supabase/brick_supabase.dart';
|
||||
|
||||
import '../utils/app_time.dart';
|
||||
|
||||
/// Work categories for a programmer task. Values are the TitleCase display
|
||||
/// strings stored directly in the `programmer_task_category` Postgres enum
|
||||
/// (same convention as the IT-support request_type / request_category enums).
|
||||
class ProgrammerTaskCategory {
|
||||
static const softwareSupport = 'Software Support';
|
||||
static const softwareDevelopment = 'Software Development';
|
||||
static const softwareEnhancement = 'Software Enhancement';
|
||||
static const bugFix = 'Bug Fix';
|
||||
static const meeting = 'Meeting';
|
||||
static const facilitateEvent = 'Facilitate Event';
|
||||
static const others = 'Others';
|
||||
|
||||
static const all = [
|
||||
softwareSupport,
|
||||
softwareDevelopment,
|
||||
softwareEnhancement,
|
||||
bugFix,
|
||||
meeting,
|
||||
facilitateEvent,
|
||||
others,
|
||||
];
|
||||
|
||||
/// Enum values already read as display labels, but this keeps the call site
|
||||
/// symmetric with other models and guards unknown values.
|
||||
static String label(String category) =>
|
||||
all.contains(category) ? category : category;
|
||||
}
|
||||
|
||||
/// Status lifecycle for a programmer task.
|
||||
///
|
||||
/// `queued -> in_progress -> completed | cancelled`. Pause/resume are recorded
|
||||
/// as activity-log events (not statuses): a paused task keeps `inProgress`.
|
||||
class ProgrammerTaskStatus {
|
||||
static const queued = 'queued';
|
||||
static const inProgress = 'in_progress';
|
||||
static const completed = 'completed';
|
||||
static const cancelled = 'cancelled';
|
||||
|
||||
static const all = [queued, inProgress, completed, cancelled];
|
||||
|
||||
static const terminal = [completed, cancelled];
|
||||
|
||||
static String label(String status) {
|
||||
switch (status) {
|
||||
case queued:
|
||||
return 'Queued';
|
||||
case inProgress:
|
||||
return 'In Progress';
|
||||
case completed:
|
||||
return 'Completed';
|
||||
case cancelled:
|
||||
return 'Cancelled';
|
||||
default:
|
||||
return status;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ConnectOfflineFirstWithSupabase(
|
||||
supabaseConfig: SupabaseSerializable(tableName: 'programmer_tasks'),
|
||||
)
|
||||
class ProgrammerTask extends OfflineFirstWithSupabaseModel {
|
||||
final String id;
|
||||
final String title;
|
||||
final String? description;
|
||||
final String category;
|
||||
final String status;
|
||||
final int priority;
|
||||
final String? assigneeId;
|
||||
final String? creatorId;
|
||||
final String? projectId;
|
||||
final DateTime createdAt;
|
||||
final DateTime? startedAt;
|
||||
final DateTime? completedAt;
|
||||
final DateTime? cancelledAt;
|
||||
final String? cancellationReason;
|
||||
final DateTime updatedAt;
|
||||
|
||||
ProgrammerTask({
|
||||
required this.id,
|
||||
required this.title,
|
||||
this.description,
|
||||
required this.category,
|
||||
required this.status,
|
||||
this.priority = 1,
|
||||
this.assigneeId,
|
||||
this.creatorId,
|
||||
this.projectId,
|
||||
required this.createdAt,
|
||||
this.startedAt,
|
||||
this.completedAt,
|
||||
this.cancelledAt,
|
||||
this.cancellationReason,
|
||||
required this.updatedAt,
|
||||
});
|
||||
|
||||
bool get isTerminal => ProgrammerTaskStatus.terminal.contains(status);
|
||||
|
||||
@Supabase(ignore: true)
|
||||
@Sqlite(ignore: true)
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is ProgrammerTask &&
|
||||
runtimeType == other.runtimeType &&
|
||||
id == other.id &&
|
||||
status == other.status &&
|
||||
updatedAt == other.updatedAt;
|
||||
|
||||
@Supabase(ignore: true)
|
||||
@Sqlite(ignore: true)
|
||||
@override
|
||||
int get hashCode => Object.hash(id, status, updatedAt);
|
||||
|
||||
factory ProgrammerTask.fromMap(Map<String, dynamic> map) {
|
||||
return ProgrammerTask(
|
||||
id: map['id'].toString(),
|
||||
title: map['title'] as String? ?? '',
|
||||
description: map['description'] as String?,
|
||||
category: map['category'] as String? ?? ProgrammerTaskCategory.others,
|
||||
status: map['status'] as String? ?? ProgrammerTaskStatus.queued,
|
||||
priority: (map['priority'] as num?)?.toInt() ?? 1,
|
||||
assigneeId: map['assignee_id']?.toString(),
|
||||
creatorId: map['creator_id']?.toString(),
|
||||
projectId: map['project_id']?.toString(),
|
||||
createdAt: AppTime.parse(map['created_at'] as String),
|
||||
startedAt: map['started_at'] == null
|
||||
? null
|
||||
: AppTime.parse(map['started_at'] as String),
|
||||
completedAt: map['completed_at'] == null
|
||||
? null
|
||||
: AppTime.parse(map['completed_at'] as String),
|
||||
cancelledAt: map['cancelled_at'] == null
|
||||
? null
|
||||
: AppTime.parse(map['cancelled_at'] as String),
|
||||
cancellationReason: map['cancellation_reason'] as String?,
|
||||
updatedAt: map['updated_at'] == null
|
||||
? AppTime.parse(map['created_at'] as String)
|
||||
: AppTime.parse(map['updated_at'] as String),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:brick_offline_first_with_supabase/brick_offline_first_with_supabase.dart';
|
||||
import 'package:brick_supabase/brick_supabase.dart';
|
||||
|
||||
import '../utils/app_time.dart';
|
||||
|
||||
/// Event log for a programmer task. Mirrors [TaskActivityLog] but keyed to
|
||||
/// `programmer_tasks`. `actionType` values: created, started, paused, resumed,
|
||||
/// completed, cancelled, assigned, reassigned.
|
||||
@ConnectOfflineFirstWithSupabase(
|
||||
supabaseConfig: SupabaseSerializable(tableName: 'programmer_task_activity_logs'),
|
||||
)
|
||||
class ProgrammerTaskActivityLog extends OfflineFirstWithSupabaseModel {
|
||||
final String id;
|
||||
final String taskId;
|
||||
final String? actorId;
|
||||
final String actionType;
|
||||
final Map<String, dynamic>? meta;
|
||||
final DateTime createdAt;
|
||||
|
||||
ProgrammerTaskActivityLog({
|
||||
required this.id,
|
||||
required this.taskId,
|
||||
this.actorId,
|
||||
required this.actionType,
|
||||
this.meta,
|
||||
required this.createdAt,
|
||||
});
|
||||
|
||||
factory ProgrammerTaskActivityLog.fromMap(Map<String, dynamic> map) {
|
||||
final id = map['id'] == null ? '' : map['id'].toString();
|
||||
final taskId = map['task_id'] == null ? '' : map['task_id'].toString();
|
||||
final actorId = map['actor_id']?.toString();
|
||||
final actionType = (map['action_type'] as String?) ?? 'unknown';
|
||||
|
||||
Map<String, dynamic>? meta;
|
||||
final rawMeta = map['meta'];
|
||||
if (rawMeta is Map<String, dynamic>) {
|
||||
meta = rawMeta;
|
||||
} else if (rawMeta is Map) {
|
||||
meta = rawMeta.map((k, v) => MapEntry(k.toString(), v));
|
||||
} else if (rawMeta is String && rawMeta.isNotEmpty) {
|
||||
try {
|
||||
final decoded = jsonDecode(rawMeta);
|
||||
if (decoded is Map) {
|
||||
meta = decoded.map((k, v) => MapEntry(k.toString(), v));
|
||||
}
|
||||
} catch (_) {
|
||||
meta = null;
|
||||
}
|
||||
}
|
||||
|
||||
final rawCreated = map['created_at'];
|
||||
DateTime createdAt;
|
||||
if (rawCreated is DateTime) {
|
||||
createdAt = AppTime.toAppTime(rawCreated);
|
||||
} else if (rawCreated is String) {
|
||||
try {
|
||||
createdAt = AppTime.parse(rawCreated);
|
||||
} catch (_) {
|
||||
createdAt = AppTime.now();
|
||||
}
|
||||
} else {
|
||||
createdAt = AppTime.now();
|
||||
}
|
||||
|
||||
return ProgrammerTaskActivityLog(
|
||||
id: id,
|
||||
taskId: taskId,
|
||||
actorId: actorId,
|
||||
actionType: actionType,
|
||||
meta: meta,
|
||||
createdAt: createdAt,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import '../utils/app_time.dart';
|
||||
|
||||
/// One file attached to a comment (stored in the task_attachments bucket).
|
||||
class ProgrammerCommentAttachment {
|
||||
final String path;
|
||||
final String name;
|
||||
|
||||
const ProgrammerCommentAttachment({required this.path, required this.name});
|
||||
|
||||
Map<String, dynamic> toJson() => {'path': path, 'name': name};
|
||||
|
||||
factory ProgrammerCommentAttachment.fromJson(Map<String, dynamic> json) {
|
||||
return ProgrammerCommentAttachment(
|
||||
path: json['path']?.toString() ?? '',
|
||||
name: json['name']?.toString() ?? '',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A comment on a programmer task (plain, online-first model). Any user who can
|
||||
/// see the task may comment and attach files, whether or not they are the
|
||||
/// assignee.
|
||||
class ProgrammerTaskComment {
|
||||
final String id;
|
||||
final String taskId;
|
||||
final String authorId;
|
||||
final String body;
|
||||
final List<ProgrammerCommentAttachment> attachments;
|
||||
final DateTime createdAt;
|
||||
|
||||
ProgrammerTaskComment({
|
||||
required this.id,
|
||||
required this.taskId,
|
||||
required this.authorId,
|
||||
required this.body,
|
||||
this.attachments = const [],
|
||||
required this.createdAt,
|
||||
});
|
||||
|
||||
factory ProgrammerTaskComment.fromMap(Map<String, dynamic> map) {
|
||||
return ProgrammerTaskComment(
|
||||
id: map['id'].toString(),
|
||||
taskId: map['task_id'].toString(),
|
||||
authorId: map['author_id'].toString(),
|
||||
body: map['body'] as String? ?? '',
|
||||
attachments: _parseAttachments(map['attachments']),
|
||||
createdAt: AppTime.parse(map['created_at'] as String),
|
||||
);
|
||||
}
|
||||
|
||||
static List<ProgrammerCommentAttachment> _parseAttachments(dynamic raw) {
|
||||
List<dynamic> list;
|
||||
if (raw is List) {
|
||||
list = raw;
|
||||
} else if (raw is String && raw.isNotEmpty) {
|
||||
try {
|
||||
final decoded = jsonDecode(raw);
|
||||
list = decoded is List ? decoded : const [];
|
||||
} catch (_) {
|
||||
list = const [];
|
||||
}
|
||||
} else {
|
||||
return const [];
|
||||
}
|
||||
return list
|
||||
.whereType<Map>()
|
||||
.map((e) => ProgrammerCommentAttachment.fromJson(
|
||||
e.map((k, v) => MapEntry(k.toString(), v)),
|
||||
))
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import '../utils/app_time.dart';
|
||||
import '../utils/programmer_daily_time.dart';
|
||||
|
||||
/// A work-log entry on a programmer task (plain, online-first model).
|
||||
///
|
||||
/// The assignee logs progress notes with [minutes] == null (their time comes
|
||||
/// from the running clock). A non-assignee "helper" logs with [minutes] set —
|
||||
/// their contributed time, which feeds the per-contributor time ledger and can
|
||||
/// be deducted from the helper's own running task.
|
||||
class ProgrammerTaskWorkLog {
|
||||
final String id;
|
||||
final String taskId;
|
||||
final String authorId;
|
||||
final String description;
|
||||
final int? minutes;
|
||||
final DateTime createdAt;
|
||||
final DateTime workDate;
|
||||
|
||||
ProgrammerTaskWorkLog({
|
||||
required this.id,
|
||||
required this.taskId,
|
||||
required this.authorId,
|
||||
required this.description,
|
||||
this.minutes,
|
||||
required this.createdAt,
|
||||
DateTime? workDate,
|
||||
}) : workDate = workDate ?? manilaDay(createdAt);
|
||||
|
||||
factory ProgrammerTaskWorkLog.fromMap(Map<String, dynamic> map) {
|
||||
final createdAt = AppTime.parse(map['created_at'] as String);
|
||||
final workDate = map['work_date'] != null
|
||||
? parseDayKey(map['work_date'] as String)
|
||||
: manilaDay(createdAt);
|
||||
return ProgrammerTaskWorkLog(
|
||||
id: map['id'].toString(),
|
||||
taskId: map['task_id'].toString(),
|
||||
authorId: map['author_id'].toString(),
|
||||
description: map['description'] as String? ?? '',
|
||||
minutes: (map['minutes'] as num?)?.toInt(),
|
||||
createdAt: createdAt,
|
||||
workDate: workDate,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -310,3 +310,11 @@ final isAdminProvider = Provider<bool>((ref) {
|
||||
orElse: () => false,
|
||||
);
|
||||
});
|
||||
|
||||
final isStrictAdminProvider = Provider<bool>((ref) {
|
||||
final profileAsync = ref.watch(currentProfileProvider);
|
||||
return profileAsync.maybeWhen(
|
||||
data: (profile) => profile?.role == 'admin',
|
||||
orElse: () => false,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
|
||||
import '../models/programmer_task.model.dart';
|
||||
import '../models/programmer_task_activity_log.model.dart';
|
||||
import '../models/programmer_task_work_log.model.dart';
|
||||
import '../utils/app_time.dart';
|
||||
import '../utils/programmer_daily_time.dart';
|
||||
import 'programmer_tasks_provider.dart';
|
||||
import 'supabase_provider.dart';
|
||||
|
||||
enum ProgrammerRunState { running, paused, notStarted, completed, cancelled }
|
||||
|
||||
typedef ProgrammerRunInfo = ({ProgrammerRunState state, DateTime? since});
|
||||
|
||||
/// Pure function — no providers.
|
||||
Map<String, ProgrammerRunInfo> computeRunStates(
|
||||
List<ProgrammerTask> tasks,
|
||||
Map<String, List<ProgrammerTaskActivityLog>> logsByTask,
|
||||
) {
|
||||
return {for (final t in tasks) t.id: _runInfoFor(t, logsByTask[t.id] ?? [])};
|
||||
}
|
||||
|
||||
ProgrammerRunInfo _runInfoFor(
|
||||
ProgrammerTask task,
|
||||
List<ProgrammerTaskActivityLog> logs,
|
||||
) {
|
||||
switch (task.status) {
|
||||
case ProgrammerTaskStatus.queued:
|
||||
return (state: ProgrammerRunState.notStarted, since: null);
|
||||
case ProgrammerTaskStatus.completed:
|
||||
return (state: ProgrammerRunState.completed, since: null);
|
||||
case ProgrammerTaskStatus.cancelled:
|
||||
return (state: ProgrammerRunState.cancelled, since: null);
|
||||
default:
|
||||
return _inProgressInfo(task, logs);
|
||||
}
|
||||
}
|
||||
|
||||
ProgrammerRunInfo _inProgressInfo(
|
||||
ProgrammerTask task,
|
||||
List<ProgrammerTaskActivityLog> logs,
|
||||
) {
|
||||
const types = ['started', 'paused', 'resumed'];
|
||||
final relevant = logs.where((l) => types.contains(l.actionType)).toList()
|
||||
..sort((a, b) => b.createdAt.compareTo(a.createdAt));
|
||||
if (relevant.isEmpty) {
|
||||
return (state: ProgrammerRunState.running, since: task.startedAt);
|
||||
}
|
||||
final latest = relevant.first;
|
||||
if (latest.actionType == 'paused') {
|
||||
return (state: ProgrammerRunState.paused, since: latest.createdAt);
|
||||
}
|
||||
return (state: ProgrammerRunState.running, since: latest.createdAt);
|
||||
}
|
||||
|
||||
class DayActivityData {
|
||||
const DayActivityData({
|
||||
required this.day,
|
||||
required this.tasks,
|
||||
required this.logsByTask,
|
||||
required this.workLogs,
|
||||
});
|
||||
|
||||
final DateTime day;
|
||||
final List<ProgrammerTask> tasks;
|
||||
final Map<String, List<ProgrammerTaskActivityLog>> logsByTask;
|
||||
final List<ProgrammerTaskWorkLog> workLogs;
|
||||
}
|
||||
|
||||
bool _isTaskActiveOnDay(
|
||||
ProgrammerTask t,
|
||||
DateTime dayStart,
|
||||
DateTime dayEnd,
|
||||
DateTime now,
|
||||
) {
|
||||
if (t.status == ProgrammerTaskStatus.inProgress) return true;
|
||||
if (t.startedAt == null) return false;
|
||||
final terminal = t.completedAt ?? t.cancelledAt ?? now;
|
||||
return t.startedAt!.isBefore(dayEnd) && !terminal.isBefore(dayStart);
|
||||
}
|
||||
|
||||
Future<List<ProgrammerTaskWorkLog>> _fetchWorkLogs(
|
||||
dynamic client,
|
||||
DateTime day,
|
||||
) async {
|
||||
final raw = await client
|
||||
.from('programmer_task_work_logs')
|
||||
.select()
|
||||
.eq('work_date', formatDayKey(day)) as List;
|
||||
return raw
|
||||
.map((r) => ProgrammerTaskWorkLog.fromMap(r as Map<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
|
||||
Future<Map<String, List<ProgrammerTaskActivityLog>>> _fetchActivityLogs(
|
||||
dynamic client,
|
||||
List<String> ids,
|
||||
) async {
|
||||
final raw = await client
|
||||
.from('programmer_task_activity_logs')
|
||||
.select()
|
||||
.inFilter('task_id', ids)
|
||||
.order('created_at', ascending: false) as List;
|
||||
final map = <String, List<ProgrammerTaskActivityLog>>{};
|
||||
for (final r in raw) {
|
||||
final log = ProgrammerTaskActivityLog.fromMap(r as Map<String, dynamic>);
|
||||
map.putIfAbsent(log.taskId, () => []).add(log);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
Future<DayActivityData> fetchDayActivityData(
|
||||
dynamic client, {
|
||||
required DateTime day,
|
||||
required List<ProgrammerTask> tasks,
|
||||
required DateTime now,
|
||||
}) async {
|
||||
final dayStart = manilaDayStart(day);
|
||||
final dayEnd = dayStart.add(const Duration(days: 1));
|
||||
final candidates =
|
||||
tasks.where((t) => _isTaskActiveOnDay(t, dayStart, dayEnd, now)).toList();
|
||||
|
||||
final workLogs = await _fetchWorkLogs(client, day);
|
||||
|
||||
final cIds = {for (final t in candidates) t.id};
|
||||
candidates.addAll(
|
||||
tasks.where((t) => workLogs.any((l) => l.taskId == t.id) && !cIds.contains(t.id)),
|
||||
);
|
||||
|
||||
if (candidates.isEmpty) {
|
||||
return DayActivityData(day: day, tasks: [], logsByTask: {}, workLogs: workLogs);
|
||||
}
|
||||
|
||||
final logsByTask =
|
||||
await _fetchActivityLogs(client, candidates.map((t) => t.id).toList());
|
||||
return DayActivityData(
|
||||
day: day,
|
||||
tasks: candidates,
|
||||
logsByTask: logsByTask,
|
||||
workLogs: workLogs,
|
||||
);
|
||||
}
|
||||
|
||||
/// Emits [AppTime.now()] immediately then every 30 seconds.
|
||||
final clockTickerProvider = StreamProvider<DateTime>((ref) async* {
|
||||
yield AppTime.now();
|
||||
yield* Stream.periodic(const Duration(seconds: 30), (_) => AppTime.now());
|
||||
});
|
||||
|
||||
/// Manila calendar day derived from the clock ticker.
|
||||
final todayKeyProvider = Provider<DateTime>((ref) {
|
||||
final tick = ref.watch(clockTickerProvider).valueOrNull;
|
||||
return manilaDay(tick ?? AppTime.now());
|
||||
});
|
||||
|
||||
/// Increments by 1 for each INSERT on either log table.
|
||||
final programmerActivitySignalProvider = StreamProvider<int>((ref) {
|
||||
final client = ref.watch(supabaseClientProvider);
|
||||
final controller = StreamController<int>();
|
||||
var count = 0;
|
||||
|
||||
void onInsert(PostgresChangePayload _) {
|
||||
count++;
|
||||
if (!controller.isClosed) controller.add(count);
|
||||
}
|
||||
|
||||
final channel = client
|
||||
.channel('programmer_activity_signal')
|
||||
.onPostgresChanges(
|
||||
event: PostgresChangeEvent.insert,
|
||||
schema: 'public',
|
||||
table: 'programmer_task_work_logs',
|
||||
callback: onInsert,
|
||||
)
|
||||
.onPostgresChanges(
|
||||
event: PostgresChangeEvent.insert,
|
||||
schema: 'public',
|
||||
table: 'programmer_task_activity_logs',
|
||||
callback: onInsert,
|
||||
);
|
||||
channel.subscribe();
|
||||
|
||||
ref.onDispose(() {
|
||||
client.removeChannel(channel);
|
||||
controller.close();
|
||||
});
|
||||
|
||||
return controller.stream;
|
||||
});
|
||||
|
||||
/// Fetches activity data for a given day key. Invalidated by the activity signal.
|
||||
final programmerDayDataProvider =
|
||||
FutureProvider.family<DayActivityData, DateTime>((ref, day) async {
|
||||
ref.watch(programmerActivitySignalProvider);
|
||||
final tasks = ref.watch(programmerTasksProvider).valueOrNull ?? [];
|
||||
final client = ref.watch(supabaseClientProvider);
|
||||
return fetchDayActivityData(client, day: day, tasks: tasks, now: AppTime.now());
|
||||
});
|
||||
|
||||
/// Total help-given seconds for [userId] in the current Manila calendar month.
|
||||
///
|
||||
/// Queries work logs with minutes > 0 authored by [userId] this month.
|
||||
/// Re-fetches whenever [programmerActivitySignalProvider] fires.
|
||||
final helpGivenThisMonthProvider =
|
||||
FutureProvider.family<int, String>((ref, userId) async {
|
||||
ref.watch(programmerActivitySignalProvider);
|
||||
final client = ref.watch(supabaseClientProvider);
|
||||
final today = ref.watch(todayKeyProvider);
|
||||
final monthStart = DateTime.utc(today.year, today.month, 1);
|
||||
final nextMonthStart = today.month < 12
|
||||
? DateTime.utc(today.year, today.month + 1, 1)
|
||||
: DateTime.utc(today.year + 1, 1, 1);
|
||||
|
||||
final raw = await client
|
||||
.from('programmer_task_work_logs')
|
||||
.select('minutes')
|
||||
.eq('author_id', userId)
|
||||
.gte('work_date', formatDayKey(monthStart))
|
||||
.lt('work_date', formatDayKey(nextMonthStart)) as List;
|
||||
|
||||
int totalSec = 0;
|
||||
for (final r in raw) {
|
||||
final minutes = r['minutes'] as int?;
|
||||
if (minutes != null && minutes > 0) totalSec += minutes * 60;
|
||||
}
|
||||
return totalSec;
|
||||
});
|
||||
|
||||
/// Run states for today's tasks, derived from today's [programmerDayDataProvider].
|
||||
final programmerRunStatesProvider = Provider<Map<String, ProgrammerRunInfo>>((ref) {
|
||||
final today = ref.watch(todayKeyProvider);
|
||||
final data = ref.watch(programmerDayDataProvider(today)).valueOrNull;
|
||||
if (data == null) return {};
|
||||
return computeRunStates(data.tasks, data.logsByTask);
|
||||
});
|
||||
@@ -0,0 +1,388 @@
|
||||
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());
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../brick/cache_helpers.dart';
|
||||
import '../models/programmer_project.model.dart';
|
||||
import 'profile_provider.dart';
|
||||
import 'realtime_controller.dart';
|
||||
import 'stream_recovery.dart';
|
||||
import 'supabase_provider.dart';
|
||||
|
||||
void _sortProjects(List<ProgrammerProject> projects) {
|
||||
projects.sort((a, b) {
|
||||
// Active before archived, then alphabetical.
|
||||
if (a.isArchived != b.isArchived) return a.isArchived ? 1 : -1;
|
||||
return a.name.toLowerCase().compareTo(b.name.toLowerCase());
|
||||
});
|
||||
}
|
||||
|
||||
/// All programmer projects (offline-readable via Brick cache).
|
||||
final programmerProjectsProvider = StreamProvider<List<ProgrammerProject>>((ref) {
|
||||
final userId = ref.watch(currentUserIdProvider);
|
||||
if (userId == null) return const Stream.empty();
|
||||
final client = ref.watch(supabaseClientProvider);
|
||||
|
||||
final wrapper = StreamRecoveryWrapper<ProgrammerProject>(
|
||||
stream: client
|
||||
.from('programmer_projects')
|
||||
.stream(primaryKey: ['id']).order('created_at', ascending: false),
|
||||
onPollData: () async {
|
||||
final data = await client
|
||||
.from('programmer_projects')
|
||||
.select()
|
||||
.order('created_at', ascending: false);
|
||||
return data.map(ProgrammerProject.fromMap).toList();
|
||||
},
|
||||
fromMap: ProgrammerProject.fromMap,
|
||||
channelName: 'programmer_projects',
|
||||
onStatusChanged: ref.read(realtimeControllerProvider).handleChannelStatus,
|
||||
onOfflineData: () async {
|
||||
final all = await cachedListFromBrick<ProgrammerProject>();
|
||||
_sortProjects(all);
|
||||
return all;
|
||||
},
|
||||
onCacheMirror: (rows) =>
|
||||
mirrorBatchToBrick<ProgrammerProject>(rows, tag: 'programmer_projects'),
|
||||
);
|
||||
|
||||
ref.onDispose(wrapper.dispose);
|
||||
return wrapper.stream.map((result) {
|
||||
final list = List<ProgrammerProject>.from(result.data);
|
||||
_sortProjects(list);
|
||||
return list;
|
||||
});
|
||||
});
|
||||
|
||||
/// Active projects only — used by the task form's project picker.
|
||||
final activeProgrammerProjectsProvider = Provider<List<ProgrammerProject>>((ref) {
|
||||
final all = ref.watch(programmerProjectsProvider).valueOrNull ?? const [];
|
||||
return all.where((p) => !p.isArchived).toList();
|
||||
});
|
||||
|
||||
final programmerProjectByIdProvider =
|
||||
Provider.family<ProgrammerProject?, String>((ref, id) {
|
||||
final projects = ref.watch(programmerProjectsProvider).valueOrNull;
|
||||
if (projects == null) return null;
|
||||
try {
|
||||
return projects.firstWhere((p) => p.id == id);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
final programmerProjectsControllerProvider =
|
||||
Provider<ProgrammerProjectsController>((ref) {
|
||||
return ProgrammerProjectsController(ref.watch(supabaseClientProvider));
|
||||
});
|
||||
|
||||
/// Projects are created/edited online (inline from the task form or the
|
||||
/// projects screen). Reads fall back to the Brick cache when offline.
|
||||
class ProgrammerProjectsController {
|
||||
ProgrammerProjectsController(this._client);
|
||||
final dynamic _client;
|
||||
|
||||
Future<String> createProject({
|
||||
required String name,
|
||||
String? description,
|
||||
}) async {
|
||||
final userId = _client.auth.currentUser?.id;
|
||||
if (userId == null) throw Exception('Not authenticated');
|
||||
final id = const Uuid().v4();
|
||||
await _client.from('programmer_projects').insert({
|
||||
'id': id,
|
||||
'name': name,
|
||||
'description': ?description,
|
||||
'creator_id': userId,
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
||||
Future<void> updateProject({
|
||||
required String projectId,
|
||||
String? name,
|
||||
String? description,
|
||||
}) async {
|
||||
final updates = <String, dynamic>{};
|
||||
if (name != null) updates['name'] = name;
|
||||
if (description != null) updates['description'] = description;
|
||||
if (updates.isEmpty) return;
|
||||
await _client.from('programmer_projects').update(updates).eq('id', projectId);
|
||||
}
|
||||
|
||||
Future<void> setArchived({
|
||||
required String projectId,
|
||||
required bool archived,
|
||||
}) async {
|
||||
await _client.from('programmer_projects').update({
|
||||
'status': archived
|
||||
? ProgrammerProjectStatus.archived
|
||||
: ProgrammerProjectStatus.active,
|
||||
}).eq('id', projectId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../models/programmer_task_comment.model.dart';
|
||||
import 'supabase_provider.dart';
|
||||
|
||||
/// Bucket reused for programmer-task comment attachments (public read;
|
||||
/// authenticated write; owner/privileged delete — already hardened by
|
||||
/// 20260926120000_task_attachment_write_hardening.sql).
|
||||
const _attachmentBucket = 'task_attachments';
|
||||
|
||||
/// A file selected for upload with a comment.
|
||||
typedef CommentUpload = ({String name, Uint8List bytes});
|
||||
|
||||
/// Comments for a task (newest first), streamed live.
|
||||
final programmerTaskCommentsProvider =
|
||||
StreamProvider.family<List<ProgrammerTaskComment>, String>((ref, taskId) {
|
||||
final client = ref.watch(supabaseClientProvider);
|
||||
return client
|
||||
.from('programmer_task_comments')
|
||||
.stream(primaryKey: ['id'])
|
||||
.eq('task_id', taskId)
|
||||
.order('created_at', ascending: false)
|
||||
.map((rows) => rows.map(ProgrammerTaskComment.fromMap).toList());
|
||||
});
|
||||
|
||||
final programmerTaskCommentsControllerProvider =
|
||||
Provider<ProgrammerTaskCommentsController>((ref) {
|
||||
return ProgrammerTaskCommentsController(ref.watch(supabaseClientProvider));
|
||||
});
|
||||
|
||||
class ProgrammerTaskCommentsController {
|
||||
ProgrammerTaskCommentsController(this._client);
|
||||
final dynamic _client;
|
||||
|
||||
/// Public URL for a stored attachment path.
|
||||
String attachmentUrl(String path) =>
|
||||
_client.storage.from(_attachmentBucket).getPublicUrl(path);
|
||||
|
||||
/// Adds a comment, uploading any [files] to the attachment bucket first.
|
||||
/// Requires either a non-empty [body] or at least one file.
|
||||
Future<void> addComment({
|
||||
required String taskId,
|
||||
required String body,
|
||||
List<CommentUpload> files = const [],
|
||||
}) async {
|
||||
final userId = _client.auth.currentUser?.id;
|
||||
if (userId == null) throw Exception('Not authenticated');
|
||||
|
||||
final attachments = <Map<String, dynamic>>[];
|
||||
for (final file in files) {
|
||||
final path =
|
||||
'programmer_tasks/$taskId/comments/${const Uuid().v4()}_${file.name}';
|
||||
await _client.storage.from(_attachmentBucket).uploadBinary(
|
||||
path,
|
||||
file.bytes,
|
||||
fileOptions: const FileOptions(upsert: true),
|
||||
);
|
||||
attachments.add({'path': path, 'name': file.name});
|
||||
}
|
||||
|
||||
await _client.from('programmer_task_comments').insert({
|
||||
'task_id': taskId,
|
||||
'author_id': userId,
|
||||
'body': body,
|
||||
'attachments': attachments,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../models/programmer_task_work_log.model.dart';
|
||||
import '../utils/programmer_daily_time.dart';
|
||||
import 'programmer_day_sheets_provider.dart';
|
||||
import 'supabase_provider.dart';
|
||||
|
||||
/// Work-log entries for a task (newest first), streamed live.
|
||||
final programmerTaskWorkLogsProvider =
|
||||
StreamProvider.family<List<ProgrammerTaskWorkLog>, String>((ref, taskId) {
|
||||
final client = ref.watch(supabaseClientProvider);
|
||||
return client
|
||||
.from('programmer_task_work_logs')
|
||||
.stream(primaryKey: ['id'])
|
||||
.eq('task_id', taskId)
|
||||
.order('created_at', ascending: false)
|
||||
.map((rows) => rows.map(ProgrammerTaskWorkLog.fromMap).toList());
|
||||
});
|
||||
|
||||
final programmerTaskWorkLogsControllerProvider =
|
||||
Provider<ProgrammerTaskWorkLogsController>((ref) {
|
||||
return ProgrammerTaskWorkLogsController(ref.watch(supabaseClientProvider));
|
||||
});
|
||||
|
||||
class ProgrammerTaskWorkLogsController {
|
||||
ProgrammerTaskWorkLogsController(this._client);
|
||||
final dynamic _client;
|
||||
|
||||
/// Adds a work-log entry. [minutes] is null for the assignee's progress notes
|
||||
/// (their time comes from the running clock) and set for a helper's
|
||||
/// Time Consumed. [workDate] overrides the date the entry is attributed to;
|
||||
/// when null the DB default applies (today's Manila date).
|
||||
Future<void> addWorkLog({
|
||||
required String taskId,
|
||||
required String description,
|
||||
int? minutes,
|
||||
DateTime? workDate,
|
||||
}) async {
|
||||
final userId = _client.auth.currentUser?.id;
|
||||
if (userId == null) throw Exception('Not authenticated');
|
||||
try {
|
||||
await _client.from('programmer_task_work_logs').insert({
|
||||
'task_id': taskId,
|
||||
'author_id': userId,
|
||||
'description': description,
|
||||
'minutes': ?minutes,
|
||||
if (workDate != null) 'work_date': formatDayKey(workDate),
|
||||
});
|
||||
} catch (e) {
|
||||
if (e is DaySheetActionException) rethrow;
|
||||
throw DaySheetActionException(mapDaySheetError(e));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,624 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../brick/cache_helpers.dart';
|
||||
import '../models/programmer_task.model.dart';
|
||||
import '../models/programmer_task_activity_log.model.dart';
|
||||
import '../utils/snackbar.dart' show isOfflineSaveError;
|
||||
import 'connectivity_provider.dart';
|
||||
import 'profile_provider.dart';
|
||||
import 'realtime_controller.dart';
|
||||
import 'stream_recovery.dart';
|
||||
import 'supabase_provider.dart';
|
||||
|
||||
/// Lifecycle action types that determine whether a task is currently paused.
|
||||
const _pauseStateActions = ['started', 'paused', 'resumed', 'completed', 'cancelled'];
|
||||
|
||||
int _statusRank(String status) {
|
||||
switch (status) {
|
||||
case ProgrammerTaskStatus.inProgress:
|
||||
return 0;
|
||||
case ProgrammerTaskStatus.queued:
|
||||
return 1;
|
||||
case ProgrammerTaskStatus.completed:
|
||||
return 2;
|
||||
case ProgrammerTaskStatus.cancelled:
|
||||
return 3;
|
||||
default:
|
||||
return 4;
|
||||
}
|
||||
}
|
||||
|
||||
void _sortTasks(List<ProgrammerTask> tasks) {
|
||||
tasks.sort((a, b) {
|
||||
final r = _statusRank(a.status).compareTo(_statusRank(b.status));
|
||||
if (r != 0) return r;
|
||||
return b.createdAt.compareTo(a.createdAt);
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Offline-pending state (merged into the live stream; replayed on reconnect)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
final offlinePendingProgrammerTasksProvider =
|
||||
StateProvider<List<ProgrammerTask>>((ref) => const []);
|
||||
|
||||
final offlinePendingProgrammerTaskRawProvider =
|
||||
StateProvider<List<Map<String, dynamic>>>((ref) => const []);
|
||||
|
||||
final offlinePendingProgrammerTaskUpdatesProvider =
|
||||
StateProvider<Map<String, Map<String, dynamic>>>((ref) => const {});
|
||||
|
||||
final offlinePendingProgrammerTaskLogsProvider =
|
||||
StateProvider<List<Map<String, dynamic>>>((ref) => const []);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stream providers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
final programmerTasksProvider = StreamProvider<List<ProgrammerTask>>((ref) {
|
||||
final userId = ref.watch(currentUserIdProvider);
|
||||
if (userId == null) return const Stream.empty();
|
||||
final client = ref.watch(supabaseClientProvider);
|
||||
|
||||
final pendingNew = ref.watch(offlinePendingProgrammerTasksProvider);
|
||||
final pendingUpdates = ref.watch(offlinePendingProgrammerTaskUpdatesProvider);
|
||||
|
||||
List<ProgrammerTask> applyPending(List<ProgrammerTask> rows) {
|
||||
final byId = {for (final r in rows) r.id: r};
|
||||
pendingUpdates.forEach((id, fields) {
|
||||
final existing = byId[id];
|
||||
if (existing == null) return;
|
||||
byId[id] = _applyUpdateFields(existing, fields);
|
||||
});
|
||||
for (final p in pendingNew) {
|
||||
byId.putIfAbsent(p.id, () => p);
|
||||
}
|
||||
final result = byId.values.toList();
|
||||
_sortTasks(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
final wrapper = StreamRecoveryWrapper<ProgrammerTask>(
|
||||
stream: client
|
||||
.from('programmer_tasks')
|
||||
.stream(primaryKey: ['id'])
|
||||
.order('created_at', ascending: false),
|
||||
onPollData: () async {
|
||||
final data = await client
|
||||
.from('programmer_tasks')
|
||||
.select()
|
||||
.order('created_at', ascending: false)
|
||||
.range(0, 199);
|
||||
return data.map(ProgrammerTask.fromMap).toList();
|
||||
},
|
||||
fromMap: ProgrammerTask.fromMap,
|
||||
channelName: 'programmer_tasks',
|
||||
onStatusChanged: ref.read(realtimeControllerProvider).handleChannelStatus,
|
||||
onOfflineData: () async {
|
||||
final all = await cachedListFromBrick<ProgrammerTask>();
|
||||
return applyPending(all);
|
||||
},
|
||||
onCacheMirror: (rows) =>
|
||||
mirrorBatchToBrick<ProgrammerTask>(rows, tag: 'programmer_tasks'),
|
||||
);
|
||||
|
||||
ref.onDispose(wrapper.dispose);
|
||||
|
||||
// Reconnect-replay: POST queued items directly, bypassing Brick's queue.
|
||||
ref.listen<bool>(isOnlineProvider, (wasOnline, isNowOnline) async {
|
||||
if (wasOnline == true || !isNowOnline) return;
|
||||
|
||||
// 1. Replay offline-created tasks.
|
||||
final pendingRaw = List<Map<String, dynamic>>.from(
|
||||
ref.read(offlinePendingProgrammerTaskRawProvider),
|
||||
);
|
||||
if (pendingRaw.isNotEmpty) {
|
||||
final syncedIds = <String>[];
|
||||
for (final payload in pendingRaw) {
|
||||
final id = payload['id'] as String;
|
||||
try {
|
||||
await client.from('programmer_tasks').insert(payload);
|
||||
try {
|
||||
await client.from('programmer_task_activity_logs').insert({
|
||||
'task_id': id,
|
||||
'actor_id': payload['creator_id'],
|
||||
'action_type': 'created',
|
||||
});
|
||||
} catch (_) {}
|
||||
syncedIds.add(id);
|
||||
} catch (e) {
|
||||
final msg = e.toString();
|
||||
if (msg.contains('23505') || msg.contains('duplicate key')) {
|
||||
syncedIds.add(id);
|
||||
} else {
|
||||
debugPrint('[programmerTasksProvider] failed to sync id=$id: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
if (syncedIds.isNotEmpty) {
|
||||
ref.read(offlinePendingProgrammerTaskRawProvider.notifier).state =
|
||||
List.unmodifiable(
|
||||
ref
|
||||
.read(offlinePendingProgrammerTaskRawProvider)
|
||||
.where((p) => !syncedIds.contains(p['id'] as String)),
|
||||
);
|
||||
ref.read(offlinePendingProgrammerTasksProvider.notifier).state =
|
||||
List.unmodifiable(
|
||||
ref
|
||||
.read(offlinePendingProgrammerTasksProvider)
|
||||
.where((t) => !syncedIds.contains(t.id)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Replay offline field/status updates.
|
||||
final pendingUpdatesMap = Map<String, Map<String, dynamic>>.from(
|
||||
ref.read(offlinePendingProgrammerTaskUpdatesProvider),
|
||||
);
|
||||
if (pendingUpdatesMap.isNotEmpty) {
|
||||
final syncedIds = <String>[];
|
||||
for (final entry in pendingUpdatesMap.entries) {
|
||||
try {
|
||||
await client
|
||||
.from('programmer_tasks')
|
||||
.update(entry.value)
|
||||
.eq('id', entry.key);
|
||||
syncedIds.add(entry.key);
|
||||
} catch (e) {
|
||||
debugPrint(
|
||||
'[programmerTasksProvider] failed to sync update id=${entry.key}: $e',
|
||||
);
|
||||
}
|
||||
}
|
||||
if (syncedIds.isNotEmpty) {
|
||||
final remaining = Map<String, Map<String, dynamic>>.from(
|
||||
ref.read(offlinePendingProgrammerTaskUpdatesProvider),
|
||||
);
|
||||
for (final id in syncedIds) {
|
||||
remaining.remove(id);
|
||||
}
|
||||
ref.read(offlinePendingProgrammerTaskUpdatesProvider.notifier).state =
|
||||
remaining;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Replay queued activity logs (started/paused/resumed/etc.).
|
||||
final pendingLogs = List<Map<String, dynamic>>.from(
|
||||
ref.read(offlinePendingProgrammerTaskLogsProvider),
|
||||
);
|
||||
if (pendingLogs.isNotEmpty) {
|
||||
final synced = <Map<String, dynamic>>[];
|
||||
for (final log in pendingLogs) {
|
||||
try {
|
||||
await client.from('programmer_task_activity_logs').insert(log);
|
||||
synced.add(log);
|
||||
} catch (e) {
|
||||
debugPrint('[programmerTasksProvider] failed to sync log: $e');
|
||||
}
|
||||
}
|
||||
if (synced.isNotEmpty) {
|
||||
ref.read(offlinePendingProgrammerTaskLogsProvider.notifier).state =
|
||||
List.unmodifiable(
|
||||
ref
|
||||
.read(offlinePendingProgrammerTaskLogsProvider)
|
||||
.where((l) => !synced.contains(l)),
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return wrapper.stream.map((result) => applyPending(result.data));
|
||||
});
|
||||
|
||||
final programmerTaskByIdProvider = Provider.family<ProgrammerTask?, String>((
|
||||
ref,
|
||||
id,
|
||||
) {
|
||||
final tasks = ref.watch(programmerTasksProvider).valueOrNull;
|
||||
if (tasks == null) return null;
|
||||
try {
|
||||
return tasks.firstWhere((t) => t.id == id);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
/// The current user's actively-running task id (status in_progress AND latest
|
||||
/// activity log is not a pause), or null. Lets the list distinguish a *running*
|
||||
/// task from a *paused* one (both share the `in_progress` status). Re-queries
|
||||
/// whenever the task list changes. Given the single-active invariant, at most
|
||||
/// one task is running per user.
|
||||
final myRunningProgrammerTaskIdProvider = FutureProvider<String?>((ref) async {
|
||||
// Re-run whenever tasks change (a start/pause/resume mutates a row).
|
||||
ref.watch(programmerTasksProvider);
|
||||
final running =
|
||||
await ref.watch(programmerTasksControllerProvider).findRunningTaskForCurrentUser();
|
||||
return running?.id;
|
||||
});
|
||||
|
||||
final programmerTaskActivityLogsProvider =
|
||||
StreamProvider.family<List<ProgrammerTaskActivityLog>, String>((ref, taskId) {
|
||||
final client = ref.watch(supabaseClientProvider);
|
||||
|
||||
final wrapper = StreamRecoveryWrapper<ProgrammerTaskActivityLog>(
|
||||
stream: client
|
||||
.from('programmer_task_activity_logs')
|
||||
.stream(primaryKey: ['id'])
|
||||
.eq('task_id', taskId)
|
||||
.order('created_at', ascending: false),
|
||||
onPollData: () async {
|
||||
final data = await client
|
||||
.from('programmer_task_activity_logs')
|
||||
.select()
|
||||
.eq('task_id', taskId)
|
||||
.order('created_at', ascending: false);
|
||||
return data.map(ProgrammerTaskActivityLog.fromMap).toList();
|
||||
},
|
||||
fromMap: ProgrammerTaskActivityLog.fromMap,
|
||||
channelName: 'programmer_task_activity_logs_$taskId',
|
||||
onStatusChanged: ref
|
||||
.read(realtimeControllerProvider)
|
||||
.handleChannelStatus,
|
||||
onOfflineData: () async {
|
||||
final all = await cachedListFromBrick<ProgrammerTaskActivityLog>();
|
||||
return all.where((l) => l.taskId == taskId).toList()
|
||||
..sort((a, b) => b.createdAt.compareTo(a.createdAt));
|
||||
},
|
||||
onCacheMirror: (rows) => mirrorBatchToBrick<ProgrammerTaskActivityLog>(
|
||||
rows,
|
||||
tag: 'programmer_task_activity_logs',
|
||||
),
|
||||
);
|
||||
|
||||
ref.onDispose(wrapper.dispose);
|
||||
return wrapper.stream.map((result) => result.data);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Controller
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
final programmerTasksControllerProvider = Provider<ProgrammerTasksController>((
|
||||
ref,
|
||||
) {
|
||||
final client = ref.watch(supabaseClientProvider);
|
||||
return ProgrammerTasksController(client, ref);
|
||||
});
|
||||
|
||||
class ProgrammerTasksController {
|
||||
ProgrammerTasksController(this._client, [this._ref]);
|
||||
|
||||
final dynamic _client;
|
||||
final Ref? _ref;
|
||||
|
||||
bool get _isOnline => _ref?.read(isOnlineProvider) ?? true;
|
||||
|
||||
/// Whether [taskId]'s most recent lifecycle event is a pause (i.e. the task
|
||||
/// is in_progress but not actively running).
|
||||
Future<bool> _isCurrentlyPaused(String taskId) async {
|
||||
try {
|
||||
final rows = await _client
|
||||
.from('programmer_task_activity_logs')
|
||||
.select('action_type, created_at')
|
||||
.eq('task_id', taskId)
|
||||
.inFilter('action_type', _pauseStateActions)
|
||||
.order('created_at', ascending: false)
|
||||
.limit(1);
|
||||
if (rows is List && rows.isNotEmpty) {
|
||||
return (rows.first['action_type']?.toString() ?? '') == 'paused';
|
||||
}
|
||||
} catch (_) {}
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<void> _insertLog(
|
||||
String taskId,
|
||||
String actionType, {
|
||||
Map<String, dynamic>? meta,
|
||||
}) async {
|
||||
final row = <String, dynamic>{
|
||||
'task_id': taskId,
|
||||
'actor_id': _client.auth.currentUser?.id,
|
||||
'action_type': actionType,
|
||||
'meta': ?meta,
|
||||
};
|
||||
if (!_isOnline) {
|
||||
_queueLog(row);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await _client.from('programmer_task_activity_logs').insert(row);
|
||||
} catch (e) {
|
||||
if (isOfflineSaveError(e)) {
|
||||
_queueLog(row);
|
||||
} else {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a task. [assigneeId] defaults to the current user (self-logged);
|
||||
/// pass another programmer's id to assign it to them. Returns the task id.
|
||||
Future<String> createTask({
|
||||
required String title,
|
||||
required String category,
|
||||
String? description,
|
||||
String? assigneeId,
|
||||
String? projectId,
|
||||
int priority = 1,
|
||||
}) async {
|
||||
final userId = _client.auth.currentUser?.id;
|
||||
if (userId == null) throw Exception('Not authenticated');
|
||||
|
||||
final id = const Uuid().v4();
|
||||
final assignee = assigneeId ?? userId;
|
||||
final payload = <String, dynamic>{
|
||||
'id': id,
|
||||
'title': title,
|
||||
'description': description,
|
||||
'category': category,
|
||||
'status': ProgrammerTaskStatus.queued,
|
||||
'priority': priority,
|
||||
'assignee_id': assignee,
|
||||
'creator_id': userId,
|
||||
'project_id': ?projectId,
|
||||
};
|
||||
|
||||
try {
|
||||
await _client.from('programmer_tasks').insert(payload);
|
||||
await _insertLog(id, 'created');
|
||||
if (assignee != userId) {
|
||||
await _insertLog(id, 'assigned', meta: {'assignee_id': assignee});
|
||||
}
|
||||
return id;
|
||||
} catch (e) {
|
||||
if (!isOfflineSaveError(e)) rethrow;
|
||||
final now = DateTime.now().toUtc();
|
||||
final local = ProgrammerTask(
|
||||
id: id,
|
||||
title: title,
|
||||
description: description,
|
||||
category: category,
|
||||
status: ProgrammerTaskStatus.queued,
|
||||
priority: priority,
|
||||
assigneeId: assignee,
|
||||
creatorId: userId,
|
||||
projectId: projectId,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
);
|
||||
_queueNewTask(local, payload);
|
||||
return id;
|
||||
}
|
||||
}
|
||||
|
||||
/// The current user's actively-running task (status in_progress, latest event
|
||||
/// not a pause), or null. Used to drive the pause-on-switch prompt. Online
|
||||
/// only — returns null offline (the prompt is a best-effort UX aid).
|
||||
Future<ProgrammerTask?> findRunningTaskForCurrentUser() async {
|
||||
final userId = _client.auth.currentUser?.id;
|
||||
if (userId == null || !_isOnline) return null;
|
||||
try {
|
||||
final rows = await _client
|
||||
.from('programmer_tasks')
|
||||
.select()
|
||||
.eq('assignee_id', userId)
|
||||
.eq('status', ProgrammerTaskStatus.inProgress);
|
||||
if (rows is! List) return null;
|
||||
for (final row in rows) {
|
||||
final task = ProgrammerTask.fromMap(row as Map<String, dynamic>);
|
||||
if (!await _isCurrentlyPaused(task.id)) return task;
|
||||
}
|
||||
} catch (_) {}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Moves a queued task to in_progress (sets started_at on first start) and
|
||||
/// logs a `started` event.
|
||||
Future<void> startTask({required String taskId}) async {
|
||||
final updates = <String, dynamic>{
|
||||
'status': ProgrammerTaskStatus.inProgress,
|
||||
'started_at': DateTime.now().toUtc().toIso8601String(),
|
||||
};
|
||||
await _updateTaskRow(taskId, updates, onlyIfStartNull: true);
|
||||
await _insertLog(taskId, 'started');
|
||||
}
|
||||
|
||||
/// Logs a `paused` event for a running task (idempotent).
|
||||
Future<void> pauseTask({required String taskId}) async {
|
||||
if (_isOnline && await _isCurrentlyPaused(taskId)) return;
|
||||
await _insertLog(taskId, 'paused');
|
||||
}
|
||||
|
||||
/// Logs a `resumed` event for a paused task (idempotent).
|
||||
Future<void> resumeTask({required String taskId}) async {
|
||||
if (_isOnline && !await _isCurrentlyPaused(taskId)) return;
|
||||
await _insertLog(taskId, 'resumed');
|
||||
}
|
||||
|
||||
Future<void> completeTask({required String taskId}) async {
|
||||
await _updateTaskRow(taskId, {
|
||||
'status': ProgrammerTaskStatus.completed,
|
||||
'completed_at': DateTime.now().toUtc().toIso8601String(),
|
||||
});
|
||||
await _insertLog(taskId, 'completed');
|
||||
}
|
||||
|
||||
Future<void> cancelTask({
|
||||
required String taskId,
|
||||
required String reason,
|
||||
}) async {
|
||||
await _updateTaskRow(taskId, {
|
||||
'status': ProgrammerTaskStatus.cancelled,
|
||||
'cancelled_at': DateTime.now().toUtc().toIso8601String(),
|
||||
'cancellation_reason': reason,
|
||||
});
|
||||
await _insertLog(taskId, 'cancelled', meta: {'reason': reason});
|
||||
}
|
||||
|
||||
Future<void> reassign({
|
||||
required String taskId,
|
||||
required String newAssigneeId,
|
||||
}) async {
|
||||
await _updateTaskRow(taskId, {'assignee_id': newAssigneeId});
|
||||
await _insertLog(taskId, 'reassigned', meta: {'assignee_id': newAssigneeId});
|
||||
}
|
||||
|
||||
Future<void> updateTask({
|
||||
required String taskId,
|
||||
String? title,
|
||||
String? description,
|
||||
String? category,
|
||||
int? priority,
|
||||
}) async {
|
||||
final updates = <String, dynamic>{};
|
||||
if (title != null) updates['title'] = title;
|
||||
if (description != null) updates['description'] = description;
|
||||
if (category != null) updates['category'] = category;
|
||||
if (priority != null) updates['priority'] = priority;
|
||||
if (updates.isEmpty) return;
|
||||
await _updateTaskRow(taskId, updates);
|
||||
await _insertLog(taskId, 'updated', meta: {'fields': updates.keys.toList()});
|
||||
}
|
||||
|
||||
/// Assigns the task to a project (pass null to clear it).
|
||||
Future<void> setProject({
|
||||
required String taskId,
|
||||
required String? projectId,
|
||||
}) async {
|
||||
await _updateTaskRow(taskId, {'project_id': projectId});
|
||||
await _insertLog(taskId, 'updated', meta: {
|
||||
'fields': ['project_id'],
|
||||
});
|
||||
}
|
||||
|
||||
/// Records a time adjustment (negative [seconds]) against a task's worked
|
||||
/// duration — e.g. when the current user helped on another task and chose to
|
||||
/// deduct that time from their own running task.
|
||||
Future<void> addAdjustment({
|
||||
required String taskId,
|
||||
required int seconds,
|
||||
String? reason,
|
||||
String? sourceTaskId,
|
||||
}) async {
|
||||
await _insertLog(taskId, 'adjustment', meta: {
|
||||
'seconds': seconds,
|
||||
'reason': ?reason,
|
||||
'source_task_id': ?sourceTaskId,
|
||||
});
|
||||
}
|
||||
|
||||
/// Applies a field patch to a task row. When [onlyIfStartNull] is set,
|
||||
/// `started_at` is dropped if the task already has one (preserves the first
|
||||
/// execution start across pause/resume cycles).
|
||||
Future<void> _updateTaskRow(
|
||||
String taskId,
|
||||
Map<String, dynamic> updates, {
|
||||
bool onlyIfStartNull = false,
|
||||
}) async {
|
||||
var payload = updates;
|
||||
if (!_isOnline) {
|
||||
_queueUpdate(taskId, payload);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (onlyIfStartNull && payload.containsKey('started_at')) {
|
||||
final existing = await _client
|
||||
.from('programmer_tasks')
|
||||
.select('started_at')
|
||||
.eq('id', taskId)
|
||||
.maybeSingle();
|
||||
if (existing is Map && existing['started_at'] != null) {
|
||||
payload = Map<String, dynamic>.from(payload)..remove('started_at');
|
||||
}
|
||||
}
|
||||
await _client.from('programmer_tasks').update(payload).eq('id', taskId);
|
||||
} catch (e) {
|
||||
if (isOfflineSaveError(e)) {
|
||||
_queueUpdate(taskId, payload);
|
||||
} else {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ queues
|
||||
|
||||
void _queueNewTask(ProgrammerTask model, Map<String, dynamic> payload) {
|
||||
final ref = _ref;
|
||||
if (ref == null) return;
|
||||
ref.read(offlinePendingProgrammerTasksProvider.notifier).state =
|
||||
List.unmodifiable([
|
||||
...ref.read(offlinePendingProgrammerTasksProvider),
|
||||
model,
|
||||
]);
|
||||
ref.read(offlinePendingProgrammerTaskRawProvider.notifier).state =
|
||||
List.unmodifiable([
|
||||
...ref.read(offlinePendingProgrammerTaskRawProvider),
|
||||
payload,
|
||||
]);
|
||||
mirrorBatchToBrick<ProgrammerTask>([model], tag: 'offline_programmer_task');
|
||||
}
|
||||
|
||||
void _queueUpdate(String taskId, Map<String, dynamic> fields) {
|
||||
final ref = _ref;
|
||||
if (ref == null) return;
|
||||
final current = Map<String, Map<String, dynamic>>.from(
|
||||
ref.read(offlinePendingProgrammerTaskUpdatesProvider),
|
||||
);
|
||||
current[taskId] = {...?current[taskId], ...fields};
|
||||
ref.read(offlinePendingProgrammerTaskUpdatesProvider.notifier).state =
|
||||
current;
|
||||
}
|
||||
|
||||
void _queueLog(Map<String, dynamic> row) {
|
||||
final ref = _ref;
|
||||
if (ref == null) return;
|
||||
ref.read(offlinePendingProgrammerTaskLogsProvider.notifier).state =
|
||||
List.unmodifiable([
|
||||
...ref.read(offlinePendingProgrammerTaskLogsProvider),
|
||||
row,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Field-patch helper for optimistic offline updates
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
ProgrammerTask _applyUpdateFields(ProgrammerTask t, Map<String, dynamic> f) {
|
||||
DateTime? parseOpt(String key) {
|
||||
if (!f.containsKey(key)) return null;
|
||||
final v = f[key];
|
||||
return v == null ? null : DateTime.parse(v as String).toUtc();
|
||||
}
|
||||
|
||||
return ProgrammerTask(
|
||||
id: t.id,
|
||||
title: (f['title'] as String?) ?? t.title,
|
||||
description: f.containsKey('description')
|
||||
? f['description'] as String?
|
||||
: t.description,
|
||||
category: (f['category'] as String?) ?? t.category,
|
||||
status: (f['status'] as String?) ?? t.status,
|
||||
priority: (f['priority'] as num?)?.toInt() ?? t.priority,
|
||||
assigneeId:
|
||||
f.containsKey('assignee_id') ? f['assignee_id'] as String? : t.assigneeId,
|
||||
creatorId: t.creatorId,
|
||||
projectId:
|
||||
f.containsKey('project_id') ? f['project_id'] as String? : t.projectId,
|
||||
createdAt: t.createdAt,
|
||||
startedAt: f.containsKey('started_at') ? parseOpt('started_at') : t.startedAt,
|
||||
completedAt:
|
||||
f.containsKey('completed_at') ? parseOpt('completed_at') : t.completedAt,
|
||||
cancelledAt:
|
||||
f.containsKey('cancelled_at') ? parseOpt('cancelled_at') : t.cancelledAt,
|
||||
cancellationReason: f.containsKey('cancellation_reason')
|
||||
? f['cancellation_reason'] as String?
|
||||
: t.cancellationReason,
|
||||
updatedAt: DateTime.now().toUtc(),
|
||||
);
|
||||
}
|
||||
@@ -30,6 +30,12 @@ import '../widgets/app_shell.dart';
|
||||
import '../screens/teams/teams_screen.dart';
|
||||
import '../screens/it_service_requests/it_service_requests_list_screen.dart';
|
||||
import '../screens/it_service_requests/it_service_request_detail_screen.dart';
|
||||
import '../screens/programmer_tasks/day_sheets/day_sheet_detail_screen.dart';
|
||||
import '../screens/programmer_tasks/programmer_drill_down_screen.dart';
|
||||
import '../screens/programmer_tasks/programmer_tasks_list_screen.dart';
|
||||
import '../screens/programmer_tasks/programmer_task_detail_screen.dart';
|
||||
import '../screens/programmer_projects/programmer_projects_list_screen.dart';
|
||||
import '../screens/programmer_projects/programmer_project_detail_screen.dart';
|
||||
import '../screens/network_map/network_map_overview_screen.dart';
|
||||
import '../screens/network_map/network_map_site_screen.dart';
|
||||
import '../screens/network_map/network_map_device_screen.dart';
|
||||
@@ -93,6 +99,10 @@ final appRouterProvider = Provider<GoRouter>((ref) {
|
||||
role == 'it_staff' ||
|
||||
role == 'dispatcher' ||
|
||||
role == 'programmer';
|
||||
final isProgrammerTasksRoute =
|
||||
state.matchedLocation.startsWith('/programmer-');
|
||||
final hasProgrammerTasksAccess =
|
||||
role == 'admin' || role == 'programmer';
|
||||
|
||||
if (!isSignedIn && !isAuthRoute) {
|
||||
return '/login';
|
||||
@@ -125,6 +135,9 @@ final appRouterProvider = Provider<GoRouter>((ref) {
|
||||
if (isNetworkMapRoute && !hasNetworkMapAccess) {
|
||||
return '/tickets';
|
||||
}
|
||||
if (isProgrammerTasksRoute && !hasProgrammerTasksAccess) {
|
||||
return '/tickets';
|
||||
}
|
||||
// Attendance & Whereabouts: not accessible to standard users
|
||||
final isStandardOnly = role == 'standard';
|
||||
final isAttendanceRoute =
|
||||
@@ -207,6 +220,60 @@ final appRouterProvider = Provider<GoRouter>((ref) {
|
||||
),
|
||||
],
|
||||
),
|
||||
GoRoute(
|
||||
path: '/programmer-tasks',
|
||||
pageBuilder: (context, state) => M3SharedAxisPage(
|
||||
key: state.pageKey,
|
||||
child: ProgrammerTasksListScreen(
|
||||
initialTab: state.uri.queryParameters['tab'],
|
||||
),
|
||||
),
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: 'programmers/:userId',
|
||||
pageBuilder: (context, state) => M3ContainerTransformPage(
|
||||
key: state.pageKey,
|
||||
child: ProgrammerDrillDownScreen(
|
||||
userId: state.pathParameters['userId'] ?? '',
|
||||
),
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
name: 'daySheetDetail',
|
||||
path: 'day-sheets/:id',
|
||||
pageBuilder: (context, state) => M3ContainerTransformPage(
|
||||
key: state.pageKey,
|
||||
child: DaySheetDetailScreen(
|
||||
sheetId: state.pathParameters['id'] ?? '',
|
||||
),
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: ':id',
|
||||
pageBuilder: (context, state) => M3ContainerTransformPage(
|
||||
key: state.pageKey,
|
||||
child: ProgrammerTaskDetailScreen(
|
||||
taskId: state.pathParameters['id'] ?? '',
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
GoRoute(
|
||||
path: '/programmer-projects',
|
||||
pageBuilder: _page(const ProgrammerProjectsListScreen()),
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: ':id',
|
||||
pageBuilder: (context, state) => M3ContainerTransformPage(
|
||||
key: state.pageKey,
|
||||
child: ProgrammerProjectDetailScreen(
|
||||
projectId: state.pathParameters['id'] ?? '',
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
GoRoute(
|
||||
path: '/announcements',
|
||||
pageBuilder: _page(const AnnouncementsScreen()),
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../theme/app_status_colors.dart';
|
||||
import '../../theme/m3_motion.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
@@ -197,10 +199,12 @@ class _UserManagementScreenState extends ConsumerState<UserManagementScreen> {
|
||||
),
|
||||
TasQColumn<Profile>(
|
||||
header: 'Role',
|
||||
cellBuilder: (context, profile) => Text(profile.role),
|
||||
minWidth: 120,
|
||||
cellBuilder: (context, profile) => Text(_roleLabel(profile.role)),
|
||||
),
|
||||
TasQColumn<Profile>(
|
||||
header: 'Offices',
|
||||
minWidth: 100,
|
||||
cellBuilder: (context, profile) {
|
||||
final officesAssigned = officeCountByUser[profile.id] ?? 0;
|
||||
return Text(officesAssigned == 0 ? 'None' : '$officesAssigned');
|
||||
@@ -208,6 +212,7 @@ class _UserManagementScreenState extends ConsumerState<UserManagementScreen> {
|
||||
),
|
||||
TasQColumn<Profile>(
|
||||
header: 'Status',
|
||||
minWidth: 120,
|
||||
cellBuilder: (context, profile) {
|
||||
final statusAsync = ref.watch(adminUserStatusProvider(profile.id));
|
||||
return statusAsync.when(
|
||||
@@ -222,6 +227,7 @@ class _UserManagementScreenState extends ConsumerState<UserManagementScreen> {
|
||||
),
|
||||
TasQColumn<Profile>(
|
||||
header: 'Last active',
|
||||
minWidth: 150,
|
||||
cellBuilder: (context, profile) {
|
||||
final lastActive = lastActiveByUser[profile.id];
|
||||
return Text(_formatLastActiveLabel(lastActive));
|
||||
@@ -245,7 +251,7 @@ class _UserManagementScreenState extends ConsumerState<UserManagementScreen> {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: 2),
|
||||
Text('Role: ${profile.role}'),
|
||||
Text('Role: ${_roleLabel(profile.role)}'),
|
||||
Text('Offices: $officesAssigned'),
|
||||
Text('Last active: ${_formatLastActiveLabel(lastActive)}'),
|
||||
const SizedBox(height: 4),
|
||||
@@ -386,7 +392,10 @@ class _UserManagementScreenState extends ConsumerState<UserManagementScreen> {
|
||||
key: ValueKey('role_${_selectedUserId ?? 'none'}'),
|
||||
initialValue: _selectedRole,
|
||||
items: _roles
|
||||
.map((role) => DropdownMenuItem(value: role, child: Text(role)))
|
||||
.map(
|
||||
(role) =>
|
||||
DropdownMenuItem(value: role, child: Text(_roleLabel(role))),
|
||||
)
|
||||
.toList(),
|
||||
onChanged: (value) => setDialogState(() => _selectedRole = value),
|
||||
decoration: const InputDecoration(labelText: 'Role'),
|
||||
@@ -717,6 +726,16 @@ String _formatLastActiveLabel(DateTime? value) {
|
||||
return DateFormat('yyyy-MM-dd').format(value);
|
||||
}
|
||||
|
||||
/// Human-friendly display label for a stored role value.
|
||||
String _roleLabel(String role) => switch (role) {
|
||||
'it_staff' => 'IT Staff',
|
||||
'standard' => 'Standard',
|
||||
'dispatcher' => 'Dispatcher',
|
||||
'programmer' => 'Programmer',
|
||||
'admin' => 'Admin',
|
||||
_ => role.replaceAll('_', ' '),
|
||||
};
|
||||
|
||||
class _StatusBadge extends StatelessWidget {
|
||||
const _StatusBadge({required this.label});
|
||||
|
||||
@@ -724,14 +743,15 @@ class _StatusBadge extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final isError = label.toLowerCase().contains('error');
|
||||
final background = isError
|
||||
? scheme.errorContainer
|
||||
: scheme.secondaryContainer;
|
||||
final foreground = isError
|
||||
? scheme.onErrorContainer
|
||||
: scheme.onSecondaryContainer;
|
||||
final status = AppStatusColors.of(context);
|
||||
final swatch = switch (label.toLowerCase()) {
|
||||
'active' => status.success,
|
||||
'locked' => status.danger,
|
||||
final l when l.contains('error') => status.danger,
|
||||
_ => status.neutral,
|
||||
};
|
||||
final background = swatch.container;
|
||||
final foreground = swatch.onContainer;
|
||||
|
||||
return Badge(
|
||||
backgroundColor: background,
|
||||
|
||||
@@ -36,6 +36,7 @@ import 'work_log_tab.dart';
|
||||
import '../../theme/m3_motion.dart';
|
||||
import '../../theme/app_surfaces.dart';
|
||||
import '../../theme/app_status_colors.dart';
|
||||
import '../../widgets/app_section_header.dart';
|
||||
import '../../utils/app_time.dart';
|
||||
import '../../utils/device_security.dart';
|
||||
import '../../utils/location_permission.dart';
|
||||
@@ -885,11 +886,9 @@ class _CheckInTabState extends ConsumerState<_CheckInTab> {
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Today's schedule
|
||||
Text(
|
||||
const AppSectionHeader(
|
||||
"Today's Schedule",
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
padding: EdgeInsets.zero,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
@@ -5704,24 +5703,9 @@ class _PassSlipTabState extends ConsumerState<_PassSlipTab> {
|
||||
|
||||
// Pending slips for admin approval
|
||||
if (isAdmin) ...[
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 4,
|
||||
height: 20,
|
||||
decoration: BoxDecoration(
|
||||
color: colors.tertiary,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Text(
|
||||
'Pending Approvals',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
const AppSectionHeader(
|
||||
'Pending Approvals',
|
||||
padding: EdgeInsets.zero,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
for (int i = 0; i < pendingSlipList.length; i++)
|
||||
@@ -5755,24 +5739,9 @@ class _PassSlipTabState extends ConsumerState<_PassSlipTab> {
|
||||
],
|
||||
|
||||
// History
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 4,
|
||||
height: 20,
|
||||
decoration: BoxDecoration(
|
||||
color: colors.outlineVariant,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Text(
|
||||
'Pass Slip History',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
const AppSectionHeader(
|
||||
'Pass Slip History',
|
||||
padding: EdgeInsets.zero,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
for (int i = 0; i < historySlipList.length; i++)
|
||||
@@ -6069,24 +6038,9 @@ class _LeaveTabState extends ConsumerState<_LeaveTab> {
|
||||
children: [
|
||||
// ── Pending Approvals (admin only) ──
|
||||
if (isAdmin) ...[
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 4,
|
||||
height: 20,
|
||||
decoration: BoxDecoration(
|
||||
color: colors.tertiary,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Text(
|
||||
'Pending Approvals',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
const AppSectionHeader(
|
||||
'Pending Approvals',
|
||||
padding: EdgeInsets.zero,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (pendingApprovals.isEmpty)
|
||||
|
||||
@@ -44,6 +44,7 @@ import '../../widgets/app_metric_card.dart';
|
||||
import '../../widgets/app_page_header.dart';
|
||||
import '../../widgets/app_section_header.dart';
|
||||
import '../../utils/app_time.dart';
|
||||
import '../../utils/staff_presence.dart' as staff_presence;
|
||||
|
||||
class DashboardMetrics {
|
||||
DashboardMetrics({
|
||||
@@ -369,22 +370,10 @@ final dashboardMetricsProvider = Provider<AsyncValue<DashboardMetrics>>((ref) {
|
||||
}
|
||||
|
||||
// Index today's leaves by user.
|
||||
final todayLeaveByUser = <String, LeaveOfAbsence>{};
|
||||
for (final l in allLeaves) {
|
||||
if (l.status == 'approved' &&
|
||||
!l.startTime.isAfter(now) &&
|
||||
l.endTime.isAfter(now)) {
|
||||
todayLeaveByUser[l.userId] = l;
|
||||
}
|
||||
}
|
||||
final todayLeaveByUser = staff_presence.activeLeaveByUser(allLeaves, now);
|
||||
|
||||
// Index active pass slips by user.
|
||||
final activePassSlipByUser = <String, PassSlip>{};
|
||||
for (final slip in allPassSlips) {
|
||||
if (slip.isActive) {
|
||||
activePassSlipByUser[slip.userId] = slip;
|
||||
}
|
||||
}
|
||||
final activePassSlipByUser = staff_presence.activePassSlipByUser(allPassSlips);
|
||||
|
||||
final noon = DateTime(now.year, now.month, now.day, 12, 0);
|
||||
final onePM = DateTime(now.year, now.month, now.day, 13, 0);
|
||||
|
||||
@@ -16,6 +16,48 @@ import '../../widgets/responsive_body.dart';
|
||||
import '../../theme/app_surfaces.dart';
|
||||
import '../../theme/m3_motion.dart';
|
||||
|
||||
String notificationTitle(String type, String actorName) {
|
||||
return switch (type) {
|
||||
'assignment' => '$actorName assigned you',
|
||||
'created' => '$actorName created a new item',
|
||||
'swap_request' => '$actorName requested a shift swap',
|
||||
'swap_update' => '$actorName updated a swap request',
|
||||
'announcement' => '$actorName posted an announcement',
|
||||
'announcement_comment' => '$actorName commented on an announcement',
|
||||
'it_job_reminder' => 'IT Job submission reminder',
|
||||
'isr_approved' => 'IT Service Request approved',
|
||||
'isr_status_changed' => 'IT Service Request status updated',
|
||||
'isr_assigned' => '$actorName assigned you an IT Service Request',
|
||||
'day_sheet_submitted' => '$actorName submitted a day sheet',
|
||||
'day_sheet_disapproved' => 'Day sheet disapproved',
|
||||
'day_sheet_justified' => '$actorName justified a day sheet',
|
||||
'day_sheet_approved' => 'Day sheet approved',
|
||||
'day_sheet_digest' => 'Day sheets awaiting review',
|
||||
_ => '$actorName mentioned you',
|
||||
};
|
||||
}
|
||||
|
||||
IconData notificationIcon(String type) {
|
||||
return switch (type) {
|
||||
'assignment' => Icons.assignment_ind_outlined,
|
||||
'created' => Icons.campaign_outlined,
|
||||
'swap_request' => Icons.swap_horiz,
|
||||
'swap_update' => Icons.update,
|
||||
'announcement' => Icons.campaign,
|
||||
'announcement_comment' => Icons.comment_outlined,
|
||||
'it_job_reminder' => Icons.print,
|
||||
'isr_approved' => Icons.check_circle_outline,
|
||||
'isr_status_changed' => Icons.sync_alt_outlined,
|
||||
'isr_assigned' => Icons.engineering_outlined,
|
||||
'day_sheet_submitted' => Icons.assignment_turned_in_outlined,
|
||||
'day_sheet_disapproved' => Icons.report_outlined,
|
||||
'day_sheet_justified' => Icons.reply_outlined,
|
||||
'day_sheet_approved' => Icons.verified_outlined,
|
||||
'day_sheet_digest' => Icons.inbox_outlined,
|
||||
_ => Icons.alternate_email,
|
||||
};
|
||||
}
|
||||
|
||||
class NotificationsScreen extends ConsumerStatefulWidget {
|
||||
const NotificationsScreen({super.key});
|
||||
|
||||
@@ -80,42 +122,11 @@ class _NotificationsScreenState extends ConsumerState<NotificationsScreen> {
|
||||
};
|
||||
}
|
||||
|
||||
String _notificationTitle(String type, String actorName) {
|
||||
return switch (type) {
|
||||
'assignment' => '$actorName assigned you',
|
||||
'created' => '$actorName created a new item',
|
||||
'swap_request' => '$actorName requested a shift swap',
|
||||
'swap_update' => '$actorName updated a swap request',
|
||||
'announcement' => '$actorName posted an announcement',
|
||||
'announcement_comment' => '$actorName commented on an announcement',
|
||||
'it_job_reminder' => 'IT Job submission reminder',
|
||||
'isr_approved' => 'IT Service Request approved',
|
||||
'isr_status_changed' => 'IT Service Request status updated',
|
||||
'isr_assigned' => '$actorName assigned you an IT Service Request',
|
||||
_ => '$actorName mentioned you',
|
||||
};
|
||||
}
|
||||
|
||||
IconData _notificationIcon(String type) {
|
||||
return switch (type) {
|
||||
'assignment' => Icons.assignment_ind_outlined,
|
||||
'created' => Icons.campaign_outlined,
|
||||
'swap_request' => Icons.swap_horiz,
|
||||
'swap_update' => Icons.update,
|
||||
'announcement' => Icons.campaign,
|
||||
'announcement_comment' => Icons.comment_outlined,
|
||||
'it_job_reminder' => Icons.print,
|
||||
'isr_approved' => Icons.check_circle_outline,
|
||||
'isr_status_changed' => Icons.sync_alt_outlined,
|
||||
'isr_assigned' => Icons.engineering_outlined,
|
||||
_ => Icons.alternate_email,
|
||||
};
|
||||
}
|
||||
|
||||
Future<void> _handleTap(BuildContext context, NotificationItem item) async {
|
||||
final ticketId = item.ticketId;
|
||||
final taskId = item.taskId;
|
||||
final isrId = item.itServiceRequestId;
|
||||
final daySheetId = item.daySheetId;
|
||||
|
||||
if (ticketId != null) {
|
||||
await ref
|
||||
@@ -136,6 +147,10 @@ class _NotificationsScreenState extends ConsumerState<NotificationsScreen> {
|
||||
context.go('/tickets/$ticketId');
|
||||
} else if (isrId != null) {
|
||||
context.go('/it-service-requests/$isrId');
|
||||
} else if (item.type == 'day_sheet_digest') {
|
||||
context.go('/programmer-tasks?tab=approvals');
|
||||
} else if (daySheetId != null) {
|
||||
context.go('/programmer-tasks/day-sheets/$daySheetId');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -248,8 +263,8 @@ class _NotificationsScreenState extends ConsumerState<NotificationsScreen> {
|
||||
animDelay: Duration(
|
||||
milliseconds: animIndexes[index].clamp(0, 6) * 50,
|
||||
),
|
||||
title: _notificationTitle(item.type, actorName),
|
||||
icon: _notificationIcon(item.type),
|
||||
title: notificationTitle(item.type, actorName),
|
||||
icon: notificationIcon(item.type),
|
||||
iconContainerColor:
|
||||
_iconContainerColor(context, item.type),
|
||||
iconForegroundColor:
|
||||
@@ -331,6 +346,7 @@ class _NotificationCard extends StatelessWidget {
|
||||
if (item.announcementId != null) return 'Announcement';
|
||||
if (item.taskId != null) return taskTitle ?? 'Task';
|
||||
if (item.itServiceRequestId != null) return 'IT Service Request';
|
||||
if (item.daySheetId != null) return 'Day Sheet';
|
||||
return ticketSubject ?? 'Ticket';
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import '../../widgets/face_verification_overlay.dart';
|
||||
import '../../widgets/multi_select_picker.dart';
|
||||
import '../../widgets/qr_verification_dialog.dart';
|
||||
import '../../widgets/app_page_header.dart';
|
||||
import '../../widgets/app_section_header.dart';
|
||||
import '../../widgets/responsive_body.dart';
|
||||
import '../../utils/snackbar.dart';
|
||||
|
||||
@@ -102,10 +103,7 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen> {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Account details',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const AppSectionHeader('Account details', padding: EdgeInsets.zero),
|
||||
const SizedBox(height: 12),
|
||||
// Email (read-only)
|
||||
TextFormField(
|
||||
@@ -151,10 +149,7 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen> {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Password',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const AppSectionHeader('Password', padding: EdgeInsets.zero),
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
'Set or change your password. OAuth users (Google/Meta) can set a password here.',
|
||||
@@ -224,10 +219,7 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen> {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Offices',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const AppSectionHeader('Offices', padding: EdgeInsets.zero),
|
||||
const SizedBox(height: 12),
|
||||
officesAsync.when(
|
||||
data: (offices) {
|
||||
@@ -286,56 +278,71 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen> {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Profile Photo', style: theme.textTheme.titleMedium),
|
||||
const AppSectionHeader('Profile Photo', padding: EdgeInsets.zero),
|
||||
const SizedBox(height: 16),
|
||||
Center(
|
||||
child: Stack(
|
||||
children: [
|
||||
Hero(
|
||||
tag: 'profile-avatar',
|
||||
child: CircleAvatar(
|
||||
radius: 56,
|
||||
backgroundColor: colors.surfaceContainerHighest,
|
||||
backgroundImage: avatarUrl != null
|
||||
? NetworkImage(avatarUrl)
|
||||
: null,
|
||||
child: avatarUrl == null
|
||||
? Icon(
|
||||
Icons.person,
|
||||
size: 48,
|
||||
color: colors.onSurfaceVariant,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
if (_uploadingAvatar)
|
||||
const Positioned.fill(
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
FilledButton.tonalIcon(
|
||||
onPressed: _uploadingAvatar
|
||||
? null
|
||||
: () => _pickAvatar(ImageSource.gallery),
|
||||
icon: const Icon(Icons.photo_library),
|
||||
label: const Text('Upload'),
|
||||
Stack(
|
||||
children: [
|
||||
Hero(
|
||||
tag: 'profile-avatar',
|
||||
child: CircleAvatar(
|
||||
radius: 44,
|
||||
backgroundColor: colors.surfaceContainerHighest,
|
||||
backgroundImage: avatarUrl != null
|
||||
? NetworkImage(avatarUrl)
|
||||
: null,
|
||||
child: avatarUrl == null
|
||||
? Icon(
|
||||
Icons.person,
|
||||
size: 40,
|
||||
color: colors.onSurfaceVariant,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
if (_uploadingAvatar)
|
||||
const Positioned.fill(
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (!kIsWeb) ...[
|
||||
const SizedBox(width: 12),
|
||||
FilledButton.tonalIcon(
|
||||
onPressed: _uploadingAvatar
|
||||
? null
|
||||
: () => _pickAvatar(ImageSource.camera),
|
||||
icon: const Icon(Icons.camera_alt),
|
||||
label: const Text('Camera'),
|
||||
const SizedBox(width: 20),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'A clear headshot helps teammates recognize you.',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
FilledButton.tonalIcon(
|
||||
onPressed: _uploadingAvatar
|
||||
? null
|
||||
: () => _pickAvatar(ImageSource.gallery),
|
||||
icon: const Icon(Icons.photo_library),
|
||||
label: const Text('Upload'),
|
||||
),
|
||||
if (!kIsWeb)
|
||||
FilledButton.tonalIcon(
|
||||
onPressed: _uploadingAvatar
|
||||
? null
|
||||
: () => _pickAvatar(ImageSource.camera),
|
||||
icon: const Icon(Icons.camera_alt),
|
||||
label: const Text('Camera'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
@@ -359,7 +366,7 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen> {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Face Verification', style: theme.textTheme.titleMedium),
|
||||
const AppSectionHeader('Face Verification', padding: EdgeInsets.zero),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Enroll your face for attendance verification. '
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../models/programmer_project.model.dart';
|
||||
import '../../models/programmer_task.model.dart';
|
||||
import '../../providers/programmer_projects_provider.dart';
|
||||
import '../../providers/programmer_tasks_provider.dart';
|
||||
import '../../theme/app_status_colors.dart';
|
||||
import '../../widgets/app_section_header.dart';
|
||||
import '../../widgets/m3_card.dart';
|
||||
import '../../widgets/responsive_body.dart';
|
||||
import '../../widgets/status_pill.dart';
|
||||
import '../programmer_tasks/programmer_task_create.dart';
|
||||
import '../programmer_tasks/programmer_task_status_kind.dart';
|
||||
|
||||
class ProgrammerProjectDetailScreen extends ConsumerWidget {
|
||||
const ProgrammerProjectDetailScreen({super.key, required this.projectId});
|
||||
|
||||
final String projectId;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final project = ref.watch(programmerProjectByIdProvider(projectId));
|
||||
final tasks = (ref.watch(programmerTasksProvider).valueOrNull ?? const [])
|
||||
.where((t) => t.projectId == projectId)
|
||||
.toList();
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(project?.name ?? 'Project'),
|
||||
actions: [
|
||||
if (project != null)
|
||||
IconButton(
|
||||
tooltip: project.isArchived ? 'Unarchive' : 'Archive',
|
||||
icon: Icon(project.isArchived
|
||||
? Icons.unarchive_outlined
|
||||
: Icons.archive_outlined),
|
||||
onPressed: () async {
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
await ref.read(programmerProjectsControllerProvider).setArchived(
|
||||
projectId: projectId,
|
||||
archived: !project.isArchived,
|
||||
);
|
||||
messenger.showSnackBar(SnackBar(
|
||||
content: Text(
|
||||
project.isArchived ? 'Project unarchived' : 'Project archived'),
|
||||
));
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
floatingActionButton: project == null
|
||||
? null
|
||||
: FloatingActionButton.extended(
|
||||
heroTag: 'project_new_task',
|
||||
onPressed: () => showCreateProgrammerTaskDialog(
|
||||
context,
|
||||
ref,
|
||||
initialProjectId: projectId,
|
||||
),
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('New Task'),
|
||||
),
|
||||
body: project == null
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: ResponsiveBody(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
children: [
|
||||
M3Card.elevated(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(project.name,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleLarge
|
||||
?.copyWith(fontWeight: FontWeight.w700)),
|
||||
),
|
||||
StatusPill.kind(
|
||||
project.isArchived
|
||||
? StatusKind.neutral
|
||||
: StatusKind.success,
|
||||
label: ProgrammerProjectStatus.label(
|
||||
project.status),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (project.description != null &&
|
||||
project.description!.trim().isNotEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(project.description!,
|
||||
style: Theme.of(context).textTheme.bodyMedium),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
AppSectionHeader('Tasks (${tasks.length})'),
|
||||
if (tasks.isEmpty)
|
||||
Text('No tasks in this project yet.',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color:
|
||||
Theme.of(context).colorScheme.onSurfaceVariant))
|
||||
else
|
||||
for (final t in tasks) _ProjectTaskTile(task: t),
|
||||
const SizedBox(height: 80),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ProjectTaskTile extends StatelessWidget {
|
||||
const _ProjectTaskTile({required this.task});
|
||||
|
||||
final ProgrammerTask task;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 5),
|
||||
child: M3Card.outlined(
|
||||
onTap: () => context.go('/programmer-tasks/${task.id}'),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(task.title,
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
StatusPill.kind(
|
||||
programmerTaskStatusKind(task.status),
|
||||
label: ProgrammerTaskStatus.label(task.status),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../models/programmer_project.model.dart';
|
||||
import '../../providers/programmer_projects_provider.dart';
|
||||
import '../../providers/programmer_tasks_provider.dart';
|
||||
import '../../theme/app_status_colors.dart';
|
||||
import '../../theme/m3_motion.dart';
|
||||
import '../../widgets/app_page_header.dart';
|
||||
import '../../widgets/app_state_view.dart';
|
||||
import '../../widgets/m3_card.dart';
|
||||
import '../../widgets/reconnect_overlay.dart';
|
||||
import '../../widgets/responsive_body.dart';
|
||||
import '../../widgets/status_pill.dart';
|
||||
|
||||
class ProgrammerProjectsListScreen extends ConsumerWidget {
|
||||
const ProgrammerProjectsListScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final projectsAsync = ref.watch(programmerProjectsProvider);
|
||||
final tasks = ref.watch(programmerTasksProvider).valueOrNull ?? const [];
|
||||
final taskCountByProject = <String, int>{};
|
||||
for (final t in tasks) {
|
||||
if (t.projectId != null) {
|
||||
taskCountByProject[t.projectId!] =
|
||||
(taskCountByProject[t.projectId!] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
ResponsiveBody(
|
||||
maxWidth: double.infinity,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const AppPageHeader(
|
||||
title: 'Projects',
|
||||
subtitle: 'Group programmer tasks by project',
|
||||
),
|
||||
Expanded(
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
if (projectsAsync.hasError && !projectsAsync.hasValue) {
|
||||
return AppErrorView(
|
||||
error: projectsAsync.error!,
|
||||
onRetry: () =>
|
||||
ref.invalidate(programmerProjectsProvider),
|
||||
);
|
||||
}
|
||||
if (!projectsAsync.hasValue && projectsAsync.isLoading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
final projects = projectsAsync.valueOrNull ?? const [];
|
||||
if (projects.isEmpty) {
|
||||
return const AppEmptyView(
|
||||
icon: Icons.folder_outlined,
|
||||
title: 'No projects yet',
|
||||
subtitle: 'Create one with the + button, or add a '
|
||||
'project on the fly from a task.',
|
||||
);
|
||||
}
|
||||
return ListView.separated(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
itemCount: projects.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(height: 10),
|
||||
itemBuilder: (context, i) {
|
||||
final p = projects[i];
|
||||
return _ProjectCard(
|
||||
project: p,
|
||||
taskCount: taskCountByProject[p.id] ?? 0,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
right: 16,
|
||||
bottom: 16,
|
||||
child: M3ExpandedFab(
|
||||
heroTag: 'create_programmer_project',
|
||||
onPressed: () => _showCreateDialog(context, ref),
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('New Project'),
|
||||
),
|
||||
),
|
||||
const ReconnectIndicator(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _showCreateDialog(BuildContext context, WidgetRef ref) async {
|
||||
final nameCtrl = TextEditingController();
|
||||
final descCtrl = TextEditingController();
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
semanticLabel: 'New project',
|
||||
title: const Text('New Project'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextField(
|
||||
controller: nameCtrl,
|
||||
autofocus: true,
|
||||
textCapitalization: TextCapitalization.words,
|
||||
decoration: const InputDecoration(labelText: 'Name'),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: descCtrl,
|
||||
minLines: 2,
|
||||
maxLines: 4,
|
||||
textCapitalization: TextCapitalization.sentences,
|
||||
decoration:
|
||||
const InputDecoration(labelText: 'Description (optional)'),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
child: const Text('Create'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
final name = nameCtrl.text.trim();
|
||||
final description = descCtrl.text.trim();
|
||||
nameCtrl.dispose();
|
||||
descCtrl.dispose();
|
||||
if (ok != true || name.isEmpty || !context.mounted) return;
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
try {
|
||||
final id = await ref.read(programmerProjectsControllerProvider).createProject(
|
||||
name: name,
|
||||
description: description.isEmpty ? null : description,
|
||||
);
|
||||
if (!context.mounted) return;
|
||||
context.go('/programmer-projects/$id');
|
||||
} catch (e) {
|
||||
messenger
|
||||
.showSnackBar(SnackBar(content: Text('Could not create project: $e')));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _ProjectCard extends StatelessWidget {
|
||||
const _ProjectCard({required this.project, required this.taskCount});
|
||||
|
||||
final ProgrammerProject project;
|
||||
final int taskCount;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tt = Theme.of(context).textTheme;
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
return M3Card.outlined(
|
||||
onTap: () => context.go('/programmer-projects/${project.id}'),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(14),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(project.name,
|
||||
style: tt.titleMedium
|
||||
?.copyWith(fontWeight: FontWeight.w600),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
StatusPill.kind(
|
||||
project.isArchived ? StatusKind.neutral : StatusKind.success,
|
||||
label: ProgrammerProjectStatus.label(project.status),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (project.description != null &&
|
||||
project.description!.trim().isNotEmpty) ...[
|
||||
const SizedBox(height: 6),
|
||||
Text(project.description!,
|
||||
style: tt.bodySmall?.copyWith(color: cs.onSurfaceVariant),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis),
|
||||
],
|
||||
const SizedBox(height: 8),
|
||||
Text('$taskCount task${taskCount == 1 ? '' : 's'}',
|
||||
style: tt.bodySmall?.copyWith(color: cs.onSurfaceVariant)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../theme/app_status_colors.dart';
|
||||
import '../../../utils/programmer_stats.dart';
|
||||
|
||||
/// A horizontal wrap of badge chips for the programmer dashboard.
|
||||
///
|
||||
/// Earned badges use the success container colour; locked badges show neutral
|
||||
/// styling and display `current/target` as their label. Tapping any chip opens
|
||||
/// an [AlertDialog] with the badge title and description.
|
||||
class BadgesStrip extends StatelessWidget {
|
||||
const BadgesStrip({
|
||||
super.key,
|
||||
required this.badges,
|
||||
});
|
||||
|
||||
final List<BadgeProgress> badges;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (badges.isEmpty) return const SizedBox.shrink();
|
||||
return Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
for (final badge in badges) _BadgeChip(badge: badge),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _BadgeChip extends StatelessWidget {
|
||||
const _BadgeChip({required this.badge});
|
||||
|
||||
final BadgeProgress badge;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final statusColors = AppStatusColors.of(context);
|
||||
final earned = badge.earned;
|
||||
|
||||
return ActionChip(
|
||||
avatar: Icon(badge.icon, size: 16),
|
||||
label: Text(earned ? badge.title : '${badge.current}/${badge.target}'),
|
||||
backgroundColor: earned ? statusColors.success.container : null,
|
||||
onPressed: () {
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: Text(badge.title),
|
||||
content: Text(badge.description),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../models/programmer_task.model.dart';
|
||||
import '../../../providers/programmer_tasks_provider.dart';
|
||||
import '../../../utils/programmer_task_ledger.dart';
|
||||
import '../../../widgets/m3_card.dart';
|
||||
import '../../../widgets/tech_chip.dart';
|
||||
import '../programmer_task_actions.dart';
|
||||
|
||||
/// Hero card at the top of the programmer dashboard showing the currently
|
||||
/// running task (or an idle state when nothing is running).
|
||||
class NowWorkingCard extends ConsumerWidget {
|
||||
const NowWorkingCard({
|
||||
super.key,
|
||||
required this.running,
|
||||
required this.nextUp,
|
||||
required this.todayOnTask,
|
||||
required this.totalOnTask,
|
||||
required this.readOnly,
|
||||
});
|
||||
|
||||
/// The currently running task, or null when nothing is running.
|
||||
final ProgrammerTask? running;
|
||||
|
||||
/// First queued (not-started) task, shown as a start target when idle.
|
||||
final ProgrammerTask? nextUp;
|
||||
|
||||
/// How long the user has worked on [running] today.
|
||||
final Duration todayOnTask;
|
||||
|
||||
/// Total worked time on [running] across all days.
|
||||
final Duration totalOnTask;
|
||||
|
||||
final bool readOnly;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return M3Card.elevated(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: running != null
|
||||
? _RunningView(
|
||||
task: running!,
|
||||
todayOnTask: todayOnTask,
|
||||
totalOnTask: totalOnTask,
|
||||
readOnly: readOnly,
|
||||
)
|
||||
: _IdleView(nextUp: nextUp, readOnly: readOnly),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RunningView extends ConsumerWidget {
|
||||
const _RunningView({
|
||||
required this.task,
|
||||
required this.todayOnTask,
|
||||
required this.totalOnTask,
|
||||
required this.readOnly,
|
||||
});
|
||||
|
||||
final ProgrammerTask task;
|
||||
final Duration todayOnTask;
|
||||
final Duration totalOnTask;
|
||||
final bool readOnly;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final tt = Theme.of(context).textTheme;
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
task.title,
|
||||
style: tt.titleMedium?.copyWith(fontWeight: FontWeight.w600),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 4,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
TechChip(task.category, icon: Icons.category_outlined, dense: true),
|
||||
Text(
|
||||
'Today ${formatDurationHm(todayOnTask)} · '
|
||||
'Total ${formatDurationHm(totalOnTask)}',
|
||||
style: tt.bodySmall?.copyWith(color: cs.onSurfaceVariant),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (!readOnly) ...[
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
OutlinedButton.icon(
|
||||
onPressed: () => ref
|
||||
.read(programmerTasksControllerProvider)
|
||||
.pauseTask(taskId: task.id),
|
||||
icon: const Icon(Icons.pause_rounded, size: 18),
|
||||
label: const Text('Pause'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
FilledButton.icon(
|
||||
onPressed: () =>
|
||||
completeProgrammerTaskWithConfirm(context, ref, task),
|
||||
icon: const Icon(Icons.check_rounded, size: 18),
|
||||
label: const Text('Complete'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _IdleView extends ConsumerWidget {
|
||||
const _IdleView({required this.nextUp, required this.readOnly});
|
||||
|
||||
final ProgrammerTask? nextUp;
|
||||
final bool readOnly;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final tt = Theme.of(context).textTheme;
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Nothing running',
|
||||
style: tt.bodyLarge?.copyWith(color: cs.onSurfaceVariant),
|
||||
),
|
||||
),
|
||||
if (nextUp != null && !readOnly)
|
||||
FilledButton.icon(
|
||||
onPressed: () =>
|
||||
startProgrammerTaskWithPausePrompt(context, ref, nextUp!),
|
||||
icon: const Icon(Icons.play_arrow_rounded, size: 18),
|
||||
label: const Text('Start'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../models/profile.model.dart';
|
||||
import '../../../models/programmer_day_sheet.model.dart';
|
||||
import '../../../models/programmer_task.model.dart';
|
||||
import '../../../providers/leave_provider.dart';
|
||||
import '../../../providers/profile_provider.dart';
|
||||
import '../../../providers/programmer_day_data_provider.dart';
|
||||
import '../../../providers/programmer_day_sheets_provider.dart';
|
||||
import '../../../providers/programmer_tasks_provider.dart';
|
||||
import '../../../utils/app_time.dart';
|
||||
import '../../../utils/programmer_daily_time.dart';
|
||||
import '../../../utils/programmer_day_rows.dart';
|
||||
import '../../../utils/programmer_stats.dart';
|
||||
import '../../../utils/staff_presence.dart';
|
||||
import 'badges_strip.dart';
|
||||
import 'now_working_card.dart';
|
||||
import 'stat_tiles.dart';
|
||||
import 'task_sections.dart';
|
||||
|
||||
/// The programmer's own work dashboard — hero card, stat tiles (Task 8), and
|
||||
/// task sections. Drop it into the Mine tab with `userId: me`.
|
||||
class ProgrammerDashboard extends ConsumerWidget {
|
||||
const ProgrammerDashboard({
|
||||
super.key,
|
||||
required this.userId,
|
||||
this.readOnly = false,
|
||||
});
|
||||
|
||||
final String userId;
|
||||
final bool readOnly;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
// Drive 30-second rebuilds so duration values stay fresh.
|
||||
ref.watch(clockTickerProvider);
|
||||
|
||||
final tasksAsync = ref.watch(programmerTasksProvider);
|
||||
final runStates = ref.watch(programmerRunStatesProvider);
|
||||
final todayKey = ref.watch(todayKeyProvider);
|
||||
final dayDataAsync = ref.watch(programmerDayDataProvider(todayKey));
|
||||
final profilesAsync = ref.watch(profilesProvider);
|
||||
|
||||
final now = ref.watch(clockTickerProvider).valueOrNull ?? AppTime.now();
|
||||
|
||||
final sheetsAsync = ref.watch(daySheetsForUserProvider(userId));
|
||||
final sheets = sheetsAsync.valueOrNull ?? const <ProgrammerDaySheet>[];
|
||||
|
||||
final liveHelpThisMonthSec =
|
||||
ref.watch(helpGivenThisMonthProvider(userId)).valueOrNull;
|
||||
|
||||
final tasks = tasksAsync.valueOrNull ?? const [];
|
||||
final profileById = <String, Profile>{
|
||||
for (final p in profilesAsync.valueOrNull ?? <Profile>[]) p.id: p,
|
||||
};
|
||||
final profileNames = <String, String>{
|
||||
for (final p in profilesAsync.valueOrNull ?? <Profile>[]) p.id: p.fullName,
|
||||
};
|
||||
|
||||
final dayData = dayDataAsync.valueOrNull;
|
||||
final leaveDays = approvedLeaveDays(
|
||||
ref.watch(leavesProvider).valueOrNull ?? [],
|
||||
userId,
|
||||
);
|
||||
|
||||
final todayRows = dayData == null
|
||||
? const <DaySheetRow>[]
|
||||
: buildDayRows(
|
||||
userId: userId,
|
||||
day: todayKey,
|
||||
tasks: tasks,
|
||||
logsByTask: dayData.logsByTask,
|
||||
workLogs: dayData.workLogs,
|
||||
projectNames: const {},
|
||||
profileNames: profileNames,
|
||||
now: now,
|
||||
);
|
||||
final todayFocus = Duration(seconds: totalSeconds(todayRows));
|
||||
|
||||
final stats = computeProgrammerStats(
|
||||
userId: userId,
|
||||
now: now,
|
||||
tasks: tasks,
|
||||
sheets: sheets,
|
||||
leaveDays: leaveDays,
|
||||
todayFocus: todayFocus,
|
||||
liveHelpThisMonthSec: liveHelpThisMonthSec,
|
||||
);
|
||||
|
||||
final showSheetStats =
|
||||
ref.watch(currentProfileProvider).valueOrNull?.role == 'programmer';
|
||||
|
||||
final badges = computeBadges(stats, includeSheetBadges: showSheetStats);
|
||||
|
||||
final groups = groupTasksForDashboard(tasks, runStates, userId);
|
||||
final runningTask =
|
||||
groups.running.isNotEmpty ? groups.running.first : null;
|
||||
final nextUp =
|
||||
groups.notStarted.isNotEmpty ? groups.notStarted.first : null;
|
||||
|
||||
final todayOnTask = _computeTodayDuration(
|
||||
runningTask: runningTask,
|
||||
dayDataAsync: dayDataAsync,
|
||||
userId: userId,
|
||||
todayKey: todayKey,
|
||||
);
|
||||
final totalOnTask = _computeTotalDuration(
|
||||
runningTask: runningTask,
|
||||
dayDataAsync: dayDataAsync,
|
||||
userId: userId,
|
||||
);
|
||||
|
||||
return ListView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
children: [
|
||||
NowWorkingCard(
|
||||
running: runningTask,
|
||||
nextUp: nextUp,
|
||||
todayOnTask: todayOnTask,
|
||||
totalOnTask: totalOnTask,
|
||||
readOnly: readOnly,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
StatTiles(stats: stats, showSheetStats: showSheetStats),
|
||||
const SizedBox(height: 8),
|
||||
BadgesStrip(badges: badges),
|
||||
const SizedBox(height: 16),
|
||||
TaskSections(
|
||||
groups: groups,
|
||||
profileById: profileById,
|
||||
readOnly: readOnly,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Duration _computeTodayDuration({
|
||||
required ProgrammerTask? runningTask,
|
||||
required AsyncValue<dynamic> dayDataAsync,
|
||||
required String userId,
|
||||
required DateTime todayKey,
|
||||
}) {
|
||||
if (runningTask == null) return Duration.zero;
|
||||
final dayData = dayDataAsync.valueOrNull;
|
||||
if (dayData == null) return Duration.zero;
|
||||
final logs = dayData.logsByTask[runningTask.id] ?? const [];
|
||||
final dailyByAssignee = dailySecondsByAssignee(
|
||||
task: runningTask,
|
||||
logs: logs,
|
||||
now: DateTime.now().toUtc(),
|
||||
);
|
||||
final secs = dailyByAssignee[userId]?[todayKey] ?? 0;
|
||||
return Duration(seconds: secs);
|
||||
}
|
||||
|
||||
Duration _computeTotalDuration({
|
||||
required ProgrammerTask? runningTask,
|
||||
required AsyncValue<dynamic> dayDataAsync,
|
||||
required String userId,
|
||||
}) {
|
||||
if (runningTask == null) return Duration.zero;
|
||||
final dayData = dayDataAsync.valueOrNull;
|
||||
if (dayData == null) return Duration.zero;
|
||||
final logs = dayData.logsByTask[runningTask.id] ?? const [];
|
||||
final dailyByAssignee = dailySecondsByAssignee(
|
||||
task: runningTask,
|
||||
logs: logs,
|
||||
now: DateTime.now().toUtc(),
|
||||
);
|
||||
final totalSecs =
|
||||
(dailyByAssignee[userId] ?? const {}).values.fold<int>(0, (s, v) => s + v);
|
||||
return Duration(seconds: totalSecs);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../utils/programmer_stats.dart';
|
||||
import '../../../utils/programmer_task_ledger.dart';
|
||||
import '../../../widgets/app_metric_card.dart';
|
||||
|
||||
/// A vertical list of KPI stat tiles for the programmer dashboard.
|
||||
///
|
||||
/// When [showSheetStats] is false only the first two tiles (today's focus and
|
||||
/// completed-this-week) are shown; sheet-based tiles (streak, first-pass
|
||||
/// approval, help given) are hidden and badges are computed without sheet data.
|
||||
class StatTiles extends StatelessWidget {
|
||||
const StatTiles({
|
||||
super.key,
|
||||
required this.stats,
|
||||
required this.showSheetStats,
|
||||
});
|
||||
|
||||
final ProgrammerStats stats;
|
||||
final bool showSheetStats;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final tiles = <Widget>[
|
||||
_buildFocusTile(context, cs),
|
||||
const SizedBox(height: 8),
|
||||
_buildCompletedTile(context),
|
||||
];
|
||||
if (showSheetStats) {
|
||||
tiles.addAll([
|
||||
const SizedBox(height: 8),
|
||||
_buildStreakTile(context),
|
||||
const SizedBox(height: 8),
|
||||
_buildFirstPassTile(context),
|
||||
const SizedBox(height: 8),
|
||||
_buildHelpGivenTile(context),
|
||||
]);
|
||||
}
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: tiles,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFocusTile(BuildContext context, ColorScheme cs) {
|
||||
final progressValue =
|
||||
math.min(1.0, stats.todayFocus.inSeconds / (8 * 3600));
|
||||
final trailing = Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 40,
|
||||
height: 40,
|
||||
child: CircularProgressIndicator(
|
||||
value: progressValue,
|
||||
strokeWidth: 6,
|
||||
backgroundColor: cs.surfaceContainerHighest,
|
||||
color: cs.primary,
|
||||
strokeCap: StrokeCap.round,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'/ 8h',
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: cs.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
return AppMetricCard(
|
||||
title: "TODAY'S FOCUS",
|
||||
value: formatDurationHm(stats.todayFocus),
|
||||
trailing: trailing,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCompletedTile(BuildContext context) {
|
||||
final diff = stats.completedThisWeek - stats.completedLastWeek;
|
||||
final String trailingText;
|
||||
if (diff > 0) {
|
||||
trailingText = '↑$diff vs last week';
|
||||
} else if (diff < 0) {
|
||||
trailingText = '↓${-diff} vs last week';
|
||||
} else {
|
||||
trailingText = 'Same as last week';
|
||||
}
|
||||
return AppMetricCard(
|
||||
title: 'COMPLETED THIS WEEK',
|
||||
value: stats.completedThisWeek.toString(),
|
||||
trailing: Text(
|
||||
trailingText,
|
||||
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStreakTile(BuildContext context) {
|
||||
final n = stats.currentStreak;
|
||||
final dayLabel = n == 1 ? '1 day' : '$n days';
|
||||
return AppMetricCard(
|
||||
title: 'STREAK',
|
||||
value: dayLabel,
|
||||
icon: Icons.local_fire_department_outlined,
|
||||
trailing: Text(
|
||||
'Best ${stats.bestStreak}',
|
||||
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFirstPassTile(BuildContext context) {
|
||||
final value = stats.firstPassRate != null
|
||||
? '${(stats.firstPassRate! * 100).round()}%'
|
||||
: '—';
|
||||
return AppMetricCard(
|
||||
title: 'FIRST-PASS APPROVAL',
|
||||
value: value,
|
||||
trailing: Text(
|
||||
'Last 30 days',
|
||||
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHelpGivenTile(BuildContext context) {
|
||||
return AppMetricCard(
|
||||
title: 'HELP GIVEN',
|
||||
value: formatDurationHm(stats.helpGivenThisMonth),
|
||||
trailing: Text(
|
||||
'This month',
|
||||
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../models/profile.model.dart';
|
||||
import '../../../models/programmer_task.model.dart';
|
||||
import '../../../providers/programmer_day_data_provider.dart';
|
||||
import '../../../widgets/app_section_header.dart';
|
||||
import '../widgets/programmer_task_card.dart';
|
||||
|
||||
typedef TaskSectionGroups = ({
|
||||
List<ProgrammerTask> running,
|
||||
List<ProgrammerTask> paused,
|
||||
List<ProgrammerTask> notStarted,
|
||||
List<ProgrammerTask> completed,
|
||||
List<ProgrammerTask> cancelled,
|
||||
});
|
||||
|
||||
/// Groups [tasks] into dashboard buckets for [userId], using [runStates] to
|
||||
/// distinguish running from paused from not-started. Only tasks assigned to
|
||||
/// [userId] are included.
|
||||
TaskSectionGroups groupTasksForDashboard(
|
||||
List<ProgrammerTask> tasks,
|
||||
Map<String, ProgrammerRunInfo> runStates,
|
||||
String userId,
|
||||
) {
|
||||
final running = <ProgrammerTask>[];
|
||||
final paused = <ProgrammerTask>[];
|
||||
final notStarted = <ProgrammerTask>[];
|
||||
final completed = <ProgrammerTask>[];
|
||||
final cancelled = <ProgrammerTask>[];
|
||||
|
||||
for (final task in tasks) {
|
||||
if (task.assigneeId != userId) continue;
|
||||
|
||||
final state = runStates[task.id]?.state;
|
||||
if (state == ProgrammerRunState.running) {
|
||||
running.add(task);
|
||||
} else if (state == ProgrammerRunState.paused) {
|
||||
paused.add(task);
|
||||
} else if (state == ProgrammerRunState.notStarted) {
|
||||
notStarted.add(task);
|
||||
} else if (task.status == ProgrammerTaskStatus.completed) {
|
||||
completed.add(task);
|
||||
} else if (task.status == ProgrammerTaskStatus.cancelled) {
|
||||
cancelled.add(task);
|
||||
}
|
||||
}
|
||||
|
||||
completed.sort(
|
||||
(a, b) =>
|
||||
(b.completedAt ?? DateTime(0)).compareTo(a.completedAt ?? DateTime(0)),
|
||||
);
|
||||
cancelled.sort(
|
||||
(a, b) =>
|
||||
(b.cancelledAt ?? DateTime(0)).compareTo(a.cancelledAt ?? DateTime(0)),
|
||||
);
|
||||
|
||||
return (
|
||||
running: running,
|
||||
paused: paused,
|
||||
notStarted: notStarted,
|
||||
completed: completed,
|
||||
cancelled: cancelled,
|
||||
);
|
||||
}
|
||||
|
||||
/// Renders the five task groups as collapsible / always-visible sections.
|
||||
///
|
||||
/// - In progress, Paused, Not started: always visible when non-empty.
|
||||
/// - Completed, Cancelled: start collapsed; tap header to expand; shows first
|
||||
/// 10 + "Show all (N)" when N > 10; tap "Show all" to see all.
|
||||
/// - Empty sections produce no header.
|
||||
class TaskSections extends StatefulWidget {
|
||||
const TaskSections({
|
||||
super.key,
|
||||
required this.groups,
|
||||
required this.profileById,
|
||||
required this.readOnly,
|
||||
});
|
||||
|
||||
final TaskSectionGroups groups;
|
||||
final Map<String, Profile> profileById;
|
||||
final bool readOnly;
|
||||
|
||||
@override
|
||||
State<TaskSections> createState() => _TaskSectionsState();
|
||||
}
|
||||
|
||||
class _TaskSectionsState extends State<TaskSections> {
|
||||
bool _completedExpanded = false;
|
||||
bool _completedShowAll = false;
|
||||
bool _cancelledExpanded = false;
|
||||
bool _cancelledShowAll = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final g = widget.groups;
|
||||
final isEmpty = g.running.isEmpty &&
|
||||
g.paused.isEmpty &&
|
||||
g.notStarted.isEmpty &&
|
||||
g.completed.isEmpty &&
|
||||
g.cancelled.isEmpty;
|
||||
|
||||
if (isEmpty) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 32),
|
||||
child: Center(child: Text('No tasks assigned to you')),
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
..._alwaysSection('In progress', g.running),
|
||||
..._alwaysSection('Paused', g.paused),
|
||||
..._alwaysSection('Not started', g.notStarted),
|
||||
..._collapsibleSection(
|
||||
'Completed',
|
||||
g.completed,
|
||||
_completedExpanded,
|
||||
_completedShowAll,
|
||||
(v) => setState(() => _completedExpanded = v),
|
||||
(v) => setState(() => _completedShowAll = v),
|
||||
),
|
||||
..._collapsibleSection(
|
||||
'Cancelled',
|
||||
g.cancelled,
|
||||
_cancelledExpanded,
|
||||
_cancelledShowAll,
|
||||
(v) => setState(() => _cancelledExpanded = v),
|
||||
(v) => setState(() => _cancelledShowAll = v),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _alwaysSection(String name, List<ProgrammerTask> tasks) {
|
||||
if (tasks.isEmpty) return const [];
|
||||
return [
|
||||
AppSectionHeader(name, trailing: Text('${tasks.length}')),
|
||||
...tasks.map(_taskCard),
|
||||
const SizedBox(height: 8),
|
||||
];
|
||||
}
|
||||
|
||||
List<Widget> _collapsibleSection(
|
||||
String name,
|
||||
List<ProgrammerTask> tasks,
|
||||
bool expanded,
|
||||
bool showAll,
|
||||
void Function(bool) onToggle,
|
||||
void Function(bool) onShowAll,
|
||||
) {
|
||||
if (tasks.isEmpty) return const [];
|
||||
|
||||
List<ProgrammerTask> visible;
|
||||
if (!expanded) {
|
||||
visible = const [];
|
||||
} else if (showAll || tasks.length <= 10) {
|
||||
visible = tasks;
|
||||
} else {
|
||||
visible = tasks.take(10).toList();
|
||||
}
|
||||
|
||||
return [
|
||||
GestureDetector(
|
||||
onTap: () => onToggle(!expanded),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: AppSectionHeader(name, trailing: Text('${tasks.length}')),
|
||||
),
|
||||
if (expanded) ...[
|
||||
...visible.map(_taskCard),
|
||||
if (tasks.length > 10 && !showAll)
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: TextButton(
|
||||
onPressed: () => onShowAll(true),
|
||||
child: Text('Show all (${tasks.length})'),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
Widget _taskCard(ProgrammerTask t) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
child: ProgrammerTaskCard(
|
||||
task: t,
|
||||
assignee: t.assigneeId != null
|
||||
? widget.profileById[t.assigneeId!]
|
||||
: null,
|
||||
isMine: true,
|
||||
readOnly: widget.readOnly,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,423 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../models/profile.model.dart';
|
||||
import '../../../models/programmer_day_sheet.model.dart';
|
||||
import '../../../providers/profile_provider.dart';
|
||||
import '../../../providers/programmer_day_sheets_provider.dart';
|
||||
import '../../../providers/reports_provider.dart';
|
||||
import '../../../theme/app_status_colors.dart';
|
||||
import '../../../utils/app_time.dart';
|
||||
import '../../../utils/programmer_task_ledger.dart';
|
||||
import '../../../widgets/app_state_view.dart';
|
||||
import '../../../widgets/app_status_summary.dart';
|
||||
import '../../../widgets/tech_chip.dart';
|
||||
import '../../reports/report_date_filter.dart';
|
||||
import 'day_sheet_snapshot_builder.dart';
|
||||
import '../report/accomplishment_report_dialog.dart';
|
||||
|
||||
/// Admin tab for reviewing and bulk-approving programmer day sheets.
|
||||
///
|
||||
/// Features:
|
||||
/// * Status filter chips: Pending (default) / Awaiting justification / Approved.
|
||||
/// * Programmer dropdown: All programmers or a specific person.
|
||||
/// * Controlled date range filter (default: this month).
|
||||
/// * Cards grouped by work-date; pending cards have selection checkboxes.
|
||||
/// * "Approve selected (n)" button that calls [buildSnapshotForSheet] for each
|
||||
/// selected sheet before calling the bulk-approve RPC.
|
||||
class ApprovalsTab extends ConsumerStatefulWidget {
|
||||
const ApprovalsTab({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ApprovalsTab> createState() => _ApprovalsTabState();
|
||||
}
|
||||
|
||||
class _ApprovalsTabState extends ConsumerState<ApprovalsTab> {
|
||||
/// Active status filter — one of the [DaySheetStatus] constants.
|
||||
String _filter = DaySheetStatus.pending;
|
||||
|
||||
/// Selected programmer ID, or null for "all programmers".
|
||||
String? _programmerFilter;
|
||||
|
||||
late ReportDateRange _dateRange;
|
||||
|
||||
/// IDs of pending sheets selected for bulk approval.
|
||||
final Set<String> _selected = {};
|
||||
|
||||
bool _approving = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_dateRange = thisMonthRange(AppTime.now());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
bool _inDateRange(DateTime workDate) =>
|
||||
!workDate.isBefore(_dateRange.start) && workDate.isBefore(_dateRange.end);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Actions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Future<void> _onApprove(List<ProgrammerDaySheet> allSheets) async {
|
||||
setState(() => _approving = true);
|
||||
try {
|
||||
final toApprove = allSheets
|
||||
.where((s) =>
|
||||
_selected.contains(s.id) &&
|
||||
s.status == DaySheetStatus.pending)
|
||||
.toList();
|
||||
|
||||
final items = <({String sheetId, DaySheetSnapshot snapshot})>[];
|
||||
for (final sheet in toApprove) {
|
||||
final snapshot = await buildSnapshotForSheet(ref, sheet);
|
||||
items.add((sheetId: sheet.id, snapshot: snapshot));
|
||||
}
|
||||
|
||||
final result = await ref.read(daySheetsControllerProvider).approve(items);
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_selected.clear();
|
||||
_approving = false;
|
||||
});
|
||||
|
||||
final count = result.approvedIds.length;
|
||||
final skipped = result.skippedIds.length;
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
icon: const Icon(Icons.check_circle_outline, size: 48),
|
||||
title: Text('Approved $count ${count == 1 ? 'Sheet' : 'Sheets'}'),
|
||||
content: skipped > 0
|
||||
? Text(
|
||||
'$skipped ${skipped == 1 ? 'sheet was' : 'sheets were'} '
|
||||
'already reviewed and skipped.',
|
||||
)
|
||||
: null,
|
||||
actions: [
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(),
|
||||
child: const Text('Done'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
} on DaySheetActionException catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() => _approving = false);
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(SnackBar(content: Text(e.message)));
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() => _approving = false);
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(SnackBar(content: Text(e.toString())));
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Build
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final allSheets = ref.watch(allDaySheetsProvider).valueOrNull ?? const [];
|
||||
final profiles = ref.watch(profilesProvider).valueOrNull ?? const [];
|
||||
|
||||
final profileById = {for (final p in profiles) p.id: p};
|
||||
final programmerProfiles =
|
||||
profiles.where((p) => p.role == 'programmer').toList()
|
||||
..sort((a, b) => a.fullName.compareTo(b.fullName));
|
||||
|
||||
// Sheets passing the date + programmer filters (used for chip counts).
|
||||
final dateAndProgFiltered = allSheets.where((s) {
|
||||
return _inDateRange(s.workDate) &&
|
||||
(_programmerFilter == null || s.programmerId == _programmerFilter);
|
||||
}).toList();
|
||||
|
||||
final counts = approvalCounts(dateAndProgFiltered);
|
||||
|
||||
// Sheets also matching the status filter (displayed in the list).
|
||||
final filtered =
|
||||
dateAndProgFiltered.where((s) => s.status == _filter).toList();
|
||||
|
||||
final grouped = groupSheetsByDate(filtered);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// ── Status filter chips ────────────────────────────────────────────
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
|
||||
child: AppStatusSummaryRow(
|
||||
items: [
|
||||
AppStatusSummaryItem(
|
||||
label: 'Pending',
|
||||
count: counts.pending,
|
||||
kind: StatusKind.info,
|
||||
selected: _filter == DaySheetStatus.pending,
|
||||
onTap: () => setState(() {
|
||||
_filter = DaySheetStatus.pending;
|
||||
_selected.clear();
|
||||
}),
|
||||
),
|
||||
AppStatusSummaryItem(
|
||||
label: 'Awaiting justification',
|
||||
count: counts.awaiting,
|
||||
kind: StatusKind.warning,
|
||||
selected: _filter == DaySheetStatus.disapproved,
|
||||
onTap: () => setState(() {
|
||||
_filter = DaySheetStatus.disapproved;
|
||||
_selected.clear();
|
||||
}),
|
||||
),
|
||||
AppStatusSummaryItem(
|
||||
label: 'Approved',
|
||||
count: counts.approved,
|
||||
kind: StatusKind.success,
|
||||
selected: _filter == DaySheetStatus.approved,
|
||||
onTap: () => setState(() {
|
||||
_filter = DaySheetStatus.approved;
|
||||
_selected.clear();
|
||||
}),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// ── Programmer dropdown + date filter ──────────────────────────────
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
DropdownButton<String?>(
|
||||
value: _programmerFilter,
|
||||
hint: const Text('All programmers'),
|
||||
underline: const SizedBox.shrink(),
|
||||
items: [
|
||||
const DropdownMenuItem<String?>(
|
||||
value: null,
|
||||
child: Text('All programmers'),
|
||||
),
|
||||
for (final p in programmerProfiles)
|
||||
DropdownMenuItem<String?>(
|
||||
value: p.id,
|
||||
child: Text(p.fullName),
|
||||
),
|
||||
],
|
||||
onChanged: (v) => setState(() {
|
||||
_programmerFilter = v;
|
||||
_selected.clear();
|
||||
}),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: ReportDateFilter.controlled(
|
||||
value: _dateRange,
|
||||
onChanged: (r) => setState(() {
|
||||
_dateRange = r;
|
||||
_selected.clear();
|
||||
}),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.description_outlined),
|
||||
tooltip: 'Accomplishment Report',
|
||||
onPressed: () => showAccomplishmentReportDialog(
|
||||
context,
|
||||
ref,
|
||||
programmerId: _programmerFilter,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// ── Card list ──────────────────────────────────────────────────────
|
||||
Expanded(
|
||||
child: filtered.isEmpty
|
||||
? AppEmptyView(
|
||||
icon: Icons.check_circle_outline,
|
||||
title: _filter == DaySheetStatus.pending
|
||||
? 'No pending sheets'
|
||||
: 'Nothing here',
|
||||
subtitle: 'All caught up!',
|
||||
)
|
||||
: ListView.builder(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
itemCount: grouped.length,
|
||||
itemBuilder: (context, groupIdx) {
|
||||
final dateKey = grouped.keys.elementAt(groupIdx);
|
||||
final groupSheets = grouped[dateKey]!;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8, bottom: 4),
|
||||
child: Text(
|
||||
AppTime.formatDate(dateKey),
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.labelMedium
|
||||
?.copyWith(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
for (final sheet in groupSheets)
|
||||
_SheetApprovalCard(
|
||||
sheet: sheet,
|
||||
profile: profileById[sheet.programmerId],
|
||||
showCheckbox: _filter == DaySheetStatus.pending,
|
||||
selected: _selected.contains(sheet.id),
|
||||
totalSecs: sheet.approvedSnapshot?.totalSeconds,
|
||||
taskCount: sheet.approvedSnapshot?.rows.length,
|
||||
onToggle: _filter == DaySheetStatus.pending
|
||||
? (v) => setState(() {
|
||||
if (v == true) {
|
||||
_selected.add(sheet.id);
|
||||
} else {
|
||||
_selected.remove(sheet.id);
|
||||
}
|
||||
})
|
||||
: null,
|
||||
onTap: () => context.pushNamed(
|
||||
'daySheetDetail',
|
||||
pathParameters: {'id': sheet.id},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
// ── Approve button ─────────────────────────────────────────────────
|
||||
if (_selected.isNotEmpty)
|
||||
SafeArea(
|
||||
top: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
||||
child: FilledButton(
|
||||
onPressed: _approving ? null : () => _onApprove(allSheets),
|
||||
child: _approving
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: Text('Approve selected (${_selected.length})'),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Card widget
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class _SheetApprovalCard extends StatelessWidget {
|
||||
const _SheetApprovalCard({
|
||||
required this.sheet,
|
||||
required this.profile,
|
||||
required this.showCheckbox,
|
||||
required this.selected,
|
||||
required this.onToggle,
|
||||
required this.onTap,
|
||||
this.totalSecs,
|
||||
this.taskCount,
|
||||
});
|
||||
|
||||
final ProgrammerDaySheet sheet;
|
||||
final Profile? profile;
|
||||
final bool showCheckbox;
|
||||
final bool selected;
|
||||
final ValueChanged<bool?>? onToggle;
|
||||
final VoidCallback onTap;
|
||||
/// Null when no approved snapshot exists yet (pending/disapproved sheets).
|
||||
final int? totalSecs;
|
||||
final int? taskCount;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final roundNum = sheet.resubmissions + 1;
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
if (showCheckbox && onToggle != null) ...[
|
||||
Checkbox(value: selected, onChanged: onToggle),
|
||||
const SizedBox(width: 4),
|
||||
],
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
profile?.fullName ?? sheet.programmerId,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 4,
|
||||
children: [
|
||||
TechChip(
|
||||
totalSecs != null
|
||||
? formatDurationHm(Duration(seconds: totalSecs!))
|
||||
: '—',
|
||||
),
|
||||
TechChip(
|
||||
taskCount != null ? '$taskCount tasks' : '—',
|
||||
),
|
||||
if (sheet.autoSubmitted)
|
||||
const TechChip('Auto', status: StatusKind.info)
|
||||
else
|
||||
const TechChip('Early', status: StatusKind.neutral),
|
||||
if (roundNum > 1)
|
||||
TechChip(
|
||||
'Round $roundNum',
|
||||
status: StatusKind.warning,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
Icons.chevron_right,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,527 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../models/programmer_day_sheet.model.dart';
|
||||
import '../../../models/programmer_day_sheet_event.model.dart';
|
||||
import '../../../models/programmer_task.model.dart';
|
||||
import '../../../providers/connectivity_provider.dart';
|
||||
import '../../../providers/programmer_day_data_provider.dart';
|
||||
import '../../../providers/programmer_day_sheets_provider.dart';
|
||||
import '../../../providers/programmer_tasks_provider.dart';
|
||||
import '../../../providers/profile_provider.dart';
|
||||
import '../../../theme/app_status_colors.dart';
|
||||
import '../../../utils/app_time.dart';
|
||||
import '../../../utils/programmer_day_rows.dart';
|
||||
import '../../../utils/programmer_task_ledger.dart';
|
||||
import '../../../widgets/app_section_header.dart';
|
||||
import '../../../widgets/app_state_view.dart';
|
||||
import '../../../widgets/offline_banner.dart';
|
||||
import '../../../widgets/status_pill.dart';
|
||||
import '../widgets/work_log_section.dart';
|
||||
import 'day_sheet_dialogs.dart';
|
||||
import 'day_sheet_rows_table.dart';
|
||||
import 'day_sheet_thread.dart';
|
||||
|
||||
/// The full detail view for a single programmer day sheet.
|
||||
class DaySheetDetailScreen extends ConsumerStatefulWidget {
|
||||
const DaySheetDetailScreen({super.key, required this.sheetId});
|
||||
|
||||
final String sheetId;
|
||||
|
||||
@override
|
||||
ConsumerState<DaySheetDetailScreen> createState() =>
|
||||
_DaySheetDetailScreenState();
|
||||
}
|
||||
|
||||
class _DaySheetDetailScreenState extends ConsumerState<DaySheetDetailScreen> {
|
||||
Set<String> _selectedTaskIds = {};
|
||||
bool _approvingSheet = false;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
StatusKind _kindForStatus(String s) => switch (s) {
|
||||
DaySheetStatus.approved => StatusKind.success,
|
||||
DaySheetStatus.pending => StatusKind.info,
|
||||
DaySheetStatus.disapproved => StatusKind.warning,
|
||||
_ => StatusKind.neutral,
|
||||
};
|
||||
|
||||
Set<String> _flaggedTaskIdsFromEvents(List<ProgrammerDaySheetEvent> events) {
|
||||
final disapproved = events
|
||||
.where((e) => e.kind == 'disapproved')
|
||||
.toList()
|
||||
..sort((a, b) => b.createdAt.compareTo(a.createdAt));
|
||||
if (disapproved.isEmpty) return const {};
|
||||
return Set<String>.from(disapproved.first.flaggedTaskIds);
|
||||
}
|
||||
|
||||
void _showError(String message) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(SnackBar(content: Text(message)));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Actions — Owner
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Future<void> _onAddCorrection(
|
||||
BuildContext ctx,
|
||||
ProgrammerTask task,
|
||||
String currentUserId,
|
||||
DateTime workDate,
|
||||
) async {
|
||||
await showModalBottomSheet<void>(
|
||||
context: ctx,
|
||||
isScrollControlled: true,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
||||
),
|
||||
builder: (_) => Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: MediaQuery.of(ctx).viewInsets.bottom,
|
||||
left: 16,
|
||||
right: 16,
|
||||
top: 16,
|
||||
),
|
||||
child: WorkLogSection(
|
||||
task: task,
|
||||
isAssignee: task.assigneeId == currentUserId,
|
||||
workDate: workDate,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onJustify(String sheetId) async {
|
||||
final done = await showJustifyDialog(
|
||||
context,
|
||||
onConfirm: (body) => ref
|
||||
.read(daySheetsControllerProvider)
|
||||
.justify(sheetId: sheetId, body: body),
|
||||
);
|
||||
if (done == true && mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Justification submitted')),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Actions — Admin
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Future<void> _onDisapprove(
|
||||
String sheetId,
|
||||
List<DaySheetRow> rows,
|
||||
) async {
|
||||
final flaggedRows =
|
||||
rows.where((r) => _selectedTaskIds.contains(r.taskId)).toList();
|
||||
final done = await showDisapproveDialog(
|
||||
context,
|
||||
flaggedRows: flaggedRows,
|
||||
onConfirm: (remarks, flaggedTaskIds) =>
|
||||
ref.read(daySheetsControllerProvider).disapprove(
|
||||
sheetId: sheetId,
|
||||
remarks: remarks,
|
||||
flaggedTaskIds: flaggedTaskIds,
|
||||
),
|
||||
);
|
||||
if (done == true && mounted) {
|
||||
setState(() => _selectedTaskIds = {});
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Sheet disapproved')),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onApprove(
|
||||
ProgrammerDaySheet sheet,
|
||||
List<DaySheetRow> rows,
|
||||
String programmerName,
|
||||
) async {
|
||||
final today = ref.read(todayKeyProvider);
|
||||
final isToday = sheet.workDate == today;
|
||||
if (isToday) {
|
||||
final allTasks =
|
||||
ref.read(programmerTasksProvider).valueOrNull ?? const [];
|
||||
final programmerTaskIds = allTasks
|
||||
.where((t) => t.assigneeId == sheet.programmerId)
|
||||
.map((t) => t.id)
|
||||
.toSet();
|
||||
final runStates = ref.read(programmerRunStatesProvider);
|
||||
final hasRunning = runStates.entries.any((e) =>
|
||||
programmerTaskIds.contains(e.key) &&
|
||||
e.value.state == ProgrammerRunState.running);
|
||||
if (hasRunning) {
|
||||
final confirm = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Timer still running'),
|
||||
content: Text(
|
||||
'$programmerName has a timer running. '
|
||||
'Work logged after approval will reopen this sheet. Approve anyway?',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(true),
|
||||
child: const Text('Approve'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirm != true || !mounted) return;
|
||||
}
|
||||
}
|
||||
|
||||
setState(() => _approvingSheet = true);
|
||||
final snapshot = DaySheetSnapshot.fromRows(rows);
|
||||
try {
|
||||
await ref.read(daySheetsControllerProvider).approve(
|
||||
[(sheetId: sheet.id, snapshot: snapshot)],
|
||||
);
|
||||
if (!mounted) return;
|
||||
setState(() { _approvingSheet = false; _selectedTaskIds = {}; });
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
icon: const Icon(Icons.check_circle_outline, size: 48),
|
||||
title: const Text('Sheet Approved'),
|
||||
content: Text("$programmerName's day sheet has been approved."),
|
||||
actions: [
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(),
|
||||
child: const Text('Done'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
} on DaySheetActionException catch (e) {
|
||||
if (mounted) setState(() => _approvingSheet = false);
|
||||
_showError(e.message);
|
||||
} catch (e) {
|
||||
if (mounted) setState(() => _approvingSheet = false);
|
||||
_showError(e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Build
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final sheetAsync = ref.watch(daySheetByIdProvider(widget.sheetId));
|
||||
final eventsAsync = ref.watch(daySheetEventsProvider(widget.sheetId));
|
||||
final profilesAsync = ref.watch(profilesProvider);
|
||||
final currentUserId = ref.watch(currentUserIdProvider);
|
||||
final isAdmin = ref.watch(isStrictAdminProvider);
|
||||
final isOnline = ref.watch(isOnlineProvider);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Day Sheet')),
|
||||
body: OfflineBanner(
|
||||
child: sheetAsync.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (e, _) =>
|
||||
AppErrorView(error: e, onRetry: () => ref.invalidate(daySheetByIdProvider(widget.sheetId))),
|
||||
data: (sheet) {
|
||||
if (sheet == null) {
|
||||
return const AppEmptyView(
|
||||
icon: Icons.receipt_long_outlined,
|
||||
title: 'Sheet not found',
|
||||
subtitle: 'This day sheet may have been removed.',
|
||||
);
|
||||
}
|
||||
|
||||
final events = eventsAsync.valueOrNull ?? const [];
|
||||
final profiles = profilesAsync.valueOrNull ?? const [];
|
||||
final profileById = {for (final p in profiles) p.id: p};
|
||||
final profileNames = {for (final p in profiles) p.id: p.fullName};
|
||||
|
||||
final programmerName =
|
||||
profileById[sheet.programmerId]?.fullName ?? 'Programmer';
|
||||
final isOwner = currentUserId == sheet.programmerId;
|
||||
final flaggedIds = _flaggedTaskIdsFromEvents(events);
|
||||
|
||||
// Build live rows from tasks provider.
|
||||
final allTasks =
|
||||
ref.watch(programmerTasksProvider).valueOrNull ?? const [];
|
||||
final sheetTasks = allTasks
|
||||
.where((t) => t.assigneeId == sheet.programmerId)
|
||||
.toList();
|
||||
|
||||
// For approved sheets use the stored snapshot. For all other
|
||||
// statuses fetch live row data from the day-activity provider.
|
||||
final dayDataForSheet = ref
|
||||
.watch(programmerDayDataProvider(sheet.workDate))
|
||||
.valueOrNull;
|
||||
final liveRows = sheet.status == DaySheetStatus.approved &&
|
||||
sheet.approvedSnapshot != null
|
||||
? sheet.approvedSnapshot!.rows
|
||||
: dayDataForSheet != null
|
||||
? buildDayRows(
|
||||
userId: sheet.programmerId,
|
||||
day: sheet.workDate,
|
||||
tasks: allTasks,
|
||||
logsByTask: dayDataForSheet.logsByTask,
|
||||
workLogs: dayDataForSheet.workLogs,
|
||||
projectNames: const {},
|
||||
profileNames: profileNames,
|
||||
now: AppTime.now(),
|
||||
)
|
||||
: const <DaySheetRow>[];
|
||||
final rows = rowsForSheet(sheet, liveRows);
|
||||
|
||||
final totalSecs = rows.fold<int>(0, (s, r) => s + r.seconds);
|
||||
final totalDuration = Duration(seconds: totalSecs);
|
||||
|
||||
final canAdminAct = isAdmin &&
|
||||
(sheet.status == DaySheetStatus.pending ||
|
||||
sheet.status == DaySheetStatus.disapproved);
|
||||
final canOwnerJustify = isOwner &&
|
||||
sheet.status == DaySheetStatus.disapproved;
|
||||
final canOwnerCorrect = isOwner &&
|
||||
(sheet.status == DaySheetStatus.disapproved ||
|
||||
(sheet.status == DaySheetStatus.pending &&
|
||||
sheet.workDate.isBefore(ref.read(todayKeyProvider))));
|
||||
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
// ── Offline indicator chip ──────────────────────────────────
|
||||
if (!isOnline)
|
||||
Container(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.errorContainer,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.wifi_off_rounded,
|
||||
size: 16,
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onErrorContainer),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'You are offline. Actions are unavailable.',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodySmall
|
||||
?.copyWith(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onErrorContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// ── Header ────────────────────────────────────────────────
|
||||
_Header(
|
||||
sheet: sheet,
|
||||
programmerName: programmerName,
|
||||
totalDuration: totalDuration,
|
||||
statusKind: _kindForStatus(sheet.status),
|
||||
),
|
||||
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// ── Rows table ────────────────────────────────────────────
|
||||
AppSectionHeader(
|
||||
'Work Entries',
|
||||
trailing: canAdminAct && rows.isNotEmpty
|
||||
? TextButton(
|
||||
onPressed: () {
|
||||
if (_selectedTaskIds.length == rows.length) {
|
||||
setState(() => _selectedTaskIds = {});
|
||||
} else {
|
||||
setState(() => _selectedTaskIds = {
|
||||
for (final r in rows) r.taskId,
|
||||
});
|
||||
}
|
||||
},
|
||||
child: Text(
|
||||
_selectedTaskIds.length == rows.length
|
||||
? 'Deselect all'
|
||||
: 'Select all',
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
DaySheetRowsTable(
|
||||
rows: rows,
|
||||
flaggedTaskIds: sheet.status == DaySheetStatus.approved
|
||||
? const {}
|
||||
: flaggedIds,
|
||||
selectedTaskIds: canAdminAct ? _selectedTaskIds : null,
|
||||
onSelectionChanged: canAdminAct
|
||||
? (ids) => setState(() => _selectedTaskIds = ids)
|
||||
: null,
|
||||
onAddCorrection: canOwnerCorrect
|
||||
? (row) {
|
||||
final task = sheetTasks
|
||||
.where((t) => t.id == row.taskId)
|
||||
.firstOrNull;
|
||||
if (task != null && currentUserId != null) {
|
||||
_onAddCorrection(
|
||||
context, task, currentUserId, sheet.workDate);
|
||||
}
|
||||
}
|
||||
: null,
|
||||
),
|
||||
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// ── Admin action bar ─────────────────────────────────────
|
||||
if (canAdminAct) ...[
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
icon: const Icon(Icons.thumb_down_alt_outlined),
|
||||
label: const Text('Disapprove'),
|
||||
onPressed: isOnline
|
||||
? () => _onDisapprove(sheet.id, rows)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: FilledButton.icon(
|
||||
icon: _approvingSheet
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.check_circle_outline),
|
||||
label: const Text('Approve'),
|
||||
onPressed: (_approvingSheet || !isOnline)
|
||||
? null
|
||||
: () => _onApprove(
|
||||
sheet,
|
||||
rows,
|
||||
programmerName,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
|
||||
// ── Owner justify bar ─────────────────────────────────────
|
||||
if (canOwnerJustify) ...[
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton.icon(
|
||||
icon: const Icon(Icons.chat_bubble_outline),
|
||||
label: const Text('Justify & Resubmit'),
|
||||
onPressed: isOnline
|
||||
? () => _onJustify(sheet.id)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
|
||||
// ── Event thread ──────────────────────────────────────────
|
||||
DaySheetThread(
|
||||
events: events,
|
||||
profileNames: profileNames,
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Header widget
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class _Header extends StatelessWidget {
|
||||
const _Header({
|
||||
required this.sheet,
|
||||
required this.programmerName,
|
||||
required this.totalDuration,
|
||||
required this.statusKind,
|
||||
});
|
||||
|
||||
final ProgrammerDaySheet sheet;
|
||||
final String programmerName;
|
||||
final Duration totalDuration;
|
||||
final StatusKind statusKind;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tt = Theme.of(context).textTheme;
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
AppTime.formatDate(sheet.workDate),
|
||||
style: tt.titleLarge?.copyWith(fontWeight: FontWeight.w700),
|
||||
),
|
||||
),
|
||||
StatusPill.kind(
|
||||
statusKind,
|
||||
label: DaySheetStatus.label(sheet.status),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.person_outline, size: 16, color: cs.onSurfaceVariant),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(
|
||||
child: Text(
|
||||
programmerName,
|
||||
style: tt.bodyMedium?.copyWith(color: cs.onSurfaceVariant),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
formatDurationHm(totalDuration),
|
||||
style: tt.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
color: cs.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (sheet.resubmissions > 0) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Round ${sheet.resubmissions + 1}',
|
||||
style: tt.labelSmall?.copyWith(color: cs.onSurfaceVariant),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../models/programmer_day_sheet.model.dart';
|
||||
import '../../../utils/programmer_day_rows.dart';
|
||||
|
||||
/// Pure helper: if the sheet is approved with a snapshot, returns the snapshot
|
||||
/// rows; otherwise returns the live rows.
|
||||
List<DaySheetRow> rowsForSheet(
|
||||
ProgrammerDaySheet sheet,
|
||||
List<DaySheetRow> liveRows,
|
||||
) {
|
||||
if (sheet.status == DaySheetStatus.approved &&
|
||||
sheet.approvedSnapshot != null) {
|
||||
return sheet.approvedSnapshot!.rows;
|
||||
}
|
||||
return liveRows;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// showDisapproveDialog
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Shows an AlertDialog collecting disapproval remarks and optionally
|
||||
/// toggling which rows are flagged. Executes [onConfirm] while showing a
|
||||
/// loading state; handles errors inline. Returns true if the action
|
||||
/// completed, or null if cancelled.
|
||||
Future<bool?> showDisapproveDialog(
|
||||
BuildContext context, {
|
||||
required List<DaySheetRow> flaggedRows,
|
||||
required Future<void> Function(
|
||||
String remarks, List<String> flaggedTaskIds)
|
||||
onConfirm,
|
||||
}) {
|
||||
return showDialog<bool>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (ctx) =>
|
||||
_DisapproveDialog(flaggedRows: flaggedRows, onConfirm: onConfirm),
|
||||
);
|
||||
}
|
||||
|
||||
class _DisapproveDialog extends StatefulWidget {
|
||||
const _DisapproveDialog({required this.flaggedRows, required this.onConfirm});
|
||||
|
||||
final List<DaySheetRow> flaggedRows;
|
||||
final Future<void> Function(String remarks, List<String> flaggedTaskIds)
|
||||
onConfirm;
|
||||
|
||||
@override
|
||||
State<_DisapproveDialog> createState() => _DisapproveDialogState();
|
||||
}
|
||||
|
||||
class _DisapproveDialogState extends State<_DisapproveDialog> {
|
||||
final _remarksCtrl = TextEditingController();
|
||||
String? _error;
|
||||
bool _loading = false;
|
||||
late Set<String> _selected;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_selected = {for (final r in widget.flaggedRows) r.taskId};
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_remarksCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
final remarks = _remarksCtrl.text.trim();
|
||||
if (remarks.isEmpty) {
|
||||
setState(() => _error = 'Remarks are required');
|
||||
return;
|
||||
}
|
||||
setState(() { _loading = true; _error = null; });
|
||||
try {
|
||||
await widget.onConfirm(remarks, _selected.toList());
|
||||
if (mounted) Navigator.of(context).pop(true);
|
||||
} catch (e) {
|
||||
if (mounted) setState(() { _loading = false; _error = e.toString(); });
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tt = Theme.of(context).textTheme;
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
|
||||
return AlertDialog(
|
||||
title: const Text('Disapprove'),
|
||||
content: SizedBox(
|
||||
width: 360,
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
TextField(
|
||||
controller: _remarksCtrl,
|
||||
maxLines: 3,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Remarks',
|
||||
hintText: 'Explain what needs to change',
|
||||
errorText: _error,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
onChanged: (_) {
|
||||
if (_error != null) setState(() => _error = null);
|
||||
},
|
||||
),
|
||||
if (widget.flaggedRows.isNotEmpty) ...[
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Flagged tasks',
|
||||
style: tt.labelMedium?.copyWith(
|
||||
color: cs.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
for (final row in widget.flaggedRows)
|
||||
CheckboxListTile(
|
||||
dense: true,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: Text(row.title, style: tt.bodySmall),
|
||||
value: _selected.contains(row.taskId),
|
||||
onChanged: (v) {
|
||||
setState(() {
|
||||
if (v == true) {
|
||||
_selected.add(row.taskId);
|
||||
} else {
|
||||
_selected.remove(row.taskId);
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: _loading ? null : () => Navigator.of(context).pop(null),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: _loading ? null : _submit,
|
||||
child: _loading
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Text('Disapprove'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// showJustifyDialog
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Shows an AlertDialog for the programmer to write a justification before
|
||||
/// resubmitting. Executes [onConfirm] while showing a loading state; handles
|
||||
/// errors inline. Returns true if the action completed, or null if cancelled.
|
||||
Future<bool?> showJustifyDialog(
|
||||
BuildContext context, {
|
||||
required Future<void> Function(String body) onConfirm,
|
||||
}) {
|
||||
return showDialog<bool>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (ctx) => _JustifyDialog(onConfirm: onConfirm),
|
||||
);
|
||||
}
|
||||
|
||||
class _JustifyDialog extends StatefulWidget {
|
||||
const _JustifyDialog({required this.onConfirm});
|
||||
|
||||
final Future<void> Function(String body) onConfirm;
|
||||
|
||||
@override
|
||||
State<_JustifyDialog> createState() => _JustifyDialogState();
|
||||
}
|
||||
|
||||
class _JustifyDialogState extends State<_JustifyDialog> {
|
||||
final _bodyCtrl = TextEditingController();
|
||||
String? _error;
|
||||
bool _loading = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_bodyCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
final body = _bodyCtrl.text.trim();
|
||||
if (body.isEmpty) {
|
||||
setState(() => _error = 'Please write a justification');
|
||||
return;
|
||||
}
|
||||
setState(() { _loading = true; _error = null; });
|
||||
try {
|
||||
await widget.onConfirm(body);
|
||||
if (mounted) Navigator.of(context).pop(true);
|
||||
} catch (e) {
|
||||
if (mounted) setState(() { _loading = false; _error = e.toString(); });
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: const Text('Justify & Resubmit'),
|
||||
content: SizedBox(
|
||||
width: 360,
|
||||
child: TextField(
|
||||
controller: _bodyCtrl,
|
||||
maxLines: 4,
|
||||
enabled: !_loading,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Justification',
|
||||
hintText: 'Explain what changed or why the sheet is correct',
|
||||
errorText: _error,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
onChanged: (_) {
|
||||
if (_error != null) setState(() => _error = null);
|
||||
},
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: _loading ? null : () => Navigator.of(context).pop(null),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: _loading ? null : _submit,
|
||||
child: _loading
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Text('Submit'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../theme/app_status_colors.dart';
|
||||
import '../../../utils/programmer_day_rows.dart';
|
||||
import '../../../utils/programmer_task_ledger.dart';
|
||||
import '../../../widgets/tech_chip.dart';
|
||||
|
||||
/// Table of day-sheet rows with optional selection checkboxes and a per-row
|
||||
/// "add correction" action icon.
|
||||
class DaySheetRowsTable extends StatelessWidget {
|
||||
const DaySheetRowsTable({
|
||||
super.key,
|
||||
required this.rows,
|
||||
required this.flaggedTaskIds,
|
||||
this.selectedTaskIds,
|
||||
this.onSelectionChanged,
|
||||
this.onAddCorrection,
|
||||
});
|
||||
|
||||
/// The rows to render (assignee + helper tasks for this sheet).
|
||||
final List<DaySheetRow> rows;
|
||||
|
||||
/// Task IDs flagged in the most recent disapproved event. These are
|
||||
/// highlighted while the sheet is not yet approved.
|
||||
final Set<String> flaggedTaskIds;
|
||||
|
||||
/// When non-null, admin selection checkboxes are shown and changes are
|
||||
/// forwarded to this callback.
|
||||
final Set<String>? selectedTaskIds;
|
||||
final ValueChanged<Set<String>>? onSelectionChanged;
|
||||
|
||||
/// When non-null, a correction icon is shown on each row and tapping it
|
||||
/// invokes this callback with the affected row.
|
||||
final ValueChanged<DaySheetRow>? onAddCorrection;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tt = Theme.of(context).textTheme;
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final status = AppStatusColors.of(context);
|
||||
|
||||
if (rows.isEmpty) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Text(
|
||||
'No rows yet.',
|
||||
style: tt.bodySmall?.copyWith(color: cs.onSurfaceVariant),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
for (final row in rows)
|
||||
_RowTile(
|
||||
key: ValueKey('${row.taskId}:${row.kind}'),
|
||||
row: row,
|
||||
isFlagged: flaggedTaskIds.contains(row.taskId),
|
||||
isSelected: selectedTaskIds?.contains(row.taskId) ?? false,
|
||||
showCheckbox: selectedTaskIds != null,
|
||||
showCorrection: onAddCorrection != null,
|
||||
statusColors: status,
|
||||
onToggle: selectedTaskIds != null && onSelectionChanged != null
|
||||
? (checked) {
|
||||
final next = Set<String>.from(selectedTaskIds!);
|
||||
if (checked) {
|
||||
next.add(row.taskId);
|
||||
} else {
|
||||
next.remove(row.taskId);
|
||||
}
|
||||
onSelectionChanged!(next);
|
||||
}
|
||||
: null,
|
||||
onAddCorrection:
|
||||
onAddCorrection != null ? () => onAddCorrection!(row) : null,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RowTile extends StatelessWidget {
|
||||
const _RowTile({
|
||||
super.key,
|
||||
required this.row,
|
||||
required this.isFlagged,
|
||||
required this.isSelected,
|
||||
required this.showCheckbox,
|
||||
required this.showCorrection,
|
||||
required this.statusColors,
|
||||
this.onToggle,
|
||||
this.onAddCorrection,
|
||||
});
|
||||
|
||||
final DaySheetRow row;
|
||||
final bool isFlagged;
|
||||
final bool isSelected;
|
||||
final bool showCheckbox;
|
||||
final bool showCorrection;
|
||||
final AppStatusColors statusColors;
|
||||
final ValueChanged<bool>? onToggle;
|
||||
final VoidCallback? onAddCorrection;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tt = Theme.of(context).textTheme;
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
|
||||
final flaggedColor = statusColors.warning.container;
|
||||
final bgColor = isFlagged ? flaggedColor : Colors.transparent;
|
||||
|
||||
return AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 250),
|
||||
color: bgColor,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 4),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (showCheckbox)
|
||||
Checkbox(
|
||||
value: isSelected,
|
||||
onChanged: onToggle != null ? (v) => onToggle!(v ?? false) : null,
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
row.title,
|
||||
style: tt.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
formatDurationHm(Duration(seconds: row.seconds)),
|
||||
style: tt.bodySmall?.copyWith(
|
||||
color: cs.primary,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 4,
|
||||
children: [
|
||||
TechChip(row.category, dense: true),
|
||||
if (row.kind == 'helper')
|
||||
TechChip(
|
||||
'Helper${row.helpedName != null ? ' → ${row.helpedName}' : ''}',
|
||||
dense: true,
|
||||
status: StatusKind.info,
|
||||
),
|
||||
if (row.projectName != null)
|
||||
TechChip(row.projectName!, dense: true),
|
||||
if (isFlagged)
|
||||
TechChip(
|
||||
'Flagged',
|
||||
dense: true,
|
||||
status: StatusKind.warning,
|
||||
icon: Icons.flag_outlined,
|
||||
),
|
||||
],
|
||||
),
|
||||
if (row.notes.isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
for (final note in row.notes)
|
||||
Text(
|
||||
'• $note',
|
||||
style: tt.bodySmall
|
||||
?.copyWith(color: cs.onSurfaceVariant),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
if (showCorrection)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.add_comment_outlined, size: 18),
|
||||
tooltip: 'Add correction',
|
||||
onPressed: onAddCorrection,
|
||||
padding: EdgeInsets.zero,
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../models/programmer_day_sheet.model.dart';
|
||||
import '../../../providers/programmer_day_data_provider.dart';
|
||||
import '../../../utils/app_time.dart';
|
||||
import '../../../utils/programmer_day_rows.dart';
|
||||
|
||||
/// Builds a [DaySheetSnapshot] for [sheet].
|
||||
///
|
||||
/// * **Approved** sheets: the stored [ProgrammerDaySheet.approvedSnapshot] is
|
||||
/// returned directly — no live fetch required.
|
||||
/// * **All other statuses** (pending / disapproved / draft): live activity data
|
||||
/// for the sheet's work-date is fetched via [programmerDayDataProvider] and
|
||||
/// rows are computed with [buildDayRows].
|
||||
///
|
||||
/// This is the canonical snapshot source for the bulk-approve flow in
|
||||
/// [ApprovalsTab]. Pass `ref` from a [ConsumerStatefulWidget]'s state.
|
||||
Future<DaySheetSnapshot> buildSnapshotForSheet(
|
||||
WidgetRef ref,
|
||||
ProgrammerDaySheet sheet,
|
||||
) async {
|
||||
if (sheet.status == DaySheetStatus.approved &&
|
||||
sheet.approvedSnapshot != null) {
|
||||
return sheet.approvedSnapshot!;
|
||||
}
|
||||
|
||||
final data =
|
||||
await ref.read(programmerDayDataProvider(sheet.workDate).future);
|
||||
|
||||
final rows = buildDayRows(
|
||||
userId: sheet.programmerId,
|
||||
day: sheet.workDate,
|
||||
tasks: data.tasks,
|
||||
logsByTask: data.logsByTask,
|
||||
workLogs: data.workLogs,
|
||||
// Project / profile names are display metadata only — pass empty maps so
|
||||
// that approval RPCs are not blocked on a separate name lookup.
|
||||
projectNames: const {},
|
||||
profileNames: const {},
|
||||
now: AppTime.now(),
|
||||
);
|
||||
|
||||
return DaySheetSnapshot.fromRows(rows);
|
||||
}
|
||||
|
||||
/// Groups [sheets] by [ProgrammerDaySheet.workDate].
|
||||
///
|
||||
/// Returns a [LinkedHashMap]-backed map whose **keys are in descending date
|
||||
/// order** (most recent date first). Insertion order within each date group
|
||||
/// is preserved.
|
||||
Map<DateTime, List<ProgrammerDaySheet>> groupSheetsByDate(
|
||||
List<ProgrammerDaySheet> sheets,
|
||||
) {
|
||||
final map = <DateTime, List<ProgrammerDaySheet>>{};
|
||||
for (final sheet in sheets) {
|
||||
map.putIfAbsent(sheet.workDate, () => []).add(sheet);
|
||||
}
|
||||
final keys = map.keys.toList()..sort((a, b) => b.compareTo(a));
|
||||
return {for (final k in keys) k: map[k]!};
|
||||
}
|
||||
|
||||
/// Counts the approval-relevant statuses across [sheets].
|
||||
///
|
||||
/// * [pending] — submitted by the programmer, awaiting admin review.
|
||||
/// * [awaiting] — disapproved; programmer needs to justify / resubmit.
|
||||
/// * [approved] — fully reviewed and approved.
|
||||
({int pending, int awaiting, int approved}) approvalCounts(
|
||||
List<ProgrammerDaySheet> sheets,
|
||||
) {
|
||||
int pending = 0, awaiting = 0, approved = 0;
|
||||
for (final s in sheets) {
|
||||
switch (s.status) {
|
||||
case DaySheetStatus.pending:
|
||||
pending++;
|
||||
break;
|
||||
case DaySheetStatus.disapproved:
|
||||
awaiting++;
|
||||
break;
|
||||
case DaySheetStatus.approved:
|
||||
approved++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return (pending: pending, awaiting: awaiting, approved: approved);
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../models/programmer_day_sheet_event.model.dart';
|
||||
import '../../../theme/app_status_colors.dart';
|
||||
import '../../../utils/app_time.dart';
|
||||
import '../../../widgets/app_section_header.dart';
|
||||
|
||||
/// Maps an event kind to a human-readable label.
|
||||
String _threadLabel(String kind) => switch (kind) {
|
||||
'submitted' => 'Submitted',
|
||||
'auto_submitted' => 'Auto-submitted at end of day',
|
||||
'disapproved' => 'Disapproved',
|
||||
'justified' => 'Justified',
|
||||
'approved' => 'Approved',
|
||||
'amended' => 'Reopened — new work after approval',
|
||||
_ => kind,
|
||||
};
|
||||
|
||||
IconData _threadIcon(String kind) => switch (kind) {
|
||||
'submitted' => Icons.send_outlined,
|
||||
'auto_submitted' => Icons.schedule_send_outlined,
|
||||
'disapproved' => Icons.thumb_down_alt_outlined,
|
||||
'justified' => Icons.chat_bubble_outline,
|
||||
'approved' => Icons.check_circle_outline,
|
||||
'amended' => Icons.redo_outlined,
|
||||
_ => Icons.circle_outlined,
|
||||
};
|
||||
|
||||
StatusKind _threadKind(String kind) => switch (kind) {
|
||||
'approved' => StatusKind.success,
|
||||
'disapproved' => StatusKind.warning,
|
||||
'justified' => StatusKind.info,
|
||||
_ => StatusKind.neutral,
|
||||
};
|
||||
|
||||
/// Renders day-sheet events as a vertical timeline, oldest first.
|
||||
class DaySheetThread extends StatelessWidget {
|
||||
const DaySheetThread({
|
||||
super.key,
|
||||
required this.events,
|
||||
required this.profileNames,
|
||||
});
|
||||
|
||||
final List<ProgrammerDaySheetEvent> events;
|
||||
|
||||
/// Map of userId → display name used to show actor names.
|
||||
final Map<String, String> profileNames;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tt = Theme.of(context).textTheme;
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const AppSectionHeader('Activity'),
|
||||
if (events.isEmpty)
|
||||
Text(
|
||||
'No activity yet.',
|
||||
style: tt.bodySmall?.copyWith(color: cs.onSurfaceVariant),
|
||||
)
|
||||
else
|
||||
for (final event in events)
|
||||
_ThreadRow(
|
||||
event: event,
|
||||
actorName: event.actorId != null
|
||||
? profileNames[event.actorId] ?? 'Someone'
|
||||
: null,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ThreadRow extends StatelessWidget {
|
||||
const _ThreadRow({required this.event, this.actorName});
|
||||
|
||||
final ProgrammerDaySheetEvent event;
|
||||
final String? actorName;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tt = Theme.of(context).textTheme;
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final status = AppStatusColors.of(context);
|
||||
final kind = _threadKind(event.kind);
|
||||
final swatch = status.byKind(kind);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 28,
|
||||
height: 28,
|
||||
decoration: BoxDecoration(
|
||||
color: swatch.container,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
_threadIcon(event.kind),
|
||||
size: 14,
|
||||
color: swatch.onContainer,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
actorName != null
|
||||
? '$actorName · ${_threadLabel(event.kind)}'
|
||||
: _threadLabel(event.kind),
|
||||
style: tt.bodyMedium,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
AppTime.relative(event.createdAt),
|
||||
style: tt.bodySmall
|
||||
?.copyWith(color: cs.onSurfaceVariant),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (event.body != null && event.body!.isNotEmpty) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
event.body!,
|
||||
style: tt.bodySmall
|
||||
?.copyWith(color: cs.onSurfaceVariant),
|
||||
maxLines: 4,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
if (event.flaggedTaskIds.isNotEmpty) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'Flagged: ${event.flaggedTaskIds.length} task(s)',
|
||||
style: tt.bodySmall?.copyWith(
|
||||
color: cs.onSurfaceVariant,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../models/programmer_day_sheet.model.dart';
|
||||
import '../../../providers/profile_provider.dart';
|
||||
import '../../../providers/programmer_day_data_provider.dart';
|
||||
import '../../../providers/programmer_day_sheets_provider.dart';
|
||||
import '../../../theme/app_status_colors.dart';
|
||||
import '../../../utils/app_time.dart';
|
||||
import '../../../utils/programmer_day_rows.dart';
|
||||
import '../../../utils/programmer_task_ledger.dart';
|
||||
import '../../../widgets/app_state_view.dart';
|
||||
import '../../../widgets/app_status_summary.dart';
|
||||
import '../../../widgets/status_pill.dart';
|
||||
import '../report/accomplishment_report_dialog.dart';
|
||||
|
||||
/// Programmer-facing tab for managing personal day sheets.
|
||||
///
|
||||
/// Sections:
|
||||
/// 1. **Today** — live work rows, total duration, sheet status and a
|
||||
/// "Submit day" button (enabled when the sheet is missing/draft and there
|
||||
/// is at least one logged row).
|
||||
/// 2. **Needs your attention** — disapproved sheets waiting for justification.
|
||||
/// 3. **History** — filterable list of past sheets
|
||||
/// (All / Pending / Disapproved / Approved); tapping a card opens the
|
||||
/// [DaySheetDetailScreen].
|
||||
class DaySheetsTab extends ConsumerStatefulWidget {
|
||||
const DaySheetsTab({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<DaySheetsTab> createState() => _DaySheetsTabState();
|
||||
}
|
||||
|
||||
class _DaySheetsTabState extends ConsumerState<DaySheetsTab> {
|
||||
/// Null → show all history; or a [DaySheetStatus] constant to filter.
|
||||
String? _historyFilter;
|
||||
|
||||
bool _submitting = false;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Actions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Future<void> _submit(DateTime today) async {
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text("Submit today's work?"),
|
||||
content: const Text(
|
||||
"This will send your day sheet to admin for review."),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(true),
|
||||
child: const Text('Submit'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (ok != true) return;
|
||||
|
||||
setState(() => _submitting = true);
|
||||
try {
|
||||
await ref.read(daySheetsControllerProvider).submit(today);
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(const SnackBar(content: Text('Day submitted')));
|
||||
} on DaySheetActionException catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(SnackBar(content: Text(e.message)));
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(SnackBar(content: Text(e.toString())));
|
||||
} finally {
|
||||
if (mounted) setState(() => _submitting = false);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
StatusKind _kindFor(String status) => switch (status) {
|
||||
DaySheetStatus.approved => StatusKind.success,
|
||||
DaySheetStatus.pending => StatusKind.info,
|
||||
DaySheetStatus.disapproved => StatusKind.warning,
|
||||
_ => StatusKind.neutral,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Build
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final userId = ref.watch(currentUserIdProvider);
|
||||
if (userId == null) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
final today = ref.watch(todayKeyProvider);
|
||||
final dayDataAsync = ref.watch(programmerDayDataProvider(today));
|
||||
final mySheetsAsync = ref.watch(myDaySheetsProvider);
|
||||
final profiles = ref.watch(profilesProvider).valueOrNull ?? const [];
|
||||
final profileNames = {for (final p in profiles) p.id: p.fullName};
|
||||
|
||||
final mySheets = mySheetsAsync.valueOrNull ?? const [];
|
||||
|
||||
// Today's sheet (if any) — compare UTC date keys.
|
||||
final todaySheet =
|
||||
mySheets.where((s) => s.workDate == today).firstOrNull;
|
||||
|
||||
// Build live rows for today.
|
||||
final todayRows = dayDataAsync.when(
|
||||
data: (data) => buildDayRows(
|
||||
userId: userId,
|
||||
day: today,
|
||||
tasks: data.tasks,
|
||||
logsByTask: data.logsByTask,
|
||||
workLogs: data.workLogs,
|
||||
projectNames: const {},
|
||||
profileNames: profileNames,
|
||||
now: AppTime.now(),
|
||||
),
|
||||
loading: () => const <DaySheetRow>[],
|
||||
error: (_, _) => const <DaySheetRow>[],
|
||||
);
|
||||
|
||||
final todayTotal = Duration(seconds: totalSeconds(todayRows));
|
||||
final todayStatus = todaySheet?.status ?? DaySheetStatus.draft;
|
||||
final canSubmit =
|
||||
(todaySheet == null || todayStatus == DaySheetStatus.draft) &&
|
||||
todayRows.isNotEmpty;
|
||||
|
||||
// Attention: disapproved sheets.
|
||||
final disapproved =
|
||||
mySheets.where((s) => s.status == DaySheetStatus.disapproved).toList();
|
||||
|
||||
// History: past sheets (exclude today), optionally filtered by status.
|
||||
final history = mySheets.where((s) {
|
||||
if (s.workDate == today) return false;
|
||||
if (_historyFilter == null) return true;
|
||||
return s.status == _historyFilter;
|
||||
}).toList();
|
||||
|
||||
// Counts for the history filter chips.
|
||||
final historyAll = mySheets.where((s) => s.workDate != today).toList();
|
||||
final countPending =
|
||||
historyAll.where((s) => s.status == DaySheetStatus.pending).length;
|
||||
final countDisapproved =
|
||||
historyAll.where((s) => s.status == DaySheetStatus.disapproved).length;
|
||||
final countApproved =
|
||||
historyAll.where((s) => s.status == DaySheetStatus.approved).length;
|
||||
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
// ── Report action ──────────────────────────────────────────────────
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.description_outlined),
|
||||
tooltip: 'Accomplishment Report',
|
||||
onPressed: () => showAccomplishmentReportDialog(context, ref),
|
||||
),
|
||||
),
|
||||
|
||||
// ── Today card ─────────────────────────────────────────────────────
|
||||
_TodayCard(
|
||||
total: todayTotal,
|
||||
status: todayStatus,
|
||||
statusKind: _kindFor(todayStatus),
|
||||
canSubmit: canSubmit,
|
||||
submitting: _submitting,
|
||||
onSubmit: () => _submit(today),
|
||||
onTap: todaySheet != null
|
||||
? () => context.pushNamed(
|
||||
'daySheetDetail',
|
||||
pathParameters: {'id': todaySheet.id},
|
||||
)
|
||||
: null,
|
||||
),
|
||||
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// ── Needs your attention ──────────────────────────────────────────
|
||||
if (disapproved.isNotEmpty) ...[
|
||||
Text(
|
||||
'Needs your attention',
|
||||
style: Theme.of(context).textTheme.titleSmall,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
for (final sheet in disapproved)
|
||||
_SheetHistoryTile(
|
||||
sheet: sheet,
|
||||
statusKind: _kindFor(sheet.status),
|
||||
onTap: () => context.pushNamed(
|
||||
'daySheetDetail',
|
||||
pathParameters: {'id': sheet.id},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
|
||||
// ── History ────────────────────────────────────────────────────────
|
||||
Text('History', style: Theme.of(context).textTheme.titleSmall),
|
||||
const SizedBox(height: 8),
|
||||
AppStatusSummaryRow(
|
||||
items: [
|
||||
AppStatusSummaryItem(
|
||||
label: 'All',
|
||||
count: historyAll.length,
|
||||
kind: StatusKind.neutral,
|
||||
selected: _historyFilter == null,
|
||||
onTap: () => setState(() => _historyFilter = null),
|
||||
),
|
||||
AppStatusSummaryItem(
|
||||
label: 'Pending',
|
||||
count: countPending,
|
||||
kind: StatusKind.info,
|
||||
selected: _historyFilter == DaySheetStatus.pending,
|
||||
onTap: () => setState(() => _historyFilter = DaySheetStatus.pending),
|
||||
),
|
||||
AppStatusSummaryItem(
|
||||
label: 'Disapproved',
|
||||
count: countDisapproved,
|
||||
kind: StatusKind.warning,
|
||||
selected: _historyFilter == DaySheetStatus.disapproved,
|
||||
onTap: () =>
|
||||
setState(() => _historyFilter = DaySheetStatus.disapproved),
|
||||
),
|
||||
AppStatusSummaryItem(
|
||||
label: 'Approved',
|
||||
count: countApproved,
|
||||
kind: StatusKind.success,
|
||||
selected: _historyFilter == DaySheetStatus.approved,
|
||||
onTap: () =>
|
||||
setState(() => _historyFilter = DaySheetStatus.approved),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (history.isEmpty)
|
||||
const AppEmptyView(
|
||||
icon: Icons.history,
|
||||
title: 'No sheets yet',
|
||||
subtitle: 'Submit your first day sheet above.',
|
||||
)
|
||||
else
|
||||
for (final sheet in history)
|
||||
_SheetHistoryTile(
|
||||
sheet: sheet,
|
||||
statusKind: _kindFor(sheet.status),
|
||||
onTap: () => context.pushNamed(
|
||||
'daySheetDetail',
|
||||
pathParameters: {'id': sheet.id},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Today card
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class _TodayCard extends StatelessWidget {
|
||||
const _TodayCard({
|
||||
required this.total,
|
||||
required this.status,
|
||||
required this.statusKind,
|
||||
required this.canSubmit,
|
||||
required this.submitting,
|
||||
required this.onSubmit,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
final Duration total;
|
||||
final String status;
|
||||
final StatusKind statusKind;
|
||||
final bool canSubmit;
|
||||
final bool submitting;
|
||||
final VoidCallback onSubmit;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tt = Theme.of(context).textTheme;
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
|
||||
return Card(
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text('Today', style: tt.titleMedium),
|
||||
),
|
||||
StatusPill.kind(
|
||||
statusKind,
|
||||
label: DaySheetStatus.label(status),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
formatDurationHm(total),
|
||||
style: tt.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
color: cs.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton.tonal(
|
||||
onPressed: canSubmit && !submitting ? onSubmit : null,
|
||||
child: submitting
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Text('Submit day'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// History tile
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class _SheetHistoryTile extends StatelessWidget {
|
||||
const _SheetHistoryTile({
|
||||
required this.sheet,
|
||||
required this.statusKind,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final ProgrammerDaySheet sheet;
|
||||
final StatusKind statusKind;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
child: ListTile(
|
||||
title: Text(AppTime.formatDate(sheet.workDate)),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
StatusPill.kind(
|
||||
statusKind,
|
||||
label: DaySheetStatus.label(sheet.status),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
const Icon(Icons.chevron_right),
|
||||
],
|
||||
),
|
||||
onTap: onTap,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
import '../../../providers/programmer_day_data_provider.dart';
|
||||
import '../../../providers/programmer_tasks_provider.dart';
|
||||
import '../../../widgets/app_section_header.dart';
|
||||
import 'monitor_model.dart';
|
||||
|
||||
final _dateFmt = DateFormat('EEE, MMM d');
|
||||
|
||||
/// Side panel that shows "who's helping whom" for a selected day, with a
|
||||
/// ← / → stepper that cannot advance past today.
|
||||
///
|
||||
/// Uses a [ValueNotifier<DateTime>] initialized to [todayKeyProvider] so the
|
||||
/// displayed day is independent of the parent; the family provider is
|
||||
/// re-watched whenever the day changes.
|
||||
class HelpPanel extends ConsumerStatefulWidget {
|
||||
const HelpPanel({super.key, this.profileNames = const {}});
|
||||
|
||||
final Map<String, String> profileNames;
|
||||
|
||||
@override
|
||||
ConsumerState<HelpPanel> createState() => _HelpPanelState();
|
||||
}
|
||||
|
||||
class _HelpPanelState extends ConsumerState<HelpPanel> {
|
||||
late final ValueNotifier<DateTime> _day;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final today = ref.read(todayKeyProvider);
|
||||
_day = ValueNotifier(today);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_day.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _stepDay(int delta) {
|
||||
final today = ref.read(todayKeyProvider);
|
||||
final next = _day.value.add(Duration(days: delta));
|
||||
if (delta > 0 && !next.isBefore(today) && next != today) return;
|
||||
if (delta > 0 && next.isAfter(today)) return;
|
||||
setState(() {
|
||||
_day.value = next;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final tt = Theme.of(context).textTheme;
|
||||
final today = ref.watch(todayKeyProvider);
|
||||
final tasksAsync = ref.watch(programmerTasksProvider);
|
||||
final dayDataAsync = ref.watch(programmerDayDataProvider(_day.value));
|
||||
|
||||
final isAtToday = !_day.value.isBefore(today);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const AppSectionHeader("Who's helping whom"),
|
||||
// Day stepper
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.chevron_left),
|
||||
onPressed: () => _stepDay(-1),
|
||||
tooltip: 'Previous day',
|
||||
),
|
||||
Text(
|
||||
_dateFmt.format(_day.value),
|
||||
style: tt.labelMedium?.copyWith(color: cs.onSurface),
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
Icons.chevron_right,
|
||||
color: isAtToday ? cs.onSurface.withValues(alpha: 0.3) : null,
|
||||
),
|
||||
onPressed: isAtToday ? null : () => _stepDay(1),
|
||||
tooltip: 'Next day',
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// Help edges
|
||||
Expanded(
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
if (dayDataAsync.isLoading && !dayDataAsync.hasValue) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
final dayData = dayDataAsync.valueOrNull;
|
||||
if (dayData == null) {
|
||||
return _emptyText(
|
||||
'No data for this day.',
|
||||
cs: cs,
|
||||
tt: tt,
|
||||
);
|
||||
}
|
||||
|
||||
final tasks = tasksAsync.valueOrNull ?? [];
|
||||
final taskById = {for (final t in tasks) t.id: t};
|
||||
|
||||
final edges = buildHelpEdges(
|
||||
workLogs: dayData.workLogs,
|
||||
taskById: taskById,
|
||||
day: _day.value,
|
||||
);
|
||||
|
||||
if (edges.isEmpty) {
|
||||
return _emptyText(
|
||||
'No help logged this day.',
|
||||
cs: cs,
|
||||
tt: tt,
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.separated(
|
||||
itemCount: edges.length,
|
||||
separatorBuilder: (_, _) => const Divider(height: 1),
|
||||
itemBuilder: (context, i) {
|
||||
final e = edges[i];
|
||||
final names = widget.profileNames;
|
||||
final helperName = names[e.helperId] ?? e.helperId;
|
||||
final assigneeName = names[e.assigneeId] ?? e.assigneeId;
|
||||
final timeStr = TimeOfDay.fromDateTime(e.at).format(context);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
child: Text(
|
||||
'$helperName → $assigneeName · ${e.taskTitle} · ${e.minutes}m · $timeStr',
|
||||
style: tt.bodySmall?.copyWith(color: cs.onSurface),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _emptyText(
|
||||
String msg, {
|
||||
required ColorScheme cs,
|
||||
required TextTheme tt,
|
||||
}) {
|
||||
return Center(
|
||||
child: Text(
|
||||
msg,
|
||||
style: tt.bodySmall?.copyWith(color: cs.onSurfaceVariant),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
import 'package:tasq/models/leave_of_absence.model.dart';
|
||||
import 'package:tasq/models/pass_slip.model.dart';
|
||||
import 'package:tasq/models/profile.model.dart';
|
||||
import 'package:tasq/models/programmer_task.model.dart';
|
||||
import 'package:tasq/models/programmer_task_work_log.model.dart';
|
||||
import 'package:tasq/providers/programmer_day_data_provider.dart';
|
||||
import 'package:tasq/utils/programmer_day_rows.dart';
|
||||
|
||||
enum MonitorState { working, paused, idle, onLeave, passSlip }
|
||||
|
||||
class HelpEdge {
|
||||
final String helperId;
|
||||
final String assigneeId;
|
||||
final String taskId;
|
||||
final String taskTitle;
|
||||
final int minutes;
|
||||
final DateTime at;
|
||||
|
||||
HelpEdge({
|
||||
required this.helperId,
|
||||
required this.assigneeId,
|
||||
required this.taskId,
|
||||
required this.taskTitle,
|
||||
required this.minutes,
|
||||
required this.at,
|
||||
});
|
||||
}
|
||||
|
||||
/// Build help edges from work logs for a given day.
|
||||
///
|
||||
/// Returns edges where:
|
||||
/// - author != assignee (author is helping)
|
||||
/// - minutes > 0
|
||||
/// - workDate == day
|
||||
///
|
||||
/// Sorted by createdAt newest first.
|
||||
List<HelpEdge> buildHelpEdges({
|
||||
required List<ProgrammerTaskWorkLog> workLogs,
|
||||
required Map<String, ProgrammerTask> taskById,
|
||||
required DateTime day,
|
||||
}) {
|
||||
final edges = <HelpEdge>[];
|
||||
|
||||
for (final log in workLogs) {
|
||||
final task = taskById[log.taskId];
|
||||
if (task == null) continue;
|
||||
|
||||
// Filter: author != assignee AND minutes > 0 AND workDate == day
|
||||
if (log.authorId == task.assigneeId) continue;
|
||||
if (log.minutes == null || log.minutes! <= 0) continue;
|
||||
if (log.workDate != day) continue;
|
||||
|
||||
edges.add(HelpEdge(
|
||||
helperId: log.authorId,
|
||||
assigneeId: task.assigneeId ?? '',
|
||||
taskId: log.taskId,
|
||||
taskTitle: task.title,
|
||||
minutes: log.minutes!,
|
||||
at: log.createdAt,
|
||||
));
|
||||
}
|
||||
|
||||
// Sort by createdAt newest first
|
||||
edges.sort((a, b) => b.at.compareTo(a.at));
|
||||
|
||||
return edges;
|
||||
}
|
||||
|
||||
class ProgrammerMonitorEntry {
|
||||
final Profile programmer;
|
||||
final MonitorState state;
|
||||
final ProgrammerTask? currentTask;
|
||||
final Duration currentTaskToday;
|
||||
final Duration todayTotal;
|
||||
final int inProgress;
|
||||
final int paused;
|
||||
final int notStarted;
|
||||
final String? sheetStatus;
|
||||
final List<HelpEdge> helped;
|
||||
final List<HelpEdge> helpedBy;
|
||||
|
||||
ProgrammerMonitorEntry({
|
||||
required this.programmer,
|
||||
required this.state,
|
||||
required this.currentTask,
|
||||
required this.currentTaskToday,
|
||||
required this.todayTotal,
|
||||
required this.inProgress,
|
||||
required this.paused,
|
||||
required this.notStarted,
|
||||
this.sheetStatus,
|
||||
required this.helped,
|
||||
required this.helpedBy,
|
||||
});
|
||||
}
|
||||
|
||||
/// Build monitor entries for all programmers for a given day.
|
||||
///
|
||||
/// Sort order: working -> paused -> idle -> passSlip -> onLeave,
|
||||
/// then alphabetical by programmer.fullName within each state group.
|
||||
///
|
||||
/// Parameters [leaves] and [passSlips] should be pre-computed maps from
|
||||
/// activeLeaveByUser() and activePassSlipByUser() respectively.
|
||||
List<ProgrammerMonitorEntry> buildMonitorEntries({
|
||||
required List<Profile> programmers,
|
||||
required DayActivityData today,
|
||||
required Map<String, ProgrammerRunInfo> runStates,
|
||||
required Map<String, LeaveOfAbsence> leaves,
|
||||
required Map<String, PassSlip> passSlips,
|
||||
required Map<String, String> sheetStatusByUser,
|
||||
required Map<String, String> projectNames,
|
||||
required Map<String, String> profileNames,
|
||||
required DateTime now,
|
||||
}) {
|
||||
final taskById = {for (final t in today.tasks) t.id: t};
|
||||
|
||||
final entries = <ProgrammerMonitorEntry>[];
|
||||
|
||||
for (final programmer in programmers) {
|
||||
final programmerId = programmer.id;
|
||||
|
||||
// Step 1: Determine state
|
||||
// Priority: onLeave > passSlip > working/paused/idle
|
||||
MonitorState state;
|
||||
if (leaves.containsKey(programmerId)) {
|
||||
state = MonitorState.onLeave;
|
||||
} else if (passSlips.containsKey(programmerId)) {
|
||||
state = MonitorState.passSlip;
|
||||
} else {
|
||||
// Determine from task states
|
||||
state = _determineState(programmerId, today.tasks, runStates);
|
||||
}
|
||||
|
||||
// Step 2: Find current task (oldest running task)
|
||||
ProgrammerTask? currentTask;
|
||||
if (state == MonitorState.working) {
|
||||
currentTask = _findCurrentTask(programmerId, today.tasks, runStates);
|
||||
}
|
||||
|
||||
// Step 3: Count tasks by state
|
||||
final (inProgress, paused, notStarted) =
|
||||
_countTasksByState(programmerId, today.tasks, runStates);
|
||||
|
||||
// Step 4: Calculate durations
|
||||
final allTasksRows = buildDayRows(
|
||||
userId: programmerId,
|
||||
day: today.day,
|
||||
tasks: today.tasks,
|
||||
logsByTask: today.logsByTask,
|
||||
workLogs: today.workLogs,
|
||||
projectNames: projectNames,
|
||||
profileNames: profileNames,
|
||||
now: now,
|
||||
);
|
||||
final todayTotal = Duration(seconds: totalSeconds(allTasksRows));
|
||||
|
||||
// For current task, filter to only that task's work logs
|
||||
final currentTaskToday = _calculateCurrentTaskDuration(
|
||||
programmerId,
|
||||
currentTask,
|
||||
today,
|
||||
projectNames,
|
||||
profileNames,
|
||||
now,
|
||||
);
|
||||
|
||||
// Step 5: Build helped and helpedBy edges
|
||||
final helped = _buildHelpedEdges(programmerId, today.workLogs, taskById, today.day);
|
||||
final helpedBy = _buildHelpedByEdges(programmerId, today.workLogs, taskById, today.day);
|
||||
|
||||
entries.add(ProgrammerMonitorEntry(
|
||||
programmer: programmer,
|
||||
state: state,
|
||||
currentTask: currentTask,
|
||||
currentTaskToday: currentTaskToday,
|
||||
todayTotal: todayTotal,
|
||||
inProgress: inProgress,
|
||||
paused: paused,
|
||||
notStarted: notStarted,
|
||||
sheetStatus: sheetStatusByUser[programmerId],
|
||||
helped: helped,
|
||||
helpedBy: helpedBy,
|
||||
));
|
||||
}
|
||||
|
||||
// Sort by state then by name
|
||||
entries.sort((a, b) {
|
||||
final stateOrder = [
|
||||
MonitorState.working,
|
||||
MonitorState.paused,
|
||||
MonitorState.idle,
|
||||
MonitorState.passSlip,
|
||||
MonitorState.onLeave,
|
||||
];
|
||||
final aStateIdx = stateOrder.indexOf(a.state);
|
||||
final bStateIdx = stateOrder.indexOf(b.state);
|
||||
|
||||
if (aStateIdx != bStateIdx) {
|
||||
return aStateIdx.compareTo(bStateIdx);
|
||||
}
|
||||
|
||||
return a.programmer.fullName.compareTo(b.programmer.fullName);
|
||||
});
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
/// Determine state from task states
|
||||
MonitorState _determineState(
|
||||
String programmerId,
|
||||
List<ProgrammerTask> tasks,
|
||||
Map<String, ProgrammerRunInfo> runStates,
|
||||
) {
|
||||
final programmerTasks = tasks.where((t) => t.assigneeId == programmerId).toList();
|
||||
|
||||
var hasRunning = false;
|
||||
var hasPaused = false;
|
||||
|
||||
for (final task in programmerTasks) {
|
||||
final runInfo = runStates[task.id];
|
||||
if (runInfo == null) continue;
|
||||
|
||||
if (runInfo.state == ProgrammerRunState.running) {
|
||||
hasRunning = true;
|
||||
} else if (runInfo.state == ProgrammerRunState.paused) {
|
||||
hasPaused = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasRunning) return MonitorState.working;
|
||||
if (hasPaused) return MonitorState.paused;
|
||||
return MonitorState.idle;
|
||||
}
|
||||
|
||||
/// Find the current task (oldest running task by since)
|
||||
ProgrammerTask? _findCurrentTask(
|
||||
String programmerId,
|
||||
List<ProgrammerTask> tasks,
|
||||
Map<String, ProgrammerRunInfo> runStates,
|
||||
) {
|
||||
final runningTasks = tasks
|
||||
.where((t) =>
|
||||
t.assigneeId == programmerId &&
|
||||
runStates[t.id]?.state == ProgrammerRunState.running)
|
||||
.toList();
|
||||
|
||||
if (runningTasks.isEmpty) return null;
|
||||
|
||||
// Sort by since (earliest first)
|
||||
runningTasks.sort((a, b) {
|
||||
final aSince = runStates[a.id]?.since ?? DateTime(0);
|
||||
final bSince = runStates[b.id]?.since ?? DateTime(0);
|
||||
return aSince.compareTo(bSince);
|
||||
});
|
||||
|
||||
return runningTasks.first;
|
||||
}
|
||||
|
||||
/// Count tasks by state
|
||||
(int inProgress, int paused, int notStarted) _countTasksByState(
|
||||
String programmerId,
|
||||
List<ProgrammerTask> tasks,
|
||||
Map<String, ProgrammerRunInfo> runStates,
|
||||
) {
|
||||
var inProgress = 0;
|
||||
var paused = 0;
|
||||
var notStarted = 0;
|
||||
|
||||
final programmerTasks = tasks.where((t) => t.assigneeId == programmerId).toList();
|
||||
|
||||
for (final task in programmerTasks) {
|
||||
final runInfo = runStates[task.id];
|
||||
if (runInfo == null) continue;
|
||||
|
||||
if (runInfo.state == ProgrammerRunState.running ||
|
||||
runInfo.state == ProgrammerRunState.paused) {
|
||||
// This is still "in progress" (running or paused)
|
||||
// We count these separately
|
||||
if (runInfo.state == ProgrammerRunState.running) {
|
||||
inProgress++;
|
||||
} else {
|
||||
paused++;
|
||||
}
|
||||
} else if (runInfo.state == ProgrammerRunState.notStarted) {
|
||||
notStarted++;
|
||||
}
|
||||
}
|
||||
|
||||
return (inProgress, paused, notStarted);
|
||||
}
|
||||
|
||||
/// Calculate duration for current task
|
||||
Duration _calculateCurrentTaskDuration(
|
||||
String programmerId,
|
||||
ProgrammerTask? currentTask,
|
||||
DayActivityData today,
|
||||
Map<String, String> projectNames,
|
||||
Map<String, String> profileNames,
|
||||
DateTime now,
|
||||
) {
|
||||
if (currentTask == null) return Duration.zero;
|
||||
|
||||
// Filter work logs to only this task
|
||||
final currentTaskLogs =
|
||||
today.workLogs.where((w) => w.taskId == currentTask.id).toList();
|
||||
|
||||
// Filter activity logs to only this task
|
||||
final currentTaskActivityLogs = {
|
||||
currentTask.id: today.logsByTask[currentTask.id] ?? []
|
||||
};
|
||||
|
||||
final rows = buildDayRows(
|
||||
userId: programmerId,
|
||||
day: today.day,
|
||||
tasks: [currentTask],
|
||||
logsByTask: currentTaskActivityLogs,
|
||||
workLogs: currentTaskLogs,
|
||||
projectNames: projectNames,
|
||||
profileNames: profileNames,
|
||||
now: now,
|
||||
);
|
||||
|
||||
return Duration(seconds: totalSeconds(rows));
|
||||
}
|
||||
|
||||
/// Build helped edges (where programmer helped others)
|
||||
List<HelpEdge> _buildHelpedEdges(
|
||||
String programmerId,
|
||||
List<ProgrammerTaskWorkLog> workLogs,
|
||||
Map<String, ProgrammerTask> taskById,
|
||||
DateTime day,
|
||||
) {
|
||||
final edges = <HelpEdge>[];
|
||||
|
||||
for (final log in workLogs) {
|
||||
// Filter: author == programmer AND author != assignee AND minutes > 0 AND workDate == day
|
||||
if (log.authorId != programmerId) continue;
|
||||
|
||||
final task = taskById[log.taskId];
|
||||
if (task == null) continue;
|
||||
|
||||
if (log.authorId == task.assigneeId) continue;
|
||||
if (log.minutes == null || log.minutes! <= 0) continue;
|
||||
if (log.workDate != day) continue;
|
||||
|
||||
edges.add(HelpEdge(
|
||||
helperId: log.authorId,
|
||||
assigneeId: task.assigneeId ?? '',
|
||||
taskId: log.taskId,
|
||||
taskTitle: task.title,
|
||||
minutes: log.minutes!,
|
||||
at: log.createdAt,
|
||||
));
|
||||
}
|
||||
|
||||
// Sort by createdAt newest first
|
||||
edges.sort((a, b) => b.at.compareTo(a.at));
|
||||
return edges;
|
||||
}
|
||||
|
||||
/// Build helpedBy edges (where others helped programmer)
|
||||
List<HelpEdge> _buildHelpedByEdges(
|
||||
String programmerId,
|
||||
List<ProgrammerTaskWorkLog> workLogs,
|
||||
Map<String, ProgrammerTask> taskById,
|
||||
DateTime day,
|
||||
) {
|
||||
final edges = <HelpEdge>[];
|
||||
|
||||
for (final log in workLogs) {
|
||||
final task = taskById[log.taskId];
|
||||
if (task == null) continue;
|
||||
|
||||
// Filter: task assigned to programmer AND author != programmer AND minutes > 0 AND workDate == day
|
||||
if (task.assigneeId != programmerId) continue;
|
||||
|
||||
if (log.authorId == programmerId) continue;
|
||||
if (log.minutes == null || log.minutes! <= 0) continue;
|
||||
if (log.workDate != day) continue;
|
||||
|
||||
edges.add(HelpEdge(
|
||||
helperId: log.authorId,
|
||||
assigneeId: programmerId,
|
||||
taskId: log.taskId,
|
||||
taskTitle: task.title,
|
||||
minutes: log.minutes!,
|
||||
at: log.createdAt,
|
||||
));
|
||||
}
|
||||
|
||||
// Sort by createdAt newest first
|
||||
edges.sort((a, b) => b.at.compareTo(a.at));
|
||||
return edges;
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../models/programmer_day_sheet.model.dart';
|
||||
import '../../../providers/leave_provider.dart';
|
||||
import '../../../providers/pass_slip_provider.dart';
|
||||
import '../../../providers/profile_provider.dart';
|
||||
import '../../../providers/programmer_day_data_provider.dart';
|
||||
import '../../../providers/programmer_day_sheets_provider.dart';
|
||||
import '../../../utils/app_time.dart';
|
||||
import '../../../utils/programmer_daily_time.dart';
|
||||
import '../../../utils/staff_presence.dart';
|
||||
import '../../../widgets/app_status_summary.dart';
|
||||
import '../../../theme/app_status_colors.dart';
|
||||
import 'help_panel.dart';
|
||||
import 'monitor_model.dart';
|
||||
import 'programmer_monitor_card.dart';
|
||||
|
||||
/// Summary row + programmer grid + help panel for the Monitor tab.
|
||||
class MonitorTab extends ConsumerWidget {
|
||||
const MonitorTab({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
// Tick every 30 s to keep durations fresh.
|
||||
final now = ref.watch(clockTickerProvider).valueOrNull ?? AppTime.now();
|
||||
final todayKey = ref.watch(todayKeyProvider);
|
||||
final profilesAsync = ref.watch(profilesProvider);
|
||||
final dayDataAsync = ref.watch(programmerDayDataProvider(todayKey));
|
||||
final runStates = ref.watch(programmerRunStatesProvider);
|
||||
final leavesAsync = ref.watch(leavesProvider);
|
||||
final passSlipsAsync = ref.watch(passSlipsProvider);
|
||||
final allSheetsAsync = ref.watch(allDaySheetsProvider);
|
||||
|
||||
final profiles = profilesAsync.valueOrNull ?? [];
|
||||
final programmerProfiles =
|
||||
profiles.where((p) => p.role == 'programmer').toList();
|
||||
final profileNames = <String, String>{
|
||||
for (final p in profiles) p.id: p.fullName,
|
||||
};
|
||||
|
||||
final leaves = leavesAsync.valueOrNull ?? [];
|
||||
final passSlips = passSlipsAsync.valueOrNull ?? [];
|
||||
final activeLeaves = activeLeaveByUser(leaves, now);
|
||||
final activePassSlips = activePassSlipByUser(passSlips);
|
||||
|
||||
// Build sheetStatusByUser for today: programmerId → status.
|
||||
final allSheets = allSheetsAsync.valueOrNull ?? const <ProgrammerDaySheet>[];
|
||||
final sheetStatusByUser = <String, String>{};
|
||||
var sheetsPending = 0;
|
||||
for (final sheet in allSheets) {
|
||||
if (sheet.workDate == manilaDay(now)) {
|
||||
sheetStatusByUser[sheet.programmerId] = sheet.status;
|
||||
if (sheet.status == DaySheetStatus.pending ||
|
||||
sheet.status == DaySheetStatus.disapproved) {
|
||||
sheetsPending++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (dayDataAsync.isLoading && !dayDataAsync.hasValue) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
final dayData = dayDataAsync.valueOrNull;
|
||||
|
||||
List<ProgrammerMonitorEntry> entries = [];
|
||||
if (dayData != null) {
|
||||
entries = buildMonitorEntries(
|
||||
programmers: programmerProfiles,
|
||||
today: dayData,
|
||||
runStates: runStates,
|
||||
leaves: activeLeaves,
|
||||
passSlips: activePassSlips,
|
||||
sheetStatusByUser: sheetStatusByUser,
|
||||
projectNames: const {},
|
||||
profileNames: profileNames,
|
||||
now: now,
|
||||
);
|
||||
}
|
||||
|
||||
// Compute summary counts.
|
||||
var working = 0;
|
||||
var paused = 0;
|
||||
var idle = 0;
|
||||
var away = 0;
|
||||
for (final e in entries) {
|
||||
switch (e.state) {
|
||||
case MonitorState.working:
|
||||
working++;
|
||||
case MonitorState.paused:
|
||||
paused++;
|
||||
case MonitorState.idle:
|
||||
idle++;
|
||||
case MonitorState.onLeave:
|
||||
case MonitorState.passSlip:
|
||||
away++;
|
||||
}
|
||||
}
|
||||
|
||||
final summaryItems = [
|
||||
AppStatusSummaryItem(
|
||||
label: 'Working',
|
||||
count: working,
|
||||
kind: StatusKind.success,
|
||||
),
|
||||
AppStatusSummaryItem(
|
||||
label: 'Paused',
|
||||
count: paused,
|
||||
kind: StatusKind.warning,
|
||||
),
|
||||
AppStatusSummaryItem(
|
||||
label: 'Idle',
|
||||
count: idle,
|
||||
kind: StatusKind.neutral,
|
||||
),
|
||||
AppStatusSummaryItem(
|
||||
label: 'Away',
|
||||
count: away,
|
||||
kind: StatusKind.info,
|
||||
),
|
||||
AppStatusSummaryItem(
|
||||
label: 'Sheets pending',
|
||||
count: sheetsPending,
|
||||
kind: StatusKind.warning,
|
||||
),
|
||||
];
|
||||
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final width = constraints.maxWidth;
|
||||
// Help panel breakpoint: show side-by-side at >= 900.
|
||||
final sideBySide = width >= 900;
|
||||
|
||||
final grid = _ProgrammerGrid(
|
||||
entries: entries,
|
||||
profileNames: profileNames,
|
||||
width: sideBySide ? width - 320 : width,
|
||||
);
|
||||
|
||||
final body = sideBySide
|
||||
? Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(child: grid),
|
||||
const SizedBox(width: 12),
|
||||
SizedBox(
|
||||
width: 300,
|
||||
child: HelpPanel(profileNames: profileNames),
|
||||
),
|
||||
],
|
||||
)
|
||||
: Column(
|
||||
children: [
|
||||
grid,
|
||||
const SizedBox(height: 16),
|
||||
SizedBox(
|
||||
height: 340,
|
||||
child: HelpPanel(profileNames: profileNames),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
AppStatusSummaryRow(
|
||||
items: summaryItems,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: body,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ProgrammerGrid extends StatelessWidget {
|
||||
const _ProgrammerGrid({
|
||||
required this.entries,
|
||||
required this.profileNames,
|
||||
required this.width,
|
||||
});
|
||||
|
||||
final List<ProgrammerMonitorEntry> entries;
|
||||
final Map<String, String> profileNames;
|
||||
final double width;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final crossAxisCount = width < 700
|
||||
? 1
|
||||
: width < 1100
|
||||
? 2
|
||||
: 3;
|
||||
|
||||
if (entries.isEmpty) {
|
||||
return const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(24),
|
||||
child: Text('No programmers found.'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return GridView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: crossAxisCount,
|
||||
mainAxisSpacing: 10,
|
||||
crossAxisSpacing: 10,
|
||||
childAspectRatio: 2.4,
|
||||
),
|
||||
itemCount: entries.length,
|
||||
itemBuilder: (context, i) {
|
||||
final entry = entries[i];
|
||||
return ProgrammerMonitorCard(
|
||||
entry: entry,
|
||||
profileNames: profileNames,
|
||||
onTap: () =>
|
||||
context.go('/programmer-tasks/programmers/${entry.programmer.id}'),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../theme/app_status_colors.dart';
|
||||
import '../../../theme/app_surfaces.dart';
|
||||
import '../../../widgets/tech_chip.dart';
|
||||
import 'monitor_model.dart';
|
||||
|
||||
/// State pill label and colour for a [MonitorState].
|
||||
({String label, StatusKind kind}) _statePill(MonitorState state) {
|
||||
switch (state) {
|
||||
case MonitorState.working:
|
||||
return (label: 'Working', kind: StatusKind.success);
|
||||
case MonitorState.paused:
|
||||
return (label: 'Paused', kind: StatusKind.warning);
|
||||
case MonitorState.idle:
|
||||
return (label: 'Idle', kind: StatusKind.neutral);
|
||||
case MonitorState.onLeave:
|
||||
return (label: 'On leave', kind: StatusKind.info);
|
||||
case MonitorState.passSlip:
|
||||
return (label: 'Pass slip', kind: StatusKind.info);
|
||||
}
|
||||
}
|
||||
|
||||
String _fmtDuration(Duration d) {
|
||||
final h = d.inHours;
|
||||
final m = d.inMinutes.remainder(60);
|
||||
if (h > 0) return '${h}h ${m}m';
|
||||
return '${m}m';
|
||||
}
|
||||
|
||||
/// Card displayed in the Monitor tab for a single programmer.
|
||||
///
|
||||
/// Shows:
|
||||
/// - programmer name + state pill
|
||||
/// - current task title (if working)
|
||||
/// - running time on current task + total today
|
||||
/// - task counts (in-progress / paused / not-started)
|
||||
/// - help edges (Helped → / Helped by ←)
|
||||
class ProgrammerMonitorCard extends StatelessWidget {
|
||||
const ProgrammerMonitorCard({
|
||||
super.key,
|
||||
required this.entry,
|
||||
required this.onTap,
|
||||
this.profileNames = const {},
|
||||
});
|
||||
|
||||
final ProgrammerMonitorEntry entry;
|
||||
final VoidCallback onTap;
|
||||
final Map<String, String> profileNames;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final tt = Theme.of(context).textTheme;
|
||||
final surfaces = AppSurfaces.of(context);
|
||||
final pill = _statePill(entry.state);
|
||||
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(surfaces.cardRadius),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: cs.surfaceContainerLow,
|
||||
borderRadius: BorderRadius.circular(surfaces.cardRadius),
|
||||
border: Border.all(color: cs.outlineVariant),
|
||||
),
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Header: name + state pill
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
entry.programmer.fullName,
|
||||
style: tt.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: cs.onSurface,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
TechChip(
|
||||
pill.label,
|
||||
status: pill.kind,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// Current task
|
||||
if (entry.currentTask != null) ...[
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
entry.currentTask!.title,
|
||||
style: tt.bodySmall?.copyWith(color: cs.onSurfaceVariant),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// Time row
|
||||
Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 4,
|
||||
children: [
|
||||
if (entry.state == MonitorState.working &&
|
||||
entry.currentTaskToday > Duration.zero)
|
||||
_InfoChip(
|
||||
label:
|
||||
'running ${_fmtDuration(entry.currentTaskToday)} today',
|
||||
cs: cs,
|
||||
tt: tt,
|
||||
),
|
||||
_InfoChip(
|
||||
label: 'Today ${_fmtDuration(entry.todayTotal)}',
|
||||
cs: cs,
|
||||
tt: tt,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// Task counts
|
||||
const SizedBox(height: 6),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 4,
|
||||
children: [
|
||||
if (entry.inProgress > 0)
|
||||
_InfoChip(
|
||||
label: '${entry.inProgress} in-progress',
|
||||
cs: cs,
|
||||
tt: tt,
|
||||
),
|
||||
if (entry.paused > 0)
|
||||
_InfoChip(
|
||||
label: '${entry.paused} paused',
|
||||
cs: cs,
|
||||
tt: tt,
|
||||
),
|
||||
if (entry.notStarted > 0)
|
||||
_InfoChip(
|
||||
label: '${entry.notStarted} not started',
|
||||
cs: cs,
|
||||
tt: tt,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// Help edges
|
||||
if (entry.helped.isNotEmpty || entry.helpedBy.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
for (final e in entry.helped)
|
||||
_HelpRow(
|
||||
prefix: 'Helped →',
|
||||
name: profileNames[e.assigneeId] ?? e.assigneeId,
|
||||
task: e.taskTitle,
|
||||
minutes: e.minutes,
|
||||
cs: cs,
|
||||
tt: tt,
|
||||
),
|
||||
for (final e in entry.helpedBy)
|
||||
_HelpRow(
|
||||
prefix: 'Helped by ←',
|
||||
name: profileNames[e.helperId] ?? e.helperId,
|
||||
task: e.taskTitle,
|
||||
minutes: e.minutes,
|
||||
cs: cs,
|
||||
tt: tt,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _InfoChip extends StatelessWidget {
|
||||
const _InfoChip({
|
||||
required this.label,
|
||||
required this.cs,
|
||||
required this.tt,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final ColorScheme cs;
|
||||
final TextTheme tt;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Text(
|
||||
label,
|
||||
style: tt.labelSmall?.copyWith(color: cs.onSurfaceVariant),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _HelpRow extends StatelessWidget {
|
||||
const _HelpRow({
|
||||
required this.prefix,
|
||||
required this.name,
|
||||
required this.task,
|
||||
required this.minutes,
|
||||
required this.cs,
|
||||
required this.tt,
|
||||
});
|
||||
|
||||
final String prefix;
|
||||
final String name;
|
||||
final String task;
|
||||
final int minutes;
|
||||
final ColorScheme cs;
|
||||
final TextTheme tt;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 2),
|
||||
child: Text(
|
||||
'$prefix $name · $task · ${minutes}m',
|
||||
style: tt.labelSmall?.copyWith(color: cs.onSurfaceVariant),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../models/profile.model.dart';
|
||||
import '../../models/programmer_day_sheet.model.dart';
|
||||
import '../../providers/profile_provider.dart';
|
||||
import '../../providers/programmer_day_sheets_provider.dart';
|
||||
import '../../theme/app_status_colors.dart';
|
||||
import '../../utils/app_time.dart';
|
||||
import '../../widgets/app_section_header.dart';
|
||||
import '../../widgets/app_state_view.dart';
|
||||
import '../../widgets/status_pill.dart';
|
||||
import 'dashboard/programmer_dashboard.dart';
|
||||
import 'report/accomplishment_report_dialog.dart';
|
||||
|
||||
/// Admin-only drill-down screen that shows [ProgrammerDashboard] for a
|
||||
/// specific user in read-only mode, plus their recent day sheets history.
|
||||
///
|
||||
/// Route: /programmer-tasks/programmers/:userId
|
||||
class ProgrammerDrillDownScreen extends ConsumerWidget {
|
||||
const ProgrammerDrillDownScreen({
|
||||
super.key,
|
||||
required this.userId,
|
||||
});
|
||||
|
||||
final String userId;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final isStrictAdmin = ref.watch(isStrictAdminProvider);
|
||||
final profilesAsync = ref.watch(profilesProvider);
|
||||
|
||||
if (!isStrictAdmin) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Programmer')),
|
||||
body: const AppEmptyView(
|
||||
icon: Icons.lock_outline,
|
||||
title: 'Admins only',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Resolve the programmer's name for the AppBar title.
|
||||
final profiles = profilesAsync.valueOrNull ?? <Profile>[];
|
||||
Profile? profile;
|
||||
for (final p in profiles) {
|
||||
if (p.id == userId) {
|
||||
profile = p;
|
||||
break;
|
||||
}
|
||||
}
|
||||
final name = profile?.fullName ?? 'Programmer';
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(name),
|
||||
actions: [
|
||||
OutlinedButton(
|
||||
onPressed: () => showAccomplishmentReportDialog(
|
||||
context,
|
||||
ref,
|
||||
programmerId: userId,
|
||||
),
|
||||
child: const Text('Generate Accomplishment Report'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Dashboard fills the upper portion of the screen.
|
||||
Expanded(
|
||||
child: ProgrammerDashboard(userId: userId, readOnly: true),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
// Recent day sheets section — fixed-height scrollable panel.
|
||||
_RecentDaySheetsPanel(userId: userId),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Recent sheets panel
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class _RecentDaySheetsPanel extends ConsumerWidget {
|
||||
const _RecentDaySheetsPanel({required this.userId});
|
||||
|
||||
final String userId;
|
||||
|
||||
StatusKind _kindFor(String status) => switch (status) {
|
||||
DaySheetStatus.approved => StatusKind.success,
|
||||
DaySheetStatus.pending => StatusKind.info,
|
||||
DaySheetStatus.disapproved => StatusKind.warning,
|
||||
_ => StatusKind.neutral,
|
||||
};
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final sheetsAsync = ref.watch(daySheetsForUserProvider(userId));
|
||||
final allSheets = sheetsAsync.valueOrNull ?? const [];
|
||||
// Show the most-recent 10 sheets (provider already orders newest-first).
|
||||
final recent = allSheets.length > 10 ? allSheets.sublist(0, 10) : allSheets;
|
||||
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final tt = Theme.of(context).textTheme;
|
||||
|
||||
return ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxHeight: 320),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
|
||||
child: AppSectionHeader('Recent day sheets'),
|
||||
),
|
||||
if (recent.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Text(
|
||||
'No day sheets yet.',
|
||||
style: tt.bodySmall?.copyWith(color: cs.onSurfaceVariant),
|
||||
),
|
||||
)
|
||||
else
|
||||
Flexible(
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
itemCount: recent.length,
|
||||
itemBuilder: (context, i) {
|
||||
final sheet = recent[i];
|
||||
return InkWell(
|
||||
onTap: () => context.pushNamed(
|
||||
'daySheetDetail',
|
||||
pathParameters: {'id': sheet.id},
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16, vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
AppTime.formatDate(sheet.workDate),
|
||||
style: tt.bodyMedium,
|
||||
),
|
||||
),
|
||||
StatusPill.kind(
|
||||
_kindFor(sheet.status),
|
||||
label: DaySheetStatus.label(sheet.status),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../models/programmer_task.model.dart';
|
||||
import '../../providers/programmer_tasks_provider.dart';
|
||||
|
||||
/// Confirms with the user and completes [task]. Body moved verbatim from the
|
||||
/// detail screen's `_complete` so it can be called from both the card and the
|
||||
/// detail action bar.
|
||||
Future<void> completeProgrammerTaskWithConfirm(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
ProgrammerTask task,
|
||||
) async {
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
semanticLabel: 'Complete task',
|
||||
title: const Text('Complete task?'),
|
||||
content: Text('Mark "${task.title}" as completed?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
child: const Text('Complete'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (ok != true) return;
|
||||
await ref
|
||||
.read(programmerTasksControllerProvider)
|
||||
.completeTask(taskId: task.id);
|
||||
messenger.showSnackBar(const SnackBar(content: Text('Task completed')));
|
||||
}
|
||||
|
||||
/// Starts (or resumes) [task] for the current user, enforcing the single-active
|
||||
/// task discipline: if the user already has a *running* task, they are prompted
|
||||
/// to pause it first. This is the headline "proper time management" behavior and
|
||||
/// is shared by the list and detail screens.
|
||||
///
|
||||
/// Returns true if the task was started/resumed, false if the user cancelled.
|
||||
Future<bool> startProgrammerTaskWithPausePrompt(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
ProgrammerTask task,
|
||||
) async {
|
||||
final controller = ref.read(programmerTasksControllerProvider);
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
|
||||
final running = await controller.findRunningTaskForCurrentUser();
|
||||
if (running != null && running.id != task.id) {
|
||||
if (!context.mounted) return false;
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
semanticLabel: 'Pause current task',
|
||||
title: const Text('Pause current task?'),
|
||||
content: Text(
|
||||
"You're currently working on \"${running.title}\". "
|
||||
'Pause it and start "${task.title}"?',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
child: const Text('Pause & Start'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed != true) return false;
|
||||
await controller.pauseTask(taskId: running.id);
|
||||
}
|
||||
|
||||
try {
|
||||
if (task.status == ProgrammerTaskStatus.queued) {
|
||||
await controller.startTask(taskId: task.id);
|
||||
} else if (task.status == ProgrammerTaskStatus.inProgress) {
|
||||
await controller.resumeTask(taskId: task.id);
|
||||
}
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text('Started "${task.title}"')),
|
||||
);
|
||||
return true;
|
||||
} catch (e) {
|
||||
messenger.showSnackBar(SnackBar(content: Text('Could not start task: $e')));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../models/profile.model.dart';
|
||||
import '../../models/programmer_project.model.dart';
|
||||
import '../../models/programmer_task.model.dart';
|
||||
import '../../providers/profile_provider.dart';
|
||||
import '../../providers/programmer_projects_provider.dart';
|
||||
import '../../providers/programmer_tasks_provider.dart';
|
||||
import '../../widgets/quill_field.dart';
|
||||
|
||||
/// Shows the "New Programmer Task" dialog and, on success, navigates to the new
|
||||
/// task's detail. Shared by the tasks list FAB and the project detail screen
|
||||
/// (pass [initialProjectId] to pre-select a project).
|
||||
Future<void> showCreateProgrammerTaskDialog(
|
||||
BuildContext context,
|
||||
WidgetRef ref, {
|
||||
String? initialProjectId,
|
||||
}) async {
|
||||
final userId = ref.read(currentUserIdProvider);
|
||||
final profiles = ref.read(profilesProvider).valueOrNull ?? const <Profile>[];
|
||||
final eligible = profiles
|
||||
.where((p) => p.role == 'programmer' || p.role == 'admin')
|
||||
.toList()
|
||||
..sort((a, b) => a.fullName.compareTo(b.fullName));
|
||||
final projectOptions =
|
||||
List<ProgrammerProject>.from(ref.read(activeProgrammerProjectsProvider));
|
||||
|
||||
final titleCtrl = TextEditingController();
|
||||
final descQuill = quillControllerFromDelta(null);
|
||||
var category = ProgrammerTaskCategory.softwareDevelopment;
|
||||
var assigneeId = userId;
|
||||
var projectId = initialProjectId;
|
||||
|
||||
final created = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return StatefulBuilder(
|
||||
builder: (context, setDialogState) {
|
||||
final dialogWidth =
|
||||
math.min(480.0, MediaQuery.of(context).size.width - 48);
|
||||
return AlertDialog(
|
||||
semanticLabel: 'New programmer task',
|
||||
title: const Text('New Programmer Task'),
|
||||
content: SizedBox(
|
||||
width: dialogWidth,
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
TextField(
|
||||
controller: titleCtrl,
|
||||
autofocus: true,
|
||||
textCapitalization: TextCapitalization.sentences,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Title',
|
||||
hintText: 'What are you working on?',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text('Description (optional)',
|
||||
style: Theme.of(context).textTheme.bodySmall),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
QuillFieldEditor(
|
||||
controller: descQuill,
|
||||
placeholder: 'Describe the task…',
|
||||
height: 140,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
DropdownButtonFormField<String>(
|
||||
initialValue: category,
|
||||
decoration: const InputDecoration(labelText: 'Category'),
|
||||
items: [
|
||||
for (final c in ProgrammerTaskCategory.all)
|
||||
DropdownMenuItem(value: c, child: Text(c)),
|
||||
],
|
||||
onChanged: (v) =>
|
||||
setDialogState(() => category = v ?? category),
|
||||
),
|
||||
if (eligible.isNotEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
DropdownButtonFormField<String>(
|
||||
initialValue: assigneeId,
|
||||
decoration: const InputDecoration(labelText: 'Assignee'),
|
||||
items: [
|
||||
for (final p in eligible)
|
||||
DropdownMenuItem(
|
||||
value: p.id,
|
||||
child: Text(
|
||||
p.id == userId ? '${p.fullName} (me)' : p.fullName,
|
||||
),
|
||||
),
|
||||
],
|
||||
onChanged: (v) =>
|
||||
setDialogState(() => assigneeId = v ?? assigneeId),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
DropdownButtonFormField<String?>(
|
||||
initialValue: projectOptions.any((p) => p.id == projectId)
|
||||
? projectId
|
||||
: null,
|
||||
decoration:
|
||||
const InputDecoration(labelText: 'Project (optional)'),
|
||||
items: [
|
||||
const DropdownMenuItem<String?>(
|
||||
value: null, child: Text('No project')),
|
||||
for (final p in projectOptions)
|
||||
DropdownMenuItem<String?>(
|
||||
value: p.id, child: Text(p.name)),
|
||||
],
|
||||
onChanged: (v) => setDialogState(() => projectId = v),
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: TextButton.icon(
|
||||
onPressed: () async {
|
||||
final proj = await _promptNewProject(context, ref);
|
||||
if (proj != null) {
|
||||
setDialogState(() {
|
||||
projectOptions.add(proj);
|
||||
projectId = proj.id;
|
||||
});
|
||||
}
|
||||
},
|
||||
icon: const Icon(Icons.add, size: 18),
|
||||
label: const Text('New project'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
child: const Text('Create'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
if (created != true) {
|
||||
titleCtrl.dispose();
|
||||
descQuill.dispose();
|
||||
return;
|
||||
}
|
||||
|
||||
final title = titleCtrl.text.trim();
|
||||
titleCtrl.dispose();
|
||||
final description =
|
||||
quillPlainText(descQuill).isEmpty ? null : quillToDeltaJson(descQuill);
|
||||
descQuill.dispose();
|
||||
if (title.isEmpty) return;
|
||||
if (!context.mounted) return;
|
||||
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
try {
|
||||
final id = await ref.read(programmerTasksControllerProvider).createTask(
|
||||
title: title,
|
||||
category: category,
|
||||
description: description,
|
||||
assigneeId: assigneeId,
|
||||
projectId: projectId,
|
||||
);
|
||||
if (!context.mounted) return;
|
||||
context.go('/programmer-tasks/$id');
|
||||
} catch (e) {
|
||||
messenger.showSnackBar(SnackBar(content: Text('Could not create task: $e')));
|
||||
}
|
||||
}
|
||||
|
||||
/// Prompts for a project name, creates it, and returns a lightweight model for
|
||||
/// the picker (the realtime insert with the same id syncs shortly after).
|
||||
Future<ProgrammerProject?> _promptNewProject(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
) async {
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final nameCtrl = TextEditingController();
|
||||
final name = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
semanticLabel: 'New project',
|
||||
title: const Text('New project'),
|
||||
content: TextField(
|
||||
controller: nameCtrl,
|
||||
autofocus: true,
|
||||
textCapitalization: TextCapitalization.words,
|
||||
decoration: const InputDecoration(labelText: 'Project name'),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(context).pop(nameCtrl.text.trim()),
|
||||
child: const Text('Create'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
nameCtrl.dispose();
|
||||
if (name == null || name.isEmpty) return null;
|
||||
try {
|
||||
final id = await ref
|
||||
.read(programmerProjectsControllerProvider)
|
||||
.createProject(name: name);
|
||||
final now = DateTime.now().toUtc();
|
||||
return ProgrammerProject(
|
||||
id: id,
|
||||
name: name,
|
||||
status: ProgrammerProjectStatus.active,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
);
|
||||
} catch (e) {
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text('Could not create project: $e')),
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,438 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../models/profile.model.dart';
|
||||
import '../../models/programmer_task.model.dart';
|
||||
import '../../models/programmer_task_activity_log.model.dart';
|
||||
import '../../providers/profile_provider.dart';
|
||||
import '../../providers/programmer_projects_provider.dart';
|
||||
import '../../providers/programmer_task_work_logs_provider.dart';
|
||||
import '../../providers/programmer_tasks_provider.dart';
|
||||
import '../../utils/app_time.dart';
|
||||
import '../../utils/programmer_task_ledger.dart';
|
||||
import '../../utils/task_duration.dart';
|
||||
import '../../widgets/m3_card.dart';
|
||||
import '../../widgets/quill_field.dart';
|
||||
import '../../widgets/responsive_body.dart';
|
||||
import '../../widgets/status_pill.dart';
|
||||
import '../../widgets/tech_chip.dart';
|
||||
import 'programmer_task_actions.dart';
|
||||
import 'programmer_task_edit.dart';
|
||||
import 'programmer_task_status_kind.dart';
|
||||
import 'widgets/activity_timeline.dart';
|
||||
import 'widgets/comment_section.dart';
|
||||
import 'widgets/time_ledger_card.dart';
|
||||
import 'widgets/work_log_section.dart';
|
||||
|
||||
/// Lifecycle events, newest-first, that determine the live running/paused state.
|
||||
const _lifecycleActions = ['started', 'paused', 'resumed', 'completed', 'cancelled'];
|
||||
|
||||
class ProgrammerTaskDetailScreen extends ConsumerWidget {
|
||||
const ProgrammerTaskDetailScreen({super.key, required this.taskId});
|
||||
|
||||
final String taskId;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final task = ref.watch(programmerTaskByIdProvider(taskId));
|
||||
final logsAsync = ref.watch(programmerTaskActivityLogsProvider(taskId));
|
||||
final userId = ref.watch(currentUserIdProvider);
|
||||
final role = ref.watch(currentProfileProvider).valueOrNull?.role;
|
||||
final profiles = ref.watch(profilesProvider).valueOrNull ?? const <Profile>[];
|
||||
final profileById = {for (final p in profiles) p.id: p};
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(task?.title ?? 'Programmer Task'),
|
||||
actions: [
|
||||
if (task != null && !task.isTerminal && _canAct(task, userId, role)) ...[
|
||||
IconButton(
|
||||
tooltip: 'Edit',
|
||||
icon: const Icon(Icons.edit_outlined),
|
||||
onPressed: () => showEditProgrammerTaskDialog(context, ref, task),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Reassign',
|
||||
icon: const Icon(Icons.person_add_alt_1_outlined),
|
||||
onPressed: () => _reassign(context, ref, task, profiles, userId),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
body: task == null
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _DetailBody(
|
||||
task: task,
|
||||
logs: logsAsync.valueOrNull ?? const [],
|
||||
profileById: profileById,
|
||||
userId: userId,
|
||||
role: role,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
bool _canAct(ProgrammerTask task, String? userId, String? role) =>
|
||||
task.assigneeId == userId || role == 'admin' || role == 'programmer';
|
||||
|
||||
class _DetailBody extends ConsumerWidget {
|
||||
const _DetailBody({
|
||||
required this.task,
|
||||
required this.logs,
|
||||
required this.profileById,
|
||||
required this.userId,
|
||||
required this.role,
|
||||
});
|
||||
|
||||
final ProgrammerTask task;
|
||||
final List<ProgrammerTaskActivityLog> logs;
|
||||
final Map<String, Profile> profileById;
|
||||
final String? userId;
|
||||
final String? role;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final tt = Theme.of(context).textTheme;
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
|
||||
// Live running/paused state from the newest lifecycle event.
|
||||
String latest = '';
|
||||
for (final l in logs) {
|
||||
if (_lifecycleActions.contains(l.actionType)) {
|
||||
latest = l.actionType;
|
||||
break;
|
||||
}
|
||||
}
|
||||
final isPaused =
|
||||
task.status == ProgrammerTaskStatus.inProgress && latest == 'paused';
|
||||
final isRunning =
|
||||
task.status == ProgrammerTaskStatus.inProgress && !isPaused;
|
||||
|
||||
final endAt = switch (task.status) {
|
||||
ProgrammerTaskStatus.completed => task.completedAt ?? AppTime.now(),
|
||||
ProgrammerTaskStatus.cancelled => task.cancelledAt ?? AppTime.now(),
|
||||
_ => AppTime.now(),
|
||||
};
|
||||
// Sum any time deducted (helped elsewhere) from the activity log.
|
||||
var adjustmentSeconds = 0;
|
||||
for (final l in logs) {
|
||||
if (l.actionType == 'adjustment') {
|
||||
adjustmentSeconds += (l.meta?['seconds'] as num?)?.toInt() ?? 0;
|
||||
}
|
||||
}
|
||||
final worked = computeEffectiveDuration(
|
||||
fallbackStartedAt: task.startedAt,
|
||||
endAt: endAt,
|
||||
events: logs
|
||||
.map((l) => (createdAt: l.createdAt, actionType: l.actionType))
|
||||
.toList(),
|
||||
adjustment: Duration(seconds: adjustmentSeconds),
|
||||
);
|
||||
|
||||
final workLogs =
|
||||
ref.watch(programmerTaskWorkLogsProvider(task.id)).valueOrNull ?? const [];
|
||||
final ledger = computeTaskLedger(
|
||||
assigneeId: task.assigneeId,
|
||||
assigneeDuration: worked,
|
||||
workLogs: workLogs,
|
||||
);
|
||||
|
||||
final assignee =
|
||||
task.assigneeId == null ? null : profileById[task.assigneeId];
|
||||
final project = task.projectId == null
|
||||
? null
|
||||
: ref.watch(programmerProjectByIdProvider(task.projectId!));
|
||||
final canAct = _canAct(task, userId, role);
|
||||
final isAssignee = task.assigneeId == userId;
|
||||
|
||||
return DefaultTabController(
|
||||
length: 3,
|
||||
child: ResponsiveBody(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const SizedBox(height: 16),
|
||||
M3Card.elevated(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
task.title,
|
||||
style: tt.titleLarge
|
||||
?.copyWith(fontWeight: FontWeight.w700),
|
||||
),
|
||||
),
|
||||
StatusPill.kind(
|
||||
programmerTaskStatusKind(task.status),
|
||||
label: isPaused
|
||||
? 'Paused'
|
||||
: ProgrammerTaskStatus.label(task.status),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 8,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
TechChip(task.category, icon: Icons.category_outlined),
|
||||
if (project != null)
|
||||
TechChip(project.name, icon: Icons.folder_outlined),
|
||||
if (assignee != null)
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.person_outline,
|
||||
size: 15, color: cs.onSurfaceVariant),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
assignee.fullName,
|
||||
style: tt.bodyMedium
|
||||
?.copyWith(color: cs.onSurfaceVariant),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
if (task.description != null &&
|
||||
task.description!.trim().isNotEmpty) ...[
|
||||
const SizedBox(height: 14),
|
||||
QuillReadOnly(task.description!, style: tt.bodyMedium),
|
||||
],
|
||||
const SizedBox(height: 14),
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.timer_outlined, size: 18, color: cs.primary),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Worked: ${formatDurationHm(worked)}',
|
||||
style: tt.titleSmall
|
||||
?.copyWith(fontWeight: FontWeight.w600),
|
||||
),
|
||||
if (isRunning) ...[
|
||||
const SizedBox(width: 8),
|
||||
Text('· running',
|
||||
style: tt.bodySmall
|
||||
?.copyWith(color: cs.onSurfaceVariant)),
|
||||
],
|
||||
],
|
||||
),
|
||||
if (task.cancellationReason != null) ...[
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
'Cancelled: ${task.cancellationReason}',
|
||||
style: tt.bodySmall?.copyWith(color: cs.error),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (ledger.total > Duration.zero) ...[
|
||||
const SizedBox(height: 16),
|
||||
TimeLedgerCard(ledger: ledger, profileById: profileById),
|
||||
],
|
||||
if (canAct && !task.isTerminal) ...[
|
||||
const SizedBox(height: 16),
|
||||
_ActionBar(
|
||||
task: task,
|
||||
isRunning: isRunning,
|
||||
isPaused: isPaused,
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
const TabBar(
|
||||
tabs: [
|
||||
Tab(text: 'Work Log'),
|
||||
Tab(text: 'Comments'),
|
||||
Tab(text: 'Activity'),
|
||||
],
|
||||
),
|
||||
Expanded(
|
||||
child: TabBarView(
|
||||
children: [
|
||||
SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
child: WorkLogSection(task: task, isAssignee: isAssignee),
|
||||
),
|
||||
SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
child: CommentSection(task: task),
|
||||
),
|
||||
SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
child: ActivityTimeline(logs: logs, profileById: profileById),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ActionBar extends ConsumerWidget {
|
||||
const _ActionBar({
|
||||
required this.task,
|
||||
required this.isRunning,
|
||||
required this.isPaused,
|
||||
});
|
||||
|
||||
final ProgrammerTask task;
|
||||
final bool isRunning;
|
||||
final bool isPaused;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final controller = ref.read(programmerTasksControllerProvider);
|
||||
final isQueued = task.status == ProgrammerTaskStatus.queued;
|
||||
|
||||
return Wrap(
|
||||
spacing: 10,
|
||||
runSpacing: 10,
|
||||
children: [
|
||||
if (isQueued)
|
||||
FilledButton.icon(
|
||||
onPressed: () =>
|
||||
startProgrammerTaskWithPausePrompt(context, ref, task),
|
||||
icon: const Icon(Icons.play_arrow_rounded),
|
||||
label: const Text('Start'),
|
||||
),
|
||||
if (isPaused)
|
||||
FilledButton.icon(
|
||||
onPressed: () =>
|
||||
startProgrammerTaskWithPausePrompt(context, ref, task),
|
||||
icon: const Icon(Icons.play_arrow_rounded),
|
||||
label: const Text('Resume'),
|
||||
),
|
||||
if (isRunning)
|
||||
FilledButton.tonalIcon(
|
||||
onPressed: () async {
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
await controller.pauseTask(taskId: task.id);
|
||||
messenger.showSnackBar(const SnackBar(content: Text('Paused')));
|
||||
},
|
||||
icon: const Icon(Icons.pause_rounded),
|
||||
label: const Text('Pause'),
|
||||
),
|
||||
if (isRunning || isPaused)
|
||||
OutlinedButton.icon(
|
||||
onPressed: () =>
|
||||
completeProgrammerTaskWithConfirm(context, ref, task),
|
||||
icon: const Icon(Icons.check_rounded),
|
||||
label: const Text('Complete'),
|
||||
),
|
||||
OutlinedButton.icon(
|
||||
onPressed: () => _cancel(context, ref, task),
|
||||
icon: const Icon(Icons.close_rounded),
|
||||
label: const Text('Cancel'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Future<void> _cancel(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
ProgrammerTask task,
|
||||
) async {
|
||||
final reasonCtrl = TextEditingController();
|
||||
final reason = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
semanticLabel: 'Cancel task',
|
||||
title: const Text('Cancel task?'),
|
||||
content: TextField(
|
||||
controller: reasonCtrl,
|
||||
autofocus: true,
|
||||
textCapitalization: TextCapitalization.sentences,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Reason',
|
||||
hintText: 'Why is this being cancelled?',
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Back'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(context).pop(reasonCtrl.text.trim()),
|
||||
child: const Text('Cancel Task'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
reasonCtrl.dispose();
|
||||
if (reason == null || reason.isEmpty) return;
|
||||
if (!context.mounted) return;
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
await ref
|
||||
.read(programmerTasksControllerProvider)
|
||||
.cancelTask(taskId: task.id, reason: reason);
|
||||
messenger.showSnackBar(const SnackBar(content: Text('Task cancelled')));
|
||||
}
|
||||
|
||||
Future<void> _reassign(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
ProgrammerTask task,
|
||||
List<Profile> profiles,
|
||||
String? userId,
|
||||
) async {
|
||||
final eligible = profiles
|
||||
.where((p) => p.role == 'programmer' || p.role == 'admin')
|
||||
.toList()
|
||||
..sort((a, b) => a.fullName.compareTo(b.fullName));
|
||||
if (eligible.isEmpty) return;
|
||||
|
||||
var selected = task.assigneeId ?? userId;
|
||||
final chosen = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (context) => StatefulBuilder(
|
||||
builder: (context, setDialogState) => AlertDialog(
|
||||
semanticLabel: 'Reassign task',
|
||||
title: const Text('Reassign task'),
|
||||
content: DropdownButtonFormField<String>(
|
||||
initialValue: eligible.any((p) => p.id == selected) ? selected : null,
|
||||
decoration: const InputDecoration(labelText: 'Assignee'),
|
||||
items: [
|
||||
for (final p in eligible)
|
||||
DropdownMenuItem(
|
||||
value: p.id,
|
||||
child: Text(p.id == userId ? '${p.fullName} (me)' : p.fullName),
|
||||
),
|
||||
],
|
||||
onChanged: (v) => setDialogState(() => selected = v),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(context).pop(selected),
|
||||
child: const Text('Reassign'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
if (chosen == null || chosen == task.assigneeId) return;
|
||||
if (!context.mounted) return;
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
await ref
|
||||
.read(programmerTasksControllerProvider)
|
||||
.reassign(taskId: task.id, newAssigneeId: chosen);
|
||||
messenger.showSnackBar(const SnackBar(content: Text('Task reassigned')));
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../models/programmer_task.model.dart';
|
||||
import '../../providers/programmer_tasks_provider.dart';
|
||||
import '../../widgets/quill_field.dart';
|
||||
|
||||
/// Edit a task's Title (plain) and Description (Quill). Saves via the existing
|
||||
/// [ProgrammerTasksController.updateTask].
|
||||
Future<void> showEditProgrammerTaskDialog(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
ProgrammerTask task,
|
||||
) async {
|
||||
final titleCtrl = TextEditingController(text: task.title);
|
||||
final descQuill = quillControllerFromDelta(task.description);
|
||||
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
final dialogWidth =
|
||||
math.min(480.0, MediaQuery.of(context).size.width - 48);
|
||||
return AlertDialog(
|
||||
semanticLabel: 'Edit task',
|
||||
title: const Text('Edit Task'),
|
||||
content: SizedBox(
|
||||
width: dialogWidth,
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
TextField(
|
||||
controller: titleCtrl,
|
||||
autofocus: true,
|
||||
textCapitalization: TextCapitalization.sentences,
|
||||
decoration: const InputDecoration(labelText: 'Title'),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text('Description',
|
||||
style: Theme.of(context).textTheme.bodySmall),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
QuillFieldEditor(
|
||||
controller: descQuill,
|
||||
placeholder: 'Describe the task…',
|
||||
height: 180,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
child: const Text('Save'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
final title = titleCtrl.text.trim();
|
||||
titleCtrl.dispose();
|
||||
final description =
|
||||
quillPlainText(descQuill).isEmpty ? '' : quillToDeltaJson(descQuill);
|
||||
descQuill.dispose();
|
||||
if (ok != true || title.isEmpty || !context.mounted) return;
|
||||
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
try {
|
||||
await ref.read(programmerTasksControllerProvider).updateTask(
|
||||
taskId: task.id,
|
||||
title: title,
|
||||
description: description,
|
||||
);
|
||||
messenger.showSnackBar(const SnackBar(content: Text('Task updated')));
|
||||
} catch (e) {
|
||||
messenger.showSnackBar(SnackBar(content: Text('Could not update: $e')));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../models/programmer_task.model.dart';
|
||||
import '../../providers/profile_provider.dart';
|
||||
import '../../providers/programmer_tasks_provider.dart';
|
||||
|
||||
/// After the ASSIGNEE saves a work log or comment on their own actively-running
|
||||
/// task, ask whether to pause the timer. No-op for anyone else, or when the task
|
||||
/// isn't the caller's currently-running one.
|
||||
Future<void> maybePromptPauseTimer(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
ProgrammerTask task,
|
||||
) async {
|
||||
final userId = ref.read(currentUserIdProvider);
|
||||
if (task.assigneeId != userId) return;
|
||||
final controller = ref.read(programmerTasksControllerProvider);
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
// Fresh query — the running-task provider can lag right after a start/switch.
|
||||
final running = await controller.findRunningTaskForCurrentUser();
|
||||
if (running?.id != task.id) return;
|
||||
if (!context.mounted) return;
|
||||
|
||||
final pause = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
semanticLabel: 'Pause the timer',
|
||||
title: const Text('Pause the timer?'),
|
||||
content: Text('You logged on "${task.title}". Pause its timer now?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: const Text('Keep running'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
child: const Text('Pause'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (pause == true) {
|
||||
await controller.pauseTask(taskId: task.id);
|
||||
messenger.showSnackBar(const SnackBar(content: Text('Timer paused')));
|
||||
}
|
||||
}
|
||||
|
||||
/// After a HELPER logs Time Consumed on a task they don't own, ask whether to
|
||||
/// deduct that time from their own currently-running task. No-op if they have no
|
||||
/// running (unpaused) task. [helpedTaskId] is recorded for audit.
|
||||
Future<void> maybePromptDeductHelperTime(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
int minutes,
|
||||
String helpedTaskId,
|
||||
) async {
|
||||
if (minutes <= 0) return;
|
||||
final controller = ref.read(programmerTasksControllerProvider);
|
||||
final running = await controller.findRunningTaskForCurrentUser();
|
||||
if (running == null) return;
|
||||
if (!context.mounted) return;
|
||||
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final deduct = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
semanticLabel: 'Deduct helped time',
|
||||
title: const Text('Deduct this time?'),
|
||||
content: Text(
|
||||
'You logged $minutes min helping. Deduct it from your active task '
|
||||
'"${running.title}" so your time stays accurate?',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: const Text('Skip'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
child: const Text('Deduct'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (deduct == true) {
|
||||
await controller.addAdjustment(
|
||||
taskId: running.id,
|
||||
seconds: minutes * 60,
|
||||
reason: 'helped',
|
||||
sourceTaskId: helpedTaskId,
|
||||
);
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text('Deducted $minutes min from "${running.title}"')),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import '../../models/programmer_task.model.dart';
|
||||
import '../../theme/app_status_colors.dart';
|
||||
|
||||
/// Maps a programmer-task status to a semantic [StatusKind] so its color carries
|
||||
/// consistent meaning across the list and detail screens
|
||||
/// (grey=queued, blue=in progress, green=completed, grey=cancelled).
|
||||
StatusKind programmerTaskStatusKind(String status) => switch (status) {
|
||||
ProgrammerTaskStatus.queued => StatusKind.neutral,
|
||||
ProgrammerTaskStatus.inProgress => StatusKind.info,
|
||||
ProgrammerTaskStatus.completed => StatusKind.success,
|
||||
ProgrammerTaskStatus.cancelled => StatusKind.neutral,
|
||||
_ => StatusKind.neutral,
|
||||
};
|
||||
@@ -0,0 +1,191 @@
|
||||
import 'package:flutter/foundation.dart' show listEquals;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../models/profile.model.dart';
|
||||
import '../../providers/profile_provider.dart';
|
||||
import '../../providers/programmer_tasks_provider.dart';
|
||||
import '../../theme/m3_motion.dart';
|
||||
import '../../widgets/app_page_header.dart';
|
||||
import '../../widgets/app_state_view.dart';
|
||||
import '../../widgets/reconnect_overlay.dart';
|
||||
import '../../widgets/responsive_body.dart';
|
||||
import 'dashboard/programmer_dashboard.dart';
|
||||
import 'day_sheets/approvals_tab.dart';
|
||||
import 'day_sheets/day_sheets_tab.dart';
|
||||
import 'monitor/monitor_tab.dart';
|
||||
import 'programmer_task_create.dart';
|
||||
import 'programmer_tasks_tabs.dart';
|
||||
import 'widgets/programmer_task_card.dart';
|
||||
|
||||
class ProgrammerTasksListScreen extends ConsumerStatefulWidget {
|
||||
const ProgrammerTasksListScreen({super.key, this.initialTab});
|
||||
|
||||
final String? initialTab;
|
||||
|
||||
@override
|
||||
ConsumerState<ProgrammerTasksListScreen> createState() =>
|
||||
_ProgrammerTasksListScreenState();
|
||||
}
|
||||
|
||||
class _ProgrammerTasksListScreenState
|
||||
extends ConsumerState<ProgrammerTasksListScreen>
|
||||
with TickerProviderStateMixin {
|
||||
late TabController _tabController;
|
||||
late List<ProgrammerTasksTab> _tabs;
|
||||
bool _tabsBuilt = false;
|
||||
|
||||
void _buildTabs(bool isStrictAdmin) {
|
||||
final tabs = programmerTasksTabsFor(isStrictAdmin: isStrictAdmin);
|
||||
|
||||
// Determine initial tab index from widget.initialTab.
|
||||
int initialIndex = 0;
|
||||
final parsed = parseProgrammerTasksTab(widget.initialTab);
|
||||
if (parsed != null) {
|
||||
final idx = tabs.indexOf(parsed);
|
||||
if (idx >= 0) initialIndex = idx;
|
||||
}
|
||||
|
||||
if (_tabsBuilt && listEquals(_tabs, tabs)) {
|
||||
return;
|
||||
}
|
||||
|
||||
final oldController = _tabsBuilt ? _tabController : null;
|
||||
|
||||
_tabs = tabs;
|
||||
_tabController = TabController(
|
||||
length: tabs.length,
|
||||
initialIndex: initialIndex,
|
||||
vsync: this,
|
||||
);
|
||||
_tabController.addListener(() {
|
||||
if (mounted) setState(() {});
|
||||
});
|
||||
_tabsBuilt = true;
|
||||
oldController?.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
if (_tabsBuilt) _tabController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isStrictAdmin = ref.watch(isStrictAdminProvider);
|
||||
_buildTabs(isStrictAdmin);
|
||||
|
||||
final tasksAsync = ref.watch(programmerTasksProvider);
|
||||
final userId = ref.watch(currentUserIdProvider);
|
||||
final profilesAsync = ref.watch(profilesProvider);
|
||||
|
||||
final profileById = <String, Profile>{
|
||||
for (final p in profilesAsync.valueOrNull ?? <Profile>[]) p.id: p,
|
||||
};
|
||||
|
||||
final currentTab = _tabs[_tabController.index];
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
ResponsiveBody(
|
||||
maxWidth: double.infinity,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const AppPageHeader(
|
||||
title: 'Programmer Tasks',
|
||||
subtitle: 'Log and track development work',
|
||||
),
|
||||
TabBar(
|
||||
controller: _tabController,
|
||||
tabs: [
|
||||
for (final t in _tabs)
|
||||
Tab(text: programmerTasksTabLabel(t)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Expanded(
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
// Monitor tab — show the monitor overview.
|
||||
if (currentTab == ProgrammerTasksTab.monitor) {
|
||||
return const MonitorTab();
|
||||
}
|
||||
|
||||
// Approvals tab — admin bulk-approve day sheets.
|
||||
if (currentTab == ProgrammerTasksTab.approvals) {
|
||||
return const ApprovalsTab();
|
||||
}
|
||||
|
||||
// Mine tab — delegate entirely to ProgrammerDashboard.
|
||||
if (currentTab == ProgrammerTasksTab.mine) {
|
||||
if (userId == null) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
return ProgrammerDashboard(userId: userId);
|
||||
}
|
||||
|
||||
// Day Sheets tab — programmer personal sheet management.
|
||||
if (currentTab == ProgrammerTasksTab.daySheets) {
|
||||
return const DaySheetsTab();
|
||||
}
|
||||
|
||||
// All tab — flat list with error / loading / empty states.
|
||||
if (tasksAsync.hasError && !tasksAsync.hasValue) {
|
||||
return AppErrorView(
|
||||
error: tasksAsync.error!,
|
||||
onRetry: () =>
|
||||
ref.invalidate(programmerTasksProvider),
|
||||
);
|
||||
}
|
||||
if (!tasksAsync.hasValue && tasksAsync.isLoading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
final all = tasksAsync.valueOrNull ?? const [];
|
||||
|
||||
if (all.isEmpty) {
|
||||
return const AppEmptyView(
|
||||
icon: Icons.developer_mode_outlined,
|
||||
title: 'No tasks yet',
|
||||
subtitle:
|
||||
'Create the first programmer task with the + button.',
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.separated(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
itemCount: all.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(height: 10),
|
||||
itemBuilder: (context, i) {
|
||||
final task = all[i];
|
||||
return ProgrammerTaskCard(
|
||||
task: task,
|
||||
assignee: task.assigneeId == null
|
||||
? null
|
||||
: profileById[task.assigneeId],
|
||||
isMine: task.assigneeId == userId,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
right: 16,
|
||||
bottom: 16,
|
||||
child: M3ExpandedFab(
|
||||
heroTag: 'create_programmer_task',
|
||||
onPressed: () => showCreateProgrammerTaskDialog(context, ref),
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('New Task'),
|
||||
),
|
||||
),
|
||||
const ReconnectIndicator(),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
/// Tab enumeration and helpers for the Programmer Tasks list screen.
|
||||
///
|
||||
/// This-slice tabs (Task 10):
|
||||
/// Admin: [monitor, mine, all]
|
||||
/// Non-admin: [mine, all]
|
||||
///
|
||||
/// approvals and daySheets are deferred to later slices.
|
||||
enum ProgrammerTasksTab { monitor, approvals, mine, all, daySheets }
|
||||
|
||||
/// Returns the ordered tab list for the current role.
|
||||
///
|
||||
/// Admin: [monitor, approvals, mine, all]
|
||||
/// Programmer: [mine, all, daySheets]
|
||||
List<ProgrammerTasksTab> programmerTasksTabsFor({required bool isStrictAdmin}) {
|
||||
if (isStrictAdmin) {
|
||||
return [
|
||||
ProgrammerTasksTab.monitor,
|
||||
ProgrammerTasksTab.approvals,
|
||||
ProgrammerTasksTab.mine,
|
||||
ProgrammerTasksTab.all,
|
||||
];
|
||||
}
|
||||
return [
|
||||
ProgrammerTasksTab.mine,
|
||||
ProgrammerTasksTab.all,
|
||||
ProgrammerTasksTab.daySheets,
|
||||
];
|
||||
}
|
||||
|
||||
/// Parses a URL query-parameter value to a [ProgrammerTasksTab].
|
||||
///
|
||||
/// Valid values: 'monitor', 'approvals', 'mine', 'all', 'day-sheets'.
|
||||
/// Returns null for unrecognised or null input.
|
||||
ProgrammerTasksTab? parseProgrammerTasksTab(String? q) {
|
||||
switch (q) {
|
||||
case 'monitor':
|
||||
return ProgrammerTasksTab.monitor;
|
||||
case 'approvals':
|
||||
return ProgrammerTasksTab.approvals;
|
||||
case 'mine':
|
||||
return ProgrammerTasksTab.mine;
|
||||
case 'all':
|
||||
return ProgrammerTasksTab.all;
|
||||
case 'day-sheets':
|
||||
case 'daySheets':
|
||||
return ProgrammerTasksTab.daySheets;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Human-readable tab label for display in [TabBar].
|
||||
String programmerTasksTabLabel(ProgrammerTasksTab t) {
|
||||
switch (t) {
|
||||
case ProgrammerTasksTab.monitor:
|
||||
return 'Monitor';
|
||||
case ProgrammerTasksTab.approvals:
|
||||
return 'Approvals';
|
||||
case ProgrammerTasksTab.mine:
|
||||
return 'Mine';
|
||||
case ProgrammerTasksTab.all:
|
||||
return 'All';
|
||||
case ProgrammerTasksTab.daySheets:
|
||||
return 'Day Sheets';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
import 'package:tasq/models/programmer_day_sheet.model.dart';
|
||||
import 'package:tasq/models/programmer_task.model.dart';
|
||||
import 'package:tasq/models/profile.model.dart';
|
||||
import 'package:tasq/providers/reports_provider.dart';
|
||||
import 'package:tasq/utils/programmer_day_rows.dart';
|
||||
|
||||
/// A single day's report with status and rows.
|
||||
class ReportDay {
|
||||
final DateTime day;
|
||||
final String? statusTag; // null when approved; 'Pending' | 'Disapproved'
|
||||
final List<DaySheetRow> rows;
|
||||
|
||||
const ReportDay({
|
||||
required this.day,
|
||||
required this.statusTag,
|
||||
required this.rows,
|
||||
});
|
||||
}
|
||||
|
||||
/// Complete accomplishment report data for a programmer over a date range.
|
||||
class AccomplishmentReportData {
|
||||
final String programmerName;
|
||||
final String positionLabel; // 'Programmer' | 'Administrator'
|
||||
final DateTime periodStart;
|
||||
final DateTime periodEndInclusive; // day keys (UTC)
|
||||
final List<ReportDay> days; // ascending by day
|
||||
final int excludedPending;
|
||||
final int excludedDisapproved;
|
||||
final int daysReported;
|
||||
final int totalSeconds;
|
||||
final int tasksCompleted;
|
||||
final int helpSeconds;
|
||||
final Map<String, int> secondsByCategory;
|
||||
final String? approverName;
|
||||
|
||||
const AccomplishmentReportData({
|
||||
required this.programmerName,
|
||||
required this.positionLabel,
|
||||
required this.periodStart,
|
||||
required this.periodEndInclusive,
|
||||
required this.days,
|
||||
required this.excludedPending,
|
||||
required this.excludedDisapproved,
|
||||
required this.daysReported,
|
||||
required this.totalSeconds,
|
||||
required this.tasksCompleted,
|
||||
required this.helpSeconds,
|
||||
required this.secondsByCategory,
|
||||
required this.approverName,
|
||||
});
|
||||
}
|
||||
|
||||
/// Build accomplishment report data for a programmer over a date range.
|
||||
///
|
||||
/// Rules:
|
||||
/// - Only sheets with workDate in [range.start, range.end) are considered.
|
||||
/// - range.end is EXCLUSIVE; periodEndInclusive = range.end.subtract(Duration(days: 1)).
|
||||
/// - In approved-only mode: include only approved sheets; statusTag = null; use snapshot rows.
|
||||
/// - In include-unapproved mode: include all; statusTag varies; use liveRowsByDay for
|
||||
/// non-approved, snapshot for approved.
|
||||
/// - Days with zero rows are omitted.
|
||||
/// - helpSeconds: sum row.seconds for rows where row.kind == 'helper'.
|
||||
/// - secondsByCategory: sum row.seconds keyed by row.category (skip null).
|
||||
/// - tasksCompleted: count tasks where status == 'completed' AND completedAt (or updatedAt)
|
||||
/// falls in [range.start, range.end) AND assigneeId == programmer.id.
|
||||
/// - approverName: if all included approved days have the same reviewedBy and that set is
|
||||
/// non-empty, look up from profileNames; else null.
|
||||
/// - daysReported = days.length.
|
||||
/// - totalSeconds: sum all row.seconds.
|
||||
AccomplishmentReportData buildAccomplishmentReportData({
|
||||
required Profile programmer,
|
||||
required ReportDateRange range,
|
||||
required bool includeUnapproved,
|
||||
required List<ProgrammerDaySheet> sheets,
|
||||
required Map<DateTime, List<DaySheetRow>> liveRowsByDay,
|
||||
required List<ProgrammerTask> tasks,
|
||||
required Map<String, String> profileNames,
|
||||
}) {
|
||||
// Determine position label
|
||||
final positionLabel = programmer.role == 'administrator' ? 'Administrator' : 'Programmer';
|
||||
|
||||
// Calculate period boundaries
|
||||
final periodStart = range.start;
|
||||
final periodEndInclusive = range.end.subtract(const Duration(days: 1));
|
||||
|
||||
// Filter sheets in range and split by status
|
||||
final sheetsInRange = sheets.where((s) {
|
||||
final workDate = s.workDate;
|
||||
return !workDate.isBefore(range.start) && workDate.isBefore(range.end);
|
||||
}).toList();
|
||||
|
||||
int excludedPending = 0;
|
||||
int excludedDisapproved = 0;
|
||||
final reportDays = <ReportDay>[];
|
||||
final allReviewers = <String>{};
|
||||
int totalSeconds = 0;
|
||||
int helpSeconds = 0;
|
||||
final secondsByCategory = <String, int>{};
|
||||
|
||||
if (includeUnapproved) {
|
||||
// Include all sheets: approved + pending + disapproved + draft
|
||||
for (final sheet in sheetsInRange) {
|
||||
final statusTag = sheet.status == DaySheetStatus.approved
|
||||
? null
|
||||
: (sheet.status == DaySheetStatus.pending || sheet.status == DaySheetStatus.draft)
|
||||
? 'Pending'
|
||||
: 'Disapproved';
|
||||
|
||||
// Get rows: use snapshot for approved, liveRowsByDay for others
|
||||
final rows = sheet.status == DaySheetStatus.approved
|
||||
? sheet.approvedSnapshot?.rows ?? []
|
||||
: liveRowsByDay[sheet.workDate] ?? [];
|
||||
|
||||
if (rows.isNotEmpty) {
|
||||
reportDays.add(ReportDay(
|
||||
day: sheet.workDate,
|
||||
statusTag: statusTag,
|
||||
rows: rows,
|
||||
));
|
||||
|
||||
// Accumulate metrics
|
||||
for (final row in rows) {
|
||||
totalSeconds += row.seconds;
|
||||
if (row.kind == 'helper') {
|
||||
helpSeconds += row.seconds;
|
||||
}
|
||||
secondsByCategory[row.category] =
|
||||
(secondsByCategory[row.category] ?? 0) + row.seconds;
|
||||
}
|
||||
|
||||
// Track reviewers for included approved sheets only
|
||||
if (sheet.status == DaySheetStatus.approved && sheet.reviewedBy != null) {
|
||||
allReviewers.add(sheet.reviewedBy!);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Approved-only mode
|
||||
for (final sheet in sheetsInRange) {
|
||||
if (sheet.status == DaySheetStatus.approved) {
|
||||
final rows = sheet.approvedSnapshot?.rows ?? [];
|
||||
if (rows.isNotEmpty) {
|
||||
reportDays.add(ReportDay(
|
||||
day: sheet.workDate,
|
||||
statusTag: null,
|
||||
rows: rows,
|
||||
));
|
||||
|
||||
// Accumulate metrics
|
||||
for (final row in rows) {
|
||||
totalSeconds += row.seconds;
|
||||
if (row.kind == 'helper') {
|
||||
helpSeconds += row.seconds;
|
||||
}
|
||||
secondsByCategory[row.category] =
|
||||
(secondsByCategory[row.category] ?? 0) + row.seconds;
|
||||
}
|
||||
|
||||
// Track reviewer for included sheets only
|
||||
if (sheet.reviewedBy != null) {
|
||||
allReviewers.add(sheet.reviewedBy!);
|
||||
}
|
||||
}
|
||||
} else if (sheet.status == DaySheetStatus.pending) {
|
||||
excludedPending++;
|
||||
} else if (sheet.status == DaySheetStatus.disapproved) {
|
||||
excludedDisapproved++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort report days by day key
|
||||
reportDays.sort((a, b) => a.day.compareTo(b.day));
|
||||
|
||||
// Determine approver name: only if all approved days have the same reviewer
|
||||
String? approverName;
|
||||
if (allReviewers.isNotEmpty && allReviewers.length == 1) {
|
||||
final reviewer = allReviewers.first;
|
||||
approverName = profileNames[reviewer];
|
||||
}
|
||||
|
||||
// Count completed tasks in range for this programmer
|
||||
int tasksCompleted = 0;
|
||||
for (final task in tasks) {
|
||||
if (task.status == ProgrammerTaskStatus.completed &&
|
||||
task.assigneeId == programmer.id) {
|
||||
// Use completedAt if available, otherwise updatedAt
|
||||
final completionDate = task.completedAt ?? task.updatedAt;
|
||||
if (!completionDate.isBefore(range.start) && completionDate.isBefore(range.end)) {
|
||||
tasksCompleted++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return AccomplishmentReportData(
|
||||
programmerName: programmer.fullName,
|
||||
positionLabel: positionLabel,
|
||||
periodStart: periodStart,
|
||||
periodEndInclusive: periodEndInclusive,
|
||||
days: reportDays,
|
||||
excludedPending: excludedPending,
|
||||
excludedDisapproved: excludedDisapproved,
|
||||
daysReported: reportDays.length,
|
||||
totalSeconds: totalSeconds,
|
||||
tasksCompleted: tasksCompleted,
|
||||
helpSeconds: helpSeconds,
|
||||
secondsByCategory: secondsByCategory,
|
||||
approverName: approverName,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../models/profile.model.dart';
|
||||
import '../../../models/programmer_day_sheet.model.dart';
|
||||
import '../../../providers/profile_provider.dart';
|
||||
import '../../../providers/programmer_day_data_provider.dart';
|
||||
import '../../../providers/programmer_day_sheets_provider.dart';
|
||||
import '../../../providers/programmer_tasks_provider.dart';
|
||||
import '../../../providers/reports_provider.dart';
|
||||
import '../../../providers/supabase_provider.dart';
|
||||
import '../../../utils/app_time.dart';
|
||||
import '../../../utils/programmer_day_rows.dart';
|
||||
import '../../../widgets/pdf_preview_dialog.dart';
|
||||
import '../../reports/report_date_filter.dart';
|
||||
import 'accomplishment_report_data.dart';
|
||||
import 'accomplishment_report_pdf.dart';
|
||||
|
||||
/// Load accomplishment report data for [programmer] over [range].
|
||||
///
|
||||
/// For non-approved sheets when [includeUnapproved] is true, fetches live
|
||||
/// rows via [fetchDayActivityData] + [buildDayRows]; approved sheets use
|
||||
/// the stored snapshot.
|
||||
Future<AccomplishmentReportData> loadAccomplishmentReport(
|
||||
WidgetRef ref, {
|
||||
required Profile programmer,
|
||||
required ReportDateRange range,
|
||||
required bool includeUnapproved,
|
||||
}) async {
|
||||
final sheets =
|
||||
ref.read(daySheetsForUserProvider(programmer.id)).valueOrNull ?? [];
|
||||
final profiles = ref.read(profilesProvider).valueOrNull ?? [];
|
||||
final profileNames = {for (final p in profiles) p.id: p.fullName};
|
||||
final tasks = ref.read(programmerTasksProvider).valueOrNull ?? [];
|
||||
final client = ref.read(supabaseClientProvider);
|
||||
|
||||
final liveRowsByDay = <DateTime, List<DaySheetRow>>{};
|
||||
if (includeUnapproved) {
|
||||
for (final sheet in sheets) {
|
||||
if (!sheet.workDate.isBefore(range.start) &&
|
||||
sheet.workDate.isBefore(range.end) &&
|
||||
sheet.status != DaySheetStatus.approved) {
|
||||
final data = await fetchDayActivityData(
|
||||
client,
|
||||
day: sheet.workDate,
|
||||
tasks: tasks,
|
||||
now: AppTime.now(),
|
||||
);
|
||||
final rows = buildDayRows(
|
||||
userId: programmer.id,
|
||||
day: sheet.workDate,
|
||||
tasks: data.tasks,
|
||||
logsByTask: data.logsByTask,
|
||||
workLogs: data.workLogs,
|
||||
projectNames: const {},
|
||||
profileNames: profileNames,
|
||||
now: AppTime.now(),
|
||||
);
|
||||
liveRowsByDay[sheet.workDate] = rows;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return buildAccomplishmentReportData(
|
||||
programmer: programmer,
|
||||
range: range,
|
||||
includeUnapproved: includeUnapproved,
|
||||
sheets: sheets,
|
||||
liveRowsByDay: liveRowsByDay,
|
||||
tasks: tasks,
|
||||
profileNames: profileNames,
|
||||
);
|
||||
}
|
||||
|
||||
/// Show the Accomplishment Report generate dialog.
|
||||
///
|
||||
/// - [programmerId] non-null: admin mode locked to that programmer.
|
||||
/// - [programmerId] null + isStrictAdmin: admin picks from all programmers.
|
||||
/// - [programmerId] null + not admin: programmer self-service (own profile).
|
||||
Future<void> showAccomplishmentReportDialog(
|
||||
BuildContext context,
|
||||
WidgetRef ref, {
|
||||
String? programmerId,
|
||||
}) {
|
||||
return showDialog<void>(
|
||||
context: context,
|
||||
builder: (_) => _AccomplishmentReportDialog(programmerId: programmerId),
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dialog widget
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class _AccomplishmentReportDialog extends ConsumerStatefulWidget {
|
||||
const _AccomplishmentReportDialog({this.programmerId});
|
||||
|
||||
final String? programmerId;
|
||||
|
||||
@override
|
||||
ConsumerState<_AccomplishmentReportDialog> createState() =>
|
||||
_AccomplishmentReportDialogState();
|
||||
}
|
||||
|
||||
class _AccomplishmentReportDialogState
|
||||
extends ConsumerState<_AccomplishmentReportDialog> {
|
||||
Profile? _selectedProgrammer;
|
||||
late ReportDateRange _dateRange;
|
||||
bool _includeUnapproved = false;
|
||||
bool _generating = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_dateRange = thisMonthRange(AppTime.now());
|
||||
}
|
||||
|
||||
Future<void> _onGenerate() async {
|
||||
final profiles = ref.read(profilesProvider).valueOrNull ?? [];
|
||||
final isStrictAdmin = ref.read(isStrictAdminProvider);
|
||||
final isAdminMode = widget.programmerId != null || isStrictAdmin;
|
||||
|
||||
Profile? programmer;
|
||||
if (widget.programmerId != null) {
|
||||
for (final p in profiles) {
|
||||
if (p.id == widget.programmerId) {
|
||||
programmer = p;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else if (isAdminMode) {
|
||||
programmer = _selectedProgrammer;
|
||||
} else {
|
||||
final userId = ref.read(currentUserIdProvider);
|
||||
for (final p in profiles) {
|
||||
if (p.id == userId) {
|
||||
programmer = p;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (programmer == null) return;
|
||||
|
||||
setState(() => _generating = true);
|
||||
try {
|
||||
final data = await loadAccomplishmentReport(
|
||||
ref,
|
||||
programmer: programmer,
|
||||
range: _dateRange,
|
||||
includeUnapproved: _includeUnapproved,
|
||||
);
|
||||
if (!mounted) return;
|
||||
final name = data.programmerName;
|
||||
Navigator.of(context).pop();
|
||||
await showPdfPreviewDialog(
|
||||
context,
|
||||
title: 'Accomplishment Report – $name',
|
||||
filename: 'Accomplishment Report - $name.pdf',
|
||||
buildBytes: () => buildAccomplishmentReportPdf(data),
|
||||
);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() => _generating = false);
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(SnackBar(content: Text(e.toString())));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final profiles = ref.watch(profilesProvider).valueOrNull ?? [];
|
||||
final isStrictAdmin = ref.watch(isStrictAdminProvider);
|
||||
final isAdminMode = widget.programmerId != null || isStrictAdmin;
|
||||
|
||||
final programmerProfiles =
|
||||
profiles.where((p) => p.role == 'programmer').toList()
|
||||
..sort((a, b) => a.fullName.compareTo(b.fullName));
|
||||
|
||||
// Generate is disabled when in admin-picker mode and nothing chosen yet.
|
||||
final canGenerate = !_generating &&
|
||||
(isAdminMode && widget.programmerId == null
|
||||
? _selectedProgrammer != null
|
||||
: true);
|
||||
|
||||
return AlertDialog(
|
||||
title: const Text('Accomplishment Report'),
|
||||
content: SizedBox(
|
||||
width: 400,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Programmer picker — only in admin-picker mode.
|
||||
if (isAdminMode && widget.programmerId == null) ...[
|
||||
_ProgrammerPicker(
|
||||
programmers: programmerProfiles,
|
||||
value: _selectedProgrammer,
|
||||
onChanged: (p) => setState(() => _selectedProgrammer = p),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
// Date range filter.
|
||||
ReportDateFilter.controlled(
|
||||
value: _dateRange,
|
||||
onChanged: (r) => setState(() => _dateRange = r),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// Include-unapproved toggle.
|
||||
SwitchListTile(
|
||||
title: const Text('Include unapproved days (marked)'),
|
||||
value: _includeUnapproved,
|
||||
onChanged: (v) => setState(() => _includeUnapproved = v),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
dense: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: _generating ? null : () => Navigator.of(context).pop(),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: canGenerate ? _onGenerate : null,
|
||||
child: _generating
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Text('Generate'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Programmer picker widget
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class _ProgrammerPicker extends StatelessWidget {
|
||||
const _ProgrammerPicker({
|
||||
required this.programmers,
|
||||
required this.value,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
final List<Profile> programmers;
|
||||
final Profile? value;
|
||||
final ValueChanged<Profile?> onChanged;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return InputDecorator(
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Programmer',
|
||||
border: OutlineInputBorder(),
|
||||
isDense: true,
|
||||
contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
),
|
||||
child: DropdownButtonHideUnderline(
|
||||
child: DropdownButton<Profile>(
|
||||
value: value,
|
||||
hint: const Text('Select programmer'),
|
||||
isExpanded: true,
|
||||
items: programmers
|
||||
.map(
|
||||
(p) => DropdownMenuItem<Profile>(
|
||||
value: p,
|
||||
child: Text(p.fullName),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
onChanged: onChanged,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
import 'dart:typed_data';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:pdf/pdf.dart' as pdf;
|
||||
import 'package:pdf/widgets.dart' as pw;
|
||||
import 'package:tasq/screens/programmer_tasks/report/accomplishment_report_data.dart';
|
||||
import 'package:tasq/utils/pdf/pdf_letterhead.dart';
|
||||
import 'package:tasq/utils/programmer_task_ledger.dart';
|
||||
|
||||
/// Build an A4 Accomplishment Report PDF from [data].
|
||||
Future<Uint8List> buildAccomplishmentReportPdf(
|
||||
AccomplishmentReportData data) async {
|
||||
final brand = await PdfBrand.load();
|
||||
final doc = pw.Document();
|
||||
|
||||
doc.addPage(
|
||||
pw.MultiPage(
|
||||
pageFormat: pdf.PdfPageFormat.a4,
|
||||
margin: const pw.EdgeInsets.all(32),
|
||||
theme: brand.theme(),
|
||||
footer: brand.pageFooter,
|
||||
build: (ctx) => [
|
||||
brand.letterhead(),
|
||||
pw.SizedBox(height: 8),
|
||||
pw.Center(
|
||||
child: pw.Text(
|
||||
'ACCOMPLISHMENT REPORT',
|
||||
style: pw.TextStyle(
|
||||
font: brand.bold, fontSize: 14, letterSpacing: 1.5),
|
||||
),
|
||||
),
|
||||
pw.SizedBox(height: 10),
|
||||
_infoBox(data, brand),
|
||||
pw.SizedBox(height: 10),
|
||||
_reportTable(data, brand),
|
||||
if (data.excludedPending + data.excludedDisapproved > 0)
|
||||
_footnote(data, brand),
|
||||
pw.SizedBox(height: 12),
|
||||
_summary(data, brand),
|
||||
pw.SizedBox(height: 16),
|
||||
_signatures(data, brand),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
return doc.save();
|
||||
}
|
||||
|
||||
// ─── Section builders ─────────────────────────────────────────────────────────
|
||||
|
||||
pw.Widget _infoBox(AccomplishmentReportData data, PdfBrand brand) {
|
||||
final fmt = DateFormat('MMMM d, yyyy');
|
||||
final periodStr =
|
||||
'${fmt.format(data.periodStart)} – ${fmt.format(data.periodEndInclusive)}';
|
||||
|
||||
pw.Widget infoRow(String label, String value) => pw.RichText(
|
||||
text: pw.TextSpan(
|
||||
children: [
|
||||
pw.TextSpan(
|
||||
text: '$label ',
|
||||
style: pw.TextStyle(font: brand.bold, fontSize: 9),
|
||||
),
|
||||
pw.TextSpan(
|
||||
text: value,
|
||||
style: pw.TextStyle(font: brand.regular, fontSize: 9),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
return pw.Container(
|
||||
decoration: pw.BoxDecoration(
|
||||
border: pw.Border.all(width: 0.8, color: pdf.PdfColors.grey400),
|
||||
),
|
||||
padding: const pw.EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
child: pw.Column(
|
||||
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
||||
children: [
|
||||
infoRow('Name:', data.programmerName),
|
||||
pw.SizedBox(height: 3),
|
||||
infoRow('Position:', data.positionLabel),
|
||||
pw.SizedBox(height: 3),
|
||||
infoRow('Period:', periodStr),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
pw.Widget _reportTable(AccomplishmentReportData data, PdfBrand brand) {
|
||||
final dateFmt = DateFormat('MMM d, yyyy');
|
||||
|
||||
const colWidths = {
|
||||
0: pw.FixedColumnWidth(80),
|
||||
1: pw.FlexColumnWidth(2),
|
||||
2: pw.FlexColumnWidth(3),
|
||||
3: pw.FixedColumnWidth(50),
|
||||
};
|
||||
|
||||
final border = pw.TableBorder.all(width: 0.5, color: pdf.PdfColors.grey400);
|
||||
|
||||
pw.Widget th(String text) => pw.Padding(
|
||||
padding: const pw.EdgeInsets.symmetric(horizontal: 4, vertical: 4),
|
||||
child: pw.Text(
|
||||
text,
|
||||
style: pw.TextStyle(font: brand.bold, fontSize: 8),
|
||||
),
|
||||
);
|
||||
|
||||
pw.Widget td(String text) => pw.Padding(
|
||||
padding: const pw.EdgeInsets.symmetric(horizontal: 4, vertical: 3),
|
||||
child: pw.Text(
|
||||
text,
|
||||
style: pw.TextStyle(font: brand.regular, fontSize: 8),
|
||||
),
|
||||
);
|
||||
|
||||
final headerRow = pw.TableRow(
|
||||
decoration: const pw.BoxDecoration(color: pdf.PdfColors.grey200),
|
||||
children: [
|
||||
th('DATE'),
|
||||
th('TASK / PROJECT'),
|
||||
th('ACCOMPLISHMENT'),
|
||||
th('TIME'),
|
||||
],
|
||||
);
|
||||
|
||||
if (data.days.isEmpty) {
|
||||
return pw.Column(
|
||||
crossAxisAlignment: pw.CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
pw.Table(
|
||||
border: border,
|
||||
columnWidths: colWidths,
|
||||
children: [headerRow],
|
||||
),
|
||||
pw.Container(
|
||||
decoration: pw.BoxDecoration(
|
||||
border: pw.Border(
|
||||
left: const pw.BorderSide(width: 0.5, color: pdf.PdfColors.grey400),
|
||||
right: const pw.BorderSide(width: 0.5, color: pdf.PdfColors.grey400),
|
||||
bottom: const pw.BorderSide(width: 0.5, color: pdf.PdfColors.grey400),
|
||||
),
|
||||
),
|
||||
padding: const pw.EdgeInsets.all(8),
|
||||
child: pw.Text(
|
||||
'No approved accomplishments in this period.',
|
||||
style: pw.TextStyle(
|
||||
font: brand.regular,
|
||||
fontSize: 8,
|
||||
color: pdf.PdfColors.grey700),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
final rows = <pw.TableRow>[headerRow];
|
||||
|
||||
for (final day in data.days) {
|
||||
String dateLabel = dateFmt.format(day.day);
|
||||
if (day.statusTag != null) {
|
||||
dateLabel += '\n[${day.statusTag}]';
|
||||
}
|
||||
|
||||
for (final row in day.rows) {
|
||||
final taskProject = row.kind == 'helper'
|
||||
? 'Helped ${row.helpedName ?? ''} — ${row.title}'
|
||||
: (row.projectName != null && row.projectName!.isNotEmpty
|
||||
? '${row.title}\n(${row.projectName})'
|
||||
: row.title);
|
||||
|
||||
final accomplishment =
|
||||
row.notes.isEmpty ? '(no notes)' : row.notes.join('\n');
|
||||
|
||||
final timeStr = formatDurationHm(Duration(seconds: row.seconds));
|
||||
|
||||
rows.add(pw.TableRow(children: [
|
||||
td(dateLabel),
|
||||
td(taskProject),
|
||||
td(accomplishment),
|
||||
td(timeStr),
|
||||
]));
|
||||
}
|
||||
}
|
||||
|
||||
return pw.Table(
|
||||
border: border,
|
||||
columnWidths: colWidths,
|
||||
children: rows,
|
||||
);
|
||||
}
|
||||
|
||||
pw.Widget _footnote(AccomplishmentReportData data, PdfBrand brand) {
|
||||
final n = data.excludedPending + data.excludedDisapproved;
|
||||
return pw.Padding(
|
||||
padding: const pw.EdgeInsets.only(top: 4),
|
||||
child: pw.Text(
|
||||
'$n day(s) in this period are not included '
|
||||
'(${data.excludedPending} pending, ${data.excludedDisapproved} disapproved).',
|
||||
style: pw.TextStyle(font: brand.regular, fontSize: 8,
|
||||
color: pdf.PdfColors.grey700),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
pw.Widget _summary(AccomplishmentReportData data, PdfBrand brand) {
|
||||
pw.Widget statRow(String label, String value) => pw.Row(
|
||||
children: [
|
||||
pw.Expanded(
|
||||
child: pw.Text(label,
|
||||
style: pw.TextStyle(font: brand.bold, fontSize: 9)),
|
||||
),
|
||||
pw.Text(value,
|
||||
style: pw.TextStyle(font: brand.regular, fontSize: 9)),
|
||||
],
|
||||
);
|
||||
|
||||
return pw.Container(
|
||||
decoration: pw.BoxDecoration(
|
||||
border: pw.Border.all(width: 0.8, color: pdf.PdfColors.grey400),
|
||||
color: pdf.PdfColors.grey100,
|
||||
),
|
||||
padding: const pw.EdgeInsets.all(8),
|
||||
child: pw.Column(
|
||||
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
||||
children: [
|
||||
pw.Text(
|
||||
'SUMMARY',
|
||||
style: pw.TextStyle(font: brand.bold, fontSize: 9, letterSpacing: 1),
|
||||
),
|
||||
pw.SizedBox(height: 6),
|
||||
statRow('Days reported:', '${data.daysReported}'),
|
||||
statRow(
|
||||
'Total time:', formatDurationHm(Duration(seconds: data.totalSeconds))),
|
||||
statRow('Tasks completed:', '${data.tasksCompleted}'),
|
||||
statRow(
|
||||
'Help given:', formatDurationHm(Duration(seconds: data.helpSeconds))),
|
||||
if (data.secondsByCategory.isNotEmpty) ...[
|
||||
pw.SizedBox(height: 4),
|
||||
pw.Text(
|
||||
'Time by category:',
|
||||
style: pw.TextStyle(font: brand.bold, fontSize: 9),
|
||||
),
|
||||
for (final entry in data.secondsByCategory.entries)
|
||||
pw.Padding(
|
||||
padding: const pw.EdgeInsets.only(left: 8, top: 2),
|
||||
child: statRow(
|
||||
'${entry.key}:',
|
||||
formatDurationHm(Duration(seconds: entry.value)),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
pw.Widget _signatures(AccomplishmentReportData data, PdfBrand brand) {
|
||||
pw.Widget sig(String label, String value) => pw.RichText(
|
||||
text: pw.TextSpan(
|
||||
children: [
|
||||
pw.TextSpan(
|
||||
text: '$label ',
|
||||
style: pw.TextStyle(font: brand.bold, fontSize: 9),
|
||||
),
|
||||
pw.TextSpan(
|
||||
text: value,
|
||||
style: pw.TextStyle(font: brand.regular, fontSize: 9),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
return pw.Column(
|
||||
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
||||
children: [
|
||||
sig(
|
||||
'Prepared by:',
|
||||
'${data.programmerName} (${data.positionLabel})',
|
||||
),
|
||||
pw.SizedBox(height: 8),
|
||||
sig(
|
||||
'Approved by:',
|
||||
data.approverName ?? '____________________',
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../models/profile.model.dart';
|
||||
import '../../../models/programmer_task_activity_log.model.dart';
|
||||
import '../../../utils/app_time.dart';
|
||||
import '../../../widgets/app_section_header.dart';
|
||||
|
||||
/// The task's activity timeline (created/started/paused/adjusted/…), newest first.
|
||||
class ActivityTimeline extends StatelessWidget {
|
||||
const ActivityTimeline({
|
||||
super.key,
|
||||
required this.logs,
|
||||
required this.profileById,
|
||||
});
|
||||
|
||||
final List<ProgrammerTaskActivityLog> logs;
|
||||
final Map<String, Profile> profileById;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tt = Theme.of(context).textTheme;
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const AppSectionHeader('Activity'),
|
||||
if (logs.isEmpty)
|
||||
Text('No activity yet.',
|
||||
style: tt.bodySmall?.copyWith(color: cs.onSurfaceVariant))
|
||||
else
|
||||
for (final log in logs)
|
||||
_ActivityRow(log: log, profileById: profileById),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ActivityRow extends StatelessWidget {
|
||||
const _ActivityRow({required this.log, required this.profileById});
|
||||
|
||||
final ProgrammerTaskActivityLog log;
|
||||
final Map<String, Profile> profileById;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tt = Theme.of(context).textTheme;
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final actor = log.actorId == null ? null : profileById[log.actorId];
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(_iconFor(log.actionType), size: 16, color: cs.onSurfaceVariant),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'${actor?.fullName ?? 'Someone'} · ${_labelFor(log.actionType)}',
|
||||
style: tt.bodyMedium,
|
||||
),
|
||||
Text(
|
||||
AppTime.relative(log.createdAt),
|
||||
style: tt.bodySmall?.copyWith(color: cs.onSurfaceVariant),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _labelFor(String action) => switch (action) {
|
||||
'created' => 'created the task',
|
||||
'started' => 'started work',
|
||||
'paused' => 'paused',
|
||||
'resumed' => 'resumed',
|
||||
'completed' => 'completed the task',
|
||||
'cancelled' => 'cancelled the task',
|
||||
'assigned' => 'assigned the task',
|
||||
'reassigned' => 'reassigned the task',
|
||||
'adjusted' || 'adjustment' => 'adjusted time',
|
||||
'updated' => 'updated details',
|
||||
_ => action,
|
||||
};
|
||||
|
||||
IconData _iconFor(String action) => switch (action) {
|
||||
'created' => Icons.add_task,
|
||||
'started' => Icons.play_arrow_rounded,
|
||||
'paused' => Icons.pause_rounded,
|
||||
'resumed' => Icons.play_arrow_rounded,
|
||||
'completed' => Icons.check_circle_outline,
|
||||
'cancelled' => Icons.cancel_outlined,
|
||||
'assigned' || 'reassigned' => Icons.person_add_alt_1_outlined,
|
||||
'adjustment' => Icons.timelapse_outlined,
|
||||
'updated' => Icons.edit_outlined,
|
||||
_ => Icons.circle_outlined,
|
||||
};
|
||||
@@ -0,0 +1,280 @@
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../models/profile.model.dart';
|
||||
import '../../../models/programmer_task.model.dart';
|
||||
import '../../../models/programmer_task_comment.model.dart';
|
||||
import '../../../providers/profile_provider.dart';
|
||||
import '../../../providers/programmer_task_comments_provider.dart';
|
||||
import '../../../utils/app_time.dart';
|
||||
import '../../../widgets/app_section_header.dart';
|
||||
import '../../../widgets/m3_card.dart';
|
||||
import '../../../widgets/quill_field.dart';
|
||||
import '../programmer_task_prompts.dart';
|
||||
|
||||
const _imageExts = {'png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp'};
|
||||
|
||||
bool _isImage(String name) =>
|
||||
_imageExts.contains(name.split('.').last.toLowerCase());
|
||||
|
||||
/// Comment thread + composer. Any user who can see the task may comment and
|
||||
/// attach files. The assignee is prompted to pause after commenting.
|
||||
class CommentSection extends ConsumerStatefulWidget {
|
||||
const CommentSection({super.key, required this.task});
|
||||
|
||||
final ProgrammerTask task;
|
||||
|
||||
@override
|
||||
ConsumerState<CommentSection> createState() => _CommentSectionState();
|
||||
}
|
||||
|
||||
class _CommentSectionState extends ConsumerState<CommentSection> {
|
||||
final _bodyQuill = quillControllerFromDelta(null);
|
||||
final List<CommentUpload> _pending = [];
|
||||
bool _sending = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_bodyQuill.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _pickFiles() async {
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final result = await FilePicker.platform.pickFiles(
|
||||
type: FileType.any,
|
||||
withData: true,
|
||||
allowMultiple: true,
|
||||
);
|
||||
if (result == null) return;
|
||||
for (final f in result.files) {
|
||||
if (f.bytes == null) continue;
|
||||
if (f.bytes!.length > 25 * 1024 * 1024) {
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text('${f.name} exceeds 25MB — skipped')),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
_pending.add((name: f.name, bytes: f.bytes!));
|
||||
}
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
|
||||
Future<void> _send() async {
|
||||
final hasText = quillPlainText(_bodyQuill).isNotEmpty;
|
||||
if ((!hasText && _pending.isEmpty) || _sending) return;
|
||||
setState(() => _sending = true);
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
try {
|
||||
await ref.read(programmerTaskCommentsControllerProvider).addComment(
|
||||
taskId: widget.task.id,
|
||||
body: hasText ? quillToDeltaJson(_bodyQuill) : '',
|
||||
files: List.of(_pending),
|
||||
);
|
||||
_bodyQuill.clear();
|
||||
_pending.clear();
|
||||
if (!mounted) return;
|
||||
await maybePromptPauseTimer(context, ref, widget.task);
|
||||
} catch (e) {
|
||||
messenger.showSnackBar(SnackBar(content: Text('Could not send: $e')));
|
||||
} finally {
|
||||
if (mounted) setState(() => _sending = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tt = Theme.of(context).textTheme;
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final commentsAsync =
|
||||
ref.watch(programmerTaskCommentsProvider(widget.task.id));
|
||||
final profiles = ref.watch(profilesProvider).valueOrNull ?? const <Profile>[];
|
||||
final profileById = {for (final p in profiles) p.id: p};
|
||||
final comments = commentsAsync.valueOrNull ?? const [];
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const AppSectionHeader('Comments'),
|
||||
// Composer
|
||||
QuillFieldEditor(
|
||||
controller: _bodyQuill,
|
||||
placeholder: 'Add a comment…',
|
||||
height: 110,
|
||||
),
|
||||
if (_pending.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
for (var i = 0; i < _pending.length; i++)
|
||||
InputChip(
|
||||
label: Text(_pending[i].name,
|
||||
overflow: TextOverflow.ellipsis),
|
||||
onDeleted: () => setState(() => _pending.removeAt(i)),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
IconButton(
|
||||
tooltip: 'Attach files',
|
||||
onPressed: _sending ? null : _pickFiles,
|
||||
icon: const Icon(Icons.attach_file),
|
||||
),
|
||||
const Spacer(),
|
||||
FilledButton.tonalIcon(
|
||||
onPressed: _sending ? null : _send,
|
||||
icon: _sending
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.send, size: 18),
|
||||
label: const Text('Send'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (comments.isEmpty)
|
||||
Text('No comments yet.',
|
||||
style: tt.bodySmall?.copyWith(color: cs.onSurfaceVariant))
|
||||
else
|
||||
for (final c in comments)
|
||||
_CommentTile(comment: c, author: profileById[c.authorId]),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CommentTile extends ConsumerWidget {
|
||||
const _CommentTile({required this.comment, required this.author});
|
||||
|
||||
final ProgrammerTaskComment comment;
|
||||
final Profile? author;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final tt = Theme.of(context).textTheme;
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final controller = ref.read(programmerTaskCommentsControllerProvider);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
child: M3Card.outlined(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(author?.fullName ?? 'Someone',
|
||||
style: tt.bodyMedium
|
||||
?.copyWith(fontWeight: FontWeight.w600)),
|
||||
),
|
||||
Text(AppTime.relative(comment.createdAt),
|
||||
style:
|
||||
tt.bodySmall?.copyWith(color: cs.onSurfaceVariant)),
|
||||
],
|
||||
),
|
||||
if (comment.body.trim().isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
QuillReadOnly(comment.body, style: tt.bodyMedium),
|
||||
],
|
||||
if (comment.attachments.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
for (final a in comment.attachments)
|
||||
_AttachmentChip(
|
||||
attachment: a,
|
||||
url: controller.attachmentUrl(a.path),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AttachmentChip extends StatelessWidget {
|
||||
const _AttachmentChip({required this.attachment, required this.url});
|
||||
|
||||
final ProgrammerCommentAttachment attachment;
|
||||
final String url;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_isImage(attachment.name)) {
|
||||
return InkWell(
|
||||
onTap: () => _openImage(context),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Image.network(
|
||||
url,
|
||||
width: 84,
|
||||
height: 84,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, _, _) => _fileChip(context),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return _fileChip(context);
|
||||
}
|
||||
|
||||
Widget _fileChip(BuildContext context) => ActionChip(
|
||||
avatar: const Icon(Icons.insert_drive_file_outlined, size: 16),
|
||||
label: Text(attachment.name, overflow: TextOverflow.ellipsis),
|
||||
onPressed: () => _showUrl(context),
|
||||
);
|
||||
|
||||
void _openImage(BuildContext context) {
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
builder: (context) => Dialog(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
InteractiveViewer(child: Image.network(url)),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Text(attachment.name,
|
||||
style: Theme.of(context).textTheme.bodySmall),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showUrl(BuildContext context) {
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
semanticLabel: 'Attachment link',
|
||||
title: Text(attachment.name),
|
||||
content: SelectableText(url),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Close'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../models/profile.model.dart';
|
||||
import '../../../models/programmer_task.model.dart';
|
||||
import '../../../providers/programmer_day_data_provider.dart';
|
||||
import '../../../providers/programmer_projects_provider.dart';
|
||||
import '../../../theme/app_status_colors.dart';
|
||||
import '../../../utils/app_time.dart';
|
||||
import '../../../widgets/m3_card.dart';
|
||||
import '../../../widgets/status_pill.dart';
|
||||
import '../../../widgets/tech_chip.dart';
|
||||
import '../programmer_task_actions.dart';
|
||||
import '../programmer_task_status_kind.dart';
|
||||
|
||||
/// Shared task card that reflects the current run state (running / paused /
|
||||
/// not started) sourced from [programmerRunStatesProvider].
|
||||
///
|
||||
/// Used by the task list screen (and any future surface) so the paused-task
|
||||
/// pill and action buttons are consistent everywhere.
|
||||
class ProgrammerTaskCard extends ConsumerWidget {
|
||||
const ProgrammerTaskCard({
|
||||
super.key,
|
||||
required this.task,
|
||||
this.assignee,
|
||||
required this.isMine,
|
||||
this.readOnly = false,
|
||||
});
|
||||
|
||||
final ProgrammerTask task;
|
||||
final Profile? assignee;
|
||||
final bool isMine;
|
||||
final bool readOnly;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final tt = Theme.of(context).textTheme;
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
|
||||
final runStates = ref.watch(programmerRunStatesProvider);
|
||||
final runInfo = runStates[task.id];
|
||||
final isPaused = runInfo?.state == ProgrammerRunState.paused;
|
||||
final isRunning = runInfo?.state == ProgrammerRunState.running;
|
||||
|
||||
final canStart =
|
||||
isMine && !readOnly && task.status == ProgrammerTaskStatus.queued;
|
||||
final canResume = isMine && !readOnly && isPaused;
|
||||
final canComplete = isMine && !readOnly && (isRunning || isPaused);
|
||||
|
||||
final project = task.projectId == null
|
||||
? null
|
||||
: ref.watch(programmerProjectByIdProvider(task.projectId!));
|
||||
|
||||
final pillKind =
|
||||
isPaused ? StatusKind.warning : programmerTaskStatusKind(task.status);
|
||||
final pillLabel =
|
||||
isPaused ? 'Paused' : ProgrammerTaskStatus.label(task.status);
|
||||
|
||||
return M3Card.outlined(
|
||||
onTap: () => context.go('/programmer-tasks/${task.id}'),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(14),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
task.title,
|
||||
style:
|
||||
tt.titleMedium?.copyWith(fontWeight: FontWeight.w600),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
StatusPill.kind(pillKind, label: pillLabel),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
TechChip(task.category,
|
||||
icon: Icons.category_outlined, dense: true),
|
||||
if (project != null)
|
||||
TechChip(project.name,
|
||||
icon: Icons.folder_outlined, dense: true),
|
||||
if (assignee != null && !isMine)
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.person_outline,
|
||||
size: 14, color: cs.onSurfaceVariant),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
assignee!.fullName,
|
||||
style: tt.bodySmall
|
||||
?.copyWith(color: cs.onSurfaceVariant),
|
||||
),
|
||||
],
|
||||
),
|
||||
Text(
|
||||
AppTime.relative(task.createdAt),
|
||||
style: tt.bodySmall?.copyWith(color: cs.onSurfaceVariant),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (canStart || canResume) ...[
|
||||
const SizedBox(height: 12),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: FilledButton.tonalIcon(
|
||||
onPressed: () =>
|
||||
startProgrammerTaskWithPausePrompt(context, ref, task),
|
||||
icon: const Icon(Icons.play_arrow_rounded, size: 18),
|
||||
label: Text(canResume ? 'Resume' : 'Start'),
|
||||
),
|
||||
),
|
||||
],
|
||||
if (canComplete) ...[
|
||||
const SizedBox(height: 8),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: () =>
|
||||
completeProgrammerTaskWithConfirm(context, ref, task),
|
||||
icon: const Icon(Icons.check_rounded, size: 18),
|
||||
label: const Text('Complete'),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../models/profile.model.dart';
|
||||
import '../../../utils/programmer_task_ledger.dart';
|
||||
import '../../../widgets/app_section_header.dart';
|
||||
import '../../../widgets/m3_card.dart';
|
||||
|
||||
/// Per-contributor time breakdown for a task: the assignee's running duration
|
||||
/// plus each helper's logged minutes, and the total.
|
||||
class TimeLedgerCard extends StatelessWidget {
|
||||
const TimeLedgerCard({
|
||||
super.key,
|
||||
required this.ledger,
|
||||
required this.profileById,
|
||||
});
|
||||
|
||||
final TaskTimeLedger ledger;
|
||||
final Map<String, Profile> profileById;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (ledger.total == Duration.zero) return const SizedBox.shrink();
|
||||
final tt = Theme.of(context).textTheme;
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
|
||||
return M3Card.outlined(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(14),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
AppSectionHeader(
|
||||
'Time ledger',
|
||||
trailing: Text(
|
||||
formatDurationHm(ledger.total),
|
||||
style: tt.titleSmall?.copyWith(fontWeight: FontWeight.w700),
|
||||
),
|
||||
),
|
||||
for (final entry in ledger.all)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
entry.isAssignee
|
||||
? Icons.play_circle_outline
|
||||
: Icons.volunteer_activism_outlined,
|
||||
size: 16,
|
||||
color: cs.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
profileById[entry.userId]?.fullName ?? 'Unknown',
|
||||
style: tt.bodyMedium,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
if (entry.isAssignee)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: Text('assignee',
|
||||
style: tt.bodySmall
|
||||
?.copyWith(color: cs.onSurfaceVariant)),
|
||||
),
|
||||
Text(
|
||||
formatDurationHm(entry.duration),
|
||||
style: tt.bodyMedium?.copyWith(fontWeight: FontWeight.w600),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../models/profile.model.dart';
|
||||
import '../../../models/programmer_task.model.dart';
|
||||
import '../../../providers/profile_provider.dart';
|
||||
import '../../../providers/programmer_task_work_logs_provider.dart';
|
||||
import '../../../utils/app_time.dart';
|
||||
import '../../../utils/programmer_task_ledger.dart';
|
||||
import '../../../widgets/app_section_header.dart';
|
||||
import '../../../widgets/quill_field.dart';
|
||||
import '../programmer_task_prompts.dart';
|
||||
|
||||
/// Work-log list + composer. The assignee logs progress notes (no time field);
|
||||
/// a non-assignee helper gets Hours + Minutes fields and is prompted to deduct
|
||||
/// that time from their own running task. The assignee is prompted to pause
|
||||
/// after logging.
|
||||
///
|
||||
/// When [workDate] is supplied the entry is a correction for a past day:
|
||||
/// the header names that date and the pause/deduct prompts are skipped.
|
||||
class WorkLogSection extends ConsumerStatefulWidget {
|
||||
const WorkLogSection({super.key, required this.task, required this.isAssignee, this.workDate});
|
||||
|
||||
final ProgrammerTask task;
|
||||
final bool isAssignee;
|
||||
final DateTime? workDate;
|
||||
|
||||
@override
|
||||
ConsumerState<WorkLogSection> createState() => _WorkLogSectionState();
|
||||
}
|
||||
|
||||
class _WorkLogSectionState extends ConsumerState<WorkLogSection> {
|
||||
final _descQuill = quillControllerFromDelta(null);
|
||||
final _hoursCtrl = TextEditingController();
|
||||
final _minutesCtrl = TextEditingController();
|
||||
bool _saving = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_descQuill.dispose();
|
||||
_hoursCtrl.dispose();
|
||||
_minutesCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
if (_saving) return;
|
||||
if (quillPlainText(_descQuill).isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Describe what you worked on')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
int? minutes;
|
||||
if (!widget.isAssignee) {
|
||||
final h = int.tryParse(_hoursCtrl.text.trim()) ?? 0;
|
||||
final m = int.tryParse(_minutesCtrl.text.trim()) ?? 0;
|
||||
minutes = h * 60 + m;
|
||||
if (minutes <= 0) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Enter the time you spent helping')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setState(() => _saving = true);
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
try {
|
||||
await ref.read(programmerTaskWorkLogsControllerProvider).addWorkLog(
|
||||
taskId: widget.task.id,
|
||||
description: quillToDeltaJson(_descQuill),
|
||||
minutes: minutes,
|
||||
workDate: widget.workDate,
|
||||
);
|
||||
_descQuill.clear();
|
||||
_hoursCtrl.clear();
|
||||
_minutesCtrl.clear();
|
||||
if (!mounted) return;
|
||||
if (widget.workDate == null) {
|
||||
if (widget.isAssignee) {
|
||||
await maybePromptPauseTimer(context, ref, widget.task);
|
||||
} else if (minutes != null) {
|
||||
await maybePromptDeductHelperTime(context, ref, minutes, widget.task.id);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
messenger.showSnackBar(SnackBar(content: Text('Could not save: $e')));
|
||||
} finally {
|
||||
if (mounted) setState(() => _saving = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tt = Theme.of(context).textTheme;
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final logsAsync = ref.watch(programmerTaskWorkLogsProvider(widget.task.id));
|
||||
final profiles = ref.watch(profilesProvider).valueOrNull ?? const <Profile>[];
|
||||
final profileById = {for (final p in profiles) p.id: p};
|
||||
final logs = logsAsync.valueOrNull ?? const [];
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
AppSectionHeader(
|
||||
widget.workDate != null
|
||||
? 'Correction for ${AppTime.formatDate(widget.workDate!)}'
|
||||
: 'Work Log',
|
||||
),
|
||||
QuillFieldEditor(
|
||||
controller: _descQuill,
|
||||
placeholder: widget.isAssignee
|
||||
? 'What did you work on?'
|
||||
: 'What did you help with?',
|
||||
height: 120,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
if (!widget.isAssignee) ...[
|
||||
SizedBox(
|
||||
width: 78,
|
||||
child: TextField(
|
||||
controller: _hoursCtrl,
|
||||
keyboardType: TextInputType.number,
|
||||
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Hours', isDense: true),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
SizedBox(
|
||||
width: 88,
|
||||
child: TextField(
|
||||
controller: _minutesCtrl,
|
||||
keyboardType: TextInputType.number,
|
||||
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Minutes', isDense: true),
|
||||
),
|
||||
),
|
||||
],
|
||||
const Spacer(),
|
||||
FilledButton.tonalIcon(
|
||||
onPressed: _saving ? null : _submit,
|
||||
icon: _saving
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.add, size: 18),
|
||||
label: const Text('Log'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (logs.isEmpty)
|
||||
Text('No work logged yet.',
|
||||
style: tt.bodySmall?.copyWith(color: cs.onSurfaceVariant))
|
||||
else
|
||||
for (final log in logs)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
profileById[log.authorId]?.fullName ?? 'Someone',
|
||||
style: tt.bodyMedium
|
||||
?.copyWith(fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
if (log.minutes != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: Text(
|
||||
formatDurationHm(Duration(minutes: log.minutes!)),
|
||||
style: tt.bodySmall?.copyWith(
|
||||
color: cs.primary,
|
||||
fontWeight: FontWeight.w700),
|
||||
),
|
||||
),
|
||||
Text(AppTime.relative(log.createdAt),
|
||||
style: tt.bodySmall
|
||||
?.copyWith(color: cs.onSurfaceVariant)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
QuillReadOnly(log.description, style: tt.bodyMedium),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,14 +5,47 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../providers/reports_provider.dart';
|
||||
import '../../utils/app_time.dart';
|
||||
|
||||
/// Returns a [ReportDateRange] spanning the calendar month that contains [now].
|
||||
/// start = DateTime(y, m, 1), end = DateTime(y, m+1, 1), label = 'This Month'.
|
||||
ReportDateRange thisMonthRange(DateTime now) {
|
||||
return ReportDateRange(
|
||||
start: DateTime(now.year, now.month, 1),
|
||||
end: DateTime(now.year, now.month + 1, 1),
|
||||
label: 'This Month',
|
||||
);
|
||||
}
|
||||
|
||||
/// A Metabase-inspired date filter with quick presets, relative offsets,
|
||||
/// and a custom date-range picker.
|
||||
///
|
||||
/// Two construction modes:
|
||||
/// - Default `ReportDateFilter({key})` — provider-bound, reads/writes
|
||||
/// [reportDateRangeProvider].
|
||||
/// - `ReportDateFilter.controlled({key, value, onChanged})` — controlled;
|
||||
/// displays [value] and calls [onChanged] on apply without touching the
|
||||
/// provider.
|
||||
class ReportDateFilter extends ConsumerWidget {
|
||||
const ReportDateFilter({super.key});
|
||||
/// Provider-bound mode.
|
||||
const ReportDateFilter({super.key})
|
||||
: _value = null,
|
||||
_onChanged = null;
|
||||
|
||||
/// Controlled mode: does NOT read or write [reportDateRangeProvider].
|
||||
const ReportDateFilter.controlled({
|
||||
super.key,
|
||||
required ReportDateRange value,
|
||||
required ValueChanged<ReportDateRange> onChanged,
|
||||
}) : _value = value,
|
||||
_onChanged = onChanged;
|
||||
|
||||
final ReportDateRange? _value;
|
||||
final ValueChanged<ReportDateRange>? _onChanged;
|
||||
|
||||
bool get _isControlled => _value != null;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final range = ref.watch(reportDateRangeProvider);
|
||||
final range = _isControlled ? _value! : ref.watch(reportDateRangeProvider);
|
||||
final theme = Theme.of(context);
|
||||
final colors = theme.colorScheme;
|
||||
final text = theme.textTheme;
|
||||
@@ -51,12 +84,18 @@ class ReportDateFilter extends ConsumerWidget {
|
||||
}
|
||||
|
||||
void _showDateFilterDialog(BuildContext context, WidgetRef ref) {
|
||||
final current =
|
||||
_isControlled ? _value! : ref.read(reportDateRangeProvider);
|
||||
m3ShowDialog(
|
||||
context: context,
|
||||
builder: (ctx) => _DateFilterDialog(
|
||||
current: ref.read(reportDateRangeProvider),
|
||||
current: current,
|
||||
onApply: (newRange) {
|
||||
ref.read(reportDateRangeProvider.notifier).state = newRange;
|
||||
if (_isControlled) {
|
||||
_onChanged!(newRange);
|
||||
} else {
|
||||
ref.read(reportDateRangeProvider.notifier).state = newRange;
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
@@ -106,6 +106,11 @@ class _NotificationBridgeState extends ConsumerState<NotificationBridge>
|
||||
'isr_approved' => 'IT Service Request approved',
|
||||
'isr_status_changed' => 'IT Service Request updated',
|
||||
'isr_assigned' => 'New IT Service Request assigned',
|
||||
'day_sheet_submitted' => 'Day sheet submitted',
|
||||
'day_sheet_disapproved' => 'Day sheet disapproved',
|
||||
'day_sheet_justified' => 'Day sheet justified',
|
||||
'day_sheet_approved' => 'Day sheet approved',
|
||||
'day_sheet_digest' => 'Day sheets awaiting review',
|
||||
_ => 'New notification',
|
||||
};
|
||||
|
||||
@@ -121,7 +126,13 @@ class _NotificationBridgeState extends ConsumerState<NotificationBridge>
|
||||
action: SnackBarAction(
|
||||
label: 'View',
|
||||
onPressed: () => _goToDetail(
|
||||
route: item.announcementId != null ? '/announcements' : null,
|
||||
route: item.announcementId != null
|
||||
? '/announcements'
|
||||
: item.type == 'day_sheet_digest'
|
||||
? '/programmer-tasks?tab=approvals'
|
||||
: item.daySheetId != null
|
||||
? '/programmer-tasks/day-sheets/${item.daySheetId}'
|
||||
: null,
|
||||
taskId: item.taskId,
|
||||
ticketId: item.ticketId,
|
||||
itServiceRequestId: item.itServiceRequestId,
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import 'package:flutter/services.dart' show rootBundle;
|
||||
import 'package:pdf/pdf.dart' as pdf;
|
||||
import 'package:pdf/widgets.dart' as pw;
|
||||
|
||||
/// Shared brand assets and letterhead widget for CRMC PDF documents.
|
||||
class PdfBrand {
|
||||
final pw.Font regular;
|
||||
final pw.Font bold;
|
||||
final pw.ImageProvider logo;
|
||||
|
||||
const PdfBrand({
|
||||
required this.regular,
|
||||
required this.bold,
|
||||
required this.logo,
|
||||
});
|
||||
|
||||
/// Load fonts and logo from the Flutter asset bundle.
|
||||
static Future<PdfBrand> load() async {
|
||||
final logoData = await rootBundle.load('assets/crmc_logo.png');
|
||||
final logo = pw.MemoryImage(logoData.buffer.asUint8List());
|
||||
|
||||
final regular = pw.Font.ttf(
|
||||
await rootBundle.load('assets/fonts/Roboto-Regular.ttf'),
|
||||
);
|
||||
final bold = pw.Font.ttf(
|
||||
await rootBundle.load('assets/fonts/Roboto-Bold.ttf'),
|
||||
);
|
||||
|
||||
return PdfBrand(regular: regular, bold: bold, logo: logo);
|
||||
}
|
||||
|
||||
/// Standard CRMC letterhead: 60×60 logo + four-line organisation text.
|
||||
pw.Widget letterhead() {
|
||||
return pw.Row(
|
||||
mainAxisAlignment: pw.MainAxisAlignment.center,
|
||||
crossAxisAlignment: pw.CrossAxisAlignment.center,
|
||||
children: [
|
||||
pw.Image(logo, width: 60, height: 60),
|
||||
pw.SizedBox(width: 16),
|
||||
pw.Column(
|
||||
crossAxisAlignment: pw.CrossAxisAlignment.center,
|
||||
children: [
|
||||
pw.Text(
|
||||
'Republic of the Philippines',
|
||||
style: pw.TextStyle(font: regular, fontSize: 9),
|
||||
),
|
||||
pw.Text(
|
||||
'Department of Health',
|
||||
style: pw.TextStyle(font: regular, fontSize: 9),
|
||||
),
|
||||
pw.Text(
|
||||
'Cotabato Regional and Medical Center',
|
||||
style: pw.TextStyle(font: regular, fontSize: 9),
|
||||
),
|
||||
pw.SizedBox(height: 3),
|
||||
pw.Text(
|
||||
'Integrated Hospital Operations and Management Program (IHOMP)',
|
||||
style: pw.TextStyle(font: bold, fontSize: 9),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Convenience: create a ThemeData from these fonts.
|
||||
pw.ThemeData theme() => pw.ThemeData.withFont(base: regular, bold: bold);
|
||||
|
||||
/// Footer widget: right-aligned "Page x of y".
|
||||
pw.Widget pageFooter(pw.Context ctx) => pw.Align(
|
||||
alignment: pw.Alignment.centerRight,
|
||||
child: pw.Text(
|
||||
'Page ${ctx.pageNumber} of ${ctx.pagesCount}',
|
||||
style: pw.TextStyle(
|
||||
font: regular,
|
||||
fontSize: 8,
|
||||
color: pdf.PdfColors.grey600,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
import 'package:timezone/timezone.dart' as tz;
|
||||
import 'package:tasq/models/programmer_task.model.dart';
|
||||
import 'package:tasq/models/programmer_task_activity_log.model.dart';
|
||||
import 'app_time.dart';
|
||||
|
||||
/// Get the Manila calendar date of an instant as DateTime.utc(y, m, d)
|
||||
DateTime manilaDay(DateTime instant) {
|
||||
final utc = instant.isUtc ? instant : instant.toUtc();
|
||||
final manila = tz.TZDateTime.from(utc, tz.local);
|
||||
return DateTime.utc(manila.year, manila.month, manila.day);
|
||||
}
|
||||
|
||||
/// Get the Manila midnight of a day key as a TZDateTime
|
||||
DateTime manilaDayStart(DateTime dayKey) {
|
||||
return AppTime.fromComponents(
|
||||
year: dayKey.year,
|
||||
month: dayKey.month,
|
||||
day: dayKey.day,
|
||||
hour: 0,
|
||||
minute: 0,
|
||||
);
|
||||
}
|
||||
|
||||
/// Parse a day key string (e.g., '2026-09-27') to DateTime.utc
|
||||
DateTime parseDayKey(String yyyyMmDd) {
|
||||
final parts = yyyyMmDd.split('-');
|
||||
return DateTime.utc(int.parse(parts[0]), int.parse(parts[1]), int.parse(parts[2]));
|
||||
}
|
||||
|
||||
/// Format a day key to string (e.g., DateTime.utc(2026, 9, 27) -> '2026-09-27')
|
||||
String formatDayKey(DateTime dayKey) {
|
||||
final y = dayKey.year.toString();
|
||||
final m = dayKey.month.toString().padLeft(2, '0');
|
||||
final d = dayKey.day.toString().padLeft(2, '0');
|
||||
return '$y-$m-$d';
|
||||
}
|
||||
|
||||
/// Represents a worked interval for an assignee
|
||||
typedef WorkedInterval = ({String assigneeId, DateTime start, DateTime end});
|
||||
|
||||
/// Calculate worked intervals from task and activity logs
|
||||
List<WorkedInterval> workedIntervals({
|
||||
required ProgrammerTask task,
|
||||
required List<ProgrammerTaskActivityLog> logs,
|
||||
required DateTime now,
|
||||
}) {
|
||||
final sorted = List<ProgrammerTaskActivityLog>.from(logs)
|
||||
..sort((a, b) => a.createdAt.compareTo(b.createdAt));
|
||||
|
||||
final intervals = <WorkedInterval>[];
|
||||
DateTime? segmentStart;
|
||||
String currentAssignee = _initialAssignee(task, sorted);
|
||||
|
||||
// Check if there's a synthetic started event needed
|
||||
if (!sorted.any((l) => l.actionType == 'started') && task.startedAt != null) {
|
||||
// Inject synthetic started
|
||||
sorted.insert(
|
||||
0,
|
||||
ProgrammerTaskActivityLog(
|
||||
id: 'synthetic-start',
|
||||
taskId: task.id,
|
||||
actionType: 'started',
|
||||
createdAt: task.startedAt!,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
for (final log in sorted) {
|
||||
switch (log.actionType) {
|
||||
case 'started':
|
||||
case 'resumed':
|
||||
if (segmentStart == null) {
|
||||
segmentStart = log.createdAt;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'paused':
|
||||
case 'completed':
|
||||
case 'cancelled':
|
||||
if (segmentStart != null) {
|
||||
intervals.add((
|
||||
assigneeId: currentAssignee,
|
||||
start: segmentStart,
|
||||
end: log.createdAt,
|
||||
));
|
||||
segmentStart = null;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'reassigned':
|
||||
final wasRunning = segmentStart != null;
|
||||
if (segmentStart != null) {
|
||||
// Close segment for old assignee
|
||||
intervals.add((
|
||||
assigneeId: currentAssignee,
|
||||
start: segmentStart,
|
||||
end: log.createdAt,
|
||||
));
|
||||
segmentStart = null;
|
||||
}
|
||||
// Update assignee and reopen segment only if it was running
|
||||
if (log.meta?['assignee_id'] != null) {
|
||||
currentAssignee = log.meta!['assignee_id'] as String;
|
||||
if (wasRunning) {
|
||||
segmentStart = log.createdAt;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Close any remaining open segment
|
||||
if (segmentStart != null) {
|
||||
final endTime = task.status == ProgrammerTaskStatus.inProgress
|
||||
? now
|
||||
: (task.completedAt ?? task.cancelledAt ?? now);
|
||||
intervals.add((
|
||||
assigneeId: currentAssignee,
|
||||
start: segmentStart,
|
||||
end: endTime,
|
||||
));
|
||||
}
|
||||
|
||||
return intervals;
|
||||
}
|
||||
|
||||
/// Calculate daily seconds by assignee
|
||||
Map<String, Map<DateTime, int>> dailySecondsByAssignee({
|
||||
required ProgrammerTask task,
|
||||
required List<ProgrammerTaskActivityLog> logs,
|
||||
required DateTime now,
|
||||
}) {
|
||||
final intervals = workedIntervals(task: task, logs: logs, now: now);
|
||||
final result = <String, Map<DateTime, int>>{};
|
||||
|
||||
// Process intervals and split at Manila midnights
|
||||
for (final interval in intervals) {
|
||||
var current = interval.start;
|
||||
final end = interval.end;
|
||||
|
||||
while (current.isBefore(end)) {
|
||||
final currentDay = manilaDay(current);
|
||||
final dayStart = manilaDayStart(currentDay);
|
||||
final nextDayStart = dayStart.add(Duration(days: 1));
|
||||
final segmentEnd = end.isBefore(nextDayStart) ? end : nextDayStart;
|
||||
|
||||
final seconds = segmentEnd.difference(current).inSeconds;
|
||||
|
||||
if (!result.containsKey(interval.assigneeId)) {
|
||||
result[interval.assigneeId] = {};
|
||||
}
|
||||
|
||||
result[interval.assigneeId]![currentDay] =
|
||||
(result[interval.assigneeId]![currentDay] ?? 0) + seconds;
|
||||
|
||||
current = segmentEnd;
|
||||
}
|
||||
}
|
||||
|
||||
// Apply adjustments
|
||||
for (final log in logs) {
|
||||
if (log.actionType == 'adjustment' && log.meta?['seconds'] != null) {
|
||||
final adjustSeconds = (log.meta!['seconds'] as num).toInt();
|
||||
final adjustDay = manilaDay(log.createdAt);
|
||||
|
||||
// Find the assignee at this time
|
||||
final assigneeAtTime = _assigneeAtTime(task, logs, log.createdAt);
|
||||
if (assigneeAtTime != null) {
|
||||
if (!result.containsKey(assigneeAtTime)) {
|
||||
result[assigneeAtTime] = {};
|
||||
}
|
||||
final current =
|
||||
result[assigneeAtTime]![adjustDay] ?? 0;
|
||||
result[assigneeAtTime]![adjustDay] =
|
||||
(current - adjustSeconds).clamp(0, 999999999);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Find the initial assignee based on task and logs
|
||||
String _initialAssignee(
|
||||
ProgrammerTask task,
|
||||
List<ProgrammerTaskActivityLog> logs,
|
||||
) {
|
||||
// First: check for 'assigned' event
|
||||
for (final log in logs) {
|
||||
if (log.actionType == 'assigned' && log.meta?['assignee_id'] != null) {
|
||||
return log.meta!['assignee_id'] as String;
|
||||
}
|
||||
}
|
||||
|
||||
// Second: if any 'reassigned' event exists, use task.creatorId
|
||||
if (logs.any((l) => l.actionType == 'reassigned') && task.creatorId != null) {
|
||||
return task.creatorId!;
|
||||
}
|
||||
|
||||
// Third: use task.assigneeId
|
||||
return task.assigneeId ?? 'unknown';
|
||||
}
|
||||
|
||||
/// Find the assignee at a specific time
|
||||
String? _assigneeAtTime(
|
||||
ProgrammerTask task,
|
||||
List<ProgrammerTaskActivityLog> logs,
|
||||
DateTime time,
|
||||
) {
|
||||
final sorted = List<ProgrammerTaskActivityLog>.from(logs)
|
||||
..sort((a, b) => a.createdAt.compareTo(b.createdAt));
|
||||
|
||||
var currentAssignee = _initialAssignee(task, sorted);
|
||||
|
||||
for (final log in sorted) {
|
||||
if (log.createdAt.isAfter(time)) break;
|
||||
|
||||
if (log.actionType == 'reassigned' &&
|
||||
log.meta?['assignee_id'] != null) {
|
||||
currentAssignee = log.meta!['assignee_id'] as String;
|
||||
}
|
||||
}
|
||||
|
||||
return currentAssignee;
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import 'package:tasq/models/programmer_task.model.dart';
|
||||
import 'package:tasq/models/programmer_task_activity_log.model.dart';
|
||||
import 'package:tasq/models/programmer_task_work_log.model.dart';
|
||||
import 'package:tasq/widgets/quill_field.dart';
|
||||
import 'programmer_daily_time.dart';
|
||||
|
||||
/// A single row in a programmer's day sheet (assignee or helper work).
|
||||
class DaySheetRow {
|
||||
final String taskId;
|
||||
final String title;
|
||||
final String category;
|
||||
final String kind; // 'assignee' or 'helper'
|
||||
final String? projectName;
|
||||
final String? helpedName;
|
||||
final int seconds;
|
||||
final List<String> notes;
|
||||
|
||||
DaySheetRow({
|
||||
required this.taskId,
|
||||
required this.title,
|
||||
required this.category,
|
||||
required this.kind,
|
||||
this.projectName,
|
||||
this.helpedName,
|
||||
required this.seconds,
|
||||
required this.notes,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'task_id': taskId,
|
||||
'title': title,
|
||||
'category': category,
|
||||
'project_name': projectName,
|
||||
'kind': kind,
|
||||
'helped_name': helpedName,
|
||||
'seconds': seconds,
|
||||
'notes': notes,
|
||||
};
|
||||
|
||||
factory DaySheetRow.fromJson(Map<String, dynamic> json) => DaySheetRow(
|
||||
taskId: json['task_id'] as String,
|
||||
title: json['title'] as String,
|
||||
category: json['category'] as String,
|
||||
kind: json['kind'] as String,
|
||||
projectName: json['project_name'] as String?,
|
||||
helpedName: json['helped_name'] as String?,
|
||||
seconds: json['seconds'] as int,
|
||||
notes: List<String>.from(json['notes'] as List? ?? []),
|
||||
);
|
||||
}
|
||||
|
||||
/// Build day rows for a programmer on a specific day.
|
||||
/// Returns rows for assignee tasks (by seconds desc) then helper tasks (by seconds desc).
|
||||
List<DaySheetRow> buildDayRows({
|
||||
required String userId,
|
||||
required DateTime day,
|
||||
required List<ProgrammerTask> tasks,
|
||||
required Map<String, List<ProgrammerTaskActivityLog>> logsByTask,
|
||||
required List<ProgrammerTaskWorkLog> workLogs,
|
||||
required Map<String, String> projectNames,
|
||||
required Map<String, String> profileNames,
|
||||
required DateTime now,
|
||||
}) {
|
||||
final taskMap = {for (final t in tasks) t.id: t};
|
||||
final workLogsByTaskAndAuthor = <String, List<ProgrammerTaskWorkLog>>{};
|
||||
|
||||
// Group work logs by task and author for quick lookup
|
||||
for (final log in workLogs) {
|
||||
final key = '${log.taskId}:${log.authorId}';
|
||||
workLogsByTaskAndAuthor.putIfAbsent(key, () => []).add(log);
|
||||
}
|
||||
|
||||
final rows = <DaySheetRow>[];
|
||||
|
||||
// Process each task
|
||||
for (final task in tasks) {
|
||||
final assigneeId = task.assigneeId;
|
||||
|
||||
// Compute daily seconds for this task
|
||||
final logs = logsByTask[task.id] ?? [];
|
||||
final dailyByAssignee = dailySecondsByAssignee(
|
||||
task: task,
|
||||
logs: logs,
|
||||
now: now,
|
||||
);
|
||||
final userDailySeconds = dailyByAssignee[userId] ?? {};
|
||||
final assigneeSeconds = userDailySeconds[day] ?? 0;
|
||||
|
||||
// Check for assignee row: has seconds that day OR (is assignee AND authored notes-only log that day)
|
||||
final assigneeLogsKey = '${task.id}:$userId';
|
||||
final assigneeLogsThisDay = (userId == assigneeId)
|
||||
? (workLogsByTaskAndAuthor[assigneeLogsKey]
|
||||
?.where((l) => l.workDate == day && l.minutes == null)
|
||||
.toList() ??
|
||||
[])
|
||||
: [];
|
||||
|
||||
if (assigneeSeconds > 0 || assigneeLogsThisDay.isNotEmpty) {
|
||||
final notes = assigneeLogsThisDay
|
||||
.map((l) => deltaJsonToPlainText(l.description))
|
||||
.where((n) => n.isNotEmpty)
|
||||
.toList();
|
||||
|
||||
rows.add(DaySheetRow(
|
||||
taskId: task.id,
|
||||
title: task.title,
|
||||
category: task.category,
|
||||
kind: 'assignee',
|
||||
projectName: task.projectId != null ? projectNames[task.projectId] : null,
|
||||
helpedName: null,
|
||||
seconds: assigneeSeconds,
|
||||
notes: notes,
|
||||
));
|
||||
}
|
||||
|
||||
// Check for helper row: userId != assigneeId AND has minutes logs that day
|
||||
if (userId != assigneeId) {
|
||||
final helperLogsKey = '${task.id}:$userId';
|
||||
final helperLogs = workLogsByTaskAndAuthor[helperLogsKey]
|
||||
?.where((l) => l.workDate == day && l.minutes != null && l.minutes! > 0)
|
||||
.toList() ??
|
||||
[];
|
||||
|
||||
if (helperLogs.isNotEmpty) {
|
||||
final totalMinutes = helperLogs.fold<int>(0, (sum, l) => sum + (l.minutes ?? 0));
|
||||
final seconds = totalMinutes * 60;
|
||||
final notes = helperLogs
|
||||
.map((l) => deltaJsonToPlainText(l.description))
|
||||
.where((n) => n.isNotEmpty)
|
||||
.toList();
|
||||
|
||||
rows.add(DaySheetRow(
|
||||
taskId: task.id,
|
||||
title: task.title,
|
||||
category: task.category,
|
||||
kind: 'helper',
|
||||
projectName: task.projectId != null ? projectNames[task.projectId] : null,
|
||||
helpedName: assigneeId != null ? profileNames[assigneeId] : null,
|
||||
seconds: seconds,
|
||||
notes: notes,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort: assignee rows by seconds desc, then helper rows by seconds desc
|
||||
rows.sort((a, b) {
|
||||
if (a.kind != b.kind) {
|
||||
return a.kind == 'assignee' ? -1 : 1;
|
||||
}
|
||||
return b.seconds.compareTo(a.seconds);
|
||||
});
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
/// Calculate total seconds from day sheet rows.
|
||||
int totalSeconds(List<DaySheetRow> rows) {
|
||||
return rows.fold(0, (sum, row) => sum + row.seconds);
|
||||
}
|
||||
@@ -0,0 +1,421 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:tasq/models/programmer_day_sheet.model.dart';
|
||||
import 'package:tasq/models/programmer_task.model.dart';
|
||||
|
||||
/// Aggregated statistics for a programmer's dashboard.
|
||||
class ProgrammerStats {
|
||||
final Duration todayFocus;
|
||||
final Duration helpGivenThisMonth;
|
||||
final Duration approvedTotal;
|
||||
final Duration helpGivenTotal;
|
||||
final int completedThisWeek;
|
||||
final int completedLastWeek;
|
||||
final int currentStreak;
|
||||
final int bestStreak;
|
||||
final int completedTotal;
|
||||
final int bugFixCompleted;
|
||||
final double? firstPassRate;
|
||||
final bool hasCleanWeek;
|
||||
|
||||
const ProgrammerStats({
|
||||
required this.todayFocus,
|
||||
required this.helpGivenThisMonth,
|
||||
required this.approvedTotal,
|
||||
required this.helpGivenTotal,
|
||||
required this.completedThisWeek,
|
||||
required this.completedLastWeek,
|
||||
required this.currentStreak,
|
||||
required this.bestStreak,
|
||||
required this.completedTotal,
|
||||
required this.bugFixCompleted,
|
||||
this.firstPassRate,
|
||||
required this.hasCleanWeek,
|
||||
});
|
||||
}
|
||||
|
||||
DateTime _dateOnly(DateTime dt) => DateTime.utc(dt.year, dt.month, dt.day);
|
||||
|
||||
DateTime _weekStart(DateTime dt) {
|
||||
final d = _dateOnly(dt);
|
||||
return d.subtract(Duration(days: d.weekday - 1));
|
||||
}
|
||||
|
||||
/// Compute all programmer statistics from raw data.
|
||||
///
|
||||
/// [liveHelpThisMonthSec] — when provided (from a live work-log query), this
|
||||
/// value is used directly for [ProgrammerStats.helpGivenThisMonth] instead of
|
||||
/// the approved-snapshot sum, so the stat reflects pending work too.
|
||||
ProgrammerStats computeProgrammerStats({
|
||||
required String userId,
|
||||
required DateTime now,
|
||||
required List<ProgrammerTask> tasks,
|
||||
required List<ProgrammerDaySheet> sheets,
|
||||
required Set<DateTime> leaveDays,
|
||||
required Duration todayFocus,
|
||||
int? liveHelpThisMonthSec,
|
||||
}) {
|
||||
final today = _dateOnly(now);
|
||||
final thisWeekStart = _weekStart(today);
|
||||
final thisWeekEnd = thisWeekStart.add(const Duration(days: 6)); // Sunday
|
||||
final lastWeekStart = thisWeekStart.subtract(const Duration(days: 7));
|
||||
final lastWeekEnd = thisWeekStart.subtract(const Duration(days: 1));
|
||||
|
||||
int completedThisWeek = 0;
|
||||
int completedLastWeek = 0;
|
||||
int completedTotal = 0;
|
||||
int bugFixCompleted = 0;
|
||||
|
||||
for (final task in tasks) {
|
||||
if (task.assigneeId != userId) continue;
|
||||
if (task.status != ProgrammerTaskStatus.completed) continue;
|
||||
completedTotal++;
|
||||
if (task.category == ProgrammerTaskCategory.bugFix) bugFixCompleted++;
|
||||
if (task.completedAt != null) {
|
||||
final completedDay = _dateOnly(task.completedAt!);
|
||||
if (!completedDay.isBefore(thisWeekStart) &&
|
||||
!completedDay.isAfter(thisWeekEnd)) {
|
||||
completedThisWeek++;
|
||||
} else if (!completedDay.isBefore(lastWeekStart) &&
|
||||
!completedDay.isAfter(lastWeekEnd)) {
|
||||
completedLastWeek++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final streakResult = computeStreak(
|
||||
sheets: sheets,
|
||||
leaveDays: leaveDays,
|
||||
today: today,
|
||||
);
|
||||
|
||||
// firstPassRate: last 30 days (today-29 through today inclusive)
|
||||
final thirtyDaysAgo = today.subtract(const Duration(days: 29));
|
||||
int reviewedCount = 0;
|
||||
int firstPassApprovedCount = 0;
|
||||
for (final sheet in sheets) {
|
||||
final wd = sheet.workDate;
|
||||
if (wd.isBefore(thirtyDaysAgo) || wd.isAfter(today)) continue;
|
||||
final isReviewed = sheet.status == DaySheetStatus.approved ||
|
||||
sheet.status == DaySheetStatus.disapproved ||
|
||||
sheet.resubmissions > 0;
|
||||
if (!isReviewed) continue;
|
||||
reviewedCount++;
|
||||
if (sheet.isFirstPassApproved) firstPassApprovedCount++;
|
||||
}
|
||||
final double? firstPassRate =
|
||||
reviewedCount == 0 ? null : firstPassApprovedCount / reviewedCount;
|
||||
|
||||
// Approved sums from approvedSnapshot
|
||||
int approvedTotalSec = 0;
|
||||
int helpGivenTotalSec = 0;
|
||||
int helpGivenThisMonthSec = 0;
|
||||
for (final sheet in sheets) {
|
||||
final snap = sheet.approvedSnapshot;
|
||||
if (snap == null) continue;
|
||||
approvedTotalSec += snap.totalSeconds;
|
||||
for (final row in snap.rows) {
|
||||
if (row.kind == 'helper') {
|
||||
helpGivenTotalSec += row.seconds;
|
||||
if (sheet.workDate.year == now.year &&
|
||||
sheet.workDate.month == now.month) {
|
||||
helpGivenThisMonthSec += row.seconds;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ProgrammerStats(
|
||||
todayFocus: todayFocus,
|
||||
helpGivenThisMonth: Duration(
|
||||
seconds: liveHelpThisMonthSec ?? helpGivenThisMonthSec),
|
||||
approvedTotal: Duration(seconds: approvedTotalSec),
|
||||
helpGivenTotal: Duration(seconds: helpGivenTotalSec),
|
||||
completedThisWeek: completedThisWeek,
|
||||
completedLastWeek: completedLastWeek,
|
||||
currentStreak: streakResult.current,
|
||||
bestStreak: streakResult.best,
|
||||
completedTotal: completedTotal,
|
||||
bugFixCompleted: bugFixCompleted,
|
||||
firstPassRate: firstPassRate,
|
||||
hasCleanWeek: _computeHasCleanWeek(sheets, leaveDays),
|
||||
);
|
||||
}
|
||||
|
||||
/// Compute current and best approval streaks.
|
||||
///
|
||||
/// A day qualifies if its sheet status is `pending` or `approved`.
|
||||
/// Weekends and [leaveDays] are neutral (skipped, not breaks).
|
||||
/// A non-qualifying `today` is also skipped (not a break).
|
||||
/// Any other non-neutral weekday without a qualifying sheet breaks the current streak.
|
||||
({int current, int best}) computeStreak({
|
||||
required List<ProgrammerDaySheet> sheets,
|
||||
required Set<DateTime> leaveDays,
|
||||
required DateTime today,
|
||||
}) {
|
||||
final qualifyingDays = <DateTime>{};
|
||||
for (final sheet in sheets) {
|
||||
if (sheet.status == DaySheetStatus.pending ||
|
||||
sheet.status == DaySheetStatus.approved) {
|
||||
qualifyingDays.add(sheet.workDate);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
current: _currentStreak(qualifyingDays, leaveDays, today),
|
||||
best: _bestStreak(qualifyingDays, leaveDays, today),
|
||||
);
|
||||
}
|
||||
|
||||
int _currentStreak(
|
||||
Set<DateTime> qualifyingDays,
|
||||
Set<DateTime> leaveDays,
|
||||
DateTime today,
|
||||
) {
|
||||
int streak = 0;
|
||||
bool isFirstDay = true;
|
||||
DateTime day = today;
|
||||
|
||||
for (int i = 0; i < 1000; i++) {
|
||||
final weekday = day.weekday; // 1=Mon..7=Sun
|
||||
|
||||
if (weekday == 6 || weekday == 7) {
|
||||
day = day.subtract(const Duration(days: 1));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (leaveDays.contains(day)) {
|
||||
day = day.subtract(const Duration(days: 1));
|
||||
isFirstDay = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (qualifyingDays.contains(day)) {
|
||||
streak++;
|
||||
day = day.subtract(const Duration(days: 1));
|
||||
isFirstDay = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isFirstDay) {
|
||||
// today with no qualifying sheet is skipped, not a break
|
||||
day = day.subtract(const Duration(days: 1));
|
||||
isFirstDay = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
break; // non-neutral, non-qualifying weekday that is not today
|
||||
}
|
||||
|
||||
return streak;
|
||||
}
|
||||
|
||||
int _bestStreak(
|
||||
Set<DateTime> qualifyingDays,
|
||||
Set<DateTime> leaveDays,
|
||||
DateTime today,
|
||||
) {
|
||||
if (qualifyingDays.isEmpty) return 0;
|
||||
|
||||
final earliest =
|
||||
qualifyingDays.reduce((a, b) => a.isBefore(b) ? a : b);
|
||||
int best = 0;
|
||||
int run = 0;
|
||||
DateTime day = earliest;
|
||||
|
||||
while (!day.isAfter(today)) {
|
||||
final weekday = day.weekday;
|
||||
|
||||
if (weekday == 6 || weekday == 7 || leaveDays.contains(day)) {
|
||||
day = day.add(const Duration(days: 1));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (qualifyingDays.contains(day)) {
|
||||
run++;
|
||||
if (run > best) best = run;
|
||||
} else {
|
||||
run = 0;
|
||||
}
|
||||
|
||||
day = day.add(const Duration(days: 1));
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
|
||||
bool _computeHasCleanWeek(
|
||||
List<ProgrammerDaySheet> sheets,
|
||||
Set<DateTime> leaveDays,
|
||||
) {
|
||||
if (sheets.isEmpty) return false;
|
||||
|
||||
final firstPassDays = <DateTime>{};
|
||||
for (final sheet in sheets) {
|
||||
if (sheet.isFirstPassApproved) firstPassDays.add(sheet.workDate);
|
||||
}
|
||||
|
||||
// Collect unique Monday starts from all sheet work dates
|
||||
final weeks = <DateTime>{};
|
||||
for (final sheet in sheets) {
|
||||
final wd = sheet.workDate;
|
||||
final monday = wd.subtract(Duration(days: wd.weekday - 1));
|
||||
weeks.add(monday);
|
||||
}
|
||||
|
||||
for (final monday in weeks) {
|
||||
bool hasApproved = false;
|
||||
bool allClean = true;
|
||||
|
||||
for (int i = 0; i < 5; i++) {
|
||||
final day = monday.add(Duration(days: i));
|
||||
if (leaveDays.contains(day)) continue;
|
||||
if (firstPassDays.contains(day)) {
|
||||
hasApproved = true;
|
||||
continue;
|
||||
}
|
||||
allClean = false;
|
||||
break;
|
||||
}
|
||||
|
||||
if (allClean && hasApproved) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Progress toward a badge milestone.
|
||||
class BadgeProgress {
|
||||
final String id;
|
||||
final String title;
|
||||
final String description;
|
||||
final IconData icon;
|
||||
final int current;
|
||||
final int target;
|
||||
final bool earned;
|
||||
|
||||
const BadgeProgress({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.description,
|
||||
required this.icon,
|
||||
required this.current,
|
||||
required this.target,
|
||||
required this.earned,
|
||||
});
|
||||
}
|
||||
|
||||
/// Compute badge progress list from [stats].
|
||||
///
|
||||
/// When [includeSheetBadges] is false, sheet-based badges
|
||||
/// (deep_work, on_a_roll, team_player, clean_week) are omitted.
|
||||
List<BadgeProgress> computeBadges(
|
||||
ProgrammerStats stats, {
|
||||
required bool includeSheetBadges,
|
||||
}) {
|
||||
final badges = <BadgeProgress>[];
|
||||
|
||||
badges.add(_makeBadge(
|
||||
id: 'first_finish',
|
||||
title: 'First Finish',
|
||||
description: 'Complete your first task',
|
||||
icon: Icons.flag_outlined,
|
||||
tiers: const [1],
|
||||
current: stats.completedTotal,
|
||||
));
|
||||
|
||||
badges.add(_makeBadge(
|
||||
id: 'finisher',
|
||||
title: 'Finisher',
|
||||
description: 'Complete 10 / 50 / 100 tasks',
|
||||
icon: Icons.task_alt,
|
||||
tiers: const [10, 50, 100],
|
||||
current: stats.completedTotal,
|
||||
));
|
||||
|
||||
badges.add(_makeBadge(
|
||||
id: 'bug_squasher',
|
||||
title: 'Bug Squasher',
|
||||
description: 'Complete 10 Bug Fix tasks',
|
||||
icon: Icons.bug_report_outlined,
|
||||
tiers: const [10],
|
||||
current: stats.bugFixCompleted,
|
||||
));
|
||||
|
||||
if (includeSheetBadges) {
|
||||
badges.add(_makeBadge(
|
||||
id: 'deep_work',
|
||||
title: 'Deep Work',
|
||||
description: 'Log 100 / 500 approved hours',
|
||||
icon: Icons.timer_outlined,
|
||||
tiers: const [100, 500],
|
||||
current: stats.approvedTotal.inHours,
|
||||
));
|
||||
|
||||
badges.add(_makeBadge(
|
||||
id: 'on_a_roll',
|
||||
title: 'On a Roll',
|
||||
description: 'Reach a 5 / 10 / 20-day streak',
|
||||
icon: Icons.local_fire_department_outlined,
|
||||
tiers: const [5, 10, 20],
|
||||
current: stats.bestStreak,
|
||||
));
|
||||
|
||||
badges.add(_makeBadge(
|
||||
id: 'team_player',
|
||||
title: 'Team Player',
|
||||
description: 'Give 10 hours of help to teammates',
|
||||
icon: Icons.handshake_outlined,
|
||||
tiers: const [10],
|
||||
current: stats.helpGivenTotal.inHours,
|
||||
));
|
||||
|
||||
badges.add(_makeBadge(
|
||||
id: 'clean_week',
|
||||
title: 'Clean Week',
|
||||
description: 'A Mon–Fri week with every day approved first time',
|
||||
icon: Icons.verified_outlined,
|
||||
tiers: const [1],
|
||||
current: stats.hasCleanWeek ? 1 : 0,
|
||||
));
|
||||
}
|
||||
|
||||
return badges;
|
||||
}
|
||||
|
||||
BadgeProgress _makeBadge({
|
||||
required String id,
|
||||
required String title,
|
||||
required String description,
|
||||
required IconData icon,
|
||||
required List<int> tiers,
|
||||
required int current,
|
||||
}) {
|
||||
final earned = current >= tiers.first;
|
||||
|
||||
// Highest tier reached
|
||||
int highestReached = 0;
|
||||
for (final tier in tiers) {
|
||||
if (current >= tier) highestReached = tier;
|
||||
}
|
||||
|
||||
// Next unreached tier, or top tier when all reached
|
||||
int target = tiers.last;
|
||||
for (final tier in tiers) {
|
||||
if (current < tier) {
|
||||
target = tier;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
final displayTitle = earned ? '$title · $highestReached' : title;
|
||||
|
||||
return BadgeProgress(
|
||||
id: id,
|
||||
title: displayTitle,
|
||||
description: description,
|
||||
icon: icon,
|
||||
current: current,
|
||||
target: target,
|
||||
earned: earned,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import '../models/programmer_task_work_log.model.dart';
|
||||
|
||||
/// Formats a worked duration compactly, e.g. `2h 15m` or `45m`.
|
||||
String formatDurationHm(Duration d) {
|
||||
final h = d.inHours;
|
||||
final m = d.inMinutes.remainder(60);
|
||||
if (h > 0) return '${h}h ${m}m';
|
||||
return '${m}m';
|
||||
}
|
||||
|
||||
/// One contributor's share of a task's time.
|
||||
class LedgerEntry {
|
||||
final String userId;
|
||||
final Duration duration;
|
||||
final bool isAssignee;
|
||||
|
||||
const LedgerEntry({
|
||||
required this.userId,
|
||||
required this.duration,
|
||||
required this.isAssignee,
|
||||
});
|
||||
}
|
||||
|
||||
/// Per-contributor time breakdown for a task: the assignee's effective running
|
||||
/// duration plus each helper's summed logged minutes.
|
||||
class TaskTimeLedger {
|
||||
final LedgerEntry? assignee;
|
||||
final List<LedgerEntry> helpers;
|
||||
|
||||
const TaskTimeLedger({required this.assignee, required this.helpers});
|
||||
|
||||
Duration get total =>
|
||||
(assignee?.duration ?? Duration.zero) +
|
||||
helpers.fold(Duration.zero, (a, e) => a + e.duration);
|
||||
|
||||
List<LedgerEntry> get all => [?assignee, ...helpers];
|
||||
}
|
||||
|
||||
/// Builds the per-contributor ledger for a task.
|
||||
///
|
||||
/// [assigneeDuration] is the assignee's effective running duration (already
|
||||
/// computed from the activity log, including any adjustments). Helper entries
|
||||
/// sum the `minutes` of work logs authored by someone other than the assignee;
|
||||
/// assignee work logs (minutes == null progress notes) are excluded because the
|
||||
/// assignee's time comes from the running clock.
|
||||
TaskTimeLedger computeTaskLedger({
|
||||
required String? assigneeId,
|
||||
required Duration assigneeDuration,
|
||||
required List<ProgrammerTaskWorkLog> workLogs,
|
||||
}) {
|
||||
final byHelper = <String, int>{};
|
||||
for (final log in workLogs) {
|
||||
final minutes = log.minutes;
|
||||
if (minutes == null || minutes <= 0) continue;
|
||||
if (log.authorId == assigneeId) continue;
|
||||
byHelper[log.authorId] = (byHelper[log.authorId] ?? 0) + minutes;
|
||||
}
|
||||
|
||||
final helpers = byHelper.entries
|
||||
.map((e) => LedgerEntry(
|
||||
userId: e.key,
|
||||
duration: Duration(minutes: e.value),
|
||||
isAssignee: false,
|
||||
))
|
||||
.toList()
|
||||
..sort((a, b) => b.duration.compareTo(a.duration));
|
||||
|
||||
return TaskTimeLedger(
|
||||
assignee: assigneeId == null
|
||||
? null
|
||||
: LedgerEntry(
|
||||
userId: assigneeId,
|
||||
duration: assigneeDuration,
|
||||
isAssignee: true,
|
||||
),
|
||||
helpers: helpers,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import 'package:tasq/models/leave_of_absence.model.dart';
|
||||
import 'package:tasq/models/pass_slip.model.dart';
|
||||
import 'programmer_daily_time.dart';
|
||||
|
||||
/// Index approved leaves covering the given moment by user ID.
|
||||
///
|
||||
/// Filters to leaves where:
|
||||
/// - status == 'approved'
|
||||
/// - startTime <= now < endTime
|
||||
///
|
||||
/// Returns a map from userId to the leave record.
|
||||
Map<String, LeaveOfAbsence> activeLeaveByUser(
|
||||
List<LeaveOfAbsence> leaves,
|
||||
DateTime now,
|
||||
) {
|
||||
final result = <String, LeaveOfAbsence>{};
|
||||
for (final leave in leaves) {
|
||||
if (leave.status == 'approved' &&
|
||||
!leave.startTime.isAfter(now) &&
|
||||
leave.endTime.isAfter(now)) {
|
||||
result[leave.userId] = leave;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Index active pass slips by user ID.
|
||||
///
|
||||
/// Filters to slips where slip.isActive is true.
|
||||
///
|
||||
/// Returns a map from userId to the pass slip record.
|
||||
Map<String, PassSlip> activePassSlipByUser(List<PassSlip> slips) {
|
||||
final result = <String, PassSlip>{};
|
||||
for (final slip in slips) {
|
||||
if (slip.isActive) {
|
||||
result[slip.userId] = slip;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Get the set of Manila calendar days covered by approved leaves for a user.
|
||||
///
|
||||
/// Iterates through all approved leaves for the given user and collects the
|
||||
/// set of Manila day keys (as DateTime.utc(year, month, day)) from each
|
||||
/// leave's start date through end date, inclusive.
|
||||
Set<DateTime> approvedLeaveDays(
|
||||
List<LeaveOfAbsence> leaves,
|
||||
String userId,
|
||||
) {
|
||||
final result = <DateTime>{};
|
||||
for (final leave in leaves) {
|
||||
if (leave.userId == userId && leave.status == 'approved') {
|
||||
var current = manilaDay(leave.startTime);
|
||||
final endDay = manilaDay(leave.endTime);
|
||||
while (!current.isAfter(endDay)) {
|
||||
result.add(current);
|
||||
current = current.add(Duration(days: 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -1,17 +1,25 @@
|
||||
import '../models/task_activity_log.model.dart';
|
||||
|
||||
/// Finds the canonical execution start time for a task.
|
||||
/// A minimal activity event shape: just the fields the worked-duration math
|
||||
/// needs. Both [TaskActivityLog] and ProgrammerTaskActivityLog map onto this,
|
||||
/// so the pause-aware duration logic is shared across task modules.
|
||||
typedef DurationEvent = ({DateTime createdAt, String actionType});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Generic core (works on any DurationEvent list)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Finds the canonical execution start time from [events].
|
||||
///
|
||||
/// [logs] is expected in DESCENDING order (newest first), matching the
|
||||
/// `order('created_at', ascending: false)` used by Supabase queries.
|
||||
/// The reversed iteration therefore walks chronologically forward and stops
|
||||
/// at the earliest 'started' event.
|
||||
DateTime? resolveTaskExecutionStart(
|
||||
List<TaskActivityLog> logs,
|
||||
/// [events] is expected in DESCENDING order (newest first). The reversed
|
||||
/// iteration walks chronologically forward and stops at the earliest 'started'
|
||||
/// event, falling back to [fallbackStartedAt].
|
||||
DateTime? resolveExecutionStart(
|
||||
List<DurationEvent> events,
|
||||
DateTime? fallbackStartedAt,
|
||||
) {
|
||||
DateTime? started;
|
||||
for (final entry in logs.reversed) {
|
||||
for (final entry in events.reversed) {
|
||||
if (entry.actionType == 'started') {
|
||||
started = entry.createdAt;
|
||||
break;
|
||||
@@ -20,31 +28,33 @@ DateTime? resolveTaskExecutionStart(
|
||||
return started ?? fallbackStartedAt;
|
||||
}
|
||||
|
||||
/// Computes the effective worked duration for a task, subtracting all
|
||||
/// paused intervals between [fallbackStartedAt] (or the first 'started'
|
||||
/// log event) and [endAt].
|
||||
/// Computes the effective worked duration, subtracting all paused intervals
|
||||
/// between the resolved start and [endAt], then subtracting [adjustment]
|
||||
/// (e.g. time reassigned to another task when the user helped elsewhere).
|
||||
///
|
||||
/// [logs] must be in DESCENDING order (newest first) — the same order
|
||||
/// returned by `task_activity_logs` queries with `ascending: false`.
|
||||
Duration computeEffectiveTaskDuration({
|
||||
/// [events] must be in DESCENDING order (newest first) — the same order
|
||||
/// returned by activity-log queries with `ascending: false`. The result never
|
||||
/// goes below zero.
|
||||
Duration computeEffectiveDuration({
|
||||
required DateTime? fallbackStartedAt,
|
||||
required DateTime endAt,
|
||||
required List<TaskActivityLog> logs,
|
||||
required List<DurationEvent> events,
|
||||
Duration adjustment = Duration.zero,
|
||||
}) {
|
||||
final start = resolveTaskExecutionStart(logs, fallbackStartedAt);
|
||||
final start = resolveExecutionStart(events, fallbackStartedAt);
|
||||
if (start == null || !endAt.isAfter(start)) return Duration.zero;
|
||||
|
||||
Duration pausedTotal = Duration.zero;
|
||||
DateTime? pausedSince;
|
||||
|
||||
// logs.reversed → ascending (chronological) order within [start, endAt]
|
||||
final events = logs.reversed.where((e) {
|
||||
// events.reversed → ascending (chronological) order within [start, endAt]
|
||||
final relevant = events.reversed.where((e) {
|
||||
if (e.createdAt.isBefore(start)) return false;
|
||||
if (e.createdAt.isAfter(endAt)) return false;
|
||||
return e.actionType == 'paused' || e.actionType == 'resumed';
|
||||
});
|
||||
|
||||
for (final event in events) {
|
||||
for (final event in relevant) {
|
||||
if (event.actionType == 'paused') {
|
||||
pausedSince ??= event.createdAt;
|
||||
} else if (event.actionType == 'resumed' && pausedSince != null) {
|
||||
@@ -55,11 +65,37 @@ Duration computeEffectiveTaskDuration({
|
||||
}
|
||||
}
|
||||
|
||||
// Task is still paused at endAt — count the open pause interval.
|
||||
// Still paused at endAt — count the open pause interval.
|
||||
if (pausedSince != null && endAt.isAfter(pausedSince)) {
|
||||
pausedTotal += endAt.difference(pausedSince);
|
||||
}
|
||||
|
||||
final total = endAt.difference(start) - pausedTotal;
|
||||
final total = endAt.difference(start) - pausedTotal - adjustment;
|
||||
return total.isNegative ? Duration.zero : total;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TaskActivityLog wrappers (unchanged public API — existing call sites intact)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
List<DurationEvent> _toEvents(List<TaskActivityLog> logs) =>
|
||||
logs.map((l) => (createdAt: l.createdAt, actionType: l.actionType)).toList();
|
||||
|
||||
/// Finds the canonical execution start time for a task. See
|
||||
/// [resolveExecutionStart].
|
||||
DateTime? resolveTaskExecutionStart(
|
||||
List<TaskActivityLog> logs,
|
||||
DateTime? fallbackStartedAt,
|
||||
) => resolveExecutionStart(_toEvents(logs), fallbackStartedAt);
|
||||
|
||||
/// Computes the effective worked duration for a task. See
|
||||
/// [computeEffectiveDuration].
|
||||
Duration computeEffectiveTaskDuration({
|
||||
required DateTime? fallbackStartedAt,
|
||||
required DateTime endAt,
|
||||
required List<TaskActivityLog> logs,
|
||||
}) => computeEffectiveDuration(
|
||||
fallbackStartedAt: fallbackStartedAt,
|
||||
endAt: endAt,
|
||||
events: _toEvents(logs),
|
||||
);
|
||||
|
||||
@@ -460,6 +460,20 @@ List<NavSection> _buildSections(String role) {
|
||||
icon: Icons.task_outlined,
|
||||
selectedIcon: Icons.task,
|
||||
),
|
||||
if (role == 'admin' || role == 'programmer') ...[
|
||||
NavItem(
|
||||
label: 'Programmer Tasks',
|
||||
route: '/programmer-tasks',
|
||||
icon: Icons.developer_mode_outlined,
|
||||
selectedIcon: Icons.developer_mode,
|
||||
),
|
||||
NavItem(
|
||||
label: 'Projects',
|
||||
route: '/programmer-projects',
|
||||
icon: Icons.folder_outlined,
|
||||
selectedIcon: Icons.folder,
|
||||
),
|
||||
],
|
||||
NavItem(
|
||||
label: 'IT Service Requests',
|
||||
route: '/it-service-requests',
|
||||
@@ -715,6 +729,8 @@ String _routeToTitle(String location) {
|
||||
'/attendance' => 'Attendance',
|
||||
'/tickets' => 'Tickets',
|
||||
'/tasks' => 'Tasks',
|
||||
'/programmer-tasks' => 'Programmer Tasks',
|
||||
'/programmer-projects' => 'Projects',
|
||||
'/it-service-requests' => 'IT Service Requests',
|
||||
'/network-map' => 'Network Map',
|
||||
'/notifications' => 'Notifications',
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import 'dart:typed_data';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:pdfrx/pdfrx.dart';
|
||||
import 'package:printing/printing.dart';
|
||||
|
||||
/// Show a PDF preview dialog with Print and Download buttons.
|
||||
///
|
||||
/// [buildBytes] is called once and cached; the result is shared between the
|
||||
/// preview viewer and the action buttons.
|
||||
Future<void> showPdfPreviewDialog(
|
||||
BuildContext context, {
|
||||
required String title,
|
||||
required String filename,
|
||||
required Future<Uint8List> Function() buildBytes,
|
||||
}) {
|
||||
return showDialog<void>(
|
||||
context: context,
|
||||
builder: (_) => _PdfPreviewDialog(
|
||||
title: title,
|
||||
filename: filename,
|
||||
buildBytes: buildBytes,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _PdfPreviewDialog extends StatefulWidget {
|
||||
const _PdfPreviewDialog({
|
||||
required this.title,
|
||||
required this.filename,
|
||||
required this.buildBytes,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final String filename;
|
||||
final Future<Uint8List> Function() buildBytes;
|
||||
|
||||
@override
|
||||
State<_PdfPreviewDialog> createState() => _PdfPreviewDialogState();
|
||||
}
|
||||
|
||||
class _PdfPreviewDialogState extends State<_PdfPreviewDialog> {
|
||||
late final Future<Uint8List> _future;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_future = widget.buildBytes();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
content: SizedBox(
|
||||
width: 800,
|
||||
height: 900,
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
widget.title,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Print',
|
||||
icon: const Icon(Icons.print),
|
||||
onPressed: () async {
|
||||
final bytes = await _future;
|
||||
await Printing.layoutPdf(
|
||||
onLayout: (_) async => bytes,
|
||||
name: widget.filename,
|
||||
);
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Download',
|
||||
icon: const Icon(Icons.download),
|
||||
onPressed: () async {
|
||||
final bytes = await _future;
|
||||
await Printing.sharePdf(
|
||||
bytes: bytes,
|
||||
filename: widget.filename,
|
||||
);
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Close',
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
Expanded(
|
||||
child: FutureBuilder<Uint8List>(
|
||||
future: _future,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
if (snapshot.hasError) {
|
||||
return Center(child: Text(snapshot.error.toString()));
|
||||
}
|
||||
final data = snapshot.data;
|
||||
if (data == null) return const SizedBox.shrink();
|
||||
return PdfViewer.data(data, sourceName: widget.filename);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_quill/flutter_quill.dart' as quill;
|
||||
|
||||
/// Convert a stored Delta JSON or plain text string to trimmed plain text.
|
||||
/// - Delta JSON (starts with '[') is decoded and converted to plain text
|
||||
/// - Non-JSON strings are returned trimmed as-is
|
||||
/// - null returns empty string
|
||||
String deltaJsonToPlainText(String? stored) {
|
||||
if (stored == null) return '';
|
||||
final trimmed = stored.trim();
|
||||
if (trimmed.isEmpty) return '';
|
||||
if (trimmed.startsWith('[')) {
|
||||
try {
|
||||
final decoded = jsonDecode(trimmed);
|
||||
if (decoded is List) {
|
||||
final doc = quill.Document.fromJson(decoded);
|
||||
return doc.toPlainText().trim();
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
/// Parses a stored Delta-JSON string into a [quill.QuillController], or returns
|
||||
/// an empty one. Callers own the controller and must dispose it.
|
||||
quill.QuillController quillControllerFromDelta(String? deltaJson) {
|
||||
if (deltaJson != null && deltaJson.isNotEmpty) {
|
||||
try {
|
||||
final decoded = jsonDecode(deltaJson);
|
||||
if (decoded is List) {
|
||||
return quill.QuillController(
|
||||
document: quill.Document.fromJson(decoded),
|
||||
selection: const TextSelection.collapsed(offset: 0),
|
||||
);
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
return quill.QuillController.basic();
|
||||
}
|
||||
|
||||
/// Serializes a controller's document to a Delta-JSON string for storage.
|
||||
String quillToDeltaJson(quill.QuillController controller) =>
|
||||
jsonEncode(controller.document.toDelta().toJson());
|
||||
|
||||
/// The controller's plain text (trimmed) — for empty checks and previews.
|
||||
String quillPlainText(quill.QuillController controller) =>
|
||||
controller.document.toPlainText().trim();
|
||||
|
||||
/// Bordered rich-text editor with a minimal toolbar (bold/italic/underline +
|
||||
/// bullet/numbered lists). The caller owns [controller] and disposes it.
|
||||
class QuillFieldEditor extends StatelessWidget {
|
||||
const QuillFieldEditor({
|
||||
super.key,
|
||||
required this.controller,
|
||||
this.placeholder,
|
||||
this.height = 140,
|
||||
});
|
||||
|
||||
final quill.QuillController controller;
|
||||
final String? placeholder;
|
||||
final double height;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
return Container(
|
||||
height: height,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: cs.outlineVariant),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 40,
|
||||
child: quill.QuillSimpleToolbar(
|
||||
controller: controller,
|
||||
config: const quill.QuillSimpleToolbarConfig(
|
||||
multiRowsDisplay: false,
|
||||
showAlignmentButtons: false,
|
||||
showBackgroundColorButton: false,
|
||||
showCenterAlignment: false,
|
||||
showColorButton: false,
|
||||
showDividers: false,
|
||||
showFontFamily: false,
|
||||
showFontSize: false,
|
||||
showHeaderStyle: false,
|
||||
showIndent: false,
|
||||
showInlineCode: false,
|
||||
showLeftAlignment: false,
|
||||
showLink: false,
|
||||
showQuote: false,
|
||||
showRightAlignment: false,
|
||||
showSearchButton: false,
|
||||
showCodeBlock: false,
|
||||
showDirection: false,
|
||||
showJustifyAlignment: false,
|
||||
showListCheck: false,
|
||||
showSubscript: false,
|
||||
showSuperscript: false,
|
||||
showStrikeThrough: false,
|
||||
showSmallButton: false,
|
||||
showClearFormat: false,
|
||||
showRedo: false,
|
||||
showUndo: false,
|
||||
),
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
child: quill.QuillEditor.basic(
|
||||
controller: controller,
|
||||
config: quill.QuillEditorConfig(
|
||||
placeholder: placeholder,
|
||||
expands: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders a stored value read-only. Interprets a Delta-JSON string as rich
|
||||
/// text; falls back to a plain [Text] for legacy plain-string values.
|
||||
class QuillReadOnly extends StatelessWidget {
|
||||
const QuillReadOnly(this.deltaOrText, {super.key, this.style});
|
||||
|
||||
final String deltaOrText;
|
||||
final TextStyle? style;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final trimmed = deltaOrText.trim();
|
||||
if (trimmed.startsWith('[')) {
|
||||
try {
|
||||
final decoded = jsonDecode(trimmed);
|
||||
if (decoded is List) {
|
||||
final controller = quill.QuillController(
|
||||
document: quill.Document.fromJson(decoded),
|
||||
selection: const TextSelection.collapsed(offset: 0),
|
||||
);
|
||||
return IgnorePointer(
|
||||
child: quill.QuillEditor.basic(
|
||||
controller: controller,
|
||||
config: const quill.QuillEditorConfig(
|
||||
showCursor: false,
|
||||
autoFocus: false,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
return Text(deltaOrText, style: style);
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ class TasQColumn<T> {
|
||||
required this.cellBuilder,
|
||||
this.technical = false,
|
||||
this.hideOnMedium = false,
|
||||
this.minWidth,
|
||||
});
|
||||
|
||||
/// The column header text.
|
||||
@@ -33,6 +34,11 @@ class TasQColumn<T> {
|
||||
/// Use this for lower-priority columns (e.g. Timestamp, Status) that
|
||||
/// would otherwise force a horizontal scrollbar on tablet viewports.
|
||||
final bool hideOnMedium;
|
||||
|
||||
/// Minimum comfortable width for this column, in logical pixels. When null a
|
||||
/// default of 200 is used. Set a smaller value for short columns (a count, a
|
||||
/// status pill, a short enum) so the table fits without a horizontal scroll.
|
||||
final double? minWidth;
|
||||
}
|
||||
|
||||
/// Builds a mobile tile for [TasQAdaptiveList].
|
||||
@@ -380,9 +386,13 @@ class TasQAdaptiveList<T> extends StatelessWidget {
|
||||
const double colSpacing = 20.0;
|
||||
final actionsColumnCount = rowActions == null ? 0 : 1;
|
||||
final totalCols = visibleColumns.length + actionsColumnCount;
|
||||
final columnsMinWidth = visibleColumns.fold<double>(
|
||||
0.0,
|
||||
(sum, c) => sum + (c.minWidth ?? colMinW),
|
||||
);
|
||||
final minColumnsWidth =
|
||||
hMarginTotal +
|
||||
(visibleColumns.length * colMinW) +
|
||||
columnsMinWidth +
|
||||
(actionsColumnCount * actMinW) +
|
||||
(math.max(0, totalCols - 1) * colSpacing);
|
||||
final tableWidth = math.max(contentWidth, minColumnsWidth);
|
||||
|
||||
@@ -197,6 +197,33 @@ async function processBatch() {
|
||||
data.navigate_to = '/announcements'
|
||||
break
|
||||
}
|
||||
case 'day_sheet_digest': {
|
||||
const { count, error: countErr } = await supabase
|
||||
.from('programmer_day_sheets')
|
||||
.select('id', { count: 'exact', head: true })
|
||||
.eq('status', 'pending')
|
||||
|
||||
if (countErr) {
|
||||
console.error('Failed to count pending day sheets', countErr)
|
||||
throw countErr
|
||||
}
|
||||
|
||||
const n = count ?? 0
|
||||
if (n === 0) {
|
||||
// No pending sheets; mark processed and skip sending
|
||||
await supabase
|
||||
.from('scheduled_notifications')
|
||||
.update({ processed: true, processed_at: new Date().toISOString() })
|
||||
.eq('id', rowId)
|
||||
console.log('Day sheet digest: no pending sheets, skipped', rowId)
|
||||
continue
|
||||
}
|
||||
|
||||
title = 'Day sheets awaiting review'
|
||||
body = `${n} day sheet${n === 1 ? ' is' : 's are'} awaiting your review.`
|
||||
data.navigate_to = '/programmer-tasks?tab=approvals'
|
||||
break
|
||||
}
|
||||
default:
|
||||
title = 'Reminder'
|
||||
body = 'You have a pending notification.'
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
-- Programmer Tasks — a separate work-log / time-tracking module for the
|
||||
-- `programmer` role. Distinct from the IT help-desk `tasks` feature: no office/
|
||||
-- ticket/it_staff coupling, its own dev-flavored category vocabulary, a single
|
||||
-- assignee, and pause/resume tracked as activity-log events for time attribution.
|
||||
-- Designed as the seed of a future Project Management workflow (projects/subtasks
|
||||
-- can be added later without touching the help-desk `tasks` feature).
|
||||
|
||||
-- Category vocabulary (TitleCase display strings, matching the existing enum
|
||||
-- convention used by request_type / request_category).
|
||||
create type programmer_task_category as enum (
|
||||
'Software Support',
|
||||
'Software Development',
|
||||
'Software Enhancement',
|
||||
'Bug Fix',
|
||||
'Meeting',
|
||||
'Facilitate Event',
|
||||
'Others'
|
||||
);
|
||||
|
||||
create table if not exists programmer_tasks (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
title text not null,
|
||||
description text,
|
||||
category programmer_task_category not null,
|
||||
status text not null default 'queued',
|
||||
priority int not null default 1,
|
||||
assignee_id uuid references profiles(id),
|
||||
creator_id uuid references profiles(id),
|
||||
created_at timestamptz not null default now(),
|
||||
started_at timestamptz,
|
||||
completed_at timestamptz,
|
||||
cancelled_at timestamptz,
|
||||
cancellation_reason text,
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create index if not exists idx_programmer_tasks_assignee_id on programmer_tasks(assignee_id);
|
||||
create index if not exists idx_programmer_tasks_status on programmer_tasks(status);
|
||||
|
||||
-- Keep updated_at fresh on every write (dedicated function to avoid clashing
|
||||
-- with any generic trigger function elsewhere).
|
||||
create or replace function set_programmer_tasks_updated_at()
|
||||
returns trigger as $$
|
||||
begin
|
||||
new.updated_at = now();
|
||||
return new;
|
||||
end;
|
||||
$$ language plpgsql;
|
||||
|
||||
drop trigger if exists trg_programmer_tasks_updated_at on programmer_tasks;
|
||||
create trigger trg_programmer_tasks_updated_at
|
||||
before update on programmer_tasks
|
||||
for each row execute function set_programmer_tasks_updated_at();
|
||||
|
||||
-- Activity log — mirrors task_activity_logs. Records created/started/paused/
|
||||
-- resumed/completed/cancelled/assigned/reassigned events. Pause/resume events
|
||||
-- drive the effective-worked-duration math (see lib/utils/task_duration.dart).
|
||||
create table if not exists programmer_task_activity_logs (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
task_id uuid not null references programmer_tasks(id) on delete cascade,
|
||||
actor_id uuid references profiles(id),
|
||||
action_type text not null,
|
||||
meta jsonb,
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create index if not exists idx_programmer_task_activity_logs_task_id
|
||||
on programmer_task_activity_logs(task_id);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Row Level Security
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Programmer tasks are a programmer-team tool: admins and programmers have full
|
||||
-- access; a row's assignee or creator can always see/act on their own task.
|
||||
|
||||
alter table programmer_tasks enable row level security;
|
||||
alter table programmer_task_activity_logs enable row level security;
|
||||
|
||||
drop policy if exists "programmer_tasks_select" on programmer_tasks;
|
||||
create policy "programmer_tasks_select" on programmer_tasks
|
||||
for select to authenticated
|
||||
using (
|
||||
assignee_id = auth.uid()
|
||||
or creator_id = auth.uid()
|
||||
or exists (
|
||||
select 1 from profiles p where p.id = auth.uid() and p.role in ('admin', 'programmer')
|
||||
)
|
||||
);
|
||||
|
||||
drop policy if exists "programmer_tasks_insert" on programmer_tasks;
|
||||
create policy "programmer_tasks_insert" on programmer_tasks
|
||||
for insert to authenticated
|
||||
with check (
|
||||
creator_id = auth.uid()
|
||||
and exists (
|
||||
select 1 from profiles p where p.id = auth.uid() and p.role in ('admin', 'programmer')
|
||||
)
|
||||
);
|
||||
|
||||
drop policy if exists "programmer_tasks_update" on programmer_tasks;
|
||||
create policy "programmer_tasks_update" on programmer_tasks
|
||||
for update to authenticated
|
||||
using (
|
||||
assignee_id = auth.uid()
|
||||
or creator_id = auth.uid()
|
||||
or exists (
|
||||
select 1 from profiles p where p.id = auth.uid() and p.role in ('admin', 'programmer')
|
||||
)
|
||||
)
|
||||
with check (
|
||||
assignee_id = auth.uid()
|
||||
or creator_id = auth.uid()
|
||||
or exists (
|
||||
select 1 from profiles p where p.id = auth.uid() and p.role in ('admin', 'programmer')
|
||||
)
|
||||
);
|
||||
|
||||
drop policy if exists "programmer_tasks_delete" on programmer_tasks;
|
||||
create policy "programmer_tasks_delete" on programmer_tasks
|
||||
for delete to authenticated
|
||||
using (
|
||||
creator_id = auth.uid()
|
||||
or exists (
|
||||
select 1 from profiles p where p.id = auth.uid() and p.role in ('admin', 'programmer')
|
||||
)
|
||||
);
|
||||
|
||||
-- Activity logs: visible/writable to anyone who can see the parent task.
|
||||
drop policy if exists "programmer_task_activity_logs_select" on programmer_task_activity_logs;
|
||||
create policy "programmer_task_activity_logs_select" on programmer_task_activity_logs
|
||||
for select to authenticated
|
||||
using (
|
||||
exists (
|
||||
select 1 from programmer_tasks t
|
||||
where t.id = task_id
|
||||
and (
|
||||
t.assignee_id = auth.uid()
|
||||
or t.creator_id = auth.uid()
|
||||
or exists (
|
||||
select 1 from profiles p where p.id = auth.uid() and p.role in ('admin', 'programmer')
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
drop policy if exists "programmer_task_activity_logs_insert" on programmer_task_activity_logs;
|
||||
create policy "programmer_task_activity_logs_insert" on programmer_task_activity_logs
|
||||
for insert to authenticated
|
||||
with check (
|
||||
exists (
|
||||
select 1 from programmer_tasks t
|
||||
where t.id = task_id
|
||||
and (
|
||||
t.assignee_id = auth.uid()
|
||||
or t.creator_id = auth.uid()
|
||||
or exists (
|
||||
select 1 from profiles p where p.id = auth.uid() and p.role in ('admin', 'programmer')
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Realtime — the Riverpod .stream() subscriptions require these tables in the
|
||||
-- supabase_realtime publication.
|
||||
-- ---------------------------------------------------------------------------
|
||||
alter publication supabase_realtime add table programmer_tasks;
|
||||
alter publication supabase_realtime add table programmer_task_activity_logs;
|
||||
@@ -0,0 +1,51 @@
|
||||
-- Hardens the programmer_tasks RLS shipped in 20260927120000, per the commit
|
||||
-- security review (two MEDIUM findings). The base migration is already applied
|
||||
-- remotely, so this is a corrective follow-up.
|
||||
|
||||
-- 1) audit-log-forgery: the activity-log INSERT policy did not bind actor_id to
|
||||
-- the caller, so an authorized user could attribute a log entry to someone
|
||||
-- else. Require actor_id to be the caller (or null for system rows).
|
||||
drop policy if exists "programmer_task_activity_logs_insert" on programmer_task_activity_logs;
|
||||
create policy "programmer_task_activity_logs_insert" on programmer_task_activity_logs
|
||||
for insert to authenticated
|
||||
with check (
|
||||
(actor_id = auth.uid() or actor_id is null)
|
||||
and exists (
|
||||
select 1 from programmer_tasks t
|
||||
where t.id = task_id
|
||||
and (
|
||||
t.assignee_id = auth.uid()
|
||||
or t.creator_id = auth.uid()
|
||||
or exists (
|
||||
select 1 from profiles p where p.id = auth.uid() and p.role in ('admin', 'programmer')
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
-- 2) column-immutability: the UPDATE policy allowed an assignee to mutate any
|
||||
-- column, including creator_id/created_at (the DELETE policy keys off
|
||||
-- creator_id). Make those columns immutable via a trigger.
|
||||
create or replace function programmer_tasks_guard_immutable()
|
||||
returns trigger
|
||||
language plpgsql
|
||||
set search_path = ''
|
||||
as $$
|
||||
begin
|
||||
if new.creator_id is distinct from old.creator_id then
|
||||
raise exception 'creator_id is immutable';
|
||||
end if;
|
||||
if new.created_at is distinct from old.created_at then
|
||||
raise exception 'created_at is immutable';
|
||||
end if;
|
||||
return new;
|
||||
end;
|
||||
$$;
|
||||
|
||||
drop trigger if exists trg_programmer_tasks_guard_immutable on programmer_tasks;
|
||||
create trigger trg_programmer_tasks_guard_immutable
|
||||
before update on programmer_tasks
|
||||
for each row execute function programmer_tasks_guard_immutable();
|
||||
|
||||
-- Note: activity-logs UPDATE/DELETE remain denied (no such policies exist),
|
||||
-- keeping the audit trail append-only. New log/ledger tables follow the same shape.
|
||||
@@ -0,0 +1,151 @@
|
||||
-- Programmer Tasks PM extension: projects, work logs (per-contributor time
|
||||
-- ledger), and comments with attachments. All tables start hardened (author
|
||||
-- bound to auth.uid(); logs/work-logs append-only; immutable creator/created_at).
|
||||
|
||||
-- Visibility helper: true iff the caller can see the task. SECURITY INVOKER so
|
||||
-- it runs under the caller's own programmer_tasks RLS (no SECURITY DEFINER
|
||||
-- exposure); reused by work-log and comment policies.
|
||||
create or replace function programmer_task_visible(t_id uuid)
|
||||
returns boolean
|
||||
language sql
|
||||
stable
|
||||
security invoker
|
||||
set search_path = public
|
||||
as $$
|
||||
select exists (select 1 from programmer_tasks where id = t_id);
|
||||
$$;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Projects
|
||||
-- ---------------------------------------------------------------------------
|
||||
create table if not exists programmer_projects (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
name text not null,
|
||||
description text,
|
||||
status text not null default 'active', -- active | archived
|
||||
creator_id uuid references profiles(id),
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
create index if not exists idx_programmer_projects_status on programmer_projects(status);
|
||||
|
||||
-- Reuse the table-agnostic updated_at + immutability trigger functions from the
|
||||
-- base/harden migrations.
|
||||
drop trigger if exists trg_programmer_projects_updated_at on programmer_projects;
|
||||
create trigger trg_programmer_projects_updated_at
|
||||
before update on programmer_projects
|
||||
for each row execute function set_programmer_tasks_updated_at();
|
||||
|
||||
drop trigger if exists trg_programmer_projects_guard_immutable on programmer_projects;
|
||||
create trigger trg_programmer_projects_guard_immutable
|
||||
before update on programmer_projects
|
||||
for each row execute function programmer_tasks_guard_immutable();
|
||||
|
||||
alter table programmer_projects enable row level security;
|
||||
|
||||
drop policy if exists "programmer_projects_select" on programmer_projects;
|
||||
create policy "programmer_projects_select" on programmer_projects
|
||||
for select to authenticated
|
||||
using (
|
||||
creator_id = auth.uid()
|
||||
or exists (select 1 from profiles p where p.id = auth.uid() and p.role in ('admin', 'programmer'))
|
||||
);
|
||||
|
||||
drop policy if exists "programmer_projects_insert" on programmer_projects;
|
||||
create policy "programmer_projects_insert" on programmer_projects
|
||||
for insert to authenticated
|
||||
with check (
|
||||
creator_id = auth.uid()
|
||||
and exists (select 1 from profiles p where p.id = auth.uid() and p.role in ('admin', 'programmer'))
|
||||
);
|
||||
|
||||
drop policy if exists "programmer_projects_update" on programmer_projects;
|
||||
create policy "programmer_projects_update" on programmer_projects
|
||||
for update to authenticated
|
||||
using (
|
||||
creator_id = auth.uid()
|
||||
or exists (select 1 from profiles p where p.id = auth.uid() and p.role in ('admin', 'programmer'))
|
||||
)
|
||||
with check (
|
||||
creator_id = auth.uid()
|
||||
or exists (select 1 from profiles p where p.id = auth.uid() and p.role in ('admin', 'programmer'))
|
||||
);
|
||||
|
||||
drop policy if exists "programmer_projects_delete" on programmer_projects;
|
||||
create policy "programmer_projects_delete" on programmer_projects
|
||||
for delete to authenticated
|
||||
using (
|
||||
creator_id = auth.uid()
|
||||
or exists (select 1 from profiles p where p.id = auth.uid() and p.role in ('admin', 'programmer'))
|
||||
);
|
||||
|
||||
-- Link tasks to a project (optional; unset on project delete).
|
||||
alter table programmer_tasks add column if not exists project_id uuid
|
||||
references programmer_projects(id) on delete set null;
|
||||
create index if not exists idx_programmer_tasks_project_id on programmer_tasks(project_id);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Work logs (per-contributor time ledger)
|
||||
-- ---------------------------------------------------------------------------
|
||||
create table if not exists programmer_task_work_logs (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
task_id uuid not null references programmer_tasks(id) on delete cascade,
|
||||
author_id uuid not null references profiles(id),
|
||||
description text not null,
|
||||
minutes int check (minutes is null or minutes > 0), -- null = assignee progress note
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
create index if not exists idx_programmer_task_work_logs_task_id on programmer_task_work_logs(task_id);
|
||||
|
||||
alter table programmer_task_work_logs enable row level security;
|
||||
|
||||
-- Append-only: select + insert only (no update/delete policies).
|
||||
drop policy if exists "programmer_task_work_logs_select" on programmer_task_work_logs;
|
||||
create policy "programmer_task_work_logs_select" on programmer_task_work_logs
|
||||
for select to authenticated
|
||||
using (programmer_task_visible(task_id));
|
||||
|
||||
drop policy if exists "programmer_task_work_logs_insert" on programmer_task_work_logs;
|
||||
create policy "programmer_task_work_logs_insert" on programmer_task_work_logs
|
||||
for insert to authenticated
|
||||
with check (author_id = auth.uid() and programmer_task_visible(task_id));
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Comments (+ attachments stored in the task_attachments bucket)
|
||||
-- ---------------------------------------------------------------------------
|
||||
create table if not exists programmer_task_comments (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
task_id uuid not null references programmer_tasks(id) on delete cascade,
|
||||
author_id uuid not null references profiles(id),
|
||||
body text not null default '',
|
||||
attachments jsonb not null default '[]'::jsonb, -- [{path, name}]
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
create index if not exists idx_programmer_task_comments_task_id on programmer_task_comments(task_id);
|
||||
|
||||
alter table programmer_task_comments enable row level security;
|
||||
|
||||
drop policy if exists "programmer_task_comments_select" on programmer_task_comments;
|
||||
create policy "programmer_task_comments_select" on programmer_task_comments
|
||||
for select to authenticated
|
||||
using (programmer_task_visible(task_id));
|
||||
|
||||
drop policy if exists "programmer_task_comments_insert" on programmer_task_comments;
|
||||
create policy "programmer_task_comments_insert" on programmer_task_comments
|
||||
for insert to authenticated
|
||||
with check (author_id = auth.uid() and programmer_task_visible(task_id));
|
||||
|
||||
drop policy if exists "programmer_task_comments_delete" on programmer_task_comments;
|
||||
create policy "programmer_task_comments_delete" on programmer_task_comments
|
||||
for delete to authenticated
|
||||
using (
|
||||
author_id = auth.uid()
|
||||
or exists (select 1 from profiles p where p.id = auth.uid() and p.role in ('admin', 'programmer'))
|
||||
);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Realtime
|
||||
-- ---------------------------------------------------------------------------
|
||||
alter publication supabase_realtime add table programmer_projects;
|
||||
alter publication supabase_realtime add table programmer_task_work_logs;
|
||||
alter publication supabase_realtime add table programmer_task_comments;
|
||||
@@ -0,0 +1,311 @@
|
||||
-- Programmer Day Sheets — daily approval workflow for programmer work logs.
|
||||
-- Each programmer gets one sheet per calendar day (Manila timezone). Sheets
|
||||
-- aggregate work-log entries and activity-log events, flow through
|
||||
-- draft → pending → approved/disapproved state machine, and expose an
|
||||
-- event trail (submitted, disapproved, justified, approved, amended).
|
||||
--
|
||||
-- Depends on:
|
||||
-- 20260927120000_add_programmer_tasks.sql (programmer_tasks, programmer_task_activity_logs,
|
||||
-- set_programmer_tasks_updated_at)
|
||||
-- 20260927140000_add_programmer_projects_worklogs_comments.sql
|
||||
-- (programmer_task_work_logs)
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 1. manila_today() — canonical Manila-timezone "today" used everywhere
|
||||
-- ---------------------------------------------------------------------------
|
||||
create or replace function manila_today()
|
||||
returns date
|
||||
language sql
|
||||
stable
|
||||
security definer
|
||||
set search_path = public
|
||||
as $$
|
||||
select (now() at time zone 'Asia/Manila')::date;
|
||||
$$;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 2. programmer_day_sheets
|
||||
-- ---------------------------------------------------------------------------
|
||||
create table if not exists programmer_day_sheets (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
programmer_id uuid not null references profiles(id),
|
||||
work_date date not null,
|
||||
status text not null default 'draft'
|
||||
check (status in ('draft','pending','disapproved','approved')),
|
||||
submitted_at timestamptz,
|
||||
auto_submitted bool not null default false,
|
||||
reviewed_by uuid references profiles(id),
|
||||
reviewed_at timestamptz,
|
||||
resubmissions int not null default 0,
|
||||
approved_snapshot jsonb,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
unique (programmer_id, work_date)
|
||||
);
|
||||
|
||||
create index if not exists idx_programmer_day_sheets_status_work_date
|
||||
on programmer_day_sheets (status, work_date);
|
||||
|
||||
alter table programmer_day_sheets enable row level security;
|
||||
|
||||
-- updated_at trigger — reuse the existing function
|
||||
drop trigger if exists trg_programmer_day_sheets_updated_at on programmer_day_sheets;
|
||||
create trigger trg_programmer_day_sheets_updated_at
|
||||
before update on programmer_day_sheets
|
||||
for each row execute function set_programmer_tasks_updated_at();
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 3. programmer_day_sheet_events
|
||||
-- ---------------------------------------------------------------------------
|
||||
create table if not exists programmer_day_sheet_events (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
sheet_id uuid not null references programmer_day_sheets(id) on delete cascade,
|
||||
actor_id uuid references profiles(id),
|
||||
kind text not null
|
||||
check (kind in ('submitted','auto_submitted','disapproved',
|
||||
'justified','approved','amended')),
|
||||
body text,
|
||||
flagged_task_ids uuid[] not null default '{}',
|
||||
created_at timestamptz not null default now(),
|
||||
-- body must be non-empty for disapproval and justification events
|
||||
check (kind not in ('disapproved','justified') or (body is not null and body <> ''))
|
||||
);
|
||||
|
||||
alter table programmer_day_sheet_events enable row level security;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 4. work_date column on programmer_task_work_logs
|
||||
-- ---------------------------------------------------------------------------
|
||||
alter table programmer_task_work_logs
|
||||
add column if not exists work_date date;
|
||||
|
||||
-- Backfill: derive from created_at in Manila timezone
|
||||
update programmer_task_work_logs
|
||||
set work_date = (created_at at time zone 'Asia/Manila')::date
|
||||
where work_date is null;
|
||||
|
||||
-- Now make it default + not null
|
||||
alter table programmer_task_work_logs
|
||||
alter column work_date set default manila_today();
|
||||
|
||||
alter table programmer_task_work_logs
|
||||
alter column work_date set not null;
|
||||
|
||||
create index if not exists idx_programmer_task_work_logs_author_work_date
|
||||
on programmer_task_work_logs (author_id, work_date);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 5. ensure_day_sheet(p_programmer, p_date) — idempotent sheet bootstrap
|
||||
-- SECURITY DEFINER; callers (trigger functions) have no direct table access.
|
||||
-- ---------------------------------------------------------------------------
|
||||
create or replace function ensure_day_sheet(
|
||||
p_programmer uuid,
|
||||
p_date date
|
||||
)
|
||||
returns uuid
|
||||
language plpgsql
|
||||
security definer
|
||||
set search_path = public
|
||||
as $$
|
||||
declare
|
||||
v_role text;
|
||||
v_id uuid;
|
||||
v_status text;
|
||||
begin
|
||||
-- Only create sheets for programmers
|
||||
select role into v_role from profiles where id = p_programmer;
|
||||
if v_role is distinct from 'programmer' then
|
||||
return null;
|
||||
end if;
|
||||
|
||||
-- Insert a draft sheet; conflict means the sheet already exists
|
||||
insert into programmer_day_sheets (programmer_id, work_date, status)
|
||||
values (p_programmer, p_date, 'draft')
|
||||
on conflict (programmer_id, work_date) do nothing;
|
||||
|
||||
-- Fetch the (potentially pre-existing) sheet id and status
|
||||
select id, status
|
||||
into v_id, v_status
|
||||
from programmer_day_sheets
|
||||
where programmer_id = p_programmer
|
||||
and work_date = p_date;
|
||||
|
||||
-- If the sheet was previously approved and we are touching today's date,
|
||||
-- reopen it to pending and record an amended event.
|
||||
if v_status = 'approved' and p_date = manila_today() then
|
||||
update programmer_day_sheets
|
||||
set status = 'pending',
|
||||
updated_at = now()
|
||||
where id = v_id;
|
||||
|
||||
insert into programmer_day_sheet_events (sheet_id, actor_id, kind, body)
|
||||
values (v_id, null, 'amended', 'New work was logged after approval.');
|
||||
end if;
|
||||
|
||||
return v_id;
|
||||
end;
|
||||
$$;
|
||||
|
||||
-- Restrict direct invocation — trigger functions are the only callers
|
||||
revoke execute on function ensure_day_sheet(uuid, date) from public;
|
||||
revoke execute on function ensure_day_sheet(uuid, date) from anon;
|
||||
revoke execute on function ensure_day_sheet(uuid, date) from authenticated;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 6. BEFORE INSERT trigger on programmer_task_work_logs
|
||||
-- Validates and normalises work_date; raises work_date_locked when needed.
|
||||
-- ---------------------------------------------------------------------------
|
||||
create or replace function validate_work_log_date()
|
||||
returns trigger
|
||||
language plpgsql
|
||||
security definer
|
||||
set search_path = public
|
||||
as $$
|
||||
declare
|
||||
v_sheet_status text;
|
||||
begin
|
||||
-- Default work_date to today (Manila) when the caller left it null
|
||||
new.work_date := coalesce(new.work_date, manila_today());
|
||||
|
||||
-- Future dates are never allowed
|
||||
if new.work_date > manila_today() then
|
||||
raise exception 'work_date_locked' using errcode = 'P0001';
|
||||
end if;
|
||||
|
||||
-- Past dates are allowed only when the author already has an open sheet
|
||||
-- (status pending or disapproved) for that date — i.e. the window is still
|
||||
-- editable. No sheet or any other status → locked.
|
||||
if new.work_date < manila_today() then
|
||||
select status
|
||||
into v_sheet_status
|
||||
from programmer_day_sheets
|
||||
where programmer_id = new.author_id
|
||||
and work_date = new.work_date;
|
||||
|
||||
if v_sheet_status is null or v_sheet_status not in ('pending', 'disapproved') then
|
||||
raise exception 'work_date_locked' using errcode = 'P0001';
|
||||
end if;
|
||||
end if;
|
||||
|
||||
return new;
|
||||
end;
|
||||
$$;
|
||||
|
||||
drop trigger if exists trg_work_logs_validate_date on programmer_task_work_logs;
|
||||
create trigger trg_work_logs_validate_date
|
||||
before insert on programmer_task_work_logs
|
||||
for each row execute function validate_work_log_date();
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 7. AFTER INSERT trigger on programmer_task_work_logs
|
||||
-- Ensures a day sheet exists for the author on the work_date.
|
||||
-- ---------------------------------------------------------------------------
|
||||
create or replace function after_work_log_ensure_sheet()
|
||||
returns trigger
|
||||
language plpgsql
|
||||
security definer
|
||||
set search_path = public
|
||||
as $$
|
||||
begin
|
||||
perform ensure_day_sheet(new.author_id, new.work_date);
|
||||
return null;
|
||||
end;
|
||||
$$;
|
||||
|
||||
drop trigger if exists trg_work_logs_ensure_sheet on programmer_task_work_logs;
|
||||
create trigger trg_work_logs_ensure_sheet
|
||||
after insert on programmer_task_work_logs
|
||||
for each row execute function after_work_log_ensure_sheet();
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 8. AFTER INSERT trigger on programmer_task_activity_logs
|
||||
-- Ensures a day sheet for the task's assignee on relevant action types.
|
||||
-- ---------------------------------------------------------------------------
|
||||
create or replace function after_activity_log_ensure_sheet()
|
||||
returns trigger
|
||||
language plpgsql
|
||||
security definer
|
||||
set search_path = public
|
||||
as $$
|
||||
declare
|
||||
v_assignee uuid;
|
||||
begin
|
||||
if new.action_type not in ('started','paused','resumed','completed','cancelled','adjustment') then
|
||||
return null;
|
||||
end if;
|
||||
|
||||
select assignee_id into v_assignee
|
||||
from programmer_tasks
|
||||
where id = new.task_id;
|
||||
|
||||
if v_assignee is not null then
|
||||
perform ensure_day_sheet(
|
||||
v_assignee,
|
||||
(new.created_at at time zone 'Asia/Manila')::date
|
||||
);
|
||||
end if;
|
||||
|
||||
return null;
|
||||
end;
|
||||
$$;
|
||||
|
||||
drop trigger if exists trg_activity_logs_ensure_sheet on programmer_task_activity_logs;
|
||||
create trigger trg_activity_logs_ensure_sheet
|
||||
after insert on programmer_task_activity_logs
|
||||
for each row execute function after_activity_log_ensure_sheet();
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 9. RLS policies
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
-- programmer_day_sheets: SELECT — own sheet or admin
|
||||
drop policy if exists "programmer_day_sheets_select" on programmer_day_sheets;
|
||||
create policy "programmer_day_sheets_select" on programmer_day_sheets
|
||||
for select to authenticated
|
||||
using (
|
||||
programmer_id = auth.uid()
|
||||
or exists (
|
||||
select 1 from profiles p
|
||||
where p.id = auth.uid()
|
||||
and p.role = 'admin'
|
||||
)
|
||||
);
|
||||
|
||||
-- programmer_day_sheet_events: SELECT — must be able to see the parent sheet
|
||||
drop policy if exists "programmer_day_sheet_events_select" on programmer_day_sheet_events;
|
||||
create policy "programmer_day_sheet_events_select" on programmer_day_sheet_events
|
||||
for select to authenticated
|
||||
using (
|
||||
exists (
|
||||
select 1 from programmer_day_sheets s
|
||||
where s.id = sheet_id
|
||||
and (
|
||||
s.programmer_id = auth.uid()
|
||||
or exists (
|
||||
select 1 from profiles p
|
||||
where p.id = auth.uid()
|
||||
and p.role = 'admin'
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
-- No client INSERT/UPDATE/DELETE policies on either table — all writes go
|
||||
-- through SECURITY DEFINER functions or server-side triggers.
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 10. notifications.day_sheet_id FK
|
||||
-- ---------------------------------------------------------------------------
|
||||
alter table notifications
|
||||
add column if not exists day_sheet_id uuid
|
||||
references programmer_day_sheets(id) on delete cascade;
|
||||
|
||||
create index if not exists idx_notifications_day_sheet_id
|
||||
on notifications (day_sheet_id)
|
||||
where day_sheet_id is not null;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 11. Realtime publication
|
||||
-- ---------------------------------------------------------------------------
|
||||
alter publication supabase_realtime add table programmer_day_sheets;
|
||||
alter publication supabase_realtime add table programmer_day_sheet_events;
|
||||
@@ -0,0 +1,482 @@
|
||||
-- Day sheet RPCs, auto-submit cron, and digest queue.
|
||||
--
|
||||
-- Depends on:
|
||||
-- 20260927150000_add_programmer_day_sheets.sql (programmer_day_sheets,
|
||||
-- programmer_day_sheet_events, ensure_day_sheet, manila_today)
|
||||
--
|
||||
-- Provides (SECURITY DEFINER, set search_path = public):
|
||||
-- submit_day_sheet(p_date date) returns jsonb
|
||||
-- disapprove_day_sheet(p_sheet uuid, p_remarks text,
|
||||
-- p_flagged_task_ids uuid[]) returns jsonb
|
||||
-- justify_day_sheet(p_sheet uuid, p_body text) returns jsonb
|
||||
-- approve_day_sheets(p_items jsonb) returns jsonb
|
||||
-- auto_submit_day_sheets() returns void
|
||||
-- (revoked from public/anon/authenticated)
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 1. submit_day_sheet
|
||||
-- ---------------------------------------------------------------------------
|
||||
create or replace function public.submit_day_sheet(p_date date)
|
||||
returns jsonb
|
||||
language plpgsql
|
||||
security definer
|
||||
set search_path = public
|
||||
as $$
|
||||
declare
|
||||
v_caller_id uuid := auth.uid();
|
||||
v_caller_role text;
|
||||
v_sheet programmer_day_sheets%rowtype;
|
||||
v_admin record;
|
||||
v_notify_ids uuid[] := '{}';
|
||||
v_prog_name text;
|
||||
begin
|
||||
-- Caller must be a programmer
|
||||
select role into v_caller_role
|
||||
from profiles
|
||||
where id = v_caller_id;
|
||||
|
||||
if v_caller_role is distinct from 'programmer' then
|
||||
raise exception 'not_authorized';
|
||||
end if;
|
||||
|
||||
-- Future dates are not allowed
|
||||
if p_date > manila_today() then
|
||||
raise exception 'future_date';
|
||||
end if;
|
||||
|
||||
-- Upsert draft row (idempotent bootstrap)
|
||||
insert into programmer_day_sheets (programmer_id, work_date, status)
|
||||
values (v_caller_id, p_date, 'draft')
|
||||
on conflict (programmer_id, work_date) do nothing;
|
||||
|
||||
-- Lock the sheet row before status check
|
||||
select * into v_sheet
|
||||
from programmer_day_sheets
|
||||
where programmer_id = v_caller_id
|
||||
and work_date = p_date
|
||||
for update;
|
||||
|
||||
if v_sheet.status <> 'draft' then
|
||||
raise exception 'already_submitted';
|
||||
end if;
|
||||
|
||||
-- Submit
|
||||
update programmer_day_sheets
|
||||
set status = 'pending',
|
||||
submitted_at = now(),
|
||||
auto_submitted = false
|
||||
where id = v_sheet.id;
|
||||
|
||||
insert into programmer_day_sheet_events (sheet_id, actor_id, kind)
|
||||
values (v_sheet.id, v_caller_id, 'submitted');
|
||||
|
||||
-- Notify every admin
|
||||
for v_admin in
|
||||
select id from profiles where role = 'admin'
|
||||
loop
|
||||
insert into notifications (user_id, actor_id, type, day_sheet_id)
|
||||
values (v_admin.id, v_caller_id, 'day_sheet_submitted', v_sheet.id);
|
||||
|
||||
v_notify_ids := v_notify_ids || v_admin.id;
|
||||
end loop;
|
||||
|
||||
select full_name into v_prog_name
|
||||
from profiles
|
||||
where id = v_caller_id;
|
||||
|
||||
return jsonb_build_object(
|
||||
'sheet_id', v_sheet.id,
|
||||
'work_date', v_sheet.work_date,
|
||||
'programmer_id', v_caller_id,
|
||||
'programmer_name', v_prog_name,
|
||||
'notify_user_ids', v_notify_ids
|
||||
);
|
||||
end;
|
||||
$$;
|
||||
|
||||
grant execute on function public.submit_day_sheet(date) to authenticated;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 2. disapprove_day_sheet
|
||||
-- ---------------------------------------------------------------------------
|
||||
create or replace function public.disapprove_day_sheet(
|
||||
p_sheet uuid,
|
||||
p_remarks text,
|
||||
p_flagged_task_ids uuid[] default '{}'
|
||||
)
|
||||
returns jsonb
|
||||
language plpgsql
|
||||
security definer
|
||||
set search_path = public
|
||||
as $$
|
||||
declare
|
||||
v_caller_id uuid := auth.uid();
|
||||
v_caller_role text;
|
||||
v_sheet programmer_day_sheets%rowtype;
|
||||
v_prog_name text;
|
||||
begin
|
||||
-- Caller must be admin
|
||||
select role into v_caller_role
|
||||
from profiles
|
||||
where id = v_caller_id;
|
||||
|
||||
if v_caller_role is distinct from 'admin' then
|
||||
raise exception 'not_authorized';
|
||||
end if;
|
||||
|
||||
if btrim(p_remarks) = '' then
|
||||
raise exception 'remarks_required';
|
||||
end if;
|
||||
|
||||
-- Lock the sheet row before status check
|
||||
select * into v_sheet
|
||||
from programmer_day_sheets
|
||||
where id = p_sheet
|
||||
for update;
|
||||
|
||||
if v_sheet.status <> 'pending' then
|
||||
raise exception 'already_reviewed';
|
||||
end if;
|
||||
|
||||
update programmer_day_sheets
|
||||
set status = 'disapproved',
|
||||
reviewed_by = v_caller_id,
|
||||
reviewed_at = now()
|
||||
where id = p_sheet;
|
||||
|
||||
insert into programmer_day_sheet_events
|
||||
(sheet_id, actor_id, kind, body, flagged_task_ids)
|
||||
values
|
||||
(p_sheet, v_caller_id, 'disapproved', p_remarks, p_flagged_task_ids);
|
||||
|
||||
insert into notifications (user_id, actor_id, type, day_sheet_id)
|
||||
values (v_sheet.programmer_id, v_caller_id, 'day_sheet_disapproved', p_sheet);
|
||||
|
||||
select full_name into v_prog_name
|
||||
from profiles
|
||||
where id = v_sheet.programmer_id;
|
||||
|
||||
return jsonb_build_object(
|
||||
'sheet_id', p_sheet,
|
||||
'work_date', v_sheet.work_date,
|
||||
'programmer_id', v_sheet.programmer_id,
|
||||
'programmer_name', v_prog_name,
|
||||
'notify_user_ids', array[v_sheet.programmer_id]
|
||||
);
|
||||
end;
|
||||
$$;
|
||||
|
||||
grant execute on function public.disapprove_day_sheet(uuid, text, uuid[]) to authenticated;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 3. justify_day_sheet
|
||||
-- ---------------------------------------------------------------------------
|
||||
create or replace function public.justify_day_sheet(
|
||||
p_sheet uuid,
|
||||
p_body text
|
||||
)
|
||||
returns jsonb
|
||||
language plpgsql
|
||||
security definer
|
||||
set search_path = public
|
||||
as $$
|
||||
declare
|
||||
v_caller_id uuid := auth.uid();
|
||||
v_sheet programmer_day_sheets%rowtype;
|
||||
v_notify_ids uuid[] := '{}';
|
||||
v_prog_name text;
|
||||
begin
|
||||
if btrim(p_body) = '' then
|
||||
raise exception 'body_required';
|
||||
end if;
|
||||
|
||||
-- Lock the sheet row before status check
|
||||
select * into v_sheet
|
||||
from programmer_day_sheets
|
||||
where id = p_sheet
|
||||
for update;
|
||||
|
||||
-- Caller must be the sheet owner
|
||||
if v_sheet.programmer_id is distinct from v_caller_id then
|
||||
raise exception 'not_authorized';
|
||||
end if;
|
||||
|
||||
if v_sheet.status <> 'disapproved' then
|
||||
raise exception 'already_reviewed';
|
||||
end if;
|
||||
|
||||
update programmer_day_sheets
|
||||
set status = 'pending',
|
||||
resubmissions = resubmissions + 1,
|
||||
submitted_at = now()
|
||||
where id = p_sheet;
|
||||
|
||||
insert into programmer_day_sheet_events (sheet_id, actor_id, kind, body)
|
||||
values (p_sheet, v_caller_id, 'justified', p_body);
|
||||
|
||||
-- Notify the previous reviewer if set
|
||||
if v_sheet.reviewed_by is not null then
|
||||
insert into notifications (user_id, actor_id, type, day_sheet_id)
|
||||
values (v_sheet.reviewed_by, v_caller_id, 'day_sheet_justified', p_sheet);
|
||||
|
||||
v_notify_ids := array[v_sheet.reviewed_by];
|
||||
end if;
|
||||
|
||||
select full_name into v_prog_name
|
||||
from profiles
|
||||
where id = v_caller_id;
|
||||
|
||||
return jsonb_build_object(
|
||||
'sheet_id', p_sheet,
|
||||
'work_date', v_sheet.work_date,
|
||||
'programmer_id', v_caller_id,
|
||||
'programmer_name', v_prog_name,
|
||||
'notify_user_ids', v_notify_ids
|
||||
);
|
||||
end;
|
||||
$$;
|
||||
|
||||
grant execute on function public.justify_day_sheet(uuid, text) to authenticated;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 4. approve_day_sheets
|
||||
-- ---------------------------------------------------------------------------
|
||||
create or replace function public.approve_day_sheets(p_items jsonb)
|
||||
returns jsonb
|
||||
language plpgsql
|
||||
security definer
|
||||
set search_path = public
|
||||
as $$
|
||||
declare
|
||||
v_caller_id uuid := auth.uid();
|
||||
v_caller_role text;
|
||||
v_item jsonb;
|
||||
v_sheet_id uuid;
|
||||
v_snapshot jsonb;
|
||||
v_sheet programmer_day_sheets%rowtype;
|
||||
v_approved jsonb[] := '{}';
|
||||
v_skipped uuid[] := '{}';
|
||||
v_prog_name text;
|
||||
begin
|
||||
-- Caller must be admin
|
||||
select role into v_caller_role
|
||||
from profiles
|
||||
where id = v_caller_id;
|
||||
|
||||
if v_caller_role is distinct from 'admin' then
|
||||
raise exception 'not_authorized';
|
||||
end if;
|
||||
|
||||
for v_item in select * from jsonb_array_elements(p_items)
|
||||
loop
|
||||
v_sheet_id := (v_item->>'sheet_id')::uuid;
|
||||
v_snapshot := v_item->'snapshot';
|
||||
|
||||
-- Validate snapshot: must be an object with a 'rows' array
|
||||
if jsonb_typeof(v_snapshot) <> 'object'
|
||||
or jsonb_typeof(v_snapshot->'rows') <> 'array' then
|
||||
raise exception 'invalid_snapshot';
|
||||
end if;
|
||||
|
||||
-- Lock the sheet row before status check
|
||||
select * into v_sheet
|
||||
from programmer_day_sheets
|
||||
where id = v_sheet_id
|
||||
for update;
|
||||
|
||||
if v_sheet.status not in ('pending', 'disapproved') then
|
||||
v_skipped := v_skipped || v_sheet_id;
|
||||
continue;
|
||||
end if;
|
||||
|
||||
update programmer_day_sheets
|
||||
set status = 'approved',
|
||||
reviewed_by = v_caller_id,
|
||||
reviewed_at = now(),
|
||||
approved_snapshot = v_snapshot
|
||||
where id = v_sheet_id;
|
||||
|
||||
insert into programmer_day_sheet_events (sheet_id, actor_id, kind)
|
||||
values (v_sheet_id, v_caller_id, 'approved');
|
||||
|
||||
insert into notifications (user_id, actor_id, type, day_sheet_id)
|
||||
values (v_sheet.programmer_id, v_caller_id, 'day_sheet_approved', v_sheet_id);
|
||||
|
||||
select full_name into v_prog_name
|
||||
from profiles
|
||||
where id = v_sheet.programmer_id;
|
||||
|
||||
v_approved := v_approved || jsonb_build_object(
|
||||
'sheet_id', v_sheet_id,
|
||||
'programmer_id', v_sheet.programmer_id,
|
||||
'work_date', v_sheet.work_date
|
||||
);
|
||||
end loop;
|
||||
|
||||
return jsonb_build_object(
|
||||
'approved', to_jsonb(v_approved),
|
||||
'skipped_ids', to_jsonb(v_skipped)
|
||||
);
|
||||
end;
|
||||
$$;
|
||||
|
||||
grant execute on function public.approve_day_sheets(jsonb) to authenticated;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 5. auto_submit_day_sheets
|
||||
-- ---------------------------------------------------------------------------
|
||||
create or replace function public.auto_submit_day_sheets()
|
||||
returns void
|
||||
language plpgsql
|
||||
security definer
|
||||
set search_path = public
|
||||
as $$
|
||||
declare
|
||||
v_rec record;
|
||||
v_sheet_id uuid;
|
||||
v_sheet_status text;
|
||||
v_any_pending boolean := false;
|
||||
v_admin record;
|
||||
begin
|
||||
-- ── Step 1: tasks whose timer is still running at end of day ───────────────
|
||||
-- For each in_progress task where the latest started/paused/resumed event is
|
||||
-- not 'paused', ensure a day sheet exists for the programmer assignee on
|
||||
-- yesterday's date. If that sheet is approved, reopen it to pending.
|
||||
for v_rec in
|
||||
select distinct pt.assignee_id
|
||||
from programmer_tasks pt
|
||||
where pt.status = 'in_progress'
|
||||
and pt.assignee_id is not null
|
||||
and exists (
|
||||
select 1 from profiles p
|
||||
where p.id = pt.assignee_id
|
||||
and p.role = 'programmer'
|
||||
)
|
||||
and exists (
|
||||
select 1 from programmer_task_activity_logs pal
|
||||
where pal.task_id = pt.id
|
||||
and pal.action_type in ('started', 'paused', 'resumed')
|
||||
)
|
||||
and (
|
||||
select pal2.action_type
|
||||
from programmer_task_activity_logs pal2
|
||||
where pal2.task_id = pt.id
|
||||
and pal2.action_type in ('started', 'paused', 'resumed')
|
||||
order by pal2.created_at desc
|
||||
limit 1
|
||||
) <> 'paused'
|
||||
loop
|
||||
v_sheet_id := ensure_day_sheet(v_rec.assignee_id, manila_today() - 1);
|
||||
|
||||
if v_sheet_id is null then
|
||||
continue;
|
||||
end if;
|
||||
|
||||
-- Lock and check the sheet status
|
||||
select status into v_sheet_status
|
||||
from programmer_day_sheets
|
||||
where id = v_sheet_id
|
||||
for update;
|
||||
|
||||
if v_sheet_status = 'approved' then
|
||||
update programmer_day_sheets
|
||||
set status = 'pending',
|
||||
updated_at = now()
|
||||
where id = v_sheet_id;
|
||||
|
||||
insert into programmer_day_sheet_events (sheet_id, actor_id, kind, body)
|
||||
values (v_sheet_id, null, 'amended',
|
||||
'Timer was still running at the end of the day.');
|
||||
|
||||
v_any_pending := true;
|
||||
end if;
|
||||
end loop;
|
||||
|
||||
-- ── Step 2: auto-submit stale draft sheets ─────────────────────────────────
|
||||
-- Every draft sheet with work_date < today is promoted to pending.
|
||||
for v_rec in
|
||||
select id
|
||||
from programmer_day_sheets
|
||||
where status = 'draft'
|
||||
and work_date < manila_today()
|
||||
for update
|
||||
loop
|
||||
update programmer_day_sheets
|
||||
set status = 'pending',
|
||||
submitted_at = now(),
|
||||
auto_submitted = true
|
||||
where id = v_rec.id;
|
||||
|
||||
insert into programmer_day_sheet_events (sheet_id, actor_id, kind)
|
||||
values (v_rec.id, null, 'auto_submitted');
|
||||
|
||||
v_any_pending := true;
|
||||
end loop;
|
||||
|
||||
-- ── Step 3: digest notification for admins ─────────────────────────────────
|
||||
-- If any sheet transitioned to pending, insert a notification and a
|
||||
-- scheduled digest row (one per admin, idempotent via ON CONFLICT DO NOTHING).
|
||||
if v_any_pending then
|
||||
for v_admin in
|
||||
select id from profiles where role = 'admin'
|
||||
loop
|
||||
insert into notifications (user_id, actor_id, type)
|
||||
values (v_admin.id, null, 'day_sheet_digest');
|
||||
|
||||
insert into scheduled_notifications (
|
||||
user_id,
|
||||
notify_type,
|
||||
scheduled_for,
|
||||
epoch
|
||||
) values (
|
||||
v_admin.id,
|
||||
'day_sheet_digest',
|
||||
(manila_today() + time '08:00') at time zone 'Asia/Manila',
|
||||
to_char(manila_today(), 'YYYYMMDD')::int
|
||||
) on conflict do nothing;
|
||||
end loop;
|
||||
end if;
|
||||
end;
|
||||
$$;
|
||||
|
||||
-- auto_submit_day_sheets is a cron-only function; revoke from all client roles
|
||||
revoke execute on function public.auto_submit_day_sheets() from public;
|
||||
revoke execute on function public.auto_submit_day_sheets() from anon;
|
||||
revoke execute on function public.auto_submit_day_sheets() from authenticated;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 6. Extend chk_at_least_one_ref to allow day_sheet_digest rows
|
||||
-- (which have no FK reference, identified solely by notify_type)
|
||||
-- ---------------------------------------------------------------------------
|
||||
alter table public.scheduled_notifications
|
||||
drop constraint if exists chk_at_least_one_ref;
|
||||
|
||||
alter table public.scheduled_notifications
|
||||
add constraint chk_at_least_one_ref check (
|
||||
schedule_id is not null
|
||||
or task_id is not null
|
||||
or it_service_request_id is not null
|
||||
or pass_slip_id is not null
|
||||
or announcement_id is not null
|
||||
or notify_type = 'day_sheet_digest'
|
||||
);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 7. pg_cron: auto-submit at 00:05 Manila time (16:05 UTC)
|
||||
-- ---------------------------------------------------------------------------
|
||||
do $$
|
||||
begin
|
||||
begin
|
||||
perform cron.unschedule('programmer_day_sheet_autosubmit');
|
||||
exception when others then null;
|
||||
end;
|
||||
|
||||
perform cron.schedule(
|
||||
'programmer_day_sheet_autosubmit',
|
||||
'5 16 * * *',
|
||||
'SELECT public.auto_submit_day_sheets();'
|
||||
);
|
||||
|
||||
exception when others then
|
||||
raise notice 'pg_cron scheduling failed: %. Set up cron jobs manually or use external cron.', sqlerrm;
|
||||
end $$;
|
||||
@@ -0,0 +1,692 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:tasq/models/programmer_day_sheet.model.dart';
|
||||
import 'package:tasq/models/programmer_task.model.dart';
|
||||
import 'package:tasq/models/profile.model.dart';
|
||||
import 'package:tasq/providers/reports_provider.dart';
|
||||
import 'package:tasq/screens/programmer_tasks/report/accomplishment_report_data.dart';
|
||||
import 'package:tasq/utils/programmer_day_rows.dart';
|
||||
|
||||
void main() {
|
||||
group('buildAccomplishmentReportData', () {
|
||||
final programmer = Profile(
|
||||
id: 'prog1',
|
||||
role: 'programmer',
|
||||
fullName: 'John Doe',
|
||||
);
|
||||
|
||||
final admin = Profile(
|
||||
id: 'admin1',
|
||||
role: 'administrator',
|
||||
fullName: 'Admin User',
|
||||
);
|
||||
|
||||
final reviewer = Profile(
|
||||
id: 'reviewer1',
|
||||
role: 'reviewer',
|
||||
fullName: 'Jane Reviewer',
|
||||
);
|
||||
|
||||
test('approved-only uses snapshots and counts excluded days', () {
|
||||
final day1 = DateTime.utc(2026, 9, 1);
|
||||
final day2 = DateTime.utc(2026, 9, 2);
|
||||
final day3 = DateTime.utc(2026, 9, 3);
|
||||
final day4 = DateTime.utc(2026, 9, 4);
|
||||
|
||||
final row1 = DaySheetRow(
|
||||
taskId: 't1',
|
||||
title: 'Task 1',
|
||||
category: 'Software Development',
|
||||
kind: 'assignee',
|
||||
seconds: 3600,
|
||||
notes: [],
|
||||
);
|
||||
|
||||
final row2 = DaySheetRow(
|
||||
taskId: 't2',
|
||||
title: 'Task 2',
|
||||
category: 'Bug Fix',
|
||||
kind: 'assignee',
|
||||
seconds: 1800,
|
||||
notes: [],
|
||||
);
|
||||
|
||||
// 2 approved sheets
|
||||
final sheets = [
|
||||
ProgrammerDaySheet(
|
||||
id: 'sheet1',
|
||||
programmerId: 'prog1',
|
||||
status: 'approved',
|
||||
workDate: day1,
|
||||
autoSubmitted: false,
|
||||
resubmissions: 0,
|
||||
reviewedBy: 'reviewer1',
|
||||
approvedSnapshot: DaySheetSnapshot(totalSeconds: 3600, rows: [row1]),
|
||||
createdAt: DateTime.utc(2026, 9, 1),
|
||||
updatedAt: DateTime.utc(2026, 9, 1),
|
||||
),
|
||||
ProgrammerDaySheet(
|
||||
id: 'sheet2',
|
||||
programmerId: 'prog1',
|
||||
status: 'approved',
|
||||
workDate: day2,
|
||||
autoSubmitted: false,
|
||||
resubmissions: 0,
|
||||
reviewedBy: 'reviewer1',
|
||||
approvedSnapshot: DaySheetSnapshot(totalSeconds: 1800, rows: [row2]),
|
||||
createdAt: DateTime.utc(2026, 9, 2),
|
||||
updatedAt: DateTime.utc(2026, 9, 2),
|
||||
),
|
||||
// 1 pending
|
||||
ProgrammerDaySheet(
|
||||
id: 'sheet3',
|
||||
programmerId: 'prog1',
|
||||
status: 'pending',
|
||||
workDate: day3,
|
||||
autoSubmitted: false,
|
||||
resubmissions: 0,
|
||||
createdAt: DateTime.utc(2026, 9, 3),
|
||||
updatedAt: DateTime.utc(2026, 9, 3),
|
||||
),
|
||||
// 1 disapproved
|
||||
ProgrammerDaySheet(
|
||||
id: 'sheet4',
|
||||
programmerId: 'prog1',
|
||||
status: 'disapproved',
|
||||
workDate: day4,
|
||||
autoSubmitted: false,
|
||||
resubmissions: 1,
|
||||
reviewedBy: 'reviewer1',
|
||||
createdAt: DateTime.utc(2026, 9, 4),
|
||||
updatedAt: DateTime.utc(2026, 9, 4),
|
||||
),
|
||||
];
|
||||
|
||||
final range = ReportDateRange(
|
||||
start: DateTime.utc(2026, 9, 1),
|
||||
end: DateTime.utc(2026, 9, 5),
|
||||
label: 'Test Range',
|
||||
);
|
||||
|
||||
final data = buildAccomplishmentReportData(
|
||||
programmer: programmer,
|
||||
range: range,
|
||||
includeUnapproved: false,
|
||||
sheets: sheets,
|
||||
liveRowsByDay: {},
|
||||
tasks: [],
|
||||
profileNames: {'reviewer1': 'Jane Reviewer'},
|
||||
);
|
||||
|
||||
expect(data.days.length, 2);
|
||||
expect(data.excludedPending, 1);
|
||||
expect(data.excludedDisapproved, 1);
|
||||
expect(data.days[0].statusTag, null);
|
||||
expect(data.days[1].statusTag, null);
|
||||
expect(data.totalSeconds, 5400);
|
||||
expect(data.daysReported, 2);
|
||||
});
|
||||
|
||||
test('include unapproved tags days and uses live rows', () {
|
||||
final day1 = DateTime.utc(2026, 9, 1);
|
||||
final day2 = DateTime.utc(2026, 9, 2);
|
||||
final day3 = DateTime.utc(2026, 9, 3);
|
||||
final day4 = DateTime.utc(2026, 9, 4);
|
||||
|
||||
final approvedRow = DaySheetRow(
|
||||
taskId: 't1',
|
||||
title: 'Task 1',
|
||||
category: 'Software Development',
|
||||
kind: 'assignee',
|
||||
seconds: 3600,
|
||||
notes: [],
|
||||
);
|
||||
|
||||
final pendingRow = DaySheetRow(
|
||||
taskId: 't2',
|
||||
title: 'Task 2',
|
||||
category: 'Bug Fix',
|
||||
kind: 'assignee',
|
||||
seconds: 1800,
|
||||
notes: [],
|
||||
);
|
||||
|
||||
final disapprovedRow = DaySheetRow(
|
||||
taskId: 't3',
|
||||
title: 'Task 3',
|
||||
category: 'Meeting',
|
||||
kind: 'assignee',
|
||||
seconds: 900,
|
||||
notes: [],
|
||||
);
|
||||
|
||||
final draftRow = DaySheetRow(
|
||||
taskId: 't4',
|
||||
title: 'Task 4',
|
||||
category: 'Software Support',
|
||||
kind: 'assignee',
|
||||
seconds: 600,
|
||||
notes: [],
|
||||
);
|
||||
|
||||
final sheets = [
|
||||
// approved
|
||||
ProgrammerDaySheet(
|
||||
id: 'sheet1',
|
||||
programmerId: 'prog1',
|
||||
status: 'approved',
|
||||
workDate: day1,
|
||||
autoSubmitted: false,
|
||||
resubmissions: 0,
|
||||
reviewedBy: 'reviewer1',
|
||||
approvedSnapshot:
|
||||
DaySheetSnapshot(totalSeconds: 3600, rows: [approvedRow]),
|
||||
createdAt: DateTime.utc(2026, 9, 1),
|
||||
updatedAt: DateTime.utc(2026, 9, 1),
|
||||
),
|
||||
// pending
|
||||
ProgrammerDaySheet(
|
||||
id: 'sheet2',
|
||||
programmerId: 'prog1',
|
||||
status: 'pending',
|
||||
workDate: day2,
|
||||
autoSubmitted: false,
|
||||
resubmissions: 0,
|
||||
createdAt: DateTime.utc(2026, 9, 2),
|
||||
updatedAt: DateTime.utc(2026, 9, 2),
|
||||
),
|
||||
// disapproved
|
||||
ProgrammerDaySheet(
|
||||
id: 'sheet3',
|
||||
programmerId: 'prog1',
|
||||
status: 'disapproved',
|
||||
workDate: day3,
|
||||
autoSubmitted: false,
|
||||
resubmissions: 1,
|
||||
reviewedBy: 'reviewer1',
|
||||
createdAt: DateTime.utc(2026, 9, 3),
|
||||
updatedAt: DateTime.utc(2026, 9, 3),
|
||||
),
|
||||
// draft
|
||||
ProgrammerDaySheet(
|
||||
id: 'sheet4',
|
||||
programmerId: 'prog1',
|
||||
status: 'draft',
|
||||
workDate: day4,
|
||||
autoSubmitted: false,
|
||||
resubmissions: 0,
|
||||
createdAt: DateTime.utc(2026, 9, 4),
|
||||
updatedAt: DateTime.utc(2026, 9, 4),
|
||||
),
|
||||
];
|
||||
|
||||
final range = ReportDateRange(
|
||||
start: DateTime.utc(2026, 9, 1),
|
||||
end: DateTime.utc(2026, 9, 5),
|
||||
label: 'Test Range',
|
||||
);
|
||||
|
||||
final liveRowsByDay = {
|
||||
day2: [pendingRow],
|
||||
day3: [disapprovedRow],
|
||||
day4: [draftRow],
|
||||
};
|
||||
|
||||
final data = buildAccomplishmentReportData(
|
||||
programmer: programmer,
|
||||
range: range,
|
||||
includeUnapproved: true,
|
||||
sheets: sheets,
|
||||
liveRowsByDay: liveRowsByDay,
|
||||
tasks: [],
|
||||
profileNames: {'reviewer1': 'Jane Reviewer'},
|
||||
);
|
||||
|
||||
expect(data.days.length, 4);
|
||||
expect(data.excludedPending, 0);
|
||||
expect(data.excludedDisapproved, 0);
|
||||
expect(data.days[0].statusTag, null); // approved
|
||||
expect(data.days[1].statusTag, 'Pending'); // pending
|
||||
expect(data.days[2].statusTag, 'Disapproved'); // disapproved
|
||||
expect(data.days[3].statusTag, 'Pending'); // draft
|
||||
});
|
||||
|
||||
test('period end is inclusive', () {
|
||||
final day1 = DateTime.utc(2026, 9, 1);
|
||||
|
||||
final row = DaySheetRow(
|
||||
taskId: 't1',
|
||||
title: 'Task 1',
|
||||
category: 'Software Development',
|
||||
kind: 'assignee',
|
||||
seconds: 3600,
|
||||
notes: [],
|
||||
);
|
||||
|
||||
final sheets = [
|
||||
ProgrammerDaySheet(
|
||||
id: 'sheet1',
|
||||
programmerId: 'prog1',
|
||||
status: 'approved',
|
||||
workDate: day1,
|
||||
autoSubmitted: false,
|
||||
resubmissions: 0,
|
||||
reviewedBy: 'reviewer1',
|
||||
approvedSnapshot: DaySheetSnapshot(totalSeconds: 3600, rows: [row]),
|
||||
createdAt: DateTime.utc(2026, 9, 1),
|
||||
updatedAt: DateTime.utc(2026, 9, 1),
|
||||
),
|
||||
];
|
||||
|
||||
final range = ReportDateRange(
|
||||
start: DateTime.utc(2026, 9, 1),
|
||||
end: DateTime.utc(2026, 10, 1), // exclusive
|
||||
label: 'Test Range',
|
||||
);
|
||||
|
||||
final data = buildAccomplishmentReportData(
|
||||
programmer: programmer,
|
||||
range: range,
|
||||
includeUnapproved: false,
|
||||
sheets: sheets,
|
||||
liveRowsByDay: {},
|
||||
tasks: [],
|
||||
profileNames: {'reviewer1': 'Jane Reviewer'},
|
||||
);
|
||||
|
||||
expect(data.periodStart, DateTime.utc(2026, 9, 1));
|
||||
expect(data.periodEndInclusive, DateTime.utc(2026, 9, 30));
|
||||
});
|
||||
|
||||
test('approver name only when one reviewer approved every included day', () {
|
||||
final day1 = DateTime.utc(2026, 9, 1);
|
||||
final day2 = DateTime.utc(2026, 9, 2);
|
||||
final day3 = DateTime.utc(2026, 9, 3);
|
||||
|
||||
final row = DaySheetRow(
|
||||
taskId: 't1',
|
||||
title: 'Task 1',
|
||||
category: 'Software Development',
|
||||
kind: 'assignee',
|
||||
seconds: 3600,
|
||||
notes: [],
|
||||
);
|
||||
|
||||
// Test: same reviewer for all approved days
|
||||
final sheets1 = [
|
||||
ProgrammerDaySheet(
|
||||
id: 'sheet1',
|
||||
programmerId: 'prog1',
|
||||
status: 'approved',
|
||||
workDate: day1,
|
||||
autoSubmitted: false,
|
||||
resubmissions: 0,
|
||||
reviewedBy: 'reviewer1',
|
||||
approvedSnapshot: DaySheetSnapshot(totalSeconds: 3600, rows: [row]),
|
||||
createdAt: DateTime.utc(2026, 9, 1),
|
||||
updatedAt: DateTime.utc(2026, 9, 1),
|
||||
),
|
||||
ProgrammerDaySheet(
|
||||
id: 'sheet2',
|
||||
programmerId: 'prog1',
|
||||
status: 'approved',
|
||||
workDate: day2,
|
||||
autoSubmitted: false,
|
||||
resubmissions: 0,
|
||||
reviewedBy: 'reviewer1',
|
||||
approvedSnapshot: DaySheetSnapshot(totalSeconds: 3600, rows: [row]),
|
||||
createdAt: DateTime.utc(2026, 9, 2),
|
||||
updatedAt: DateTime.utc(2026, 9, 2),
|
||||
),
|
||||
];
|
||||
|
||||
final range = ReportDateRange(
|
||||
start: DateTime.utc(2026, 9, 1),
|
||||
end: DateTime.utc(2026, 9, 4),
|
||||
label: 'Test Range',
|
||||
);
|
||||
|
||||
final data1 = buildAccomplishmentReportData(
|
||||
programmer: programmer,
|
||||
range: range,
|
||||
includeUnapproved: false,
|
||||
sheets: sheets1,
|
||||
liveRowsByDay: {},
|
||||
tasks: [],
|
||||
profileNames: {'reviewer1': 'Jane Reviewer'},
|
||||
);
|
||||
|
||||
expect(data1.approverName, 'Jane Reviewer');
|
||||
|
||||
// Test: mixed reviewers
|
||||
final sheets2 = [
|
||||
ProgrammerDaySheet(
|
||||
id: 'sheet1',
|
||||
programmerId: 'prog1',
|
||||
status: 'approved',
|
||||
workDate: day1,
|
||||
autoSubmitted: false,
|
||||
resubmissions: 0,
|
||||
reviewedBy: 'reviewer1',
|
||||
approvedSnapshot: DaySheetSnapshot(totalSeconds: 3600, rows: [row]),
|
||||
createdAt: DateTime.utc(2026, 9, 1),
|
||||
updatedAt: DateTime.utc(2026, 9, 1),
|
||||
),
|
||||
ProgrammerDaySheet(
|
||||
id: 'sheet2',
|
||||
programmerId: 'prog1',
|
||||
status: 'approved',
|
||||
workDate: day2,
|
||||
autoSubmitted: false,
|
||||
resubmissions: 0,
|
||||
reviewedBy: 'reviewer2',
|
||||
approvedSnapshot: DaySheetSnapshot(totalSeconds: 3600, rows: [row]),
|
||||
createdAt: DateTime.utc(2026, 9, 2),
|
||||
updatedAt: DateTime.utc(2026, 9, 2),
|
||||
),
|
||||
];
|
||||
|
||||
final data2 = buildAccomplishmentReportData(
|
||||
programmer: programmer,
|
||||
range: range,
|
||||
includeUnapproved: false,
|
||||
sheets: sheets2,
|
||||
liveRowsByDay: {},
|
||||
tasks: [],
|
||||
profileNames: {'reviewer1': 'Jane Reviewer', 'reviewer2': 'Bob Reviewer'},
|
||||
);
|
||||
|
||||
expect(data2.approverName, null);
|
||||
});
|
||||
|
||||
test('tasksCompleted counts tasks completed in range for the programmer', () {
|
||||
final range = ReportDateRange(
|
||||
start: DateTime.utc(2026, 9, 1),
|
||||
end: DateTime.utc(2026, 9, 5),
|
||||
label: 'Test Range',
|
||||
);
|
||||
|
||||
final task1 = ProgrammerTask(
|
||||
id: 't1',
|
||||
title: 'Task 1',
|
||||
category: 'Software Development',
|
||||
status: 'completed',
|
||||
assigneeId: 'prog1',
|
||||
createdAt: DateTime.utc(2026, 9, 1),
|
||||
completedAt: DateTime.utc(2026, 9, 3), // in range
|
||||
updatedAt: DateTime.utc(2026, 9, 3),
|
||||
);
|
||||
|
||||
final task2 = ProgrammerTask(
|
||||
id: 't2',
|
||||
title: 'Task 2',
|
||||
category: 'Bug Fix',
|
||||
status: 'completed',
|
||||
assigneeId: 'prog1',
|
||||
createdAt: DateTime.utc(2026, 9, 1),
|
||||
completedAt: DateTime.utc(2026, 8, 31), // before range
|
||||
updatedAt: DateTime.utc(2026, 8, 31),
|
||||
);
|
||||
|
||||
final task3 = ProgrammerTask(
|
||||
id: 't3',
|
||||
title: 'Task 3',
|
||||
category: 'Meeting',
|
||||
status: 'completed',
|
||||
assigneeId: 'prog2', // different assignee
|
||||
createdAt: DateTime.utc(2026, 9, 1),
|
||||
completedAt: DateTime.utc(2026, 9, 3),
|
||||
updatedAt: DateTime.utc(2026, 9, 3),
|
||||
);
|
||||
|
||||
final task4 = ProgrammerTask(
|
||||
id: 't4',
|
||||
title: 'Task 4',
|
||||
category: 'Software Support',
|
||||
status: 'in_progress', // not completed
|
||||
assigneeId: 'prog1',
|
||||
createdAt: DateTime.utc(2026, 9, 1),
|
||||
updatedAt: DateTime.utc(2026, 9, 3),
|
||||
);
|
||||
|
||||
final task5 = ProgrammerTask(
|
||||
id: 't5',
|
||||
title: 'Task 5',
|
||||
category: 'Software Development',
|
||||
status: 'completed',
|
||||
assigneeId: 'prog1',
|
||||
createdAt: DateTime.utc(2026, 9, 1),
|
||||
completedAt: DateTime.utc(2026, 9, 5), // at end boundary (exclusive)
|
||||
updatedAt: DateTime.utc(2026, 9, 5),
|
||||
);
|
||||
|
||||
final data = buildAccomplishmentReportData(
|
||||
programmer: programmer,
|
||||
range: range,
|
||||
includeUnapproved: false,
|
||||
sheets: [],
|
||||
liveRowsByDay: {},
|
||||
tasks: [task1, task2, task3, task4, task5],
|
||||
profileNames: {},
|
||||
);
|
||||
|
||||
expect(data.tasksCompleted, 1); // only task1
|
||||
});
|
||||
|
||||
test('empty period', () {
|
||||
final range = ReportDateRange(
|
||||
start: DateTime.utc(2026, 9, 1),
|
||||
end: DateTime.utc(2026, 9, 2),
|
||||
label: 'Test Range',
|
||||
);
|
||||
|
||||
final data = buildAccomplishmentReportData(
|
||||
programmer: programmer,
|
||||
range: range,
|
||||
includeUnapproved: false,
|
||||
sheets: [],
|
||||
liveRowsByDay: {},
|
||||
tasks: [],
|
||||
profileNames: {},
|
||||
);
|
||||
|
||||
expect(data.days, isEmpty);
|
||||
expect(data.daysReported, 0);
|
||||
expect(data.totalSeconds, 0);
|
||||
expect(data.excludedPending, 0);
|
||||
expect(data.excludedDisapproved, 0);
|
||||
});
|
||||
|
||||
test('positionLabel based on programmer role', () {
|
||||
final range = ReportDateRange(
|
||||
start: DateTime.utc(2026, 9, 1),
|
||||
end: DateTime.utc(2026, 9, 2),
|
||||
label: 'Test Range',
|
||||
);
|
||||
|
||||
final dataProgrammer = buildAccomplishmentReportData(
|
||||
programmer: programmer,
|
||||
range: range,
|
||||
includeUnapproved: false,
|
||||
sheets: [],
|
||||
liveRowsByDay: {},
|
||||
tasks: [],
|
||||
profileNames: {},
|
||||
);
|
||||
|
||||
final dataAdmin = buildAccomplishmentReportData(
|
||||
programmer: admin,
|
||||
range: range,
|
||||
includeUnapproved: false,
|
||||
sheets: [],
|
||||
liveRowsByDay: {},
|
||||
tasks: [],
|
||||
profileNames: {},
|
||||
);
|
||||
|
||||
expect(dataProgrammer.positionLabel, 'Programmer');
|
||||
expect(dataAdmin.positionLabel, 'Administrator');
|
||||
});
|
||||
|
||||
test('helpSeconds and secondsByCategory calculations', () {
|
||||
final day1 = DateTime.utc(2026, 9, 1);
|
||||
|
||||
final assigneeRow = DaySheetRow(
|
||||
taskId: 't1',
|
||||
title: 'Task 1',
|
||||
category: 'Software Development',
|
||||
kind: 'assignee',
|
||||
seconds: 3600,
|
||||
notes: [],
|
||||
);
|
||||
|
||||
final helperRow = DaySheetRow(
|
||||
taskId: 't2',
|
||||
title: 'Task 2',
|
||||
category: 'Bug Fix',
|
||||
kind: 'helper',
|
||||
seconds: 1800,
|
||||
notes: [],
|
||||
);
|
||||
|
||||
final anotherHelperRow = DaySheetRow(
|
||||
taskId: 't3',
|
||||
title: 'Task 3',
|
||||
category: 'Software Development',
|
||||
kind: 'helper',
|
||||
seconds: 900,
|
||||
notes: [],
|
||||
);
|
||||
|
||||
final row = DaySheetRow(
|
||||
taskId: 't4',
|
||||
title: 'Task 4',
|
||||
category: 'Meeting',
|
||||
kind: 'assignee',
|
||||
seconds: 600,
|
||||
notes: [],
|
||||
);
|
||||
|
||||
final sheets = [
|
||||
ProgrammerDaySheet(
|
||||
id: 'sheet1',
|
||||
programmerId: 'prog1',
|
||||
status: 'approved',
|
||||
workDate: day1,
|
||||
autoSubmitted: false,
|
||||
resubmissions: 0,
|
||||
reviewedBy: 'reviewer1',
|
||||
approvedSnapshot: DaySheetSnapshot(
|
||||
totalSeconds: 6900,
|
||||
rows: [assigneeRow, helperRow, anotherHelperRow, row],
|
||||
),
|
||||
createdAt: DateTime.utc(2026, 9, 1),
|
||||
updatedAt: DateTime.utc(2026, 9, 1),
|
||||
),
|
||||
];
|
||||
|
||||
final range = ReportDateRange(
|
||||
start: DateTime.utc(2026, 9, 1),
|
||||
end: DateTime.utc(2026, 9, 2),
|
||||
label: 'Test Range',
|
||||
);
|
||||
|
||||
final data = buildAccomplishmentReportData(
|
||||
programmer: programmer,
|
||||
range: range,
|
||||
includeUnapproved: false,
|
||||
sheets: sheets,
|
||||
liveRowsByDay: {},
|
||||
tasks: [],
|
||||
profileNames: {'reviewer1': 'Jane Reviewer'},
|
||||
);
|
||||
|
||||
expect(data.helpSeconds, 2700); // 1800 + 900
|
||||
expect(data.secondsByCategory['Software Development'], 4500); // 3600 + 900
|
||||
expect(data.secondsByCategory['Bug Fix'], 1800);
|
||||
expect(data.secondsByCategory['Meeting'], 600);
|
||||
expect(data.totalSeconds, 6900);
|
||||
});
|
||||
|
||||
test('approved sheet with zero rows and different reviewer does not affect approverName',
|
||||
() {
|
||||
final day1 = DateTime.utc(2026, 9, 1);
|
||||
final day2 = DateTime.utc(2026, 9, 2);
|
||||
final day3 = DateTime.utc(2026, 9, 3);
|
||||
|
||||
final row = DaySheetRow(
|
||||
taskId: 't1',
|
||||
title: 'Task 1',
|
||||
category: 'Software Development',
|
||||
kind: 'assignee',
|
||||
seconds: 3600,
|
||||
notes: [],
|
||||
);
|
||||
|
||||
// Two included approved sheets with same reviewer
|
||||
// One zero-row approved sheet with different reviewer
|
||||
final sheets = [
|
||||
ProgrammerDaySheet(
|
||||
id: 'sheet1',
|
||||
programmerId: 'prog1',
|
||||
status: 'approved',
|
||||
workDate: day1,
|
||||
autoSubmitted: false,
|
||||
resubmissions: 0,
|
||||
reviewedBy: 'reviewer1',
|
||||
approvedSnapshot: DaySheetSnapshot(totalSeconds: 3600, rows: [row]),
|
||||
createdAt: DateTime.utc(2026, 9, 1),
|
||||
updatedAt: DateTime.utc(2026, 9, 1),
|
||||
),
|
||||
// Zero-row approved sheet with different reviewer (should be ignored)
|
||||
ProgrammerDaySheet(
|
||||
id: 'sheet2',
|
||||
programmerId: 'prog1',
|
||||
status: 'approved',
|
||||
workDate: day2,
|
||||
autoSubmitted: false,
|
||||
resubmissions: 0,
|
||||
reviewedBy: 'reviewer2',
|
||||
approvedSnapshot: DaySheetSnapshot(totalSeconds: 0, rows: []),
|
||||
createdAt: DateTime.utc(2026, 9, 2),
|
||||
updatedAt: DateTime.utc(2026, 9, 2),
|
||||
),
|
||||
ProgrammerDaySheet(
|
||||
id: 'sheet3',
|
||||
programmerId: 'prog1',
|
||||
status: 'approved',
|
||||
workDate: day3,
|
||||
autoSubmitted: false,
|
||||
resubmissions: 0,
|
||||
reviewedBy: 'reviewer1',
|
||||
approvedSnapshot: DaySheetSnapshot(totalSeconds: 3600, rows: [row]),
|
||||
createdAt: DateTime.utc(2026, 9, 3),
|
||||
updatedAt: DateTime.utc(2026, 9, 3),
|
||||
),
|
||||
];
|
||||
|
||||
final range = ReportDateRange(
|
||||
start: DateTime.utc(2026, 9, 1),
|
||||
end: DateTime.utc(2026, 9, 4),
|
||||
label: 'Test Range',
|
||||
);
|
||||
|
||||
final data = buildAccomplishmentReportData(
|
||||
programmer: programmer,
|
||||
range: range,
|
||||
includeUnapproved: false,
|
||||
sheets: sheets,
|
||||
liveRowsByDay: {},
|
||||
tasks: [],
|
||||
profileNames: {
|
||||
'reviewer1': 'Jane Reviewer',
|
||||
'reviewer2': 'Bob Reviewer',
|
||||
},
|
||||
);
|
||||
|
||||
// Only 2 days included (day2 has zero rows)
|
||||
expect(data.days.length, 2);
|
||||
// Both included days were reviewed by reviewer1
|
||||
expect(data.approverName, 'Jane Reviewer');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:tasq/models/profile.model.dart';
|
||||
import 'package:tasq/providers/profile_provider.dart';
|
||||
import 'package:tasq/screens/programmer_tasks/report/accomplishment_report_dialog.dart';
|
||||
import 'package:tasq/theme/app_theme.dart';
|
||||
import 'package:tasq/utils/app_time.dart';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Widget _buildApp(Widget child, {List<Override> overrides = const []}) {
|
||||
return ProviderScope(
|
||||
overrides: overrides,
|
||||
child: MaterialApp(
|
||||
theme: AppTheme.light(),
|
||||
home: Scaffold(body: child),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Opener widget that shows the dialog on tap.
|
||||
class _DialogOpener extends ConsumerWidget {
|
||||
const _DialogOpener({this.programmerId});
|
||||
|
||||
final String? programmerId;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return ElevatedButton(
|
||||
onPressed: () => showAccomplishmentReportDialog(
|
||||
context,
|
||||
ref,
|
||||
programmerId: programmerId,
|
||||
),
|
||||
child: const Text('Open'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void main() {
|
||||
setUpAll(AppTime.initialize);
|
||||
|
||||
// ── Programmer mode (not admin, no programmerId) ─────────────────────────
|
||||
|
||||
group('as programmer (not admin)', () {
|
||||
late List<Override> overrides;
|
||||
|
||||
setUp(() {
|
||||
overrides = [
|
||||
isStrictAdminProvider.overrideWithValue(false),
|
||||
profilesProvider.overrideWith(
|
||||
(ref) => Stream.value([
|
||||
Profile(id: 'prog-1', role: 'programmer', fullName: 'Alice'),
|
||||
]),
|
||||
),
|
||||
currentUserIdProvider.overrideWithValue('prog-1'),
|
||||
];
|
||||
});
|
||||
|
||||
testWidgets('shows Accomplishment Report title', (tester) async {
|
||||
await tester
|
||||
.pumpWidget(_buildApp(const _DialogOpener(), overrides: overrides));
|
||||
await tester.tap(find.text('Open'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Accomplishment Report'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('does not show programmer picker', (tester) async {
|
||||
await tester
|
||||
.pumpWidget(_buildApp(const _DialogOpener(), overrides: overrides));
|
||||
await tester.tap(find.text('Open'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(DropdownButton<Profile>), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('toggle Include unapproved days is present and off by default',
|
||||
(tester) async {
|
||||
await tester
|
||||
.pumpWidget(_buildApp(const _DialogOpener(), overrides: overrides));
|
||||
await tester.tap(find.text('Open'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Include unapproved days (marked)'), findsOneWidget);
|
||||
final sw = tester.widget<Switch>(find.byType(Switch).first);
|
||||
expect(sw.value, isFalse);
|
||||
});
|
||||
|
||||
testWidgets('date filter defaults to This Month', (tester) async {
|
||||
await tester
|
||||
.pumpWidget(_buildApp(const _DialogOpener(), overrides: overrides));
|
||||
await tester.tap(find.text('Open'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('This Month'), findsOneWidget);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Admin picker mode (isStrictAdmin, no programmerId passed) ────────────
|
||||
|
||||
group('as admin (no programmerId, strict admin)', () {
|
||||
final prog1 = Profile(id: 'prog-1', role: 'programmer', fullName: 'Alice');
|
||||
final prog2 = Profile(id: 'prog-2', role: 'programmer', fullName: 'Bob');
|
||||
final admin1 =
|
||||
Profile(id: 'admin-1', role: 'admin', fullName: 'Admin User');
|
||||
|
||||
late List<Override> overrides;
|
||||
|
||||
setUp(() {
|
||||
overrides = [
|
||||
isStrictAdminProvider.overrideWithValue(true),
|
||||
profilesProvider.overrideWith(
|
||||
(ref) => Stream.value([prog1, prog2, admin1]),
|
||||
),
|
||||
currentUserIdProvider.overrideWithValue('admin-1'),
|
||||
];
|
||||
});
|
||||
|
||||
testWidgets('shows programmer picker', (tester) async {
|
||||
await tester
|
||||
.pumpWidget(_buildApp(const _DialogOpener(), overrides: overrides));
|
||||
await tester.tap(find.text('Open'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(DropdownButton<Profile>), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('Generate is disabled until a programmer is chosen',
|
||||
(tester) async {
|
||||
await tester
|
||||
.pumpWidget(_buildApp(const _DialogOpener(), overrides: overrides));
|
||||
await tester.tap(find.text('Open'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final button = tester.widget<FilledButton>(
|
||||
find.ancestor(
|
||||
of: find.text('Generate'),
|
||||
matching: find.byType(FilledButton),
|
||||
),
|
||||
);
|
||||
expect(button.onPressed, isNull);
|
||||
});
|
||||
|
||||
testWidgets('picker shows only programmer-role profiles', (tester) async {
|
||||
await tester
|
||||
.pumpWidget(_buildApp(const _DialogOpener(), overrides: overrides));
|
||||
await tester.tap(find.text('Open'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Open the dropdown to expose its items.
|
||||
await tester.tap(find.byType(DropdownButton<Profile>));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Programmer names should appear in the dropdown overlay.
|
||||
expect(find.text('Alice'), findsWidgets);
|
||||
expect(find.text('Bob'), findsWidgets);
|
||||
// Admin-role profile must not appear.
|
||||
expect(find.text('Admin User'), findsNothing);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:tasq/screens/programmer_tasks/report/accomplishment_report_data.dart';
|
||||
import 'package:tasq/screens/programmer_tasks/report/accomplishment_report_pdf.dart';
|
||||
import 'package:tasq/utils/programmer_day_rows.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
AccomplishmentReportData populated() => AccomplishmentReportData(
|
||||
programmerName: 'John Doe',
|
||||
positionLabel: 'Programmer',
|
||||
periodStart: DateTime.utc(2026, 9, 1),
|
||||
periodEndInclusive: DateTime.utc(2026, 9, 30),
|
||||
days: [
|
||||
ReportDay(
|
||||
day: DateTime.utc(2026, 9, 1),
|
||||
statusTag: null,
|
||||
rows: [
|
||||
DaySheetRow(
|
||||
taskId: 't1',
|
||||
title: 'Fix login bug',
|
||||
category: 'Backend',
|
||||
kind: 'assignee',
|
||||
projectName: 'AuthService',
|
||||
helpedName: null,
|
||||
seconds: 3600,
|
||||
notes: ['Resolved null pointer exception'],
|
||||
),
|
||||
],
|
||||
),
|
||||
ReportDay(
|
||||
day: DateTime.utc(2026, 9, 2),
|
||||
statusTag: 'Pending',
|
||||
rows: [
|
||||
DaySheetRow(
|
||||
taskId: 't2',
|
||||
title: 'Add unit tests',
|
||||
category: 'Testing',
|
||||
kind: 'helper',
|
||||
projectName: null,
|
||||
helpedName: 'Jane Smith',
|
||||
seconds: 1800,
|
||||
notes: [],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
excludedPending: 1,
|
||||
excludedDisapproved: 0,
|
||||
daysReported: 2,
|
||||
totalSeconds: 5400,
|
||||
tasksCompleted: 1,
|
||||
helpSeconds: 1800,
|
||||
secondsByCategory: {'Backend': 3600, 'Testing': 1800},
|
||||
approverName: 'Jane Reviewer',
|
||||
);
|
||||
|
||||
AccomplishmentReportData emptyPeriod() => AccomplishmentReportData(
|
||||
programmerName: 'John Doe',
|
||||
positionLabel: 'Programmer',
|
||||
periodStart: DateTime.utc(2026, 9, 1),
|
||||
periodEndInclusive: DateTime.utc(2026, 9, 30),
|
||||
days: [],
|
||||
excludedPending: 0,
|
||||
excludedDisapproved: 0,
|
||||
daysReported: 0,
|
||||
totalSeconds: 0,
|
||||
tasksCompleted: 0,
|
||||
helpSeconds: 0,
|
||||
secondsByCategory: {},
|
||||
approverName: null,
|
||||
);
|
||||
|
||||
test('populated report builds non-empty PDF bytes starting with %PDF',
|
||||
() async {
|
||||
final bytes = await buildAccomplishmentReportPdf(populated());
|
||||
expect(bytes.isNotEmpty, isTrue);
|
||||
expect(utf8.decode(bytes.sublist(0, 4)), equals('%PDF'));
|
||||
});
|
||||
|
||||
test('empty period builds a valid PDF', () async {
|
||||
final bytes = await buildAccomplishmentReportPdf(emptyPeriod());
|
||||
expect(bytes.isNotEmpty, isTrue);
|
||||
expect(utf8.decode(bytes.sublist(0, 4)), equals('%PDF'));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:tasq/models/programmer_day_sheet.model.dart';
|
||||
import 'package:tasq/screens/programmer_tasks/day_sheets/day_sheet_dialogs.dart';
|
||||
import 'package:tasq/utils/programmer_day_rows.dart';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Widget _wrap(Widget child) {
|
||||
return MaterialApp(home: Scaffold(body: child));
|
||||
}
|
||||
|
||||
// Minimal tappable button that opens the dialog under test.
|
||||
class _DisapproveOpener extends StatelessWidget {
|
||||
const _DisapproveOpener({required this.rows, this.result});
|
||||
|
||||
final List<DaySheetRow> rows;
|
||||
final ValueNotifier<String?>? result;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ElevatedButton(
|
||||
onPressed: () async {
|
||||
final r = await showDisapproveDialog(context, flaggedRows: rows);
|
||||
result?.value = r;
|
||||
},
|
||||
child: const Text('Open'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _JustifyOpener extends StatelessWidget {
|
||||
const _JustifyOpener({this.result});
|
||||
|
||||
final ValueNotifier<String?>? result;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ElevatedButton(
|
||||
onPressed: () async {
|
||||
final r = await showJustifyDialog(context);
|
||||
result?.value = r;
|
||||
},
|
||||
child: const Text('Open'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DaySheetRow fixture
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
DaySheetRow _row(String taskId) => DaySheetRow(
|
||||
taskId: taskId,
|
||||
title: 'Task $taskId',
|
||||
category: 'Software Development',
|
||||
kind: 'assignee',
|
||||
seconds: 3600,
|
||||
notes: const [],
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void main() {
|
||||
group('showDisapproveDialog', () {
|
||||
testWidgets('stays open and shows error when remarks are empty',
|
||||
(tester) async {
|
||||
final result = ValueNotifier<String?>(null);
|
||||
await tester.pumpWidget(_wrap(_DisapproveOpener(
|
||||
rows: [_row('t1')],
|
||||
result: result,
|
||||
)));
|
||||
|
||||
await tester.tap(find.text('Open'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Dialog should be visible.
|
||||
expect(find.text('Disapprove'), findsWidgets);
|
||||
|
||||
// Tap the Disapprove action button with empty remarks.
|
||||
final disapproveButtons = find.text('Disapprove');
|
||||
await tester.tap(disapproveButtons.last);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Error message should appear and dialog should still be open.
|
||||
expect(find.text('Remarks are required'), findsOneWidget);
|
||||
expect(find.text('Disapprove'), findsWidgets);
|
||||
expect(result.value, isNull);
|
||||
});
|
||||
|
||||
testWidgets('returns the entered remarks when non-empty', (tester) async {
|
||||
final result = ValueNotifier<String?>(null);
|
||||
await tester.pumpWidget(_wrap(_DisapproveOpener(
|
||||
rows: [_row('t1')],
|
||||
result: result,
|
||||
)));
|
||||
|
||||
await tester.tap(find.text('Open'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.enterText(
|
||||
find.byType(TextField).first, 'Missing notes');
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final disapproveButtons = find.text('Disapprove');
|
||||
await tester.tap(disapproveButtons.last);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(result.value, 'Missing notes');
|
||||
});
|
||||
});
|
||||
|
||||
group('showJustifyDialog', () {
|
||||
testWidgets('stays open and shows error when justification is empty',
|
||||
(tester) async {
|
||||
final result = ValueNotifier<String?>(null);
|
||||
await tester.pumpWidget(_wrap(_JustifyOpener(result: result)));
|
||||
|
||||
await tester.tap(find.text('Open'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Tap submit with no text.
|
||||
await tester.tap(find.text('Submit'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Please write a justification'), findsOneWidget);
|
||||
expect(result.value, isNull);
|
||||
});
|
||||
|
||||
testWidgets('returns body when non-empty', (tester) async {
|
||||
final result = ValueNotifier<String?>(null);
|
||||
await tester.pumpWidget(_wrap(_JustifyOpener(result: result)));
|
||||
|
||||
await tester.tap(find.text('Open'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.enterText(find.byType(TextField).first, 'My justification');
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('Submit'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(result.value, 'My justification');
|
||||
});
|
||||
});
|
||||
|
||||
group('rowsForSheet', () {
|
||||
test('returns snapshot rows when approved and snapshot exists', () {
|
||||
final snapshotRow = _row('snap-1');
|
||||
final snapshot = DaySheetSnapshot(
|
||||
totalSeconds: 3600,
|
||||
rows: [snapshotRow],
|
||||
);
|
||||
final sheet = ProgrammerDaySheet(
|
||||
id: 'sheet-1',
|
||||
programmerId: 'user-1',
|
||||
status: DaySheetStatus.approved,
|
||||
workDate: DateTime.utc(2026, 9, 27),
|
||||
approvedSnapshot: snapshot,
|
||||
createdAt: DateTime.utc(2026, 9, 27),
|
||||
updatedAt: DateTime.utc(2026, 9, 27),
|
||||
);
|
||||
final liveRow = _row('live-1');
|
||||
|
||||
final result = rowsForSheet(sheet, [liveRow]);
|
||||
|
||||
expect(result, [snapshotRow]);
|
||||
});
|
||||
|
||||
test('returns live rows when not approved', () {
|
||||
final sheet = ProgrammerDaySheet(
|
||||
id: 'sheet-2',
|
||||
programmerId: 'user-1',
|
||||
status: DaySheetStatus.pending,
|
||||
workDate: DateTime.utc(2026, 9, 27),
|
||||
createdAt: DateTime.utc(2026, 9, 27),
|
||||
updatedAt: DateTime.utc(2026, 9, 27),
|
||||
);
|
||||
final liveRow = _row('live-1');
|
||||
|
||||
final result = rowsForSheet(sheet, [liveRow]);
|
||||
|
||||
expect(result, [liveRow]);
|
||||
});
|
||||
|
||||
test('returns live rows when approved but snapshot is null', () {
|
||||
final sheet = ProgrammerDaySheet(
|
||||
id: 'sheet-3',
|
||||
programmerId: 'user-1',
|
||||
status: DaySheetStatus.approved,
|
||||
workDate: DateTime.utc(2026, 9, 27),
|
||||
approvedSnapshot: null,
|
||||
createdAt: DateTime.utc(2026, 9, 27),
|
||||
updatedAt: DateTime.utc(2026, 9, 27),
|
||||
);
|
||||
final liveRow = _row('live-1');
|
||||
|
||||
final result = rowsForSheet(sheet, [liveRow]);
|
||||
|
||||
expect(result, [liveRow]);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:tasq/models/programmer_day_sheet.model.dart';
|
||||
import 'package:tasq/providers/profile_provider.dart';
|
||||
import 'package:tasq/providers/programmer_day_sheets_provider.dart';
|
||||
import 'package:tasq/screens/programmer_tasks/day_sheets/approvals_tab.dart';
|
||||
import 'package:tasq/screens/programmer_tasks/day_sheets/day_sheet_snapshot_builder.dart';
|
||||
import 'package:tasq/theme/app_theme.dart';
|
||||
import 'package:tasq/utils/app_time.dart';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
ProgrammerDaySheet _sheet({
|
||||
required String id,
|
||||
required String status,
|
||||
DateTime? workDate,
|
||||
bool autoSubmitted = false,
|
||||
int resubmissions = 0,
|
||||
}) {
|
||||
final now = AppTime.now();
|
||||
final date = workDate ?? DateTime.utc(now.year, now.month, 15);
|
||||
return ProgrammerDaySheet(
|
||||
id: id,
|
||||
programmerId: 'prog-1',
|
||||
status: status,
|
||||
workDate: date,
|
||||
autoSubmitted: autoSubmitted,
|
||||
resubmissions: resubmissions,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildApp(Widget child, {List<Override> overrides = const []}) {
|
||||
return ProviderScope(
|
||||
overrides: overrides,
|
||||
child: MaterialApp(
|
||||
theme: AppTheme.light(),
|
||||
home: Scaffold(body: child),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void main() {
|
||||
setUpAll(AppTime.initialize);
|
||||
|
||||
// ── groupSheetsByDate ────────────────────────────────────────────────────
|
||||
|
||||
group('groupSheetsByDate', () {
|
||||
test('groups by workDate, keys descending', () {
|
||||
final d1 = DateTime.utc(2024, 1, 3);
|
||||
final d2 = DateTime.utc(2024, 1, 2);
|
||||
final d3 = DateTime.utc(2024, 1, 1);
|
||||
|
||||
final sheets = [
|
||||
_sheet(id: 'a', status: DaySheetStatus.pending, workDate: d2),
|
||||
_sheet(id: 'b', status: DaySheetStatus.approved, workDate: d1),
|
||||
_sheet(id: 'c', status: DaySheetStatus.draft, workDate: d3),
|
||||
_sheet(id: 'd', status: DaySheetStatus.pending, workDate: d2),
|
||||
];
|
||||
|
||||
final grouped = groupSheetsByDate(sheets);
|
||||
final keys = grouped.keys.toList();
|
||||
|
||||
expect(keys, [d1, d2, d3]);
|
||||
expect(grouped[d1]!.map((s) => s.id).toList(), ['b']);
|
||||
expect(grouped[d2]!.map((s) => s.id).toList(), ['a', 'd']);
|
||||
expect(grouped[d3]!.map((s) => s.id).toList(), ['c']);
|
||||
});
|
||||
|
||||
test('single sheet returns single-entry map', () {
|
||||
final d = DateTime.utc(2024, 6, 1);
|
||||
final result = groupSheetsByDate([_sheet(id: 'x', status: DaySheetStatus.pending, workDate: d)]);
|
||||
expect(result.keys.toList(), [d]);
|
||||
expect(result[d]!.length, 1);
|
||||
});
|
||||
|
||||
test('empty list returns empty map', () {
|
||||
expect(groupSheetsByDate([]), isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
// ── approvalCounts ───────────────────────────────────────────────────────
|
||||
|
||||
group('approvalCounts', () {
|
||||
test('counts pending, awaiting (disapproved) and approved', () {
|
||||
final sheets = [
|
||||
_sheet(id: '1', status: DaySheetStatus.pending),
|
||||
_sheet(id: '2', status: DaySheetStatus.pending),
|
||||
_sheet(id: '3', status: DaySheetStatus.disapproved),
|
||||
_sheet(id: '4', status: DaySheetStatus.approved),
|
||||
_sheet(id: '5', status: DaySheetStatus.draft),
|
||||
];
|
||||
|
||||
final counts = approvalCounts(sheets);
|
||||
expect(counts.pending, 2);
|
||||
expect(counts.awaiting, 1);
|
||||
expect(counts.approved, 1);
|
||||
});
|
||||
|
||||
test('all zero for empty list', () {
|
||||
final counts = approvalCounts([]);
|
||||
expect(counts.pending, 0);
|
||||
expect(counts.awaiting, 0);
|
||||
expect(counts.approved, 0);
|
||||
});
|
||||
|
||||
test('draft sheets are not counted', () {
|
||||
final counts = approvalCounts([
|
||||
_sheet(id: '1', status: DaySheetStatus.draft),
|
||||
_sheet(id: '2', status: DaySheetStatus.draft),
|
||||
]);
|
||||
expect(counts.pending, 0);
|
||||
expect(counts.awaiting, 0);
|
||||
expect(counts.approved, 0);
|
||||
});
|
||||
});
|
||||
|
||||
// ── ApprovalsTab widget ──────────────────────────────────────────────────
|
||||
|
||||
group('ApprovalsTab', () {
|
||||
List<ProgrammerDaySheet> _pendingSheets(int n) {
|
||||
final now = AppTime.now();
|
||||
return List.generate(
|
||||
n,
|
||||
(i) => _sheet(
|
||||
id: 'sheet-$i',
|
||||
status: DaySheetStatus.pending,
|
||||
workDate: DateTime.utc(now.year, now.month, 15),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
testWidgets(
|
||||
'selecting 2 of 3 pending cards shows Approve selected (2)',
|
||||
(tester) async {
|
||||
final sheets = _pendingSheets(3);
|
||||
|
||||
await tester.pumpWidget(
|
||||
_buildApp(
|
||||
const ApprovalsTab(),
|
||||
overrides: [
|
||||
allDaySheetsProvider.overrideWith(
|
||||
(ref) => Stream.value(sheets),
|
||||
),
|
||||
profilesProvider.overrideWith(
|
||||
(ref) => Stream.value([]),
|
||||
),
|
||||
isStrictAdminProvider.overrideWith((ref) => true),
|
||||
currentUserIdProvider.overrideWith((ref) => 'admin-1'),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
// Let the stream settle.
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
|
||||
// All 3 pending cards should be visible with checkboxes.
|
||||
final checkboxes = find.byType(Checkbox);
|
||||
expect(checkboxes, findsAtLeastNWidgets(2));
|
||||
|
||||
// Select two cards.
|
||||
await tester.tap(checkboxes.at(0));
|
||||
await tester.pump();
|
||||
await tester.tap(checkboxes.at(1));
|
||||
await tester.pump();
|
||||
|
||||
// The approve button must reflect the selection count.
|
||||
expect(find.text('Approve selected (2)'), findsOneWidget);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:tasq/screens/notifications/notifications_screen.dart';
|
||||
|
||||
void main() {
|
||||
group('notificationTitle', () {
|
||||
test('assignment guard (existing case unchanged)', () {
|
||||
expect(notificationTitle('assignment', 'Alice'), 'Alice assigned you');
|
||||
});
|
||||
|
||||
test('day_sheet_submitted', () {
|
||||
expect(
|
||||
notificationTitle('day_sheet_submitted', 'Bob'),
|
||||
'Bob submitted a day sheet',
|
||||
);
|
||||
});
|
||||
|
||||
test('day_sheet_disapproved', () {
|
||||
expect(
|
||||
notificationTitle('day_sheet_disapproved', 'Alice'),
|
||||
'Day sheet disapproved',
|
||||
);
|
||||
});
|
||||
|
||||
test('day_sheet_justified', () {
|
||||
expect(
|
||||
notificationTitle('day_sheet_justified', 'Bob'),
|
||||
'Bob justified a day sheet',
|
||||
);
|
||||
});
|
||||
|
||||
test('day_sheet_approved', () {
|
||||
expect(
|
||||
notificationTitle('day_sheet_approved', 'Alice'),
|
||||
'Day sheet approved',
|
||||
);
|
||||
});
|
||||
|
||||
test('day_sheet_digest', () {
|
||||
expect(
|
||||
notificationTitle('day_sheet_digest', 'System'),
|
||||
'Day sheets awaiting review',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('notificationIcon', () {
|
||||
test('assignment guard (existing case unchanged)', () {
|
||||
expect(notificationIcon('assignment'), Icons.assignment_ind_outlined);
|
||||
});
|
||||
|
||||
test('day_sheet_submitted', () {
|
||||
expect(
|
||||
notificationIcon('day_sheet_submitted'),
|
||||
Icons.assignment_turned_in_outlined,
|
||||
);
|
||||
});
|
||||
|
||||
test('day_sheet_disapproved', () {
|
||||
expect(
|
||||
notificationIcon('day_sheet_disapproved'),
|
||||
Icons.report_outlined,
|
||||
);
|
||||
});
|
||||
|
||||
test('day_sheet_justified', () {
|
||||
expect(
|
||||
notificationIcon('day_sheet_justified'),
|
||||
Icons.reply_outlined,
|
||||
);
|
||||
});
|
||||
|
||||
test('day_sheet_approved', () {
|
||||
expect(
|
||||
notificationIcon('day_sheet_approved'),
|
||||
Icons.verified_outlined,
|
||||
);
|
||||
});
|
||||
|
||||
test('day_sheet_digest', () {
|
||||
expect(
|
||||
notificationIcon('day_sheet_digest'),
|
||||
Icons.inbox_outlined,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:tasq/models/programmer_task.model.dart';
|
||||
import 'package:tasq/models/programmer_task_activity_log.model.dart';
|
||||
import 'package:tasq/utils/app_time.dart';
|
||||
import 'package:tasq/utils/programmer_daily_time.dart';
|
||||
|
||||
void main() {
|
||||
setUpAll(AppTime.initialize);
|
||||
|
||||
/// Helper to create a ProgrammerTaskActivityLog at Manila time d h:m
|
||||
ProgrammerTaskActivityLog _ev(
|
||||
String action,
|
||||
int d,
|
||||
int h, [
|
||||
int m = 0,
|
||||
Map<String, dynamic>? meta,
|
||||
]) {
|
||||
return ProgrammerTaskActivityLog(
|
||||
id: 'ev-$d-$h-$m',
|
||||
taskId: 'task-1',
|
||||
actorId: 'actor',
|
||||
actionType: action,
|
||||
meta: meta,
|
||||
createdAt: AppTime.fromComponents(
|
||||
year: 2026,
|
||||
month: 9,
|
||||
day: d,
|
||||
hour: h,
|
||||
minute: m,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Helper to create a ProgrammerTask
|
||||
ProgrammerTask _task({
|
||||
String status = ProgrammerTaskStatus.inProgress,
|
||||
String? creatorId = 'a',
|
||||
String? assigneeId = 'a',
|
||||
DateTime? startedAt,
|
||||
DateTime? completedAt,
|
||||
DateTime? cancelledAt,
|
||||
}) {
|
||||
return ProgrammerTask(
|
||||
id: 'task-1',
|
||||
title: 'Test Task',
|
||||
category: ProgrammerTaskCategory.softwareDevelopment,
|
||||
status: status,
|
||||
creatorId: creatorId,
|
||||
assigneeId: assigneeId,
|
||||
createdAt: AppTime.fromComponents(year: 2026, month: 9, day: 27),
|
||||
startedAt: startedAt,
|
||||
completedAt: completedAt,
|
||||
cancelledAt: cancelledAt,
|
||||
updatedAt: AppTime.fromComponents(year: 2026, month: 9, day: 27),
|
||||
);
|
||||
}
|
||||
|
||||
test('manilaDay ignores device time zone', () {
|
||||
// Sep 26 4:30 PM UTC = Sep 27 12:30 AM Manila time
|
||||
expect(
|
||||
manilaDay(DateTime.parse('2026-09-26T16:30:00Z')),
|
||||
DateTime.utc(2026, 9, 27),
|
||||
);
|
||||
// Sep 26 3:59 PM UTC = Sep 26 11:59 PM Manila time
|
||||
expect(
|
||||
manilaDay(DateTime.parse('2026-09-26T15:59:00Z')),
|
||||
DateTime.utc(2026, 9, 26),
|
||||
);
|
||||
});
|
||||
|
||||
test('day key round-trip', () {
|
||||
expect(formatDayKey(parseDayKey('2026-09-27')), '2026-09-27');
|
||||
expect(formatDayKey(parseDayKey('2025-01-05')), '2025-01-05');
|
||||
});
|
||||
|
||||
test('single day with a pause', () {
|
||||
final task = _task(
|
||||
status: ProgrammerTaskStatus.completed,
|
||||
creatorId: 'a',
|
||||
assigneeId: 'a',
|
||||
startedAt: AppTime.fromComponents(year: 2026, month: 9, day: 27, hour: 9),
|
||||
completedAt:
|
||||
AppTime.fromComponents(year: 2026, month: 9, day: 27, hour: 15),
|
||||
);
|
||||
final logs = [
|
||||
_ev('started', 27, 9),
|
||||
_ev('paused', 27, 11),
|
||||
_ev('resumed', 27, 13),
|
||||
_ev('completed', 27, 15),
|
||||
];
|
||||
final now =
|
||||
AppTime.fromComponents(year: 2026, month: 9, day: 27, hour: 20);
|
||||
|
||||
final result = dailySecondsByAssignee(task: task, logs: logs, now: now);
|
||||
expect(result['a'], {DateTime.utc(2026, 9, 27): 4 * 3600});
|
||||
});
|
||||
|
||||
test('running across Manila midnight splits by day', () {
|
||||
final task = _task(
|
||||
status: ProgrammerTaskStatus.inProgress,
|
||||
creatorId: 'a',
|
||||
assigneeId: 'a',
|
||||
startedAt: AppTime.fromComponents(year: 2026, month: 9, day: 26, hour: 22),
|
||||
);
|
||||
final logs = [
|
||||
_ev('started', 26, 22),
|
||||
];
|
||||
final now =
|
||||
AppTime.fromComponents(year: 2026, month: 9, day: 27, hour: 2);
|
||||
|
||||
final result = dailySecondsByAssignee(task: task, logs: logs, now: now);
|
||||
expect(result['a']![DateTime.utc(2026, 9, 26)], 2 * 3600);
|
||||
expect(result['a']![DateTime.utc(2026, 9, 27)], 2 * 3600);
|
||||
});
|
||||
|
||||
test('reassignment credits each assignee for their own span', () {
|
||||
final task = _task(
|
||||
status: ProgrammerTaskStatus.completed,
|
||||
creatorId: 'a',
|
||||
assigneeId: 'b',
|
||||
startedAt: AppTime.fromComponents(year: 2026, month: 9, day: 27, hour: 9),
|
||||
completedAt:
|
||||
AppTime.fromComponents(year: 2026, month: 9, day: 27, hour: 12),
|
||||
);
|
||||
final logs = [
|
||||
_ev('started', 27, 9),
|
||||
_ev('reassigned', 27, 10, 0, {'assignee_id': 'b'}),
|
||||
_ev('completed', 27, 12),
|
||||
];
|
||||
final now =
|
||||
AppTime.fromComponents(year: 2026, month: 9, day: 27, hour: 20);
|
||||
|
||||
final result = dailySecondsByAssignee(task: task, logs: logs, now: now);
|
||||
expect(result['a']![DateTime.utc(2026, 9, 27)], 3600);
|
||||
expect(result['b']![DateTime.utc(2026, 9, 27)], 2 * 3600);
|
||||
});
|
||||
|
||||
test('initial assignee comes from the first assigned event', () {
|
||||
final task = _task(
|
||||
status: ProgrammerTaskStatus.completed,
|
||||
creatorId: 'boss',
|
||||
assigneeId: 'a',
|
||||
startedAt: AppTime.fromComponents(year: 2026, month: 9, day: 27, hour: 9),
|
||||
completedAt:
|
||||
AppTime.fromComponents(year: 2026, month: 9, day: 27, hour: 10),
|
||||
);
|
||||
final logs = [
|
||||
_ev('assigned', 27, 8, 0, {'assignee_id': 'a'}),
|
||||
_ev('started', 27, 9),
|
||||
_ev('completed', 27, 10),
|
||||
];
|
||||
final now =
|
||||
AppTime.fromComponents(year: 2026, month: 9, day: 27, hour: 20);
|
||||
|
||||
final result = dailySecondsByAssignee(task: task, logs: logs, now: now);
|
||||
expect(result.keys.toList(), ['a']);
|
||||
expect(result['a']![DateTime.utc(2026, 9, 27)], 3600);
|
||||
});
|
||||
|
||||
test('adjustment subtracts on the day created and clamps at zero', () {
|
||||
// Case 1: adjustment {seconds:1800} at 10:00
|
||||
final task1 = _task(
|
||||
status: ProgrammerTaskStatus.completed,
|
||||
creatorId: 'a',
|
||||
assigneeId: 'a',
|
||||
startedAt: AppTime.fromComponents(year: 2026, month: 9, day: 27, hour: 9),
|
||||
completedAt:
|
||||
AppTime.fromComponents(year: 2026, month: 9, day: 27, hour: 11),
|
||||
);
|
||||
final logs1 = [
|
||||
_ev('started', 27, 9),
|
||||
_ev('adjustment', 27, 10, 0, {'seconds': 1800}),
|
||||
_ev('completed', 27, 11),
|
||||
];
|
||||
final now =
|
||||
AppTime.fromComponents(year: 2026, month: 9, day: 27, hour: 20);
|
||||
|
||||
final result1 =
|
||||
dailySecondsByAssignee(task: task1, logs: logs1, now: now);
|
||||
expect(result1['a']![DateTime.utc(2026, 9, 27)], 5400); // 7200 - 1800
|
||||
|
||||
// Case 2: adjustment {seconds: 99999} -> clamps at 0
|
||||
final task2 = _task(
|
||||
status: ProgrammerTaskStatus.completed,
|
||||
creatorId: 'a',
|
||||
assigneeId: 'a',
|
||||
startedAt: AppTime.fromComponents(year: 2026, month: 9, day: 27, hour: 9),
|
||||
completedAt:
|
||||
AppTime.fromComponents(year: 2026, month: 9, day: 27, hour: 11),
|
||||
);
|
||||
final logs2 = [
|
||||
_ev('started', 27, 9),
|
||||
_ev('adjustment', 27, 10, 0, {'seconds': 99999}),
|
||||
_ev('completed', 27, 11),
|
||||
];
|
||||
|
||||
final result2 =
|
||||
dailySecondsByAssignee(task: task2, logs: logs2, now: now);
|
||||
expect(result2['a']![DateTime.utc(2026, 9, 27)], 0); // clamped
|
||||
});
|
||||
|
||||
test('falls back to task.startedAt when there is no started log', () {
|
||||
final task = _task(
|
||||
status: ProgrammerTaskStatus.completed,
|
||||
creatorId: 'a',
|
||||
assigneeId: 'a',
|
||||
startedAt: AppTime.fromComponents(year: 2026, month: 9, day: 27, hour: 9),
|
||||
completedAt:
|
||||
AppTime.fromComponents(year: 2026, month: 9, day: 27, hour: 10),
|
||||
);
|
||||
final logs = [
|
||||
_ev('completed', 27, 10),
|
||||
];
|
||||
final now =
|
||||
AppTime.fromComponents(year: 2026, month: 9, day: 27, hour: 20);
|
||||
|
||||
final result = dailySecondsByAssignee(task: task, logs: logs, now: now);
|
||||
expect(result['a']![DateTime.utc(2026, 9, 27)], 3600);
|
||||
});
|
||||
|
||||
test('a paused in-progress task stops accruing at the pause', () {
|
||||
final task = _task(
|
||||
status: ProgrammerTaskStatus.inProgress,
|
||||
creatorId: 'a',
|
||||
assigneeId: 'a',
|
||||
startedAt: AppTime.fromComponents(year: 2026, month: 9, day: 27, hour: 9),
|
||||
);
|
||||
final logs = [
|
||||
_ev('started', 27, 9),
|
||||
_ev('paused', 27, 10),
|
||||
];
|
||||
final now =
|
||||
AppTime.fromComponents(year: 2026, month: 9, day: 27, hour: 15);
|
||||
|
||||
final result = dailySecondsByAssignee(task: task, logs: logs, now: now);
|
||||
expect(result['a']![DateTime.utc(2026, 9, 27)], 3600); // not 6 hours
|
||||
});
|
||||
|
||||
test('reassignment while paused does not open a new segment for the new assignee', () {
|
||||
// task: started 09:00, paused 10:00, reassigned {assignee_id:'b'} 11:00, completed 12:00 (all Sep 27)
|
||||
// expected: a -> 3600s (09-10), b -> 0 (no interval opened after paused reassignment)
|
||||
final task = _task(
|
||||
status: ProgrammerTaskStatus.completed,
|
||||
creatorId: 'a',
|
||||
assigneeId: 'b',
|
||||
startedAt: AppTime.fromComponents(year: 2026, month: 9, day: 27, hour: 9),
|
||||
completedAt:
|
||||
AppTime.fromComponents(year: 2026, month: 9, day: 27, hour: 12),
|
||||
);
|
||||
final logs = [
|
||||
_ev('started', 27, 9),
|
||||
_ev('paused', 27, 10),
|
||||
_ev('reassigned', 27, 11, 0, {'assignee_id': 'b'}),
|
||||
_ev('completed', 27, 12),
|
||||
];
|
||||
final now =
|
||||
AppTime.fromComponents(year: 2026, month: 9, day: 27, hour: 20);
|
||||
|
||||
final result = dailySecondsByAssignee(task: task, logs: logs, now: now);
|
||||
expect(result['a']![DateTime.utc(2026, 9, 27)], 3600); // a worked 09-10
|
||||
// b should have no time, or null entry
|
||||
expect(
|
||||
result['b']?[DateTime.utc(2026, 9, 27)] ?? 0,
|
||||
0,
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:tasq/models/profile.model.dart';
|
||||
import 'package:tasq/models/programmer_task.model.dart';
|
||||
import 'package:tasq/providers/programmer_day_data_provider.dart';
|
||||
import 'package:tasq/screens/programmer_tasks/dashboard/task_sections.dart';
|
||||
import 'package:tasq/utils/app_time.dart';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
ProgrammerTask _makeTask({
|
||||
required String id,
|
||||
required String status,
|
||||
String assigneeId = 'user1',
|
||||
DateTime? completedAt,
|
||||
DateTime? cancelledAt,
|
||||
}) {
|
||||
final now = DateTime(2026, 1, 1);
|
||||
return ProgrammerTask(
|
||||
id: id,
|
||||
title: 'Task $id',
|
||||
category: ProgrammerTaskCategory.bugFix,
|
||||
status: status,
|
||||
assigneeId: assigneeId,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
completedAt: completedAt,
|
||||
cancelledAt: cancelledAt,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSections(TaskSectionGroups groups, {bool readOnly = false}) {
|
||||
return ProviderScope(
|
||||
overrides: [
|
||||
programmerRunStatesProvider.overrideWithValue({}),
|
||||
],
|
||||
child: MaterialApp(
|
||||
home: Scaffold(
|
||||
body: SingleChildScrollView(
|
||||
child: TaskSections(
|
||||
groups: groups,
|
||||
profileById: const {},
|
||||
readOnly: readOnly,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Unit tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void main() {
|
||||
setUpAll(AppTime.initialize);
|
||||
|
||||
test('groupTasksForDashboard splits by run state and excludes other assignees', () {
|
||||
final tasks = [
|
||||
_makeTask(id: 't1', status: ProgrammerTaskStatus.inProgress, assigneeId: 'user1'),
|
||||
_makeTask(id: 't2', status: ProgrammerTaskStatus.inProgress, assigneeId: 'user1'),
|
||||
_makeTask(id: 't3', status: ProgrammerTaskStatus.queued, assigneeId: 'user1'),
|
||||
_makeTask(id: 't4', status: ProgrammerTaskStatus.queued, assigneeId: 'other'),
|
||||
];
|
||||
|
||||
final runStates = <String, ProgrammerRunInfo>{
|
||||
't1': (state: ProgrammerRunState.running, since: null),
|
||||
't2': (state: ProgrammerRunState.paused, since: null),
|
||||
't3': (state: ProgrammerRunState.notStarted, since: null),
|
||||
't4': (state: ProgrammerRunState.notStarted, since: null),
|
||||
};
|
||||
|
||||
final groups = groupTasksForDashboard(tasks, runStates, 'user1');
|
||||
|
||||
expect(groups.running.map((t) => t.id), ['t1']);
|
||||
expect(groups.paused.map((t) => t.id), ['t2']);
|
||||
expect(groups.notStarted.map((t) => t.id), ['t3']);
|
||||
// t4 belongs to 'other' — must not appear
|
||||
expect(groups.running.any((t) => t.id == 't4'), isFalse);
|
||||
expect(groups.paused.any((t) => t.id == 't4'), isFalse);
|
||||
expect(groups.notStarted.any((t) => t.id == 't4'), isFalse);
|
||||
});
|
||||
|
||||
test('completed sorted by completedAt desc, cancelled by cancelledAt desc', () {
|
||||
final base = DateTime(2026, 1, 1);
|
||||
final tasks = [
|
||||
_makeTask(
|
||||
id: 'c1',
|
||||
status: ProgrammerTaskStatus.completed,
|
||||
completedAt: base.add(const Duration(hours: 1)),
|
||||
),
|
||||
_makeTask(
|
||||
id: 'c2',
|
||||
status: ProgrammerTaskStatus.completed,
|
||||
completedAt: base.add(const Duration(hours: 3)),
|
||||
),
|
||||
_makeTask(
|
||||
id: 'x1',
|
||||
status: ProgrammerTaskStatus.cancelled,
|
||||
cancelledAt: base.add(const Duration(hours: 2)),
|
||||
),
|
||||
_makeTask(
|
||||
id: 'x2',
|
||||
status: ProgrammerTaskStatus.cancelled,
|
||||
cancelledAt: base.add(const Duration(hours: 5)),
|
||||
),
|
||||
];
|
||||
|
||||
final runStates = <String, ProgrammerRunInfo>{
|
||||
'c1': (state: ProgrammerRunState.completed, since: null),
|
||||
'c2': (state: ProgrammerRunState.completed, since: null),
|
||||
'x1': (state: ProgrammerRunState.cancelled, since: null),
|
||||
'x2': (state: ProgrammerRunState.cancelled, since: null),
|
||||
};
|
||||
|
||||
final groups = groupTasksForDashboard(tasks, runStates, 'user1');
|
||||
|
||||
expect(groups.completed.map((t) => t.id).toList(), ['c2', 'c1']);
|
||||
expect(groups.cancelled.map((t) => t.id).toList(), ['x2', 'x1']);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Widget tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
testWidgets('Completed and Cancelled start collapsed', (tester) async {
|
||||
final base = DateTime(2026, 1, 1);
|
||||
final completed = List.generate(
|
||||
12,
|
||||
(i) => _makeTask(
|
||||
id: 'c$i',
|
||||
status: ProgrammerTaskStatus.completed,
|
||||
completedAt: base.add(Duration(hours: i)),
|
||||
),
|
||||
);
|
||||
|
||||
final runStates = <String, ProgrammerRunInfo>{
|
||||
for (final t in completed)
|
||||
t.id: (state: ProgrammerRunState.completed, since: null),
|
||||
};
|
||||
|
||||
final groups = groupTasksForDashboard(completed, runStates, 'user1');
|
||||
|
||||
await tester.pumpWidget(_buildSections(groups));
|
||||
await tester.pump();
|
||||
|
||||
// Header is visible
|
||||
expect(find.textContaining('COMPLETED'), findsOneWidget);
|
||||
|
||||
// First task title is NOT visible (collapsed)
|
||||
expect(find.text('Task c0'), findsNothing);
|
||||
|
||||
// Tap the header to expand
|
||||
await tester.tap(find.textContaining('COMPLETED'));
|
||||
await tester.pump();
|
||||
|
||||
// 10 titles visible (first 10 of 12)
|
||||
int visibleCount = 0;
|
||||
for (int i = 0; i < 12; i++) {
|
||||
if (tester.any(find.text('Task c${11 - i}'))) visibleCount++;
|
||||
}
|
||||
expect(visibleCount, 10);
|
||||
|
||||
// "Show all (12)" link is visible
|
||||
expect(find.text('Show all (12)'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('empty sections are hidden', (tester) async {
|
||||
final tasks = [
|
||||
_makeTask(id: 'r1', status: ProgrammerTaskStatus.inProgress),
|
||||
];
|
||||
|
||||
final runStates = <String, ProgrammerRunInfo>{
|
||||
'r1': (state: ProgrammerRunState.running, since: null),
|
||||
};
|
||||
|
||||
final groups = groupTasksForDashboard(tasks, runStates, 'user1');
|
||||
|
||||
await tester.pumpWidget(_buildSections(groups));
|
||||
await tester.pump();
|
||||
|
||||
// 'In progress' header present (at least one widget contains the text)
|
||||
expect(find.textContaining('IN PROGRESS'), findsAtLeastNWidgets(1));
|
||||
|
||||
// 'Cancelled' header not rendered (no cancelled tasks)
|
||||
expect(find.textContaining('CANCELLED'), findsNothing);
|
||||
|
||||
// 'Paused' header not rendered
|
||||
expect(find.textContaining('PAUSED'), findsNothing);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:tasq/models/programmer_task.model.dart';
|
||||
import 'package:tasq/models/programmer_task_activity_log.model.dart';
|
||||
import 'package:tasq/providers/programmer_day_data_provider.dart';
|
||||
import 'package:tasq/utils/app_time.dart';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Minimal fake Supabase client — only the surface fetchDayActivityData uses.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class _FakeClient {
|
||||
_FakeClient({required this.tables});
|
||||
final Map<String, List<Map<String, dynamic>>> tables;
|
||||
|
||||
_FakeQuery from(String table) => _FakeQuery(this, table);
|
||||
}
|
||||
|
||||
class _FakeQuery implements Future<List<Map<String, dynamic>>> {
|
||||
_FakeQuery(this._client, this._table);
|
||||
|
||||
final _FakeClient _client;
|
||||
final String _table;
|
||||
|
||||
final List<MapEntry<String, dynamic>> _eqs = [];
|
||||
String? _inField;
|
||||
List<String>? _inValues;
|
||||
String? _orderField;
|
||||
bool _orderAsc = true;
|
||||
String? _gteField;
|
||||
dynamic _gteValue;
|
||||
String? _ltField;
|
||||
dynamic _ltValue;
|
||||
|
||||
List<Map<String, dynamic>> get _rows {
|
||||
var rows = List<Map<String, dynamic>>.from(_client.tables[_table] ?? []);
|
||||
for (final eq in _eqs) {
|
||||
rows = rows.where((r) => r[eq.key] == eq.value).toList();
|
||||
}
|
||||
if (_inField != null && _inValues != null) {
|
||||
rows = rows
|
||||
.where((r) => _inValues!.contains(r[_inField]?.toString()))
|
||||
.toList();
|
||||
}
|
||||
if (_gteField != null) {
|
||||
rows = rows.where((r) {
|
||||
final v = r[_gteField];
|
||||
return v != null && v.toString().compareTo(_gteValue.toString()) >= 0;
|
||||
}).toList();
|
||||
}
|
||||
if (_ltField != null) {
|
||||
rows = rows.where((r) {
|
||||
final v = r[_ltField];
|
||||
return v != null && v.toString().compareTo(_ltValue.toString()) < 0;
|
||||
}).toList();
|
||||
}
|
||||
if (_orderField != null) {
|
||||
rows.sort((a, b) {
|
||||
final av = '${a[_orderField]}';
|
||||
final bv = '${b[_orderField]}';
|
||||
return _orderAsc ? av.compareTo(bv) : bv.compareTo(av);
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
Future<List<Map<String, dynamic>>> get _future async => _rows;
|
||||
|
||||
_FakeQuery select([String? _]) => this;
|
||||
|
||||
_FakeQuery eq(String field, dynamic value) {
|
||||
_eqs.add(MapEntry(field, value));
|
||||
return this;
|
||||
}
|
||||
|
||||
_FakeQuery inFilter(String field, List<dynamic> values) {
|
||||
_inField = field;
|
||||
_inValues = values.map((v) => v.toString()).toList();
|
||||
return this;
|
||||
}
|
||||
|
||||
_FakeQuery gte(String field, dynamic value) {
|
||||
_gteField = field;
|
||||
_gteValue = value;
|
||||
return this;
|
||||
}
|
||||
|
||||
_FakeQuery lt(String field, dynamic value) {
|
||||
_ltField = field;
|
||||
_ltValue = value;
|
||||
return this;
|
||||
}
|
||||
|
||||
_FakeQuery order(String field, {bool ascending = true}) {
|
||||
_orderField = field;
|
||||
_orderAsc = ascending;
|
||||
return this;
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<List<Map<String, dynamic>>> asStream() => _future.asStream();
|
||||
@override
|
||||
Future<List<Map<String, dynamic>>> catchError(Function onError,
|
||||
{bool Function(Object error)? test}) =>
|
||||
_future.catchError(onError, test: test);
|
||||
@override
|
||||
Future<R> then<R>(
|
||||
FutureOr<R> Function(List<Map<String, dynamic>>) onValue,
|
||||
{Function? onError}) =>
|
||||
_future.then(onValue, onError: onError);
|
||||
@override
|
||||
Future<List<Map<String, dynamic>>> timeout(Duration timeLimit,
|
||||
{FutureOr<List<Map<String, dynamic>>> Function()? onTimeout}) =>
|
||||
_future.timeout(timeLimit, onTimeout: onTimeout);
|
||||
@override
|
||||
Future<List<Map<String, dynamic>>> whenComplete(
|
||||
FutureOr<void> Function() action) =>
|
||||
_future.whenComplete(action);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
ProgrammerTask _task({
|
||||
required String id,
|
||||
required String status,
|
||||
DateTime? startedAt,
|
||||
DateTime? completedAt,
|
||||
DateTime? cancelledAt,
|
||||
}) {
|
||||
return ProgrammerTask(
|
||||
id: id,
|
||||
title: 'Task $id',
|
||||
category: ProgrammerTaskCategory.others,
|
||||
status: status,
|
||||
createdAt: DateTime.utc(2026, 9, 1),
|
||||
updatedAt: DateTime.utc(2026, 9, 1),
|
||||
startedAt: startedAt,
|
||||
completedAt: completedAt,
|
||||
cancelledAt: cancelledAt,
|
||||
);
|
||||
}
|
||||
|
||||
ProgrammerTaskActivityLog _log({
|
||||
required String taskId,
|
||||
required String actionType,
|
||||
required DateTime createdAt,
|
||||
}) {
|
||||
return ProgrammerTaskActivityLog(
|
||||
id: '${taskId}_${actionType}_${createdAt.millisecondsSinceEpoch}',
|
||||
taskId: taskId,
|
||||
actionType: actionType,
|
||||
createdAt: createdAt,
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void main() {
|
||||
setUpAll(AppTime.initialize);
|
||||
|
||||
group('computeRunStates', () {
|
||||
test(
|
||||
'queued->notStarted; latest paused->paused since pause time; '
|
||||
'resumed->running; completed', () {
|
||||
final startedAt = DateTime.utc(2026, 9, 27, 8, 0);
|
||||
final pauseAt = DateTime.utc(2026, 9, 27, 10, 0);
|
||||
final resumeAt = DateTime.utc(2026, 9, 27, 11, 0);
|
||||
|
||||
final tasks = [
|
||||
_task(id: 'q', status: ProgrammerTaskStatus.queued),
|
||||
_task(
|
||||
id: 'p',
|
||||
status: ProgrammerTaskStatus.inProgress,
|
||||
startedAt: startedAt,
|
||||
),
|
||||
_task(
|
||||
id: 'r',
|
||||
status: ProgrammerTaskStatus.inProgress,
|
||||
startedAt: DateTime.utc(2026, 9, 27, 9, 0),
|
||||
),
|
||||
_task(
|
||||
id: 'c',
|
||||
status: ProgrammerTaskStatus.completed,
|
||||
startedAt: startedAt,
|
||||
completedAt: DateTime.utc(2026, 9, 27, 12, 0),
|
||||
),
|
||||
];
|
||||
|
||||
final logsByTask = {
|
||||
'p': [
|
||||
_log(taskId: 'p', actionType: 'started', createdAt: startedAt),
|
||||
_log(taskId: 'p', actionType: 'paused', createdAt: pauseAt),
|
||||
],
|
||||
'r': [
|
||||
_log(taskId: 'r', actionType: 'started', createdAt: DateTime.utc(2026, 9, 27, 9, 0)),
|
||||
_log(taskId: 'r', actionType: 'paused', createdAt: DateTime.utc(2026, 9, 27, 10, 30)),
|
||||
_log(taskId: 'r', actionType: 'resumed', createdAt: resumeAt),
|
||||
],
|
||||
};
|
||||
|
||||
final states = computeRunStates(tasks, logsByTask);
|
||||
|
||||
// queued -> notStarted
|
||||
expect(states['q']!.state, ProgrammerRunState.notStarted);
|
||||
expect(states['q']!.since, isNull);
|
||||
|
||||
// latest action is paused -> paused since pause time
|
||||
expect(states['p']!.state, ProgrammerRunState.paused);
|
||||
expect(states['p']!.since, pauseAt);
|
||||
|
||||
// latest action is resumed -> running since resume time
|
||||
expect(states['r']!.state, ProgrammerRunState.running);
|
||||
expect(states['r']!.since, resumeAt);
|
||||
|
||||
// completed -> completed
|
||||
expect(states['c']!.state, ProgrammerRunState.completed);
|
||||
expect(states['c']!.since, isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('fetchDayActivityData', () {
|
||||
// Target day: 2026-09-27 Manila.
|
||||
// dayStart (Manila midnight) = 2026-09-26T16:00:00Z UTC.
|
||||
// dayEnd = 2026-09-27T16:00:00Z UTC.
|
||||
final day = DateTime.utc(2026, 9, 27);
|
||||
final now = DateTime.utc(2026, 9, 27, 12, 0);
|
||||
|
||||
test('includes in-progress tasks and tasks completed that day', () async {
|
||||
final tActive = _task(
|
||||
id: 'active',
|
||||
status: ProgrammerTaskStatus.inProgress,
|
||||
startedAt: DateTime.utc(2026, 9, 27, 0, 0),
|
||||
);
|
||||
// Completed at 11:00 UTC on the 27th — still within the Manila day
|
||||
final tDone = _task(
|
||||
id: 'done',
|
||||
status: ProgrammerTaskStatus.completed,
|
||||
startedAt: DateTime.utc(2026, 9, 27, 0, 0),
|
||||
completedAt: DateTime.utc(2026, 9, 27, 3, 0),
|
||||
);
|
||||
|
||||
final client = _FakeClient(tables: {
|
||||
'programmer_task_work_logs': [],
|
||||
'programmer_task_activity_logs': [
|
||||
{
|
||||
'id': 'log-1',
|
||||
'task_id': 'active',
|
||||
'action_type': 'started',
|
||||
'created_at': '2026-09-27T00:00:00Z',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
final result = await fetchDayActivityData(
|
||||
client,
|
||||
day: day,
|
||||
tasks: [tActive, tDone],
|
||||
now: now,
|
||||
);
|
||||
|
||||
expect(result.tasks.map((t) => t.id), containsAll(['active', 'done']));
|
||||
});
|
||||
|
||||
test('excludes a task completed two days earlier', () async {
|
||||
// Completed well before the Manila day boundary (2026-09-26T16:00Z).
|
||||
final tOld = _task(
|
||||
id: 'old',
|
||||
status: ProgrammerTaskStatus.completed,
|
||||
startedAt: DateTime.utc(2026, 9, 24, 8, 0),
|
||||
completedAt: DateTime.utc(2026, 9, 25, 10, 0),
|
||||
);
|
||||
|
||||
final client = _FakeClient(tables: {
|
||||
'programmer_task_work_logs': [],
|
||||
'programmer_task_activity_logs': [],
|
||||
});
|
||||
|
||||
final result = await fetchDayActivityData(
|
||||
client,
|
||||
day: day,
|
||||
tasks: [tOld],
|
||||
now: now,
|
||||
);
|
||||
|
||||
expect(result.tasks, isEmpty);
|
||||
});
|
||||
|
||||
test('includes a task referenced only by a helper work log that day',
|
||||
() async {
|
||||
// Task was completed long before; not active on 2026-09-27 by dates alone.
|
||||
final tHelper = _task(
|
||||
id: 'helper-task',
|
||||
status: ProgrammerTaskStatus.completed,
|
||||
startedAt: DateTime.utc(2026, 9, 1, 8, 0),
|
||||
completedAt: DateTime.utc(2026, 9, 1, 17, 0),
|
||||
);
|
||||
|
||||
final client = _FakeClient(tables: {
|
||||
'programmer_task_work_logs': [
|
||||
{
|
||||
'id': 'wl-1',
|
||||
'task_id': 'helper-task',
|
||||
'author_id': 'u2',
|
||||
'description': 'helped out',
|
||||
'minutes': 30,
|
||||
'created_at': '2026-09-27T01:00:00Z',
|
||||
'work_date': '2026-09-27',
|
||||
},
|
||||
],
|
||||
'programmer_task_activity_logs': [],
|
||||
});
|
||||
|
||||
final result = await fetchDayActivityData(
|
||||
client,
|
||||
day: day,
|
||||
tasks: [tHelper],
|
||||
now: now,
|
||||
);
|
||||
|
||||
expect(result.tasks.map((t) => t.id), contains('helper-task'));
|
||||
expect(result.workLogs, hasLength(1));
|
||||
expect(result.workLogs.first.taskId, 'helper-task');
|
||||
});
|
||||
|
||||
test('excludes work logs with a different work_date', () async {
|
||||
final tTask = _task(
|
||||
id: 'some-task',
|
||||
status: ProgrammerTaskStatus.inProgress,
|
||||
startedAt: DateTime.utc(2026, 9, 27, 0, 0),
|
||||
);
|
||||
final client = _FakeClient(tables: {
|
||||
'programmer_task_work_logs': [
|
||||
{
|
||||
'id': 'wl-old',
|
||||
'task_id': 'some-task',
|
||||
'author_id': 'u1',
|
||||
'description': 'yesterday',
|
||||
'minutes': 60,
|
||||
'created_at': '2026-09-26T10:00:00Z',
|
||||
'work_date': '2026-09-26',
|
||||
},
|
||||
],
|
||||
'programmer_task_activity_logs': [],
|
||||
});
|
||||
final result = await fetchDayActivityData(
|
||||
client,
|
||||
day: day,
|
||||
tasks: [tTask],
|
||||
now: now,
|
||||
);
|
||||
expect(result.workLogs, isEmpty);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,405 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:tasq/models/programmer_task.model.dart';
|
||||
import 'package:tasq/models/programmer_task_activity_log.model.dart';
|
||||
import 'package:tasq/models/programmer_task_work_log.model.dart';
|
||||
import 'package:tasq/utils/app_time.dart';
|
||||
import 'package:tasq/utils/programmer_day_rows.dart';
|
||||
import 'package:tasq/widgets/quill_field.dart';
|
||||
|
||||
void main() {
|
||||
setUpAll(AppTime.initialize);
|
||||
|
||||
group('deltaJsonToPlainText', () {
|
||||
test('legacy plain text notes pass through', () {
|
||||
expect(deltaJsonToPlainText('fixed bug'), 'fixed bug');
|
||||
expect(deltaJsonToPlainText(' spaces '), 'spaces');
|
||||
expect(deltaJsonToPlainText(''), '');
|
||||
});
|
||||
|
||||
test('null returns empty string', () {
|
||||
expect(deltaJsonToPlainText(null), '');
|
||||
});
|
||||
|
||||
test('invalid JSON falls back to returning trimmed string', () {
|
||||
expect(deltaJsonToPlainText('[{invalid json'), '[{invalid json');
|
||||
});
|
||||
|
||||
test('Delta JSON with insert is converted to plain text', () {
|
||||
expect(deltaJsonToPlainText('[{"insert":"Hello\\n"}]'), 'Hello');
|
||||
});
|
||||
|
||||
test('Delta JSON arrays starting with [ are attempted to be parsed', () {
|
||||
// The function attempts to parse JSON that starts with '['
|
||||
// If parsing fails, it returns the trimmed string
|
||||
// This test verifies the fallback behavior
|
||||
expect(deltaJsonToPlainText('[{"invalid":"json"}]'), '[{"invalid":"json"}]');
|
||||
});
|
||||
});
|
||||
|
||||
group('DaySheetRow', () {
|
||||
test('json round-trip', () {
|
||||
final row = DaySheetRow(
|
||||
taskId: 't1',
|
||||
title: 'Review PR',
|
||||
category: 'Software Support',
|
||||
kind: 'assignee',
|
||||
projectName: 'Project A',
|
||||
helpedName: null,
|
||||
seconds: 3600,
|
||||
notes: ['First note', 'Second note'],
|
||||
);
|
||||
expect(DaySheetRow.fromJson(row.toJson()).toJson(), row.toJson());
|
||||
});
|
||||
|
||||
test('helper row json round-trip', () {
|
||||
final row = DaySheetRow(
|
||||
taskId: 't2',
|
||||
title: 'Debug issue',
|
||||
category: 'Bug Fix',
|
||||
kind: 'helper',
|
||||
projectName: null,
|
||||
helpedName: 'Ana Cruz',
|
||||
seconds: 2700,
|
||||
notes: ['Helped with testing'],
|
||||
);
|
||||
expect(DaySheetRow.fromJson(row.toJson()).toJson(), row.toJson());
|
||||
});
|
||||
});
|
||||
|
||||
group('ProgrammerTaskWorkLog.workDate', () {
|
||||
test('workDate defaults to Manila day of createdAt', () {
|
||||
final base = {
|
||||
'id': 'l1',
|
||||
'task_id': 't1',
|
||||
'author_id': 'a',
|
||||
'description': 'work',
|
||||
'minutes': null,
|
||||
'created_at': '2026-09-26T16:30:00Z',
|
||||
};
|
||||
final log = ProgrammerTaskWorkLog.fromMap(base);
|
||||
// 2026-09-26 16:30 UTC is 2026-09-27 in Manila (UTC+8)
|
||||
expect(log.workDate, DateTime.utc(2026, 9, 27));
|
||||
});
|
||||
|
||||
test('workDate from map when provided', () {
|
||||
final base = {
|
||||
'id': 'l1',
|
||||
'task_id': 't1',
|
||||
'author_id': 'a',
|
||||
'description': 'work',
|
||||
'minutes': null,
|
||||
'created_at': '2026-09-26T16:30:00Z',
|
||||
'work_date': '2026-09-25',
|
||||
};
|
||||
final log = ProgrammerTaskWorkLog.fromMap(base);
|
||||
expect(log.workDate, DateTime.utc(2026, 9, 25));
|
||||
});
|
||||
|
||||
test('workDate constructor parameter', () {
|
||||
final log = ProgrammerTaskWorkLog(
|
||||
id: 'l1',
|
||||
taskId: 't1',
|
||||
authorId: 'a',
|
||||
description: 'work',
|
||||
createdAt: DateTime.utc(2026, 9, 26, 16, 30),
|
||||
workDate: DateTime.utc(2026, 9, 25),
|
||||
);
|
||||
expect(log.workDate, DateTime.utc(2026, 9, 25));
|
||||
});
|
||||
});
|
||||
|
||||
group('buildDayRows', () {
|
||||
final day = DateTime.utc(2026, 9, 27);
|
||||
final earlierDay = DateTime.utc(2026, 9, 26);
|
||||
final now = DateTime.utc(2026, 9, 27, 12, 0);
|
||||
|
||||
ProgrammerTask _task(String id, String assigneeId, [String? projectId]) =>
|
||||
ProgrammerTask(
|
||||
id: id,
|
||||
title: 'Task $id',
|
||||
category: 'Software Support',
|
||||
status: 'in_progress',
|
||||
assigneeId: assigneeId,
|
||||
creatorId: 'creator',
|
||||
projectId: projectId,
|
||||
createdAt: DateTime.utc(2026, 9, 1),
|
||||
updatedAt: DateTime.utc(2026, 9, 27),
|
||||
);
|
||||
|
||||
ProgrammerTaskActivityLog _log(String taskId, String type,
|
||||
[Map<String, dynamic>? meta]) =>
|
||||
ProgrammerTaskActivityLog(
|
||||
id: '$taskId-$type',
|
||||
taskId: taskId,
|
||||
actionType: type,
|
||||
meta: meta,
|
||||
createdAt: DateTime.utc(2026, 9, 27, 10),
|
||||
);
|
||||
|
||||
ProgrammerTaskWorkLog _workLog(
|
||||
String taskId,
|
||||
String authorId,
|
||||
String description, {
|
||||
int? minutes,
|
||||
DateTime? workDate,
|
||||
}) =>
|
||||
ProgrammerTaskWorkLog(
|
||||
id: '$taskId-$authorId-${minutes ?? "notes"}',
|
||||
taskId: taskId,
|
||||
authorId: authorId,
|
||||
description: description,
|
||||
minutes: minutes,
|
||||
createdAt: DateTime.utc(2026, 9, 27, 10),
|
||||
workDate: workDate,
|
||||
);
|
||||
|
||||
test('assignee row includes only that-day seconds and notes', () {
|
||||
// Task t1 assigned to 'a'
|
||||
// 'a' worked 1 hour on t1 that day
|
||||
// 'a' also logged a note on t1 that day
|
||||
// 'a' logged a note on t1 the day before (should be excluded)
|
||||
final task = _task('t1', 'a');
|
||||
final workLogs = [
|
||||
_workLog('t1', 'a', 'Fixed part 1', workDate: day),
|
||||
_workLog('t1', 'a', 'Fixed part 2', workDate: earlierDay),
|
||||
];
|
||||
final rows = buildDayRows(
|
||||
userId: 'a',
|
||||
day: day,
|
||||
tasks: [task],
|
||||
logsByTask: {
|
||||
't1': [
|
||||
ProgrammerTaskActivityLog(
|
||||
id: 't1-started',
|
||||
taskId: 't1',
|
||||
actionType: 'started',
|
||||
createdAt: DateTime.utc(2026, 9, 27, 8, 0),
|
||||
),
|
||||
ProgrammerTaskActivityLog(
|
||||
id: 't1-paused',
|
||||
taskId: 't1',
|
||||
actionType: 'paused',
|
||||
createdAt: DateTime.utc(2026, 9, 27, 9, 0),
|
||||
),
|
||||
],
|
||||
},
|
||||
workLogs: workLogs,
|
||||
projectNames: {},
|
||||
profileNames: {'a': 'Alice Smith'},
|
||||
now: now,
|
||||
);
|
||||
expect(rows.length, 1);
|
||||
expect(rows[0].kind, 'assignee');
|
||||
expect(rows[0].notes, ['Fixed part 1']);
|
||||
});
|
||||
|
||||
test('helper row: minutes*60 and helpedName', () {
|
||||
// Task t1 assigned to 'a'
|
||||
// 'h' (helper) logs 45 minutes of help on t1 that day
|
||||
final task = _task('t1', 'a');
|
||||
final workLogs = [
|
||||
_workLog('t1', 'h', 'Helped with debugging', minutes: 45, workDate: day),
|
||||
];
|
||||
final rows = buildDayRows(
|
||||
userId: 'h',
|
||||
day: day,
|
||||
tasks: [task],
|
||||
logsByTask: {'t1': []},
|
||||
workLogs: workLogs,
|
||||
projectNames: {},
|
||||
profileNames: {'a': 'Ana Cruz'},
|
||||
now: now,
|
||||
);
|
||||
expect(rows.length, 1);
|
||||
expect(rows[0].kind, 'helper');
|
||||
expect(rows[0].seconds, 2700);
|
||||
expect(rows[0].helpedName, 'Ana Cruz');
|
||||
});
|
||||
|
||||
test('notes-only row when assignee logged but ran no timer', () {
|
||||
// Task t1 assigned to 'a'
|
||||
// 'a' only logged notes (no timer activity)
|
||||
final task = _task('t1', 'a');
|
||||
final workLogs = [
|
||||
_workLog('t1', 'a', 'Just some notes', workDate: day),
|
||||
];
|
||||
final rows = buildDayRows(
|
||||
userId: 'a',
|
||||
day: day,
|
||||
tasks: [task],
|
||||
logsByTask: {'t1': []},
|
||||
workLogs: workLogs,
|
||||
projectNames: {},
|
||||
profileNames: {'a': 'Alice Smith'},
|
||||
now: now,
|
||||
);
|
||||
expect(rows.length, 1);
|
||||
expect(rows[0].kind, 'assignee');
|
||||
expect(rows[0].seconds, 0);
|
||||
expect(rows[0].notes, ['Just some notes']);
|
||||
});
|
||||
|
||||
test('rows sort: assignee by seconds desc, then helpers by seconds desc', () {
|
||||
// Task t1 assigned to 'a': 1 hour
|
||||
// Task t2 assigned to 'a': 30 minutes
|
||||
// Task t3 assigned to 'b': with 'a' as helper (20 minutes)
|
||||
// Task t4 assigned to 'b': with 'a' as helper (10 minutes)
|
||||
final tasks = [
|
||||
_task('t1', 'a'),
|
||||
_task('t2', 'a'),
|
||||
_task('t3', 'b'),
|
||||
_task('t4', 'b'),
|
||||
];
|
||||
final workLogs = [
|
||||
_workLog('t3', 'a', 'helped b', minutes: 20, workDate: day),
|
||||
_workLog('t4', 'a', 'helped b', minutes: 10, workDate: day),
|
||||
];
|
||||
final rows = buildDayRows(
|
||||
userId: 'a',
|
||||
day: day,
|
||||
tasks: tasks,
|
||||
logsByTask: {
|
||||
't1': [
|
||||
ProgrammerTaskActivityLog(
|
||||
id: 't1-started',
|
||||
taskId: 't1',
|
||||
actionType: 'started',
|
||||
createdAt: DateTime.utc(2026, 9, 27, 8, 0),
|
||||
),
|
||||
ProgrammerTaskActivityLog(
|
||||
id: 't1-paused',
|
||||
taskId: 't1',
|
||||
actionType: 'paused',
|
||||
createdAt: DateTime.utc(2026, 9, 27, 9, 0),
|
||||
),
|
||||
],
|
||||
't2': [
|
||||
ProgrammerTaskActivityLog(
|
||||
id: 't2-started',
|
||||
taskId: 't2',
|
||||
actionType: 'started',
|
||||
createdAt: DateTime.utc(2026, 9, 27, 9, 0),
|
||||
),
|
||||
ProgrammerTaskActivityLog(
|
||||
id: 't2-paused',
|
||||
taskId: 't2',
|
||||
actionType: 'paused',
|
||||
createdAt: DateTime.utc(2026, 9, 27, 9, 30),
|
||||
),
|
||||
],
|
||||
't3': [],
|
||||
't4': [],
|
||||
},
|
||||
workLogs: workLogs,
|
||||
projectNames: {},
|
||||
profileNames: {'a': 'Alice Smith', 'b': 'Bob Jones'},
|
||||
now: now,
|
||||
);
|
||||
// Expect: assignee t1 (3600), assignee t2 (1800), helper t3 (1200), helper t4 (600)
|
||||
expect(rows.length, 4);
|
||||
expect(rows[0].kind, 'assignee');
|
||||
expect(rows[0].taskId, 't1');
|
||||
expect(rows[0].seconds, 3600);
|
||||
|
||||
expect(rows[1].kind, 'assignee');
|
||||
expect(rows[1].taskId, 't2');
|
||||
expect(rows[1].seconds, 1800);
|
||||
|
||||
expect(rows[2].kind, 'helper');
|
||||
expect(rows[2].taskId, 't3');
|
||||
expect(rows[2].seconds, 1200);
|
||||
expect(rows[2].helpedName, 'Bob Jones');
|
||||
|
||||
expect(rows[3].kind, 'helper');
|
||||
expect(rows[3].taskId, 't4');
|
||||
expect(rows[3].seconds, 600);
|
||||
expect(rows[3].helpedName, 'Bob Jones');
|
||||
});
|
||||
|
||||
test('tasks missing from task list are skipped', () {
|
||||
// Work logs reference t1, but t1 is not in tasks list
|
||||
final workLogs = [
|
||||
_workLog('t1', 'a', 'work', workDate: day),
|
||||
];
|
||||
final rows = buildDayRows(
|
||||
userId: 'a',
|
||||
day: day,
|
||||
tasks: [],
|
||||
logsByTask: {},
|
||||
workLogs: workLogs,
|
||||
projectNames: {},
|
||||
profileNames: {},
|
||||
now: now,
|
||||
);
|
||||
expect(rows, isEmpty);
|
||||
});
|
||||
|
||||
test('helper logs with minutes=0 are ignored', () {
|
||||
final task = _task('t1', 'a');
|
||||
final workLogs = [
|
||||
_workLog('t1', 'h', 'zero minutes', minutes: 0, workDate: day),
|
||||
];
|
||||
final rows = buildDayRows(
|
||||
userId: 'h',
|
||||
day: day,
|
||||
tasks: [task],
|
||||
logsByTask: {'t1': []},
|
||||
workLogs: workLogs,
|
||||
projectNames: {},
|
||||
profileNames: {'a': 'Ana Cruz'},
|
||||
now: now,
|
||||
);
|
||||
expect(rows, isEmpty);
|
||||
});
|
||||
|
||||
test('empty notes are dropped from output', () {
|
||||
final task = _task('t1', 'a');
|
||||
final workLogs = [
|
||||
_workLog('t1', 'a', ' ', workDate: day), // whitespace-only
|
||||
_workLog('t1', 'a', 'Real note', workDate: day),
|
||||
_workLog('t1', 'a', '', workDate: day), // empty
|
||||
];
|
||||
final rows = buildDayRows(
|
||||
userId: 'a',
|
||||
day: day,
|
||||
tasks: [task],
|
||||
logsByTask: {'t1': []},
|
||||
workLogs: workLogs,
|
||||
projectNames: {},
|
||||
profileNames: {},
|
||||
now: now,
|
||||
);
|
||||
expect(rows.length, 1);
|
||||
expect(rows[0].notes, ['Real note']);
|
||||
});
|
||||
});
|
||||
|
||||
group('totalSeconds', () {
|
||||
test('sums all row seconds', () {
|
||||
final rows = [
|
||||
DaySheetRow(
|
||||
taskId: 't1',
|
||||
title: 'Task 1',
|
||||
category: 'Work',
|
||||
kind: 'assignee',
|
||||
seconds: 3600,
|
||||
notes: [],
|
||||
),
|
||||
DaySheetRow(
|
||||
taskId: 't2',
|
||||
title: 'Task 2',
|
||||
category: 'Work',
|
||||
kind: 'helper',
|
||||
seconds: 1800,
|
||||
notes: [],
|
||||
helpedName: 'Someone',
|
||||
),
|
||||
];
|
||||
expect(totalSeconds(rows), 5400);
|
||||
});
|
||||
|
||||
test('empty list returns 0', () {
|
||||
expect(totalSeconds([]), 0);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:tasq/models/programmer_day_sheet.model.dart';
|
||||
import 'package:tasq/models/programmer_day_sheet_event.model.dart';
|
||||
import 'package:tasq/providers/programmer_day_sheets_provider.dart';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fake push recorder
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class _PushCall {
|
||||
final List<String> userIds;
|
||||
final String title;
|
||||
final String body;
|
||||
final Map<String, dynamic>? data;
|
||||
|
||||
_PushCall({
|
||||
required this.userIds,
|
||||
required this.title,
|
||||
required this.body,
|
||||
this.data,
|
||||
});
|
||||
}
|
||||
|
||||
class _RecordingPush {
|
||||
final calls = <_PushCall>[];
|
||||
|
||||
Future<void> call({
|
||||
required List<String> userIds,
|
||||
required String title,
|
||||
required String body,
|
||||
Map<String, dynamic>? data,
|
||||
}) async {
|
||||
calls.add(_PushCall(userIds: userIds, title: title, body: body, data: data));
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fake Supabase-like client — only exposes rpc()
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class _RpcCall {
|
||||
final String name;
|
||||
final Map<String, dynamic>? params;
|
||||
_RpcCall(this.name, this.params);
|
||||
}
|
||||
|
||||
class _FakeClient {
|
||||
final calls = <_RpcCall>[];
|
||||
Map<String, dynamic>? _nextResult;
|
||||
Object? _nextError;
|
||||
|
||||
void setResult(Map<String, dynamic> result) => _nextResult = result;
|
||||
void setError(Object error) => _nextError = error;
|
||||
|
||||
Future<dynamic> rpc(String name, {Map<String, dynamic>? params}) async {
|
||||
calls.add(_RpcCall(name, params));
|
||||
if (_nextError != null) {
|
||||
final err = _nextError!;
|
||||
_nextError = null;
|
||||
throw err;
|
||||
}
|
||||
final result = _nextResult;
|
||||
_nextResult = null;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void main() {
|
||||
group('DaySheetsController', () {
|
||||
late _FakeClient fake;
|
||||
late _RecordingPush push;
|
||||
late DaySheetsController controller;
|
||||
|
||||
setUp(() {
|
||||
fake = _FakeClient();
|
||||
push = _RecordingPush();
|
||||
controller = DaySheetsController(fake, push: push.call);
|
||||
});
|
||||
|
||||
test('submit calls submit_day_sheet with p_date and pushes admins', () async {
|
||||
fake.setResult({
|
||||
'sheet_id': 's1',
|
||||
'work_date': '2026-09-28',
|
||||
'programmer_name': 'Paola Ross',
|
||||
'notify_user_ids': ['adm'],
|
||||
});
|
||||
|
||||
final sheetId = await controller.submit(DateTime.utc(2026, 9, 28));
|
||||
|
||||
expect(sheetId, 's1');
|
||||
expect(fake.calls.single.name, 'submit_day_sheet');
|
||||
expect(fake.calls.single.params, {'p_date': '2026-09-28'});
|
||||
expect(push.calls.single.title, 'Day sheet submitted');
|
||||
expect(
|
||||
push.calls.single.body,
|
||||
'Paola Ross submitted their day sheet for Sep 28, 2026',
|
||||
);
|
||||
});
|
||||
|
||||
test('blank remarks fail fast without an rpc call', () async {
|
||||
await expectLater(
|
||||
controller.disapprove(
|
||||
sheetId: 'sh1',
|
||||
remarks: ' ',
|
||||
flaggedTaskIds: [],
|
||||
),
|
||||
throwsA(
|
||||
isA<DaySheetActionException>().having(
|
||||
(e) => e.code,
|
||||
'code',
|
||||
DaySheetError.remarksRequired,
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(fake.calls, isEmpty);
|
||||
});
|
||||
|
||||
test('already_reviewed maps to a friendly message', () async {
|
||||
fake.setError(Exception('already_reviewed'));
|
||||
|
||||
DaySheetActionException? caught;
|
||||
try {
|
||||
await controller.submit(DateTime.utc(2026, 9, 28));
|
||||
} on DaySheetActionException catch (e) {
|
||||
caught = e;
|
||||
}
|
||||
|
||||
expect(caught, isNotNull);
|
||||
expect(caught!.code, DaySheetError.alreadyReviewed);
|
||||
expect(caught.message, 'This sheet was already reviewed.');
|
||||
});
|
||||
|
||||
test('approve reports approved and skipped ids and pushes per approved sheet',
|
||||
() async {
|
||||
fake.setResult({
|
||||
'approved': [
|
||||
{
|
||||
'sheet_id': 'sh1',
|
||||
'work_date': '2026-09-28',
|
||||
'programmer_id': 'prog1',
|
||||
},
|
||||
{
|
||||
'sheet_id': 'sh2',
|
||||
'work_date': '2026-09-27',
|
||||
'programmer_id': 'prog2',
|
||||
},
|
||||
],
|
||||
'skipped_ids': ['sh3'],
|
||||
});
|
||||
|
||||
final snapshot = DaySheetSnapshot(totalSeconds: 3600, rows: []);
|
||||
final items = [
|
||||
(sheetId: 'sh1', snapshot: snapshot),
|
||||
(sheetId: 'sh2', snapshot: snapshot),
|
||||
(sheetId: 'sh3', snapshot: snapshot),
|
||||
];
|
||||
|
||||
final result = await controller.approve(items);
|
||||
|
||||
expect(result.approvedIds, ['sh1', 'sh2']);
|
||||
expect(result.skippedIds, ['sh3']);
|
||||
expect(push.calls.length, 2);
|
||||
expect(push.calls[0].title, 'Day sheet approved');
|
||||
expect(
|
||||
push.calls[0].body,
|
||||
'Your day sheet for Sep 28, 2026 was approved',
|
||||
);
|
||||
expect(
|
||||
push.calls[1].body,
|
||||
'Your day sheet for Sep 27, 2026 was approved',
|
||||
);
|
||||
});
|
||||
|
||||
test('a failing push does not fail the action', () async {
|
||||
fake.setResult({
|
||||
'sheet_id': 's1',
|
||||
'work_date': '2026-09-28',
|
||||
'programmer_name': 'Paola Ross',
|
||||
'notify_user_ids': ['adm'],
|
||||
});
|
||||
|
||||
final throwingController = DaySheetsController(
|
||||
fake,
|
||||
push: ({
|
||||
required List<String> userIds,
|
||||
required String title,
|
||||
required String body,
|
||||
Map<String, dynamic>? data,
|
||||
}) async {
|
||||
throw Exception('push failed');
|
||||
},
|
||||
);
|
||||
|
||||
// Should succeed even though push throws.
|
||||
final sheetId = await throwingController.submit(DateTime.utc(2026, 9, 28));
|
||||
expect(sheetId, 's1');
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
group('mapDaySheetError', () {
|
||||
test('maps known substrings to the correct error code', () {
|
||||
expect(
|
||||
mapDaySheetError(Exception('not_authorized')),
|
||||
DaySheetError.notAuthorized,
|
||||
);
|
||||
expect(
|
||||
mapDaySheetError(Exception('already_reviewed')),
|
||||
DaySheetError.alreadyReviewed,
|
||||
);
|
||||
expect(
|
||||
mapDaySheetError(Exception('already_submitted')),
|
||||
DaySheetError.alreadySubmitted,
|
||||
);
|
||||
expect(
|
||||
mapDaySheetError(Exception('remarks_required')),
|
||||
DaySheetError.remarksRequired,
|
||||
);
|
||||
expect(
|
||||
mapDaySheetError(Exception('body_required')),
|
||||
DaySheetError.bodyRequired,
|
||||
);
|
||||
expect(
|
||||
mapDaySheetError(Exception('work_date_locked')),
|
||||
DaySheetError.workDateLocked,
|
||||
);
|
||||
expect(
|
||||
mapDaySheetError(Exception('invalid_snapshot')),
|
||||
DaySheetError.invalidSnapshot,
|
||||
);
|
||||
expect(
|
||||
mapDaySheetError(Exception('something else entirely')),
|
||||
DaySheetError.unknown,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
group('DaySheetActionException.message', () {
|
||||
test('returns exact strings for every error code', () {
|
||||
expect(
|
||||
DaySheetActionException(DaySheetError.alreadyReviewed).message,
|
||||
'This sheet was already reviewed.',
|
||||
);
|
||||
expect(
|
||||
DaySheetActionException(DaySheetError.alreadySubmitted).message,
|
||||
'This day sheet was already submitted.',
|
||||
);
|
||||
expect(
|
||||
DaySheetActionException(DaySheetError.notAuthorized).message,
|
||||
'You are not allowed to do that.',
|
||||
);
|
||||
expect(
|
||||
DaySheetActionException(DaySheetError.remarksRequired).message,
|
||||
'Remarks are required to disapprove.',
|
||||
);
|
||||
expect(
|
||||
DaySheetActionException(DaySheetError.bodyRequired).message,
|
||||
'Please write a justification.',
|
||||
);
|
||||
expect(
|
||||
DaySheetActionException(DaySheetError.workDateLocked).message,
|
||||
'That day is locked — you can only add entries to pending or disapproved days.',
|
||||
);
|
||||
expect(
|
||||
DaySheetActionException(DaySheetError.invalidSnapshot).message,
|
||||
'Could not approve: the sheet data was invalid.',
|
||||
);
|
||||
expect(
|
||||
DaySheetActionException(DaySheetError.unknown).message,
|
||||
'Something went wrong. Please try again.',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
group('ProgrammerDaySheetEvent', () {
|
||||
test('fromMap parses all fields correctly', () {
|
||||
final event = ProgrammerDaySheetEvent.fromMap({
|
||||
'id': 'ev1',
|
||||
'sheet_id': 'sh1',
|
||||
'kind': 'submitted',
|
||||
'actor_id': 'u1',
|
||||
'body': 'some text',
|
||||
'flagged_task_ids': ['t1', 't2'],
|
||||
'created_at': '2026-09-28T10:00:00.000Z',
|
||||
});
|
||||
|
||||
expect(event.id, 'ev1');
|
||||
expect(event.sheetId, 'sh1');
|
||||
expect(event.kind, 'submitted');
|
||||
expect(event.actorId, 'u1');
|
||||
expect(event.body, 'some text');
|
||||
expect(event.flaggedTaskIds, ['t1', 't2']);
|
||||
expect(event.createdAt, DateTime.utc(2026, 9, 28, 10, 0, 0));
|
||||
});
|
||||
|
||||
test('fromMap handles null optional fields', () {
|
||||
final event = ProgrammerDaySheetEvent.fromMap({
|
||||
'id': 'ev2',
|
||||
'sheet_id': 'sh2',
|
||||
'kind': 'approved',
|
||||
'created_at': '2026-09-28T10:00:00.000Z',
|
||||
});
|
||||
|
||||
expect(event.actorId, isNull);
|
||||
expect(event.body, isNull);
|
||||
expect(event.flaggedTaskIds, isEmpty);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:tasq/models/profile.model.dart';
|
||||
import 'package:tasq/models/programmer_day_sheet.model.dart';
|
||||
import 'package:tasq/providers/leave_provider.dart';
|
||||
import 'package:tasq/providers/programmer_day_data_provider.dart';
|
||||
import 'package:tasq/providers/programmer_day_sheets_provider.dart';
|
||||
import 'package:tasq/providers/programmer_tasks_provider.dart';
|
||||
import 'package:tasq/providers/profile_provider.dart';
|
||||
import 'package:tasq/screens/programmer_tasks/programmer_drill_down_screen.dart';
|
||||
import 'package:tasq/theme/app_theme.dart';
|
||||
import 'package:tasq/utils/app_time.dart';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
ProgrammerDaySheet _sheet({
|
||||
required String id,
|
||||
required String status,
|
||||
required DateTime workDate,
|
||||
}) {
|
||||
final now = AppTime.now();
|
||||
return ProgrammerDaySheet(
|
||||
id: id,
|
||||
programmerId: 'u1',
|
||||
status: status,
|
||||
workDate: workDate,
|
||||
autoSubmitted: false,
|
||||
resubmissions: 0,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildApp(String userId, {List<Override> overrides = const []}) {
|
||||
return ProviderScope(
|
||||
overrides: overrides,
|
||||
child: MaterialApp(
|
||||
theme: AppTheme.light(),
|
||||
home: ProgrammerDrillDownScreen(userId: userId),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<Override> _minimalOverrides({
|
||||
List<ProgrammerDaySheet> sheets = const [],
|
||||
}) {
|
||||
final today = DateTime.utc(2026, 9, 28);
|
||||
return [
|
||||
isStrictAdminProvider.overrideWith((ref) => true),
|
||||
profilesProvider.overrideWith((ref) => Stream.value([])),
|
||||
currentProfileProvider.overrideWith((ref) => Stream.value(null)),
|
||||
programmerTasksProvider.overrideWith((ref) => Stream.value([])),
|
||||
programmerRunStatesProvider.overrideWith((ref) => const {}),
|
||||
clockTickerProvider.overrideWith((ref) => Stream.value(today)),
|
||||
todayKeyProvider.overrideWith((ref) => today),
|
||||
leavesProvider.overrideWith((ref) => Stream.value([])),
|
||||
programmerDayDataProvider.overrideWith(
|
||||
(ref, day) => Future.value(
|
||||
DayActivityData(day: day, tasks: [], logsByTask: {}, workLogs: []),
|
||||
),
|
||||
),
|
||||
daySheetsForUserProvider.overrideWith((ref, userId) => Stream.value(sheets)),
|
||||
];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void main() {
|
||||
setUpAll(AppTime.initialize);
|
||||
|
||||
group('ProgrammerDrillDownScreen — recent day sheets', () {
|
||||
testWidgets('shows Recent day sheets header when there are sheets',
|
||||
(tester) async {
|
||||
final sheets = [
|
||||
_sheet(
|
||||
id: 's1',
|
||||
status: DaySheetStatus.pending,
|
||||
workDate: DateTime.utc(2026, 9, 27),
|
||||
),
|
||||
_sheet(
|
||||
id: 's2',
|
||||
status: DaySheetStatus.approved,
|
||||
workDate: DateTime.utc(2026, 9, 26),
|
||||
),
|
||||
];
|
||||
|
||||
await tester.pumpWidget(
|
||||
_buildApp('u1', overrides: _minimalOverrides(sheets: sheets)),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
// AppSectionHeader renders title in uppercase
|
||||
expect(find.text('RECENT DAY SHEETS'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('shows both sheet dates', (tester) async {
|
||||
final d1 = DateTime.utc(2026, 9, 27);
|
||||
final d2 = DateTime.utc(2026, 9, 26);
|
||||
final sheets = [
|
||||
_sheet(id: 's1', status: DaySheetStatus.pending, workDate: d1),
|
||||
_sheet(id: 's2', status: DaySheetStatus.approved, workDate: d2),
|
||||
];
|
||||
|
||||
await tester.pumpWidget(
|
||||
_buildApp('u1', overrides: _minimalOverrides(sheets: sheets)),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text(AppTime.formatDate(d1)), findsOneWidget);
|
||||
expect(find.text(AppTime.formatDate(d2)), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('shows RECENT DAY SHEETS even with empty sheets list',
|
||||
(tester) async {
|
||||
await tester.pumpWidget(
|
||||
_buildApp('u1', overrides: _minimalOverrides()),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('RECENT DAY SHEETS'), findsOneWidget);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,786 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:tasq/models/leave_of_absence.model.dart';
|
||||
import 'package:tasq/models/pass_slip.model.dart';
|
||||
import 'package:tasq/models/profile.model.dart';
|
||||
import 'package:tasq/models/programmer_task.model.dart';
|
||||
import 'package:tasq/models/programmer_task_activity_log.model.dart';
|
||||
import 'package:tasq/models/programmer_task_work_log.model.dart';
|
||||
import 'package:tasq/providers/programmer_day_data_provider.dart';
|
||||
import 'package:tasq/screens/programmer_tasks/monitor/monitor_model.dart';
|
||||
import 'package:tasq/utils/app_time.dart';
|
||||
import 'package:tasq/utils/staff_presence.dart';
|
||||
|
||||
void main() {
|
||||
setUpAll(AppTime.initialize);
|
||||
|
||||
group('HelpEdge', () {
|
||||
test('creates with all fields', () {
|
||||
final edge = HelpEdge(
|
||||
helperId: 'h1',
|
||||
assigneeId: 'a1',
|
||||
taskId: 't1',
|
||||
taskTitle: 'Fix bug',
|
||||
minutes: 30,
|
||||
at: DateTime.utc(2026, 9, 27),
|
||||
);
|
||||
expect(edge.helperId, 'h1');
|
||||
expect(edge.assigneeId, 'a1');
|
||||
expect(edge.taskId, 't1');
|
||||
expect(edge.taskTitle, 'Fix bug');
|
||||
expect(edge.minutes, 30);
|
||||
expect(edge.at, DateTime.utc(2026, 9, 27));
|
||||
});
|
||||
});
|
||||
|
||||
group('buildHelpEdges', () {
|
||||
test('no edges when no work logs', () {
|
||||
final edges = buildHelpEdges(
|
||||
workLogs: [],
|
||||
taskById: {},
|
||||
day: DateTime.utc(2026, 9, 27),
|
||||
);
|
||||
expect(edges, isEmpty);
|
||||
});
|
||||
|
||||
test('skips when author == assignee (not helping)', () {
|
||||
final task = ProgrammerTask(
|
||||
id: 't1',
|
||||
title: 'Task',
|
||||
category: 'Bug Fix',
|
||||
status: 'in_progress',
|
||||
createdAt: DateTime.utc(2026, 9, 27),
|
||||
updatedAt: DateTime.utc(2026, 9, 27),
|
||||
assigneeId: 'a1',
|
||||
);
|
||||
final log = ProgrammerTaskWorkLog(
|
||||
id: 'l1',
|
||||
taskId: 't1',
|
||||
authorId: 'a1', // Same as assignee
|
||||
description: 'work',
|
||||
minutes: 30,
|
||||
createdAt: DateTime.utc(2026, 9, 27, 10, 0),
|
||||
workDate: DateTime.utc(2026, 9, 27),
|
||||
);
|
||||
final edges = buildHelpEdges(
|
||||
workLogs: [log],
|
||||
taskById: {'t1': task},
|
||||
day: DateTime.utc(2026, 9, 27),
|
||||
);
|
||||
expect(edges, isEmpty);
|
||||
});
|
||||
|
||||
test('skips when minutes is null', () {
|
||||
final task = ProgrammerTask(
|
||||
id: 't1',
|
||||
title: 'Task',
|
||||
category: 'Bug Fix',
|
||||
status: 'in_progress',
|
||||
createdAt: DateTime.utc(2026, 9, 27),
|
||||
updatedAt: DateTime.utc(2026, 9, 27),
|
||||
assigneeId: 'a1',
|
||||
);
|
||||
final log = ProgrammerTaskWorkLog(
|
||||
id: 'l1',
|
||||
taskId: 't1',
|
||||
authorId: 'h1',
|
||||
description: 'work',
|
||||
minutes: null,
|
||||
createdAt: DateTime.utc(2026, 9, 27, 10, 0),
|
||||
workDate: DateTime.utc(2026, 9, 27),
|
||||
);
|
||||
final edges = buildHelpEdges(
|
||||
workLogs: [log],
|
||||
taskById: {'t1': task},
|
||||
day: DateTime.utc(2026, 9, 27),
|
||||
);
|
||||
expect(edges, isEmpty);
|
||||
});
|
||||
|
||||
test('skips when minutes is 0', () {
|
||||
final task = ProgrammerTask(
|
||||
id: 't1',
|
||||
title: 'Task',
|
||||
category: 'Bug Fix',
|
||||
status: 'in_progress',
|
||||
createdAt: DateTime.utc(2026, 9, 27),
|
||||
updatedAt: DateTime.utc(2026, 9, 27),
|
||||
assigneeId: 'a1',
|
||||
);
|
||||
final log = ProgrammerTaskWorkLog(
|
||||
id: 'l1',
|
||||
taskId: 't1',
|
||||
authorId: 'h1',
|
||||
description: 'work',
|
||||
minutes: 0,
|
||||
createdAt: DateTime.utc(2026, 9, 27, 10, 0),
|
||||
workDate: DateTime.utc(2026, 9, 27),
|
||||
);
|
||||
final edges = buildHelpEdges(
|
||||
workLogs: [log],
|
||||
taskById: {'t1': task},
|
||||
day: DateTime.utc(2026, 9, 27),
|
||||
);
|
||||
expect(edges, isEmpty);
|
||||
});
|
||||
|
||||
test('skips when workDate != day', () {
|
||||
final task = ProgrammerTask(
|
||||
id: 't1',
|
||||
title: 'Task',
|
||||
category: 'Bug Fix',
|
||||
status: 'in_progress',
|
||||
createdAt: DateTime.utc(2026, 9, 27),
|
||||
updatedAt: DateTime.utc(2026, 9, 27),
|
||||
assigneeId: 'a1',
|
||||
);
|
||||
final log = ProgrammerTaskWorkLog(
|
||||
id: 'l1',
|
||||
taskId: 't1',
|
||||
authorId: 'h1',
|
||||
description: 'work',
|
||||
minutes: 30,
|
||||
createdAt: DateTime.utc(2026, 9, 26, 10, 0),
|
||||
workDate: DateTime.utc(2026, 9, 26),
|
||||
);
|
||||
final edges = buildHelpEdges(
|
||||
workLogs: [log],
|
||||
taskById: {'t1': task},
|
||||
day: DateTime.utc(2026, 9, 27),
|
||||
);
|
||||
expect(edges, isEmpty);
|
||||
});
|
||||
|
||||
test('creates edge when all conditions met', () {
|
||||
final task = ProgrammerTask(
|
||||
id: 't1',
|
||||
title: 'Fix bug',
|
||||
category: 'Bug Fix',
|
||||
status: 'in_progress',
|
||||
createdAt: DateTime.utc(2026, 9, 27),
|
||||
updatedAt: DateTime.utc(2026, 9, 27),
|
||||
assigneeId: 'a1',
|
||||
);
|
||||
final log = ProgrammerTaskWorkLog(
|
||||
id: 'l1',
|
||||
taskId: 't1',
|
||||
authorId: 'h1',
|
||||
description: 'work',
|
||||
minutes: 30,
|
||||
createdAt: DateTime.utc(2026, 9, 27, 10, 0),
|
||||
workDate: DateTime.utc(2026, 9, 27),
|
||||
);
|
||||
final edges = buildHelpEdges(
|
||||
workLogs: [log],
|
||||
taskById: {'t1': task},
|
||||
day: DateTime.utc(2026, 9, 27),
|
||||
);
|
||||
expect(edges, hasLength(1));
|
||||
expect(edges[0].helperId, 'h1');
|
||||
expect(edges[0].assigneeId, 'a1');
|
||||
expect(edges[0].taskId, 't1');
|
||||
expect(edges[0].taskTitle, 'Fix bug');
|
||||
expect(edges[0].minutes, 30);
|
||||
});
|
||||
|
||||
test('sorts by createdAt newest first', () {
|
||||
final task = ProgrammerTask(
|
||||
id: 't1',
|
||||
title: 'Fix bug',
|
||||
category: 'Bug Fix',
|
||||
status: 'in_progress',
|
||||
createdAt: DateTime.utc(2026, 9, 27),
|
||||
updatedAt: DateTime.utc(2026, 9, 27),
|
||||
assigneeId: 'a1',
|
||||
);
|
||||
final log1 = ProgrammerTaskWorkLog(
|
||||
id: 'l1',
|
||||
taskId: 't1',
|
||||
authorId: 'h1',
|
||||
description: 'work',
|
||||
minutes: 30,
|
||||
createdAt: DateTime.utc(2026, 9, 27, 10, 0),
|
||||
workDate: DateTime.utc(2026, 9, 27),
|
||||
);
|
||||
final log2 = ProgrammerTaskWorkLog(
|
||||
id: 'l2',
|
||||
taskId: 't1',
|
||||
authorId: 'h2',
|
||||
description: 'work',
|
||||
minutes: 20,
|
||||
createdAt: DateTime.utc(2026, 9, 27, 11, 0),
|
||||
workDate: DateTime.utc(2026, 9, 27),
|
||||
);
|
||||
final edges = buildHelpEdges(
|
||||
workLogs: [log1, log2],
|
||||
taskById: {'t1': task},
|
||||
day: DateTime.utc(2026, 9, 27),
|
||||
);
|
||||
expect(edges, hasLength(2));
|
||||
expect(edges[0].helperId, 'h2'); // Newest first
|
||||
expect(edges[1].helperId, 'h1');
|
||||
});
|
||||
});
|
||||
|
||||
group('buildMonitorEntries', () {
|
||||
final now = DateTime.utc(2026, 9, 27, 12, 0);
|
||||
final today = DateTime.utc(2026, 9, 27);
|
||||
|
||||
test('programmer on leave -> onLeave state', () {
|
||||
final programmer = Profile(
|
||||
id: 'p1',
|
||||
role: 'programmer',
|
||||
fullName: 'Alice',
|
||||
);
|
||||
final leave = LeaveOfAbsence(
|
||||
id: 'l1',
|
||||
userId: 'p1',
|
||||
leaveType: 'Sick Leave',
|
||||
justification: 'ill',
|
||||
startTime: DateTime.utc(2026, 9, 27, 8, 0),
|
||||
endTime: DateTime.utc(2026, 9, 27, 17, 0),
|
||||
status: 'approved',
|
||||
filedBy: 'p1',
|
||||
createdAt: DateTime.utc(2026, 9, 27),
|
||||
);
|
||||
final data = DayActivityData(
|
||||
day: today,
|
||||
tasks: [],
|
||||
logsByTask: {},
|
||||
workLogs: [],
|
||||
);
|
||||
final activeLeaves = activeLeaveByUser([leave], now);
|
||||
final entries = buildMonitorEntries(
|
||||
programmers: [programmer],
|
||||
today: data,
|
||||
runStates: {},
|
||||
leaves: activeLeaves,
|
||||
passSlips: {},
|
||||
sheetStatusByUser: {},
|
||||
projectNames: {},
|
||||
profileNames: {},
|
||||
now: now,
|
||||
);
|
||||
expect(entries, hasLength(1));
|
||||
expect(entries[0].state, MonitorState.onLeave);
|
||||
});
|
||||
|
||||
test('programmer with active pass slip -> passSlip state', () {
|
||||
final programmer = Profile(
|
||||
id: 'p1',
|
||||
role: 'programmer',
|
||||
fullName: 'Alice',
|
||||
);
|
||||
final slip = PassSlip(
|
||||
id: 's1',
|
||||
userId: 'p1',
|
||||
dutyScheduleId: 'd1',
|
||||
reason: 'out',
|
||||
status: 'approved',
|
||||
requestedAt: DateTime.utc(2026, 9, 27),
|
||||
approvedBy: 'admin',
|
||||
approvedAt: DateTime.utc(2026, 9, 27),
|
||||
slipStart: DateTime.utc(2026, 9, 27, 13, 0),
|
||||
slipEnd: null,
|
||||
);
|
||||
final data = DayActivityData(
|
||||
day: today,
|
||||
tasks: [],
|
||||
logsByTask: {},
|
||||
workLogs: [],
|
||||
);
|
||||
final activePassSlips = activePassSlipByUser([slip]);
|
||||
final entries = buildMonitorEntries(
|
||||
programmers: [programmer],
|
||||
today: data,
|
||||
runStates: {},
|
||||
leaves: {},
|
||||
passSlips: activePassSlips,
|
||||
sheetStatusByUser: {},
|
||||
projectNames: {},
|
||||
profileNames: {},
|
||||
now: now,
|
||||
);
|
||||
expect(entries, hasLength(1));
|
||||
expect(entries[0].state, MonitorState.passSlip);
|
||||
});
|
||||
|
||||
test('programmer with running task -> working state with currentTask', () {
|
||||
final programmer = Profile(
|
||||
id: 'p1',
|
||||
role: 'programmer',
|
||||
fullName: 'Alice',
|
||||
);
|
||||
final task = ProgrammerTask(
|
||||
id: 't1',
|
||||
title: 'Fix bug',
|
||||
category: 'Bug Fix',
|
||||
status: 'in_progress',
|
||||
createdAt: DateTime.utc(2026, 9, 27),
|
||||
updatedAt: DateTime.utc(2026, 9, 27),
|
||||
assigneeId: 'p1',
|
||||
);
|
||||
final data = DayActivityData(
|
||||
day: today,
|
||||
tasks: [task],
|
||||
logsByTask: {},
|
||||
workLogs: [],
|
||||
);
|
||||
final runStates = {
|
||||
't1': (state: ProgrammerRunState.running, since: DateTime.utc(2026, 9, 27, 10, 0)),
|
||||
};
|
||||
final entries = buildMonitorEntries(
|
||||
programmers: [programmer],
|
||||
today: data,
|
||||
runStates: runStates,
|
||||
leaves: {},
|
||||
passSlips: {},
|
||||
sheetStatusByUser: {},
|
||||
projectNames: {},
|
||||
profileNames: {},
|
||||
now: now,
|
||||
);
|
||||
expect(entries, hasLength(1));
|
||||
expect(entries[0].state, MonitorState.working);
|
||||
expect(entries[0].currentTask, task);
|
||||
});
|
||||
|
||||
test('programmer with only paused task -> paused state', () {
|
||||
final programmer = Profile(
|
||||
id: 'p1',
|
||||
role: 'programmer',
|
||||
fullName: 'Alice',
|
||||
);
|
||||
final task = ProgrammerTask(
|
||||
id: 't1',
|
||||
title: 'Fix bug',
|
||||
category: 'Bug Fix',
|
||||
status: 'in_progress',
|
||||
createdAt: DateTime.utc(2026, 9, 27),
|
||||
updatedAt: DateTime.utc(2026, 9, 27),
|
||||
assigneeId: 'p1',
|
||||
);
|
||||
final data = DayActivityData(
|
||||
day: today,
|
||||
tasks: [task],
|
||||
logsByTask: {},
|
||||
workLogs: [],
|
||||
);
|
||||
final runStates = {
|
||||
't1': (state: ProgrammerRunState.paused, since: DateTime.utc(2026, 9, 27, 10, 0)),
|
||||
};
|
||||
final entries = buildMonitorEntries(
|
||||
programmers: [programmer],
|
||||
today: data,
|
||||
runStates: runStates,
|
||||
leaves: {},
|
||||
passSlips: {},
|
||||
sheetStatusByUser: {},
|
||||
projectNames: {},
|
||||
profileNames: {},
|
||||
now: now,
|
||||
);
|
||||
expect(entries, hasLength(1));
|
||||
expect(entries[0].state, MonitorState.paused);
|
||||
});
|
||||
|
||||
test('programmer with no running tasks -> idle state', () {
|
||||
final programmer = Profile(
|
||||
id: 'p1',
|
||||
role: 'programmer',
|
||||
fullName: 'Alice',
|
||||
);
|
||||
final data = DayActivityData(
|
||||
day: today,
|
||||
tasks: [],
|
||||
logsByTask: {},
|
||||
workLogs: [],
|
||||
);
|
||||
final entries = buildMonitorEntries(
|
||||
programmers: [programmer],
|
||||
today: data,
|
||||
runStates: {},
|
||||
leaves: {},
|
||||
passSlips: {},
|
||||
sheetStatusByUser: {},
|
||||
projectNames: {},
|
||||
profileNames: {},
|
||||
now: now,
|
||||
);
|
||||
expect(entries, hasLength(1));
|
||||
expect(entries[0].state, MonitorState.idle);
|
||||
expect(entries[0].currentTask, isNull);
|
||||
});
|
||||
|
||||
test('leave overrides running task (onLeave state)', () {
|
||||
final programmer = Profile(
|
||||
id: 'p1',
|
||||
role: 'programmer',
|
||||
fullName: 'Alice',
|
||||
);
|
||||
final task = ProgrammerTask(
|
||||
id: 't1',
|
||||
title: 'Fix bug',
|
||||
category: 'Bug Fix',
|
||||
status: 'in_progress',
|
||||
createdAt: DateTime.utc(2026, 9, 27),
|
||||
updatedAt: DateTime.utc(2026, 9, 27),
|
||||
assigneeId: 'p1',
|
||||
);
|
||||
final leave = LeaveOfAbsence(
|
||||
id: 'l1',
|
||||
userId: 'p1',
|
||||
leaveType: 'Sick Leave',
|
||||
justification: 'ill',
|
||||
startTime: DateTime.utc(2026, 9, 27, 8, 0),
|
||||
endTime: DateTime.utc(2026, 9, 27, 17, 0),
|
||||
status: 'approved',
|
||||
filedBy: 'p1',
|
||||
createdAt: DateTime.utc(2026, 9, 27),
|
||||
);
|
||||
final data = DayActivityData(
|
||||
day: today,
|
||||
tasks: [task],
|
||||
logsByTask: {},
|
||||
workLogs: [],
|
||||
);
|
||||
final runStates = {
|
||||
't1': (state: ProgrammerRunState.running, since: DateTime.utc(2026, 9, 27, 10, 0)),
|
||||
};
|
||||
final activeLeaves = activeLeaveByUser([leave], now);
|
||||
final entries = buildMonitorEntries(
|
||||
programmers: [programmer],
|
||||
today: data,
|
||||
runStates: runStates,
|
||||
leaves: activeLeaves,
|
||||
passSlips: {},
|
||||
sheetStatusByUser: {},
|
||||
projectNames: {},
|
||||
profileNames: {},
|
||||
now: now,
|
||||
);
|
||||
expect(entries, hasLength(1));
|
||||
expect(entries[0].state, MonitorState.onLeave);
|
||||
});
|
||||
|
||||
test('sort order: working, paused, idle, passSlip, onLeave, then name', () {
|
||||
final p1 = Profile(
|
||||
id: 'p1',
|
||||
role: 'programmer',
|
||||
fullName: 'Zoe', // Should be last after sorting by state
|
||||
);
|
||||
final p2 = Profile(
|
||||
id: 'p2',
|
||||
role: 'programmer',
|
||||
fullName: 'Alice',
|
||||
);
|
||||
final p3 = Profile(
|
||||
id: 'p3',
|
||||
role: 'programmer',
|
||||
fullName: 'Bob',
|
||||
);
|
||||
final p4 = Profile(
|
||||
id: 'p4',
|
||||
role: 'programmer',
|
||||
fullName: 'Charlie',
|
||||
);
|
||||
final p5 = Profile(
|
||||
id: 'p5',
|
||||
role: 'programmer',
|
||||
fullName: 'Diana',
|
||||
);
|
||||
|
||||
// p1: working
|
||||
// p2: paused
|
||||
// p3: idle
|
||||
// p4: passSlip
|
||||
// p5: onLeave
|
||||
final task1 = ProgrammerTask(
|
||||
id: 't1',
|
||||
title: 'Task',
|
||||
category: 'Bug Fix',
|
||||
status: 'in_progress',
|
||||
createdAt: DateTime.utc(2026, 9, 27),
|
||||
updatedAt: DateTime.utc(2026, 9, 27),
|
||||
assigneeId: 'p1',
|
||||
);
|
||||
final task2 = ProgrammerTask(
|
||||
id: 't2',
|
||||
title: 'Task',
|
||||
category: 'Bug Fix',
|
||||
status: 'in_progress',
|
||||
createdAt: DateTime.utc(2026, 9, 27),
|
||||
updatedAt: DateTime.utc(2026, 9, 27),
|
||||
assigneeId: 'p2',
|
||||
);
|
||||
|
||||
final slip = PassSlip(
|
||||
id: 's1',
|
||||
userId: 'p4',
|
||||
dutyScheduleId: 'd1',
|
||||
reason: 'out',
|
||||
status: 'approved',
|
||||
requestedAt: DateTime.utc(2026, 9, 27),
|
||||
approvedBy: 'admin',
|
||||
approvedAt: DateTime.utc(2026, 9, 27),
|
||||
slipStart: DateTime.utc(2026, 9, 27, 13, 0),
|
||||
slipEnd: null,
|
||||
);
|
||||
|
||||
final leave = LeaveOfAbsence(
|
||||
id: 'l1',
|
||||
userId: 'p5',
|
||||
leaveType: 'Sick Leave',
|
||||
justification: 'ill',
|
||||
startTime: DateTime.utc(2026, 9, 27, 8, 0),
|
||||
endTime: DateTime.utc(2026, 9, 27, 17, 0),
|
||||
status: 'approved',
|
||||
filedBy: 'p5',
|
||||
createdAt: DateTime.utc(2026, 9, 27),
|
||||
);
|
||||
|
||||
final data = DayActivityData(
|
||||
day: today,
|
||||
tasks: [task1, task2],
|
||||
logsByTask: {},
|
||||
workLogs: [],
|
||||
);
|
||||
|
||||
final runStates = {
|
||||
't1': (state: ProgrammerRunState.running, since: DateTime.utc(2026, 9, 27, 10, 0)),
|
||||
't2': (state: ProgrammerRunState.paused, since: DateTime.utc(2026, 9, 27, 11, 0)),
|
||||
};
|
||||
|
||||
final activeLeaves = activeLeaveByUser([leave], now);
|
||||
final activePassSlips = activePassSlipByUser([slip]);
|
||||
|
||||
final entries = buildMonitorEntries(
|
||||
programmers: [p1, p2, p3, p4, p5],
|
||||
today: data,
|
||||
runStates: runStates,
|
||||
leaves: activeLeaves,
|
||||
passSlips: activePassSlips,
|
||||
sheetStatusByUser: {},
|
||||
projectNames: {},
|
||||
profileNames: {},
|
||||
now: now,
|
||||
);
|
||||
|
||||
expect(entries, hasLength(5));
|
||||
expect(entries[0].programmer.id, 'p1'); // working
|
||||
expect(entries[1].programmer.id, 'p2'); // paused
|
||||
expect(entries[2].programmer.id, 'p3'); // idle
|
||||
expect(entries[3].programmer.id, 'p4'); // passSlip
|
||||
expect(entries[4].programmer.id, 'p5'); // onLeave
|
||||
});
|
||||
|
||||
test('within same state, sort alphabetically by fullName', () {
|
||||
final p1 = Profile(
|
||||
id: 'p1',
|
||||
role: 'programmer',
|
||||
fullName: 'Zoe',
|
||||
);
|
||||
final p2 = Profile(
|
||||
id: 'p2',
|
||||
role: 'programmer',
|
||||
fullName: 'Alice',
|
||||
);
|
||||
final p3 = Profile(
|
||||
id: 'p3',
|
||||
role: 'programmer',
|
||||
fullName: 'Bob',
|
||||
);
|
||||
|
||||
// All idle
|
||||
final data = DayActivityData(
|
||||
day: today,
|
||||
tasks: [],
|
||||
logsByTask: {},
|
||||
workLogs: [],
|
||||
);
|
||||
|
||||
final entries = buildMonitorEntries(
|
||||
programmers: [p1, p2, p3],
|
||||
today: data,
|
||||
runStates: {},
|
||||
leaves: {},
|
||||
passSlips: {},
|
||||
sheetStatusByUser: {},
|
||||
projectNames: {},
|
||||
profileNames: {},
|
||||
now: now,
|
||||
);
|
||||
|
||||
expect(entries, hasLength(3));
|
||||
expect(entries[0].programmer.fullName, 'Alice');
|
||||
expect(entries[1].programmer.fullName, 'Bob');
|
||||
expect(entries[2].programmer.fullName, 'Zoe');
|
||||
});
|
||||
|
||||
test('counts inProgress and paused and notStarted tasks', () {
|
||||
final programmer = Profile(
|
||||
id: 'p1',
|
||||
role: 'programmer',
|
||||
fullName: 'Alice',
|
||||
);
|
||||
|
||||
// 2 running, 1 paused, 1 queued
|
||||
final t1 = ProgrammerTask(
|
||||
id: 't1',
|
||||
title: 'Task 1',
|
||||
category: 'Bug Fix',
|
||||
status: 'in_progress',
|
||||
createdAt: DateTime.utc(2026, 9, 27),
|
||||
updatedAt: DateTime.utc(2026, 9, 27),
|
||||
assigneeId: 'p1',
|
||||
);
|
||||
final t2 = ProgrammerTask(
|
||||
id: 't2',
|
||||
title: 'Task 2',
|
||||
category: 'Bug Fix',
|
||||
status: 'in_progress',
|
||||
createdAt: DateTime.utc(2026, 9, 27),
|
||||
updatedAt: DateTime.utc(2026, 9, 27),
|
||||
assigneeId: 'p1',
|
||||
);
|
||||
final t3 = ProgrammerTask(
|
||||
id: 't3',
|
||||
title: 'Task 3',
|
||||
category: 'Bug Fix',
|
||||
status: 'in_progress',
|
||||
createdAt: DateTime.utc(2026, 9, 27),
|
||||
updatedAt: DateTime.utc(2026, 9, 27),
|
||||
assigneeId: 'p1',
|
||||
);
|
||||
final t4 = ProgrammerTask(
|
||||
id: 't4',
|
||||
title: 'Task 4',
|
||||
category: 'Bug Fix',
|
||||
status: 'queued',
|
||||
createdAt: DateTime.utc(2026, 9, 27),
|
||||
updatedAt: DateTime.utc(2026, 9, 27),
|
||||
assigneeId: 'p1',
|
||||
);
|
||||
|
||||
final data = DayActivityData(
|
||||
day: today,
|
||||
tasks: [t1, t2, t3, t4],
|
||||
logsByTask: {},
|
||||
workLogs: [],
|
||||
);
|
||||
|
||||
final runStates = {
|
||||
't1': (state: ProgrammerRunState.running, since: DateTime.utc(2026, 9, 27, 10, 0)),
|
||||
't2': (state: ProgrammerRunState.running, since: DateTime.utc(2026, 9, 27, 10, 0)),
|
||||
't3': (state: ProgrammerRunState.paused, since: DateTime.utc(2026, 9, 27, 11, 0)),
|
||||
't4': (state: ProgrammerRunState.notStarted, since: null),
|
||||
};
|
||||
|
||||
final entries = buildMonitorEntries(
|
||||
programmers: [programmer],
|
||||
today: data,
|
||||
runStates: runStates,
|
||||
leaves: {},
|
||||
passSlips: {},
|
||||
sheetStatusByUser: {},
|
||||
projectNames: {},
|
||||
profileNames: {},
|
||||
now: now,
|
||||
);
|
||||
|
||||
expect(entries[0].inProgress, 2);
|
||||
expect(entries[0].paused, 1);
|
||||
expect(entries[0].notStarted, 1);
|
||||
});
|
||||
|
||||
test('currentTask is oldest running task', () {
|
||||
final programmer = Profile(
|
||||
id: 'p1',
|
||||
role: 'programmer',
|
||||
fullName: 'Alice',
|
||||
);
|
||||
|
||||
final t1 = ProgrammerTask(
|
||||
id: 't1',
|
||||
title: 'Task 1',
|
||||
category: 'Bug Fix',
|
||||
status: 'in_progress',
|
||||
createdAt: DateTime.utc(2026, 9, 27),
|
||||
updatedAt: DateTime.utc(2026, 9, 27),
|
||||
assigneeId: 'p1',
|
||||
);
|
||||
final t2 = ProgrammerTask(
|
||||
id: 't2',
|
||||
title: 'Task 2',
|
||||
category: 'Bug Fix',
|
||||
status: 'in_progress',
|
||||
createdAt: DateTime.utc(2026, 9, 27),
|
||||
updatedAt: DateTime.utc(2026, 9, 27),
|
||||
assigneeId: 'p1',
|
||||
);
|
||||
|
||||
final data = DayActivityData(
|
||||
day: today,
|
||||
tasks: [t1, t2],
|
||||
logsByTask: {},
|
||||
workLogs: [],
|
||||
);
|
||||
|
||||
final runStates = {
|
||||
't1': (
|
||||
state: ProgrammerRunState.running,
|
||||
since: DateTime.utc(2026, 9, 27, 10, 0),
|
||||
), // Earlier
|
||||
't2': (
|
||||
state: ProgrammerRunState.running,
|
||||
since: DateTime.utc(2026, 9, 27, 11, 0),
|
||||
), // Later
|
||||
};
|
||||
|
||||
final entries = buildMonitorEntries(
|
||||
programmers: [programmer],
|
||||
today: data,
|
||||
runStates: runStates,
|
||||
leaves: {},
|
||||
passSlips: {},
|
||||
sheetStatusByUser: {},
|
||||
projectNames: {},
|
||||
profileNames: {},
|
||||
now: now,
|
||||
);
|
||||
|
||||
expect(entries[0].currentTask?.id, 't1'); // Oldest running
|
||||
});
|
||||
|
||||
test('todayTotal equals totalSeconds for all programmer tasks', () {
|
||||
// This test verifies that todayTotal matches the expected value
|
||||
// For now, we just verify the structure is correct
|
||||
final programmer = Profile(
|
||||
id: 'p1',
|
||||
role: 'programmer',
|
||||
fullName: 'Alice',
|
||||
);
|
||||
|
||||
final data = DayActivityData(
|
||||
day: today,
|
||||
tasks: [],
|
||||
logsByTask: {},
|
||||
workLogs: [],
|
||||
);
|
||||
|
||||
final entries = buildMonitorEntries(
|
||||
programmers: [programmer],
|
||||
today: data,
|
||||
runStates: {},
|
||||
leaves: {},
|
||||
passSlips: {},
|
||||
sheetStatusByUser: {},
|
||||
projectNames: {},
|
||||
profileNames: {},
|
||||
now: now,
|
||||
);
|
||||
|
||||
expect(entries[0].todayTotal, isA<Duration>());
|
||||
expect(entries[0].currentTaskToday, isA<Duration>());
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:tasq/screens/programmer_tasks/dashboard/badges_strip.dart';
|
||||
import 'package:tasq/screens/programmer_tasks/dashboard/stat_tiles.dart';
|
||||
import 'package:tasq/utils/programmer_stats.dart';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
ProgrammerStats _makeStats({
|
||||
int currentStreak = 0,
|
||||
int bestStreak = 0,
|
||||
int completedThisWeek = 0,
|
||||
int completedLastWeek = 0,
|
||||
double? firstPassRate,
|
||||
}) {
|
||||
return ProgrammerStats(
|
||||
todayFocus: Duration.zero,
|
||||
helpGivenThisMonth: Duration.zero,
|
||||
approvedTotal: Duration.zero,
|
||||
helpGivenTotal: Duration.zero,
|
||||
completedThisWeek: completedThisWeek,
|
||||
completedLastWeek: completedLastWeek,
|
||||
currentStreak: currentStreak,
|
||||
bestStreak: bestStreak,
|
||||
completedTotal: 0,
|
||||
bugFixCompleted: 0,
|
||||
firstPassRate: firstPassRate,
|
||||
hasCleanWeek: false,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _wrap(Widget child) {
|
||||
return MaterialApp(home: Scaffold(body: SingleChildScrollView(child: child)));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void main() {
|
||||
testWidgets('sheet stats hidden when showSheetStats is false', (tester) async {
|
||||
final stats = _makeStats(currentStreak: 6, bestStreak: 9);
|
||||
await tester.pumpWidget(_wrap(
|
||||
StatTiles(stats: stats, showSheetStats: false),
|
||||
));
|
||||
expect(find.text('STREAK'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('streak tile shows days and best', (tester) async {
|
||||
final stats = _makeStats(currentStreak: 6, bestStreak: 9);
|
||||
await tester.pumpWidget(_wrap(
|
||||
StatTiles(stats: stats, showSheetStats: true),
|
||||
));
|
||||
expect(find.text('6 days'), findsOneWidget);
|
||||
expect(find.text('Best 9'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('locked badge shows progress', (tester) async {
|
||||
const badge = BadgeProgress(
|
||||
id: 'finisher',
|
||||
title: 'Finisher',
|
||||
description: 'Complete 10 tasks',
|
||||
icon: Icons.task_alt,
|
||||
current: 7,
|
||||
target: 10,
|
||||
earned: false,
|
||||
);
|
||||
await tester.pumpWidget(_wrap(
|
||||
const BadgesStrip(badges: [badge]),
|
||||
));
|
||||
expect(find.text('7/10'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user