feat: complete leave approval MVP

This commit is contained in:
selfrelease
2026-07-18 19:20:07 +08:00
parent 2105fe3bac
commit 090a7e33ce
133 changed files with 7845 additions and 100 deletions
@@ -0,0 +1,187 @@
import 'package:aioa_mobile/features/requests/application/leave_request_controller.dart';
import 'package:aioa_mobile/features/requests/data/leave_request_repository.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:intl/intl.dart';
class LeaveRequestDetailPage extends ConsumerWidget {
const LeaveRequestDetailPage({required this.id, super.key});
final String id;
@override
Widget build(BuildContext context, WidgetRef ref) {
final detail = ref.watch(leaveRequestDetailProvider(id));
return Scaffold(
appBar: AppBar(title: const Text('申请详情')),
body: detail.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, _) => Center(child: Text('详情加载失败:$error')),
data: (value) => _DetailBody(id: id, detail: value),
),
);
}
}
class _DetailBody extends ConsumerWidget {
const _DetailBody({required this.id, required this.detail});
final String id;
final LeaveRequestDetail detail;
@override
Widget build(BuildContext context, WidgetRef ref) {
final request = detail.request;
final formatter = DateFormat('yyyy-MM-dd HH:mm');
return ListView(
padding: const EdgeInsets.all(16),
children: [
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_statusLabel(request.status),
style: Theme.of(
context,
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w800),
),
const SizedBox(height: 14),
_row('请假类型', _typeLabel(request.type)),
_row('开始时间', formatter.format(request.startsAt.toLocal())),
_row('结束时间', formatter.format(request.endsAt.toLocal())),
_row('请假原因', request.reason),
],
),
),
),
const SizedBox(height: 14),
Text(
'流程时间线',
style: Theme.of(
context,
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700),
),
const SizedBox(height: 8),
if (detail.timeline.isEmpty)
const Card(
child: ListTile(
leading: Icon(Icons.edit_note),
title: Text('草稿已创建'),
),
)
else
for (final event in detail.timeline) _TimelineTile(event: event),
const SizedBox(height: 16),
if (request.status == 'DRAFT')
FilledButton.icon(
onPressed: () => _transition(context, ref, 'submit'),
icon: const Icon(Icons.send_outlined),
label: const Padding(
padding: EdgeInsets.symmetric(vertical: 12),
child: Text('提交审批'),
),
),
if (request.status == 'PENDING')
OutlinedButton.icon(
onPressed: () => _transition(context, ref, 'withdraw'),
icon: const Icon(Icons.undo),
label: const Padding(
padding: EdgeInsets.symmetric(vertical: 12),
child: Text('撤回申请'),
),
),
],
);
}
Future<void> _transition(
BuildContext context,
WidgetRef ref,
String action,
) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(action == 'submit' ? '提交审批' : '撤回申请'),
content: Text(action == 'submit' ? '提交后将进入审批流程,确认继续?' : '确认撤回当前申请?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('取消'),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('确认'),
),
],
),
);
if (confirmed != true || !context.mounted) return;
final error = await ref
.read(leaveRequestDetailProvider(id).notifier)
.transition(action);
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(error ?? (action == 'submit' ? '已提交审批' : '已撤回'))),
);
}
Widget _row(String label, String value) => Padding(
padding: const EdgeInsets.only(bottom: 10),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(width: 78, child: Text(label)),
Expanded(
child: Text(
value,
style: const TextStyle(fontWeight: FontWeight.w600),
),
),
],
),
);
String _typeLabel(String value) => switch (value) {
'PERSONAL' => '事假',
'SICK' => '病假',
'ANNUAL' => '年假',
_ => value,
};
String _statusLabel(String value) => switch (value) {
'DRAFT' => '草稿',
'PENDING' => '审批中',
'APPROVED' => '已通过',
'REJECTED' => '已驳回',
'WITHDRAWN' => '已撤回',
_ => value,
};
}
class _TimelineTile extends StatelessWidget {
const _TimelineTile({required this.event});
final LeaveTimelineEvent event;
@override
Widget build(BuildContext context) => Card(
child: ListTile(
leading: const Icon(Icons.radio_button_checked),
title: Text(_eventLabel(event.eventType)),
subtitle: Text(
'${event.fromStatus}${event.toStatus}\n${DateFormat('MM-dd HH:mm').format(event.occurredAt.toLocal())}',
),
isThreeLine: true,
),
);
String _eventLabel(String value) => switch (value) {
'LEAVE_REQUEST_SUBMITTED' => '申请已提交',
'LEAVE_REQUEST_WITHDRAWN' => '申请已撤回',
'LEAVE_REQUEST_APPROVED' => '申请已通过',
'LEAVE_REQUEST_REJECTED' => '申请已驳回',
'LEAVE_APPROVAL_TASK_APPROVED' => '审批节点已通过',
'LEAVE_APPROVAL_TASK_REJECTED' => '审批节点已驳回',
_ => value,
};
}
@@ -0,0 +1,93 @@
import 'package:aioa_mobile/features/requests/application/leave_request_controller.dart';
import 'package:aioa_mobile/features/requests/data/leave_request_repository.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart';
class LeaveRequestListPage extends ConsumerWidget {
const LeaveRequestListPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final requests = ref.watch(leaveRequestListProvider);
return Scaffold(
appBar: AppBar(title: const Text('我的请假申请')),
body: requests.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, _) => Center(
child: FilledButton(
onPressed: ref.read(leaveRequestListProvider.notifier).refresh,
child: Text('加载失败,点击重试\n$error'),
),
),
data: (items) {
if (items.isEmpty) return const Center(child: Text('暂无请假申请'));
return RefreshIndicator(
onRefresh: ref.read(leaveRequestListProvider.notifier).refresh,
child: ListView.separated(
padding: const EdgeInsets.all(12),
itemCount: items.length,
separatorBuilder: (_, _) => const SizedBox(height: 8),
itemBuilder: (context, index) =>
_RequestCard(request: items[index]),
),
);
},
),
floatingActionButton: FloatingActionButton.extended(
onPressed: () => context.push('/leave/new'),
icon: const Icon(Icons.add),
label: const Text('发起请假'),
),
);
}
}
class _RequestCard extends StatelessWidget {
const _RequestCard({required this.request});
final LeaveRequestItem request;
@override
Widget build(BuildContext context) => Card(
child: ListTile(
onTap: () => context.push('/leave/${request.id}'),
leading: CircleAvatar(child: Icon(_statusIcon(request.status))),
title: Text(
'${_typeLabel(request.type)} · ${_statusLabel(request.status)}',
),
subtitle: Text(
'${DateFormat('MM-dd HH:mm').format(request.startsAt.toLocal())}${DateFormat('MM-dd HH:mm').format(request.endsAt.toLocal())}\n${request.reason}',
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
isThreeLine: true,
trailing: const Icon(Icons.chevron_right),
),
);
String _typeLabel(String value) => switch (value) {
'PERSONAL' => '事假',
'SICK' => '病假',
'ANNUAL' => '年假',
_ => value,
};
String _statusLabel(String value) => switch (value) {
'DRAFT' => '草稿',
'PENDING' => '审批中',
'APPROVED' => '已通过',
'REJECTED' => '已驳回',
'WITHDRAWN' => '已撤回',
_ => value,
};
IconData _statusIcon(String value) => switch (value) {
'DRAFT' => Icons.edit_note,
'PENDING' => Icons.hourglass_top,
'APPROVED' => Icons.check_circle_outline,
'REJECTED' => Icons.cancel_outlined,
'WITHDRAWN' => Icons.undo,
_ => Icons.description_outlined,
};
}