Added Task number
This commit is contained in:
parent
8bb69a80af
commit
6238c701c0
|
|
@ -6,6 +6,7 @@ class Task {
|
||||||
Task({
|
Task({
|
||||||
required this.id,
|
required this.id,
|
||||||
required this.ticketId,
|
required this.ticketId,
|
||||||
|
required this.taskNumber,
|
||||||
required this.title,
|
required this.title,
|
||||||
required this.description,
|
required this.description,
|
||||||
required this.officeId,
|
required this.officeId,
|
||||||
|
|
@ -28,6 +29,7 @@ class Task {
|
||||||
|
|
||||||
final String id;
|
final String id;
|
||||||
final String? ticketId;
|
final String? ticketId;
|
||||||
|
final String? taskNumber;
|
||||||
final String title;
|
final String title;
|
||||||
final String description;
|
final String description;
|
||||||
final String? officeId;
|
final String? officeId;
|
||||||
|
|
@ -55,6 +57,7 @@ class Task {
|
||||||
return Task(
|
return Task(
|
||||||
id: map['id'] as String,
|
id: map['id'] as String,
|
||||||
ticketId: map['ticket_id'] as String?,
|
ticketId: map['ticket_id'] as String?,
|
||||||
|
taskNumber: map['task_number'] as String?,
|
||||||
title: map['title'] as String? ?? 'Task',
|
title: map['title'] as String? ?? 'Task',
|
||||||
description: map['description'] as String? ?? '',
|
description: map['description'] as String? ?? '',
|
||||||
officeId: map['office_id'] as String?,
|
officeId: map['office_id'] as String?,
|
||||||
|
|
|
||||||
|
|
@ -153,7 +153,8 @@ final tasksProvider = StreamProvider<List<Task>>((ref) {
|
||||||
.where(
|
.where(
|
||||||
(t) =>
|
(t) =>
|
||||||
t.title.toLowerCase().contains(q) ||
|
t.title.toLowerCase().contains(q) ||
|
||||||
t.description.toLowerCase().contains(q),
|
t.description.toLowerCase().contains(q) ||
|
||||||
|
(t.taskNumber?.toLowerCase().contains(q) ?? false),
|
||||||
)
|
)
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
@ -253,15 +254,88 @@ class TasksController {
|
||||||
payload['request_category'] = requestCategory;
|
payload['request_category'] = requestCategory;
|
||||||
}
|
}
|
||||||
|
|
||||||
final data = await _client
|
// Prefer server RPC that atomically generates `task_number` and inserts
|
||||||
.from('tasks')
|
// the task; fallback to client-side insert with retry on duplicate-key.
|
||||||
.insert(payload)
|
String? taskId;
|
||||||
.select('id')
|
String? assignedNumber;
|
||||||
.single();
|
|
||||||
final taskId = data['id'] as String?;
|
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, task_number')
|
||||||
|
.single();
|
||||||
|
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;
|
if (taskId == null) return;
|
||||||
|
|
||||||
// Activity log: created
|
|
||||||
try {
|
try {
|
||||||
await _client.from('task_activity_logs').insert({
|
await _client.from('task_activity_logs').insert({
|
||||||
'task_id': taskId,
|
'task_id': taskId,
|
||||||
|
|
|
||||||
|
|
@ -315,7 +315,9 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
||||||
Align(
|
Align(
|
||||||
alignment: Alignment.center,
|
alignment: Alignment.center,
|
||||||
child: Text(
|
child: Text(
|
||||||
task.title.isNotEmpty ? task.title : 'Task ${task.id}',
|
task.title.isNotEmpty
|
||||||
|
? task.title
|
||||||
|
: 'Task ${task.taskNumber ?? task.id}',
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: Theme.of(
|
style: Theme.of(
|
||||||
context,
|
context,
|
||||||
|
|
@ -339,7 +341,11 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
||||||
children: [
|
children: [
|
||||||
_buildStatusChip(context, task, canUpdateStatus),
|
_buildStatusChip(context, task, canUpdateStatus),
|
||||||
_MetaBadge(label: 'Office', value: officeName),
|
_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) ...[
|
if (description.isNotEmpty) ...[
|
||||||
|
|
|
||||||
|
|
@ -250,9 +250,10 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen> {
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
columns: [
|
columns: [
|
||||||
TasQColumn<Task>(
|
TasQColumn<Task>(
|
||||||
header: 'Task ID',
|
header: 'Task #',
|
||||||
technical: true,
|
technical: true,
|
||||||
cellBuilder: (context, task) => Text(task.id),
|
cellBuilder: (context, task) =>
|
||||||
|
Text(task.taskNumber ?? task.id),
|
||||||
),
|
),
|
||||||
TasQColumn<Task>(
|
TasQColumn<Task>(
|
||||||
header: 'Subject',
|
header: 'Subject',
|
||||||
|
|
@ -326,7 +327,8 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen> {
|
||||||
title: Text(
|
title: Text(
|
||||||
task.title.isNotEmpty
|
task.title.isNotEmpty
|
||||||
? task.title
|
? task.title
|
||||||
: (ticket?.subject ?? 'Task ${task.id}'),
|
: (ticket?.subject ??
|
||||||
|
'Task ${task.taskNumber ?? task.id}'),
|
||||||
),
|
),
|
||||||
subtitle: Column(
|
subtitle: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
|
@ -335,7 +337,7 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen> {
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
Text('Assigned: $assigned'),
|
Text('Assigned: $assigned'),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
MonoText('ID ${task.id}'),
|
MonoText('ID ${task.taskNumber ?? task.id}'),
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
Text(_formatTimestamp(task.createdAt)),
|
Text(_formatTimestamp(task.createdAt)),
|
||||||
],
|
],
|
||||||
|
|
@ -663,6 +665,7 @@ List<Task> _applyTaskFilters(
|
||||||
: (ticket?.subject ?? 'Task ${task.id}');
|
: (ticket?.subject ?? 'Task ${task.id}');
|
||||||
if (query.isNotEmpty &&
|
if (query.isNotEmpty &&
|
||||||
!subject.toLowerCase().contains(query) &&
|
!subject.toLowerCase().contains(query) &&
|
||||||
|
!(task.taskNumber?.toLowerCase().contains(query) ?? false) &&
|
||||||
!task.id.toLowerCase().contains(query)) {
|
!task.id.toLowerCase().contains(query)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
66
supabase/migrations/20260221120000_add_task_number.sql
Normal file
66
supabase/migrations/20260221120000_add_task_number.sql
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
-- Add a human‐readable, 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);
|
||||||
|
|
@ -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();
|
||||||
55
supabase/migrations/20260221131000_insert_task_rpc.sql
Normal file
55
supabase/migrations/20260221131000_insert_task_rpc.sql
Normal 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;
|
||||||
Loading…
Reference in New Issue
Block a user