feat: complete leave approval MVP
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
import 'package:aioa_mobile/core/auth/authenticated_http_client.dart';
|
||||
import 'package:aioa_mobile/core/config/runtime_config.dart';
|
||||
import 'package:aioa_mobile/features/tasks/data/approval_task_repository.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
final approvalTaskRepositoryProvider = Provider<ApprovalTaskRepository>(
|
||||
(ref) => ApprovalTaskRepository(
|
||||
client: ref.watch(authenticatedHttpClientProvider),
|
||||
baseUrl: RuntimeConfig.apiBaseUrl,
|
||||
),
|
||||
);
|
||||
|
||||
final approvalTaskProvider =
|
||||
AsyncNotifierProvider<ApprovalTaskController, List<ApprovalTaskItem>>(
|
||||
ApprovalTaskController.new,
|
||||
);
|
||||
|
||||
class ApprovalTaskController extends AsyncNotifier<List<ApprovalTaskItem>> {
|
||||
@override
|
||||
Future<List<ApprovalTaskItem>> build() =>
|
||||
ref.read(approvalTaskRepositoryProvider).list();
|
||||
|
||||
Future<void> refresh() async {
|
||||
state = const AsyncLoading();
|
||||
state = await AsyncValue.guard(
|
||||
() => ref.read(approvalTaskRepositoryProvider).list(),
|
||||
);
|
||||
}
|
||||
|
||||
Future<String?> decide({
|
||||
required ApprovalTaskItem task,
|
||||
required bool approved,
|
||||
String? comment,
|
||||
}) async {
|
||||
try {
|
||||
await ref
|
||||
.read(approvalTaskRepositoryProvider)
|
||||
.decide(task: task, approved: approved, comment: comment);
|
||||
state = AsyncData([
|
||||
for (final item in state.value ?? const <ApprovalTaskItem>[])
|
||||
if (item.id != task.id) item,
|
||||
]);
|
||||
return null;
|
||||
} on ApprovalTaskException catch (error) {
|
||||
return error.message;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import 'package:aioa_mobile/core/auth/authenticated_http_client.dart';
|
||||
import 'package:aioa_mobile/core/config/runtime_config.dart';
|
||||
import 'package:aioa_mobile/features/tasks/data/notification_repository.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
final notificationRepositoryProvider = Provider<NotificationRepository>(
|
||||
(ref) => NotificationRepository(
|
||||
client: ref.watch(authenticatedHttpClientProvider),
|
||||
baseUrl: RuntimeConfig.apiBaseUrl,
|
||||
),
|
||||
);
|
||||
|
||||
final notificationProvider =
|
||||
AsyncNotifierProvider<NotificationController, List<AppNotification>>(
|
||||
NotificationController.new,
|
||||
);
|
||||
|
||||
class NotificationController extends AsyncNotifier<List<AppNotification>> {
|
||||
@override
|
||||
Future<List<AppNotification>> build() =>
|
||||
ref.read(notificationRepositoryProvider).list();
|
||||
|
||||
Future<void> refresh() async {
|
||||
state = const AsyncLoading();
|
||||
state = await AsyncValue.guard(
|
||||
() => ref.read(notificationRepositoryProvider).list(),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> markRead(String id) async {
|
||||
final current = state.value;
|
||||
if (current == null) return;
|
||||
final updated = await ref.read(notificationRepositoryProvider).markRead(id);
|
||||
state = AsyncData([
|
||||
for (final item in current)
|
||||
if (item.id == id) updated else item,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
class ApprovalTaskItem {
|
||||
const ApprovalTaskItem({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.createdAt,
|
||||
required this.leaveRequest,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String name;
|
||||
final DateTime createdAt;
|
||||
final ApprovalLeaveRequest leaveRequest;
|
||||
|
||||
factory ApprovalTaskItem.fromJson(Map<String, Object?> json) =>
|
||||
ApprovalTaskItem(
|
||||
id: json['id']! as String,
|
||||
name: json['name']! as String,
|
||||
createdAt: DateTime.parse(json['createdAt']! as String),
|
||||
leaveRequest: ApprovalLeaveRequest.fromJson(
|
||||
Map<String, Object?>.from(json['leaveRequest']! as Map),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class ApprovalLeaveRequest {
|
||||
const ApprovalLeaveRequest({
|
||||
required this.id,
|
||||
required this.type,
|
||||
required this.startsAt,
|
||||
required this.endsAt,
|
||||
required this.reason,
|
||||
required this.status,
|
||||
required this.version,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String type;
|
||||
final DateTime startsAt;
|
||||
final DateTime endsAt;
|
||||
final String reason;
|
||||
final String status;
|
||||
final int version;
|
||||
|
||||
factory ApprovalLeaveRequest.fromJson(Map<String, Object?> json) =>
|
||||
ApprovalLeaveRequest(
|
||||
id: json['id']! as String,
|
||||
type: json['type']! as String,
|
||||
startsAt: DateTime.parse(json['startsAt']! as String),
|
||||
endsAt: DateTime.parse(json['endsAt']! as String),
|
||||
reason: json['reason']! as String,
|
||||
status: json['status']! as String,
|
||||
version: json['version']! as int,
|
||||
);
|
||||
}
|
||||
|
||||
class ApprovalTaskException implements Exception {
|
||||
const ApprovalTaskException(this.message);
|
||||
final String message;
|
||||
@override
|
||||
String toString() => message;
|
||||
}
|
||||
|
||||
class ApprovalTaskRepository {
|
||||
ApprovalTaskRepository({
|
||||
http.Client? client,
|
||||
this.baseUrl = const String.fromEnvironment(
|
||||
'AIOA_API_BASE_URL',
|
||||
defaultValue: 'http://127.0.0.1:8080/api/v1',
|
||||
),
|
||||
this.accessToken = const String.fromEnvironment('AIOA_ACCESS_TOKEN'),
|
||||
}) : _client = client ?? http.Client();
|
||||
|
||||
final http.Client _client;
|
||||
final String baseUrl;
|
||||
final String accessToken;
|
||||
|
||||
Future<List<ApprovalTaskItem>> list() async {
|
||||
final response = await _client.get(
|
||||
Uri.parse('$baseUrl/approval-tasks'),
|
||||
headers: _headers,
|
||||
);
|
||||
_requireSuccess(response);
|
||||
return (jsonDecode(response.body) as List)
|
||||
.map(
|
||||
(item) =>
|
||||
ApprovalTaskItem.fromJson(Map<String, Object?>.from(item as Map)),
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
|
||||
Future<void> decide({
|
||||
required ApprovalTaskItem task,
|
||||
required bool approved,
|
||||
String? comment,
|
||||
}) async {
|
||||
final action = approved ? 'approve' : 'reject';
|
||||
final payload = jsonEncode({
|
||||
'version': task.leaveRequest.version,
|
||||
if (comment != null && comment.trim().isNotEmpty)
|
||||
'comment': comment.trim(),
|
||||
});
|
||||
final preferences = await SharedPreferences.getInstance();
|
||||
final storageKey = 'approval.pending.${task.id}.$action';
|
||||
final existing = preferences.getString(storageKey);
|
||||
final pending = existing == null ? null : _decodePending(existing);
|
||||
final idempotencyKey = pending?.payload == payload
|
||||
? pending!.key
|
||||
: _newKey(action);
|
||||
await preferences.setString(
|
||||
storageKey,
|
||||
jsonEncode({'key': idempotencyKey, 'payload': payload}),
|
||||
);
|
||||
|
||||
late http.Response response;
|
||||
try {
|
||||
response = await _client.post(
|
||||
Uri.parse('$baseUrl/approval-tasks/${task.id}/$action'),
|
||||
headers: {
|
||||
..._headers,
|
||||
'Content-Type': 'application/json',
|
||||
'Idempotency-Key': idempotencyKey,
|
||||
},
|
||||
body: payload,
|
||||
);
|
||||
} catch (_) {
|
||||
throw const ApprovalTaskException('网络不可用,审批请求已保存,可安全重试');
|
||||
}
|
||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||
if (response.statusCode < 500 && response.statusCode != 401) {
|
||||
await preferences.remove(storageKey);
|
||||
}
|
||||
_requireSuccess(response);
|
||||
}
|
||||
await preferences.remove(storageKey);
|
||||
}
|
||||
|
||||
Map<String, String> get _headers => {
|
||||
'Accept': 'application/json',
|
||||
if (accessToken.isNotEmpty) 'Authorization': 'Bearer $accessToken',
|
||||
};
|
||||
|
||||
void _requireSuccess(http.Response response) {
|
||||
if (response.statusCode >= 200 && response.statusCode < 300) return;
|
||||
String? message;
|
||||
try {
|
||||
message =
|
||||
(jsonDecode(response.body) as Map<String, Object?>)['detail']
|
||||
as String?;
|
||||
} catch (_) {}
|
||||
throw ApprovalTaskException(message ?? '待办请求失败(${response.statusCode})');
|
||||
}
|
||||
|
||||
_PendingApproval? _decodePending(String encoded) {
|
||||
try {
|
||||
final json = jsonDecode(encoded) as Map<String, Object?>;
|
||||
return _PendingApproval(
|
||||
json['key']! as String,
|
||||
json['payload']! as String,
|
||||
);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
String _newKey(String action) {
|
||||
final random = Random.secure();
|
||||
final entropy = List.generate(
|
||||
12,
|
||||
(_) => random.nextInt(256).toRadixString(16).padLeft(2, '0'),
|
||||
).join();
|
||||
return 'approval-$action-${DateTime.now().microsecondsSinceEpoch}-$entropy';
|
||||
}
|
||||
}
|
||||
|
||||
class _PendingApproval {
|
||||
const _PendingApproval(this.key, this.payload);
|
||||
final String key;
|
||||
final String payload;
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
class AppNotification {
|
||||
const AppNotification({
|
||||
required this.id,
|
||||
required this.type,
|
||||
required this.title,
|
||||
required this.body,
|
||||
required this.createdAt,
|
||||
this.resourceType,
|
||||
this.resourceId,
|
||||
this.readAt,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String type;
|
||||
final String title;
|
||||
final String body;
|
||||
final String? resourceType;
|
||||
final String? resourceId;
|
||||
final DateTime createdAt;
|
||||
final DateTime? readAt;
|
||||
|
||||
bool get isRead => readAt != null;
|
||||
|
||||
factory AppNotification.fromJson(Map<String, Object?> json) =>
|
||||
AppNotification(
|
||||
id: json['id']! as String,
|
||||
type: json['type']! as String,
|
||||
title: json['title']! as String,
|
||||
body: json['body']! as String,
|
||||
resourceType: json['resourceType'] as String?,
|
||||
resourceId: json['resourceId'] as String?,
|
||||
createdAt: DateTime.parse(json['createdAt']! as String),
|
||||
readAt: json['readAt'] == null
|
||||
? null
|
||||
: DateTime.parse(json['readAt']! as String),
|
||||
);
|
||||
}
|
||||
|
||||
class NotificationRepository {
|
||||
NotificationRepository({
|
||||
http.Client? client,
|
||||
this.baseUrl = const String.fromEnvironment(
|
||||
'AIOA_API_BASE_URL',
|
||||
defaultValue: 'http://127.0.0.1:8080/api/v1',
|
||||
),
|
||||
this.accessToken = const String.fromEnvironment('AIOA_ACCESS_TOKEN'),
|
||||
}) : _client = client ?? http.Client();
|
||||
|
||||
final http.Client _client;
|
||||
final String baseUrl;
|
||||
final String accessToken;
|
||||
|
||||
Future<List<AppNotification>> list() async {
|
||||
final response = await _client.get(
|
||||
Uri.parse('$baseUrl/notifications'),
|
||||
headers: _headers,
|
||||
);
|
||||
_requireSuccess(response);
|
||||
return (jsonDecode(response.body) as List)
|
||||
.map(
|
||||
(item) =>
|
||||
AppNotification.fromJson(Map<String, Object?>.from(item as Map)),
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
|
||||
Future<AppNotification> markRead(String id) async {
|
||||
final response = await _client.post(
|
||||
Uri.parse('$baseUrl/notifications/$id/read'),
|
||||
headers: _headers,
|
||||
);
|
||||
_requireSuccess(response);
|
||||
return AppNotification.fromJson(
|
||||
jsonDecode(response.body) as Map<String, Object?>,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, String> get _headers => {
|
||||
'Accept': 'application/json',
|
||||
if (accessToken.isNotEmpty) 'Authorization': 'Bearer $accessToken',
|
||||
};
|
||||
|
||||
void _requireSuccess(http.Response response) {
|
||||
if (response.statusCode >= 200 && response.statusCode < 300) return;
|
||||
throw Exception('通知请求失败(${response.statusCode})');
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,280 @@
|
||||
import 'package:aioa_mobile/features/tasks/application/approval_task_controller.dart';
|
||||
import 'package:aioa_mobile/features/tasks/application/notification_controller.dart';
|
||||
import 'package:aioa_mobile/features/tasks/data/approval_task_repository.dart';
|
||||
import 'package:aioa_mobile/features/tasks/data/notification_repository.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
class TasksPage extends StatelessWidget {
|
||||
class TasksPage extends ConsumerWidget {
|
||||
const TasksPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => const Center(child: Text('暂无待办'));
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final notifications = ref.watch(notificationProvider);
|
||||
final tasks = ref.watch(approvalTaskProvider);
|
||||
final unread =
|
||||
notifications.value?.where((item) => !item.isRead).length ?? 0;
|
||||
final taskCount = tasks.value?.length ?? 0;
|
||||
return DefaultTabController(
|
||||
length: 2,
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('待办与通知'),
|
||||
bottom: TabBar(
|
||||
tabs: [
|
||||
Tab(text: taskCount == 0 ? '待办' : '待办 ($taskCount)'),
|
||||
Tab(text: unread == 0 ? '通知' : '通知 ($unread)'),
|
||||
],
|
||||
),
|
||||
),
|
||||
body: TabBarView(
|
||||
children: [
|
||||
_ApprovalTaskList(tasks: tasks),
|
||||
_NotificationList(notifications: notifications),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ApprovalTaskList extends ConsumerWidget {
|
||||
const _ApprovalTaskList({required this.tasks});
|
||||
|
||||
final AsyncValue<List<ApprovalTaskItem>> tasks;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) => tasks.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (error, _) => Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('待办加载失败:$error'),
|
||||
const SizedBox(height: 8),
|
||||
FilledButton(
|
||||
onPressed: ref.read(approvalTaskProvider.notifier).refresh,
|
||||
child: const Text('重试'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
data: (items) {
|
||||
if (items.isEmpty) return const Center(child: Text('暂无待办'));
|
||||
return RefreshIndicator(
|
||||
onRefresh: ref.read(approvalTaskProvider.notifier).refresh,
|
||||
child: ListView.separated(
|
||||
padding: const EdgeInsets.all(12),
|
||||
itemCount: items.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(height: 8),
|
||||
itemBuilder: (context, index) =>
|
||||
_ApprovalTaskCard(task: items[index]),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
class _ApprovalTaskCard extends ConsumerWidget {
|
||||
const _ApprovalTaskCard({required this.task});
|
||||
|
||||
final ApprovalTaskItem task;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final request = task.leaveRequest;
|
||||
final formatter = DateFormat('MM-dd HH:mm');
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const CircleAvatar(child: Icon(Icons.assignment_ind_outlined)),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
task.name,
|
||||
style: const TextStyle(fontWeight: FontWeight.w700),
|
||||
),
|
||||
Text(_typeLabel(request.type)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'${formatter.format(request.startsAt.toLocal())} — ${formatter.format(request.endsAt.toLocal())}',
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(request.reason),
|
||||
const SizedBox(height: 14),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: () => _decide(context, ref, approved: false),
|
||||
child: const Text('驳回'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: FilledButton(
|
||||
onPressed: () => _decide(context, ref, approved: true),
|
||||
child: const Text('批准'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _decide(
|
||||
BuildContext context,
|
||||
WidgetRef ref, {
|
||||
required bool approved,
|
||||
}) async {
|
||||
final comment = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (context) => _DecisionDialog(approved: approved),
|
||||
);
|
||||
if (comment == null || !context.mounted) return;
|
||||
final error = await ref
|
||||
.read(approvalTaskProvider.notifier)
|
||||
.decide(task: task, approved: approved, comment: comment);
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(error ?? (approved ? '已批准' : '已驳回'))),
|
||||
);
|
||||
}
|
||||
|
||||
String _typeLabel(String type) => switch (type) {
|
||||
'PERSONAL' => '事假',
|
||||
'SICK' => '病假',
|
||||
'ANNUAL' => '年假',
|
||||
_ => type,
|
||||
};
|
||||
}
|
||||
|
||||
class _DecisionDialog extends StatefulWidget {
|
||||
const _DecisionDialog({required this.approved});
|
||||
|
||||
final bool approved;
|
||||
|
||||
@override
|
||||
State<_DecisionDialog> createState() => _DecisionDialogState();
|
||||
}
|
||||
|
||||
class _DecisionDialogState extends State<_DecisionDialog> {
|
||||
final controller = TextEditingController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => AlertDialog(
|
||||
title: Text(widget.approved ? '批准申请' : '驳回申请'),
|
||||
content: TextField(
|
||||
controller: controller,
|
||||
maxLength: 1000,
|
||||
maxLines: 3,
|
||||
decoration: InputDecoration(
|
||||
labelText: widget.approved ? '审批意见(选填)' : '驳回原因',
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(context, controller.text),
|
||||
child: Text(widget.approved ? '确认批准' : '确认驳回'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
class _NotificationList extends ConsumerWidget {
|
||||
const _NotificationList({required this.notifications});
|
||||
|
||||
final AsyncValue<List<AppNotification>> notifications;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) => notifications.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (error, _) => Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('通知加载失败:$error'),
|
||||
const SizedBox(height: 8),
|
||||
FilledButton(
|
||||
onPressed: ref.read(notificationProvider.notifier).refresh,
|
||||
child: const Text('重试'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
data: (items) {
|
||||
if (items.isEmpty) return const Center(child: Text('暂无通知'));
|
||||
return RefreshIndicator(
|
||||
onRefresh: ref.read(notificationProvider.notifier).refresh,
|
||||
child: ListView.separated(
|
||||
padding: const EdgeInsets.all(12),
|
||||
itemCount: items.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(height: 8),
|
||||
itemBuilder: (context, index) {
|
||||
final item = items[index];
|
||||
return Card(
|
||||
color: item.isRead
|
||||
? null
|
||||
: Theme.of(
|
||||
context,
|
||||
).colorScheme.primaryContainer.withValues(alpha: 0.35),
|
||||
child: ListTile(
|
||||
leading: Icon(_icon(item.type)),
|
||||
title: Text(
|
||||
item.title,
|
||||
style: TextStyle(
|
||||
fontWeight: item.isRead ? FontWeight.w500 : FontWeight.w700,
|
||||
),
|
||||
),
|
||||
subtitle: Text(
|
||||
'${item.body}\n${DateFormat('MM-dd HH:mm').format(item.createdAt.toLocal())}',
|
||||
),
|
||||
isThreeLine: true,
|
||||
trailing: item.isRead ? null : const Badge(),
|
||||
onTap: item.isRead
|
||||
? null
|
||||
: () => ref
|
||||
.read(notificationProvider.notifier)
|
||||
.markRead(item.id),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
IconData _icon(String type) => switch (type) {
|
||||
'APPROVAL_TASK_ASSIGNED' => Icons.assignment_outlined,
|
||||
'LEAVE_APPROVED' => Icons.check_circle_outline,
|
||||
'LEAVE_REJECTED' => Icons.cancel_outlined,
|
||||
_ => Icons.notifications_none,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user