Add Programmer Tasks module (work-log + time tracking)
Separate feature for the `programmer` role to record dev work by category, distinct from the IT help-desk `tasks` feature. Modeled on the IT Service Requests pattern (Brick offline-first model -> Riverpod stream/controller -> list/detail screens); seed of a future Project Management workflow. - programmer_tasks + programmer_task_activity_logs (category enum, RLS, realtime) via supabase migration - single assignee, self-logged or lead-assigned; assign/reassign logged - pause-on-switch: starting a task while another runs prompts to pause the running one, keeping one active task per user for accurate time attribution - worked-duration math shared with help-desk tasks via a generic core in task_duration.dart (existing callers unchanged) - list distinguishes running vs paused with a per-task Resume shortcut - accessible dialog labels; route /programmer-tasks + nav gated to admin/programmer - unit/widget tests: controller lifecycle, duration math, pause-switch dialog Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
@@ -0,0 +1,145 @@
|
||||
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 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,
|
||||
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(),
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user