feat: complete leave approval MVP
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user