chore: Flutter mobile app, CI, and dev tooling
- mobile/: Flutter/Dart merchant mobile app skeleton - .github/: GitHub Actions CI workflows - .dockerignore: exclude host node_modules from build context - .cursorrules: Cursor IDE project rules - .claude/: Claude Code project settings and launch config Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,165 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:shamsi_date/shamsi_date.dart';
|
||||
|
||||
import '../../core/sync/sync_engine.dart';
|
||||
import 'hr_api.dart';
|
||||
import 'hr_providers.dart';
|
||||
|
||||
class AttendanceScreen extends ConsumerStatefulWidget {
|
||||
const AttendanceScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<AttendanceScreen> createState() => _AttendanceScreenState();
|
||||
}
|
||||
|
||||
class _AttendanceScreenState extends ConsumerState<AttendanceScreen> {
|
||||
final _reasonController = TextEditingController();
|
||||
Jalali? _leaveStart;
|
||||
Jalali? _leaveEnd;
|
||||
String? _message;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_reasonController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _clock(bool isIn) async {
|
||||
final session = ref.read(hrSessionProvider);
|
||||
if (session == null) {
|
||||
setState(() => _message = 'ابتدا وارد شوید');
|
||||
return;
|
||||
}
|
||||
final api = ref.read(hrApiProvider);
|
||||
final sync = ref.read(syncEngineProvider);
|
||||
try {
|
||||
if (isIn) {
|
||||
await api.clockIn(cafeId: session.cafeId, employeeId: session.employeeId);
|
||||
} else {
|
||||
await api.clockOut(cafeId: session.cafeId, employeeId: session.employeeId);
|
||||
}
|
||||
ref.invalidate(todayShiftProvider);
|
||||
setState(() => _message = isIn ? 'ورود ثبت شد' : 'خروج ثبت شد');
|
||||
} catch (_) {
|
||||
sync.enqueueAttendance(
|
||||
action: isIn ? 'clock-in' : 'clock-out',
|
||||
cafeId: session.cafeId,
|
||||
employeeId: session.employeeId,
|
||||
);
|
||||
setState(() => _message = 'آفلاین ذخیره شد — پس از اتصال همگامسازی میشود');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _submitLeave() async {
|
||||
final session = ref.read(hrSessionProvider);
|
||||
if (session == null || _leaveStart == null || _leaveEnd == null) return;
|
||||
final api = ref.read(hrApiProvider);
|
||||
try {
|
||||
await api.submitLeave(
|
||||
cafeId: session.cafeId,
|
||||
employeeId: session.employeeId,
|
||||
startDate: _formatDate(_leaveStart!),
|
||||
endDate: _formatDate(_leaveEnd!),
|
||||
reason: _reasonController.text,
|
||||
);
|
||||
setState(() => _message = 'درخواست مرخصی ثبت شد');
|
||||
} catch (e) {
|
||||
setState(() => _message = 'خطا در ثبت مرخصی');
|
||||
}
|
||||
}
|
||||
|
||||
String _formatDate(Jalali d) =>
|
||||
'${d.year.toString().padLeft(4, '0')}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}';
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final shiftAsync = ref.watch(todayShiftProvider);
|
||||
final todayJalali = Jalali.now();
|
||||
|
||||
return Directionality(
|
||||
textDirection: TextDirection.rtl,
|
||||
child: Scaffold(
|
||||
appBar: AppBar(title: const Text('حضور و غیاب')),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
Text(
|
||||
'امروز: ${todayJalali.formatter.yyyyMMdd()}',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
shiftAsync.when(
|
||||
data: (shift) => Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text('شیفت: ${shift?['label'] ?? '—'}'),
|
||||
),
|
||||
),
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (_, __) => const Text('شیفت امروز در دسترس نیست'),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: FilledButton(
|
||||
onPressed: () => _clock(true),
|
||||
child: const Text('ورود'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: () => _clock(false),
|
||||
child: const Text('خروج'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Divider(height: 32),
|
||||
Text('درخواست مرخصی', style: Theme.of(context).textTheme.titleMedium),
|
||||
const SizedBox(height: 8),
|
||||
ListTile(
|
||||
title: Text(_leaveStart == null ? 'از تاریخ' : _formatDate(_leaveStart!)),
|
||||
trailing: const Icon(Icons.calendar_today),
|
||||
onTap: () async {
|
||||
final picked = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: DateTime.now(),
|
||||
firstDate: DateTime.now().subtract(const Duration(days: 365)),
|
||||
lastDate: DateTime.now().add(const Duration(days: 365)),
|
||||
);
|
||||
if (picked != null) setState(() => _leaveStart = Jalali.fromDateTime(picked));
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
title: Text(_leaveEnd == null ? 'تا تاریخ' : _formatDate(_leaveEnd!)),
|
||||
trailing: const Icon(Icons.calendar_today),
|
||||
onTap: () async {
|
||||
final picked = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: DateTime.now(),
|
||||
firstDate: DateTime.now().subtract(const Duration(days: 365)),
|
||||
lastDate: DateTime.now().add(const Duration(days: 365)),
|
||||
);
|
||||
if (picked != null) setState(() => _leaveEnd = Jalali.fromDateTime(picked));
|
||||
},
|
||||
),
|
||||
TextField(
|
||||
controller: _reasonController,
|
||||
decoration: const InputDecoration(labelText: 'دلیل'),
|
||||
maxLines: 2,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
FilledButton(onPressed: _submitLeave, child: const Text('ثبت مرخصی')),
|
||||
if (_message != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
Text(_message!, style: TextStyle(color: Theme.of(context).colorScheme.primary)),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../../core/api/api_client.dart';
|
||||
|
||||
class HrApi {
|
||||
HrApi(this._client);
|
||||
|
||||
final ApiClient _client;
|
||||
|
||||
Future<Map<String, dynamic>?> fetchTodayShift({
|
||||
required String cafeId,
|
||||
required String employeeId,
|
||||
}) async {
|
||||
final res = await _client.dio.get<Map<String, dynamic>>(
|
||||
'/api/cafes/$cafeId/employees/$employeeId/shift/today',
|
||||
);
|
||||
final data = res.data?['data'] as Map<String, dynamic>?;
|
||||
return data;
|
||||
}
|
||||
|
||||
Future<void> clockIn({
|
||||
required String cafeId,
|
||||
required String employeeId,
|
||||
}) async {
|
||||
await _client.dio.post(
|
||||
'/api/cafes/$cafeId/employees/$employeeId/attendance/clock-in',
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> clockOut({
|
||||
required String cafeId,
|
||||
required String employeeId,
|
||||
}) async {
|
||||
await _client.dio.post(
|
||||
'/api/cafes/$cafeId/employees/$employeeId/attendance/clock-out',
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> submitLeave({
|
||||
required String cafeId,
|
||||
required String employeeId,
|
||||
required String startDate,
|
||||
required String endDate,
|
||||
String? reason,
|
||||
}) async {
|
||||
await _client.dio.post(
|
||||
'/api/cafes/$cafeId/employees/$employeeId/leave-requests',
|
||||
data: {
|
||||
'startDate': startDate,
|
||||
'endDate': endDate,
|
||||
if (reason != null && reason.isNotEmpty) 'reason': reason,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../core/sync/sync_engine.dart';
|
||||
import '../cart/cart_state.dart' show apiClientProvider;
|
||||
import 'hr_api.dart';
|
||||
|
||||
class HrSession {
|
||||
const HrSession({required this.cafeId, required this.employeeId});
|
||||
final String cafeId;
|
||||
final String employeeId;
|
||||
}
|
||||
|
||||
const _demoCafeId = 'cafe_demo_001';
|
||||
const _demoEmployeeId = 'emp_demo_owner';
|
||||
|
||||
final hrApiProvider = Provider<HrApi>((ref) => HrApi(ref.watch(apiClientProvider)));
|
||||
|
||||
final syncEngineProvider = Provider<SyncEngine>((ref) => SyncEngine());
|
||||
|
||||
final hrSessionProvider = Provider<HrSession?>(
|
||||
(_) => const HrSession(cafeId: _demoCafeId, employeeId: _demoEmployeeId),
|
||||
);
|
||||
|
||||
final todayShiftProvider = FutureProvider<Map<String, dynamic>?>((ref) async {
|
||||
final session = ref.watch(hrSessionProvider);
|
||||
if (session == null) return null;
|
||||
return ref.watch(hrApiProvider).fetchTodayShift(
|
||||
cafeId: session.cafeId,
|
||||
employeeId: session.employeeId,
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user