Added Task number

This commit is contained in:
Marc Rejohn Castillano 2026-02-21 21:37:36 +08:00
parent 8bb69a80af
commit 6238c701c0
7 changed files with 272 additions and 14 deletions

View File

@ -6,6 +6,7 @@ class Task {
Task({
required this.id,
required this.ticketId,
required this.taskNumber,
required this.title,
required this.description,
required this.officeId,
@ -28,6 +29,7 @@ class Task {
final String id;
final String? ticketId;
final String? taskNumber;
final String title;
final String description;
final String? officeId;
@ -55,6 +57,7 @@ class Task {
return Task(
id: map['id'] as String,
ticketId: map['ticket_id'] as String?,
taskNumber: map['task_number'] as String?,
title: map['title'] as String? ?? 'Task',
description: map['description'] as String? ?? '',
officeId: map['office_id'] as String?,

View File

@ -153,7 +153,8 @@ final tasksProvider = StreamProvider<List<Task>>((ref) {
.where(
(t) =>
t.title.toLowerCase().contains(q) ||
t.description.toLowerCase().contains(q),
t.description.toLowerCase().contains(q) ||
(t.taskNumber?.toLowerCase().contains(q) ?? false),
)
.toList();
}
@ -253,15 +254,88 @@ class TasksController {
payload['request_category'] = requestCategory;
}
final data = await _client
// Prefer server RPC that atomically generates `task_number` and inserts
// the task; fallback to client-side insert with retry on duplicate-key.
String? taskId;
String? assignedNumber;
try {
final rpcParams = {
'p_title': title,
'p_description': description,
'p_office_id': officeId,
'p_ticket_id': ticketId,
'p_request_type': requestType,
'p_request_type_other': requestTypeOther,
'p_request_category': requestCategory,
'p_creator_id': actorId,
};
// Retry RPC on duplicate-key (23505) errors which may occur
// transiently due to concurrent inserts; prefer RPC always.
const int rpcMaxAttempts = 3;
Map<String, dynamic>? rpcRow;
for (var attempt = 0; attempt < rpcMaxAttempts; attempt++) {
try {
final rpcRes = await _client
.rpc('insert_task_with_number', rpcParams)
.single();
if (rpcRes is Map) {
rpcRow = Map<String, dynamic>.from(rpcRes);
} else if (rpcRes is List &&
rpcRes.isNotEmpty &&
rpcRes.first is Map) {
rpcRow = Map<String, dynamic>.from(rpcRes.first as Map);
}
break;
} catch (err) {
final msg = err.toString();
final isDuplicateKey =
msg.contains('duplicate key value') || msg.contains('23505');
if (!isDuplicateKey || attempt == rpcMaxAttempts - 1) {
rethrow;
}
await Future.delayed(Duration(milliseconds: 150 * (attempt + 1)));
// retry
}
}
if (rpcRow != null) {
taskId = rpcRow['id'] as String?;
assignedNumber = rpcRow['task_number'] as String?;
}
// ignore: avoid_print
print('createTask via RPC assigned number=$assignedNumber id=$taskId');
} catch (e) {
// RPC not available or failed; fallback to client insert with retry
const int maxAttempts = 3;
Map<String, dynamic>? insertData;
for (var attempt = 0; attempt < maxAttempts; attempt++) {
try {
insertData = await _client
.from('tasks')
.insert(payload)
.select('id')
.select('id, task_number')
.single();
final taskId = data['id'] as String?;
break;
} catch (err) {
final msg = err.toString();
final isDuplicateKey =
msg.contains('duplicate key value') || msg.contains('23505');
if (!isDuplicateKey || attempt == maxAttempts - 1) {
rethrow;
}
await Future.delayed(Duration(milliseconds: 150 * (attempt + 1)));
}
}
taskId = insertData == null ? null : insertData['id'] as String?;
assignedNumber = insertData == null
? null
: insertData['task_number'] as String?;
// ignore: avoid_print
print('createTask fallback assigned number=$assignedNumber id=$taskId');
}
if (taskId == null) return;
// Activity log: created
try {
await _client.from('task_activity_logs').insert({
'task_id': taskId,

View File

@ -315,7 +315,9 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
Align(
alignment: Alignment.center,
child: Text(
task.title.isNotEmpty ? task.title : 'Task ${task.id}',
task.title.isNotEmpty
? task.title
: 'Task ${task.taskNumber ?? task.id}',
textAlign: TextAlign.center,
style: Theme.of(
context,
@ -339,7 +341,11 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
children: [
_buildStatusChip(context, task, canUpdateStatus),
_MetaBadge(label: 'Office', value: officeName),
_MetaBadge(label: 'Task ID', value: task.id, isMono: true),
_MetaBadge(
label: 'Task #',
value: task.taskNumber ?? task.id,
isMono: true,
),
],
),
if (description.isNotEmpty) ...[

View File

@ -250,9 +250,10 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen> {
isLoading: false,
columns: [
TasQColumn<Task>(
header: 'Task ID',
header: 'Task #',
technical: true,
cellBuilder: (context, task) => Text(task.id),
cellBuilder: (context, task) =>
Text(task.taskNumber ?? task.id),
),
TasQColumn<Task>(
header: 'Subject',
@ -326,7 +327,8 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen> {
title: Text(
task.title.isNotEmpty
? task.title
: (ticket?.subject ?? 'Task ${task.id}'),
: (ticket?.subject ??
'Task ${task.taskNumber ?? task.id}'),
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@ -335,7 +337,7 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen> {
const SizedBox(height: 2),
Text('Assigned: $assigned'),
const SizedBox(height: 4),
MonoText('ID ${task.id}'),
MonoText('ID ${task.taskNumber ?? task.id}'),
const SizedBox(height: 2),
Text(_formatTimestamp(task.createdAt)),
],
@ -663,6 +665,7 @@ List<Task> _applyTaskFilters(
: (ticket?.subject ?? 'Task ${task.id}');
if (query.isNotEmpty &&
!subject.toLowerCase().contains(query) &&
!(task.taskNumber?.toLowerCase().contains(query) ?? false) &&
!task.id.toLowerCase().contains(query)) {
return false;
}

View File

@ -0,0 +1,66 @@
-- Add a humanreadable, sequential task number that is shown in the UI
-- Format: YYYY-MM-##### (resetting each month).
-- 1. Add the column (nullable for now so we can backfill)
ALTER TABLE tasks
ADD COLUMN task_number text;
-- 2. Backfill existing rows using created_at as the timestamp basis.
DO $$
DECLARE
r RECORD;
prefix text;
cnt int;
BEGIN
FOR r IN
SELECT id, created_at
FROM tasks
WHERE task_number IS NULL
ORDER BY created_at
LOOP
prefix := to_char(r.created_at::timestamp, 'YYYY-MM-');
cnt := (SELECT count(*)
FROM tasks t
WHERE to_char(t.created_at::timestamp, 'YYYY-MM-') = prefix
AND t.created_at <= r.created_at);
UPDATE tasks
SET task_number = prefix || lpad(cnt::text, 5, '0')
WHERE id = r.id;
END LOOP;
END;
$$ LANGUAGE plpgsql;
-- 3. Create trigger function to generate numbers on new inserts
CREATE OR REPLACE FUNCTION tasks_set_task_number()
RETURNS trigger AS $$
DECLARE
prefix text;
seq int;
BEGIN
-- if caller already provided one, do not overwrite
IF NEW.task_number IS NOT NULL THEN
RETURN NEW;
END IF;
prefix := to_char(now(), 'YYYY-MM-');
SELECT COALESCE(MAX((substring(task_number FROM 8))::int), 0) + 1
INTO seq
FROM tasks
WHERE task_number LIKE prefix || '%';
NEW.task_number := prefix || lpad(seq::text, 5, '0');
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER tasks_set_task_number_before_insert
BEFORE INSERT ON tasks
FOR EACH ROW
EXECUTE FUNCTION tasks_set_task_number();
-- 4. Enforce not-null and uniqueness now that every row has a value
ALTER TABLE tasks
ALTER COLUMN task_number SET NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS idx_tasks_task_number
ON tasks (task_number);

View File

@ -0,0 +1,51 @@
-- Improve task_number generation to eliminate race conditions.
-- Maintain a separate counter table so concurrent inserts can safely bump the
-- sequence using an atomic upsert. This migration retains the existing
-- trigger name but replaces its body, and creates the helper table.
-- 1. Create counter table
CREATE TABLE IF NOT EXISTS task_number_counters (
year_month text PRIMARY KEY,
counter bigint NOT NULL
);
-- 2. Initialize counters from existing tasks
INSERT INTO task_number_counters(year_month, counter)
SELECT s.prefix, s.cnt
FROM (
SELECT to_char(created_at::timestamp, 'YYYY-MM-') AS prefix,
max((substring(task_number FROM 8))::int) AS cnt
FROM tasks
GROUP BY prefix
) s
ON CONFLICT (year_month) DO UPDATE SET counter = EXCLUDED.counter;
-- 3. Replace trigger function with atomic counter logic
CREATE OR REPLACE FUNCTION tasks_set_task_number()
RETURNS trigger AS $$
DECLARE
prefix text;
seq bigint;
BEGIN
IF NEW.task_number IS NOT NULL THEN
RETURN NEW;
END IF;
prefix := to_char(now(), 'YYYY-MM-');
INSERT INTO task_number_counters(year_month, counter)
VALUES (prefix, 1)
ON CONFLICT (year_month)
DO UPDATE SET counter = task_number_counters.counter + 1
RETURNING counter INTO seq;
NEW.task_number := prefix || lpad(seq::text, 5, '0');
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- 4. Recreate trigger (drop/recreate to ensure correct function is bound)
DROP TRIGGER IF EXISTS tasks_set_task_number_before_insert ON tasks;
CREATE TRIGGER tasks_set_task_number_before_insert
BEFORE INSERT ON tasks
FOR EACH ROW
EXECUTE FUNCTION tasks_set_task_number();

View File

@ -0,0 +1,55 @@
-- Atomic insert that generates a unique task_number and inserts a task in one transaction
-- Returns: id (uuid), task_number (text)
CREATE OR REPLACE FUNCTION insert_task_with_number(
p_title text,
p_description text,
p_office_id text,
p_ticket_id text,
p_request_type text,
p_request_type_other text,
p_request_category text,
p_creator_id uuid
)
RETURNS TABLE(id uuid, task_number text)
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
DECLARE
-- The numbering trigger on `tasks` will set `task_number` before insert.
-- This RPC inserts the task row and returns the resulting id and task_number.
BEGIN
-- atomically increment (or create) the month counter and use it as the task_number
INSERT INTO task_number_counters(year_month, counter)
VALUES (to_char(now(), 'YYYY-MM-'), 1)
ON CONFLICT (year_month) DO UPDATE
SET counter = task_number_counters.counter + 1
RETURNING counter INTO seq;
-- build the formatted task number
PERFORM seq; -- ensure seq is set
new_task_number := to_char(now(), 'YYYY-MM-') || lpad(seq::text, 5, '0');
INSERT INTO tasks(
title, description, office_id, ticket_id,
request_type, request_type_other, request_category,
creator_id, created_at, task_number
) VALUES (
p_title,
p_description,
CASE WHEN p_office_id IS NULL OR p_office_id = '' THEN NULL ELSE p_office_id::uuid END,
CASE WHEN p_ticket_id IS NULL OR p_ticket_id = '' THEN NULL ELSE p_ticket_id::uuid END,
CASE WHEN p_request_type IS NULL OR p_request_type = '' THEN NULL ELSE p_request_type::request_type END,
p_request_type_other,
CASE WHEN p_request_category IS NULL OR p_request_category = '' THEN NULL ELSE p_request_category::request_category END,
p_creator_id,
now(),
new_task_number
) RETURNING tasks.id, tasks.task_number INTO id, task_number;
RETURN NEXT;
END;
$$;
-- Grant execute to authenticated so client RPC can call it
GRANT EXECUTE ON FUNCTION insert_task_with_number(text,text,text,text,text,text,text,uuid) TO authenticated;