From 46a84b4d95bdec1018bfc38818e3e2acf65269a4 Mon Sep 17 00:00:00 2001 From: Marc Rejohn Castillano Date: Sat, 21 Feb 2026 21:57:31 +0800 Subject: [PATCH] Added Service --- lib/models/office.dart | 9 +- lib/models/service.dart | 10 ++ lib/providers/services_provider.dart | 21 +++ lib/providers/tickets_provider.dart | 16 ++- lib/screens/admin/offices_screen.dart | 123 +++++++++++++----- .../20260221150000_add_services_table.sql | 30 +++++ test/layout_smoke_test.dart | 1 + test/task_detail_screen_test.dart | 2 + 8 files changed, 171 insertions(+), 41 deletions(-) create mode 100644 lib/models/service.dart create mode 100644 lib/providers/services_provider.dart create mode 100644 supabase/migrations/20260221150000_add_services_table.sql diff --git a/lib/models/office.dart b/lib/models/office.dart index fc7575fa..d84a99c2 100644 --- a/lib/models/office.dart +++ b/lib/models/office.dart @@ -1,10 +1,15 @@ class Office { - Office({required this.id, required this.name}); + Office({required this.id, required this.name, this.serviceId}); final String id; final String name; + final String? serviceId; factory Office.fromMap(Map map) { - return Office(id: map['id'] as String, name: map['name'] as String? ?? ''); + return Office( + id: map['id'] as String, + name: map['name'] as String? ?? '', + serviceId: map['service_id'] as String?, + ); } } diff --git a/lib/models/service.dart b/lib/models/service.dart new file mode 100644 index 00000000..b5049770 --- /dev/null +++ b/lib/models/service.dart @@ -0,0 +1,10 @@ +class Service { + Service({required this.id, required this.name}); + + final String id; + final String name; + + factory Service.fromMap(Map map) { + return Service(id: map['id'] as String, name: map['name'] as String? ?? ''); + } +} diff --git a/lib/providers/services_provider.dart b/lib/providers/services_provider.dart new file mode 100644 index 00000000..9c932fe6 --- /dev/null +++ b/lib/providers/services_provider.dart @@ -0,0 +1,21 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../models/service.dart'; +import 'supabase_provider.dart'; + +final servicesProvider = StreamProvider>((ref) { + final client = ref.watch(supabaseClientProvider); + return client + .from('services') + .stream(primaryKey: ['id']) + .order('name') + .map((rows) => rows.map((r) => Service.fromMap(r)).toList()); +}); + +final servicesOnceProvider = FutureProvider>((ref) async { + final client = ref.watch(supabaseClientProvider); + final rows = await client.from('services').select().order('name'); + return (rows as List) + .map((r) => Service.fromMap(r as Map)) + .toList(); +}); diff --git a/lib/providers/tickets_provider.dart b/lib/providers/tickets_provider.dart index 72e79224..3e4dcbf5 100644 --- a/lib/providers/tickets_provider.dart +++ b/lib/providers/tickets_provider.dart @@ -377,12 +377,20 @@ class OfficesController { final SupabaseClient _client; - Future createOffice({required String name}) async { - await _client.from('offices').insert({'name': name}); + Future createOffice({required String name, String? serviceId}) async { + final payload = {'name': name}; + if (serviceId != null) payload['service_id'] = serviceId; + await _client.from('offices').insert(payload); } - Future updateOffice({required String id, required String name}) async { - await _client.from('offices').update({'name': name}).eq('id', id); + Future updateOffice({ + required String id, + required String name, + String? serviceId, + }) async { + final payload = {'name': name}; + if (serviceId != null) payload['service_id'] = serviceId; + await _client.from('offices').update(payload).eq('id', id); } Future deleteOffice({required String id}) async { diff --git a/lib/screens/admin/offices_screen.dart b/lib/screens/admin/offices_screen.dart index 1e6aab8f..dd2cbbb8 100644 --- a/lib/screens/admin/offices_screen.dart +++ b/lib/screens/admin/offices_screen.dart @@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../models/office.dart'; import '../../providers/profile_provider.dart'; import '../../providers/tickets_provider.dart'; +import '../../providers/services_provider.dart'; import '../../widgets/mono_text.dart'; import '../../widgets/responsive_body.dart'; import '../../theme/app_surfaces.dart'; @@ -163,45 +164,97 @@ class _OfficesScreenState extends ConsumerState { Office? office, }) async { final nameController = TextEditingController(text: office?.name ?? ''); + String? selectedServiceId = office?.serviceId; await showDialog( context: context, builder: (dialogContext) { - return AlertDialog( - shape: AppSurfaces.of(context).dialogShape, - title: Text(office == null ? 'Create Office' : 'Edit Office'), - content: TextField( - controller: nameController, - decoration: const InputDecoration(labelText: 'Office name'), - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(dialogContext).pop(), - child: const Text('Cancel'), - ), - FilledButton( - onPressed: () async { - final name = nameController.text.trim(); - if (name.isEmpty) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Name is required.')), - ); - return; - } - final controller = ref.read(officesControllerProvider); - if (office == null) { - await controller.createOffice(name: name); - } else { - await controller.updateOffice(id: office.id, name: name); - } - ref.invalidate(officesProvider); - if (context.mounted) { - Navigator.of(dialogContext).pop(); - } - }, - child: Text(office == null ? 'Create' : 'Save'), - ), - ], + final servicesAsync = ref.watch(servicesOnceProvider); + return StatefulBuilder( + builder: (context, setState) { + return AlertDialog( + shape: AppSurfaces.of(context).dialogShape, + title: Text(office == null ? 'Create Office' : 'Edit Office'), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: nameController, + decoration: const InputDecoration( + labelText: 'Office name', + ), + ), + const SizedBox(height: 12), + servicesAsync.when( + data: (services) { + return DropdownButtonFormField( + initialValue: selectedServiceId, + decoration: const InputDecoration( + labelText: 'Service', + ), + items: [ + const DropdownMenuItem( + value: null, + child: Text('None'), + ), + ...services.map( + (s) => DropdownMenuItem( + value: s.id, + child: Text(s.name), + ), + ), + ], + onChanged: (v) => + setState(() => selectedServiceId = v), + ); + }, + loading: () => const Padding( + padding: EdgeInsets.symmetric(vertical: 8.0), + child: LinearProgressIndicator(), + ), + error: (e, _) => Text('Failed to load services: $e'), + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () async { + final name = nameController.text.trim(); + if (name.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Name is required.')), + ); + return; + } + final controller = ref.read(officesControllerProvider); + if (office == null) { + await controller.createOffice( + name: name, + serviceId: selectedServiceId, + ); + } else { + await controller.updateOffice( + id: office.id, + name: name, + serviceId: selectedServiceId, + ); + } + ref.invalidate(officesProvider); + if (context.mounted) { + Navigator.of(dialogContext).pop(); + } + }, + child: Text(office == null ? 'Create' : 'Save'), + ), + ], + ); + }, ); }, ); diff --git a/supabase/migrations/20260221150000_add_services_table.sql b/supabase/migrations/20260221150000_add_services_table.sql new file mode 100644 index 00000000..fc9f88a2 --- /dev/null +++ b/supabase/migrations/20260221150000_add_services_table.sql @@ -0,0 +1,30 @@ +-- Add services table and link offices to services + +-- 1) Create services table +CREATE TABLE IF NOT EXISTS services ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + name text NOT NULL UNIQUE +); + +-- 2) Add service_id to offices +ALTER TABLE IF EXISTS offices + ADD COLUMN IF NOT EXISTS service_id uuid REFERENCES services(id) ON DELETE SET NULL; + +-- 3) Insert default services +INSERT INTO services (name) VALUES + ('Medical Center Chief'), + ('Medical Service'), + ('Nursing Service'), + ('Hospital Operations and Patient Support Service'), + ('Finance Service'), + ('Allied/Ancillary') +ON CONFLICT (name) DO NOTHING; + +-- 4) Make existing offices default to Medical Center Chief (for now) +UPDATE offices +SET service_id = s.id +FROM services s +WHERE s.name = 'Medical Center Chief'; + +-- 5) (Optional) Add index for faster lookups +CREATE INDEX IF NOT EXISTS idx_offices_service_id ON offices(service_id); diff --git a/test/layout_smoke_test.dart b/test/layout_smoke_test.dart index 4415148c..ced89b54 100644 --- a/test/layout_smoke_test.dart +++ b/test/layout_smoke_test.dart @@ -68,6 +68,7 @@ void main() { final task = Task( id: 'TSK-1', ticketId: 'TCK-1', + taskNumber: '2026-02-00001', title: 'Reboot printer', description: 'Clear queue and reboot', officeId: 'office-1', diff --git a/test/task_detail_screen_test.dart b/test/task_detail_screen_test.dart index b813c4dd..c4626269 100644 --- a/test/task_detail_screen_test.dart +++ b/test/task_detail_screen_test.dart @@ -106,6 +106,7 @@ void main() { final emptyTask = Task( id: 'tsk-1', ticketId: null, + taskNumber: '2026-02-00002', title: 'No metadata', description: '', officeId: 'office-1', @@ -159,6 +160,7 @@ void main() { final task = Task( id: 'tsk-1', ticketId: null, + taskNumber: '2026-02-00002', title: 'Has metadata', description: '', officeId: 'office-1',