feat(mobile): add finance/workforce screen and providers

- Add FinanceWorkforceScreen and finance_workforce_providers
- Update home_screen with IndexedStack tab state; api_client updates
- Align mobile with web/desktop for workforce and finance
This commit is contained in:
Dries Peeters
2026-03-06 15:45:14 +01:00
parent 80fde2f0c9
commit 435e53957c
4 changed files with 1460 additions and 1 deletions
+170
View File
@@ -183,6 +183,20 @@ class ApiClient {
return response.data as Map<String, dynamic>;
}
/// Get clients
Future<Map<String, dynamic>> getClients({
String? status,
int? page,
int? perPage,
}) async {
final queryParams = <String, dynamic>{};
if (status != null) queryParams['status'] = status;
if (page != null) queryParams['page'] = page;
if (perPage != null) queryParams['per_page'] = perPage;
final response = await _dio.get('/api/v1/clients', queryParameters: queryParams);
return response.data as Map<String, dynamic>;
}
// ==================== Task Operations ====================
/// Get tasks
@@ -207,4 +221,160 @@ class ApiClient {
final response = await _dio.get('/api/v1/tasks/$taskId');
return response.data as Map<String, dynamic>;
}
// ==================== Freelancer Cashflow Parity ====================
Future<Map<String, dynamic>> getInvoices({
String? status,
int? clientId,
int? projectId,
int? page,
int? perPage,
}) async {
final queryParams = <String, dynamic>{};
if (status != null) queryParams['status'] = status;
if (clientId != null) queryParams['client_id'] = clientId;
if (projectId != null) queryParams['project_id'] = projectId;
if (page != null) queryParams['page'] = page;
if (perPage != null) queryParams['per_page'] = perPage;
final response = await _dio.get('/api/v1/invoices', queryParameters: queryParams);
return response.data as Map<String, dynamic>;
}
Future<Map<String, dynamic>> getInvoice(int invoiceId) async {
final response = await _dio.get('/api/v1/invoices/$invoiceId');
return response.data as Map<String, dynamic>;
}
Future<Map<String, dynamic>> createInvoice(Map<String, dynamic> data) async {
final response = await _dio.post('/api/v1/invoices', data: data);
return response.data as Map<String, dynamic>;
}
Future<Map<String, dynamic>> updateInvoice(int invoiceId, Map<String, dynamic> data) async {
final response = await _dio.put('/api/v1/invoices/$invoiceId', data: data);
return response.data as Map<String, dynamic>;
}
Future<Map<String, dynamic>> getExpenses({
int? projectId,
String? category,
String? startDate,
String? endDate,
int? page,
int? perPage,
}) async {
final queryParams = <String, dynamic>{};
if (projectId != null) queryParams['project_id'] = projectId;
if (category != null) queryParams['category'] = category;
if (startDate != null) queryParams['start_date'] = startDate;
if (endDate != null) queryParams['end_date'] = endDate;
if (page != null) queryParams['page'] = page;
if (perPage != null) queryParams['per_page'] = perPage;
final response = await _dio.get('/api/v1/expenses', queryParameters: queryParams);
return response.data as Map<String, dynamic>;
}
Future<Map<String, dynamic>> createExpense(Map<String, dynamic> data) async {
final response = await _dio.post('/api/v1/expenses', data: data);
return response.data as Map<String, dynamic>;
}
Future<Map<String, dynamic>> getCapacityReport({required String startDate, required String endDate}) async {
final response = await _dio.get(
'/api/v1/reports/capacity',
queryParameters: {
'start_date': startDate,
'end_date': endDate,
},
);
return response.data as Map<String, dynamic>;
}
Future<Map<String, dynamic>> getTimesheetPeriods({String? status, String? startDate, String? endDate}) async {
final queryParams = <String, dynamic>{};
if (status != null) queryParams['status'] = status;
if (startDate != null) queryParams['start_date'] = startDate;
if (endDate != null) queryParams['end_date'] = endDate;
final response = await _dio.get('/api/v1/timesheet-periods', queryParameters: queryParams);
return response.data as Map<String, dynamic>;
}
Future<Map<String, dynamic>> submitTimesheetPeriod(int periodId) async {
final response = await _dio.post('/api/v1/timesheet-periods/$periodId/submit');
return response.data as Map<String, dynamic>;
}
Future<Map<String, dynamic>> approveTimesheetPeriod(int periodId, {String? comment}) async {
final data = <String, dynamic>{};
if (comment != null && comment.trim().isNotEmpty) data['comment'] = comment.trim();
final response = await _dio.post('/api/v1/timesheet-periods/$periodId/approve', data: data);
return response.data as Map<String, dynamic>;
}
Future<Map<String, dynamic>> rejectTimesheetPeriod(int periodId, {String? reason}) async {
final data = <String, dynamic>{};
if (reason != null && reason.trim().isNotEmpty) data['reason'] = reason.trim();
final response = await _dio.post('/api/v1/timesheet-periods/$periodId/reject', data: data);
return response.data as Map<String, dynamic>;
}
Future<Map<String, dynamic>> getLeaveTypes() async {
final response = await _dio.get('/api/v1/time-off/leave-types');
return response.data as Map<String, dynamic>;
}
Future<Map<String, dynamic>> getTimeOffRequests({
String? status,
String? startDate,
String? endDate,
}) async {
final queryParams = <String, dynamic>{};
if (status != null) queryParams['status'] = status;
if (startDate != null) queryParams['start_date'] = startDate;
if (endDate != null) queryParams['end_date'] = endDate;
final response = await _dio.get('/api/v1/time-off/requests', queryParameters: queryParams);
return response.data as Map<String, dynamic>;
}
Future<Map<String, dynamic>> createTimeOffRequest({
required int leaveTypeId,
required String startDate,
required String endDate,
double? requestedHours,
String? comment,
bool submit = true,
}) async {
final data = <String, dynamic>{
'leave_type_id': leaveTypeId,
'start_date': startDate,
'end_date': endDate,
'submit': submit,
};
if (requestedHours != null) data['requested_hours'] = requestedHours;
if (comment != null && comment.trim().isNotEmpty) data['comment'] = comment.trim();
final response = await _dio.post('/api/v1/time-off/requests', data: data);
return response.data as Map<String, dynamic>;
}
Future<Map<String, dynamic>> getTimeOffBalances({int? userId}) async {
final queryParams = <String, dynamic>{};
if (userId != null) queryParams['user_id'] = userId;
final response = await _dio.get('/api/v1/time-off/balances', queryParameters: queryParams);
return response.data as Map<String, dynamic>;
}
Future<Map<String, dynamic>> approveTimeOffRequest(int requestId, {String? comment}) async {
final data = <String, dynamic>{};
if (comment != null && comment.trim().isNotEmpty) data['comment'] = comment.trim();
final response = await _dio.post('/api/v1/time-off/requests/$requestId/approve', data: data);
return response.data as Map<String, dynamic>;
}
Future<Map<String, dynamic>> rejectTimeOffRequest(int requestId, {String? comment}) async {
final data = <String, dynamic>{};
if (comment != null && comment.trim().isNotEmpty) data['comment'] = comment.trim();
final response = await _dio.post('/api/v1/time-off/requests/$requestId/reject', data: data);
return response.data as Map<String, dynamic>;
}
}
@@ -0,0 +1,76 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:timetracker_mobile/presentation/providers/api_provider.dart';
/// Date range for capacity/periods (current week)
({String start, String end}) _weekRange() {
final now = DateTime.now();
final start = DateTime(now.year, now.month, now.day - now.weekday + 1);
final end = start.add(const Duration(days: 6));
return (start: start.toIso8601String().split('T')[0], end: end.toIso8601String().split('T')[0]);
}
final financeInvoicesProvider = FutureProvider.family<Map<String, dynamic>, int>((ref, page) async {
final client = await ref.watch(apiClientProvider.future);
if (client == null) return {'invoices': <Map<String, dynamic>>[], 'pagination': {'page': 1, 'pages': 1}};
return client.getInvoices(page: page, perPage: 20);
});
final financeExpensesProvider = FutureProvider.family<Map<String, dynamic>, int>((ref, page) async {
final client = await ref.watch(apiClientProvider.future);
if (client == null) return {'expenses': <Map<String, dynamic>>[], 'pagination': {'page': 1, 'pages': 1}};
return client.getExpenses(page: page, perPage: 20);
});
final timesheetPeriodsProvider = FutureProvider<Map<String, dynamic>>((ref) async {
final client = await ref.watch(apiClientProvider.future);
if (client == null) return {'timesheet_periods': <Map<String, dynamic>>[]};
final range = _weekRange();
return client.getTimesheetPeriods(startDate: range.start, endDate: range.end);
});
final capacityReportProvider = FutureProvider<Map<String, dynamic>>((ref) async {
final client = await ref.watch(apiClientProvider.future);
if (client == null) return {'capacity': <Map<String, dynamic>>[]};
final range = _weekRange();
return client.getCapacityReport(startDate: range.start, endDate: range.end);
});
final timeOffRequestsProvider = FutureProvider<Map<String, dynamic>>((ref) async {
final client = await ref.watch(apiClientProvider.future);
if (client == null) return {'time_off_requests': <Map<String, dynamic>>[]};
return client.getTimeOffRequests();
});
final leaveBalancesProvider = FutureProvider<Map<String, dynamic>>((ref) async {
final client = await ref.watch(apiClientProvider.future);
if (client == null) return {'balances': <Map<String, dynamic>>[]};
return client.getTimeOffBalances();
});
final leaveTypesProvider = FutureProvider<Map<String, dynamic>>((ref) async {
final client = await ref.watch(apiClientProvider.future);
if (client == null) return {'leave_types': <Map<String, dynamic>>[]};
return client.getLeaveTypes();
});
final financeProjectsProvider = FutureProvider<Map<String, dynamic>>((ref) async {
final client = await ref.watch(apiClientProvider.future);
if (client == null) return {'projects': <Map<String, dynamic>>[]};
return client.getProjects(status: 'active', perPage: 100);
});
final financeClientsProvider = FutureProvider<Map<String, dynamic>>((ref) async {
final client = await ref.watch(apiClientProvider.future);
if (client == null) return {'clients': <Map<String, dynamic>>[]};
return client.getClients(status: 'active', perPage: 100);
});
/// Whether the current user can approve timesheets / time-off
final userCanApproveProvider = FutureProvider<bool>((ref) async {
final client = await ref.watch(apiClientProvider.future);
if (client == null) return false;
final me = await client.getUsersMe();
final user = me['user'] is Map<String, dynamic> ? me['user'] as Map<String, dynamic> : <String, dynamic>{};
final role = (user['role'] ?? '').toString().toLowerCase();
return (user['is_admin'] == true) || (role == 'admin' || role == 'owner' || role == 'manager' || role == 'approver');
});
File diff suppressed because it is too large Load Diff
@@ -12,6 +12,7 @@ import 'timer_screen.dart';
import 'projects_screen.dart';
import 'time_entries_screen.dart';
import 'settings_screen.dart';
import 'finance_workforce_screen.dart';
import 'dart:async';
class HomeScreen extends StatefulWidget {
@@ -28,13 +29,17 @@ class _HomeScreenState extends State<HomeScreen> {
const DashboardTab(),
const ProjectsScreen(),
const TimeEntriesScreen(),
const FinanceWorkforceScreen(),
const SettingsScreen(),
];
@override
Widget build(BuildContext context) {
return Scaffold(
body: _screens[_currentIndex],
body: IndexedStack(
index: _currentIndex,
children: _screens,
),
bottomNavigationBar: NavigationBar(
selectedIndex: _currentIndex,
onDestinationSelected: (index) {
@@ -58,6 +63,11 @@ class _HomeScreenState extends State<HomeScreen> {
selectedIcon: Icon(Icons.history),
label: 'Entries',
),
NavigationDestination(
icon: Icon(Icons.account_balance_wallet_outlined),
selectedIcon: Icon(Icons.account_balance_wallet),
label: 'Finance',
),
NavigationDestination(
icon: Icon(Icons.settings_outlined),
selectedIcon: Icon(Icons.settings),