186 lines
7.3 KiB
Dart
186 lines
7.3 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:intl/intl.dart';
|
|
import '../api_service.dart';
|
|
import '../theme.dart';
|
|
|
|
class LeaveScreen extends StatefulWidget {
|
|
final ApiService api;
|
|
final VoidCallback onAuthError;
|
|
const LeaveScreen({super.key, required this.api, required this.onAuthError});
|
|
|
|
@override
|
|
State<LeaveScreen> createState() => _LeaveScreenState();
|
|
}
|
|
|
|
class _LeaveScreenState extends State<LeaveScreen> {
|
|
List<LeaveBalance> _balances = [];
|
|
List<LeaveRequest> _requests = [];
|
|
bool _loading = true;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_load();
|
|
}
|
|
|
|
Future<void> _load() async {
|
|
try {
|
|
final b = await widget.api.leaveBalances();
|
|
final r = await widget.api.myLeave();
|
|
if (mounted) setState(() { _balances = b; _requests = r; _loading = false; });
|
|
} on ApiException catch (e) {
|
|
if (e.status == 401) return widget.onAuthError();
|
|
if (mounted) setState(() => _loading = false);
|
|
}
|
|
}
|
|
|
|
Future<void> _apply() async {
|
|
final types = await widget.api.leaveTypes().catchError((_) => <LeaveType>[]);
|
|
if (!mounted || types.isEmpty) {
|
|
if (mounted) ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('No leave types set up')));
|
|
return;
|
|
}
|
|
final ok = await showModalBottomSheet<bool>(
|
|
context: context,
|
|
isScrollControlled: true,
|
|
builder: (_) => _ApplySheet(api: widget.api, types: types),
|
|
);
|
|
if (ok == true) _load();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
floatingActionButton: FloatingActionButton.extended(
|
|
onPressed: _apply, backgroundColor: AppColors.emerald,
|
|
icon: const Icon(Icons.add), label: const Text('Apply'),
|
|
),
|
|
backgroundColor: Colors.transparent,
|
|
body: _loading
|
|
? const Center(child: CircularProgressIndicator(color: AppColors.emerald))
|
|
: RefreshIndicator(
|
|
onRefresh: _load,
|
|
color: AppColors.emerald,
|
|
child: ListView(
|
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 90),
|
|
children: [
|
|
const Text('Balances', style: TextStyle(fontWeight: FontWeight.w600, color: AppColors.navy)),
|
|
const SizedBox(height: 8),
|
|
if (_balances.isEmpty)
|
|
const Text('No leave types.', style: TextStyle(color: AppColors.muted))
|
|
else
|
|
..._balances.map((b) => Card(
|
|
margin: const EdgeInsets.only(bottom: 8),
|
|
child: ListTile(
|
|
title: Text(b.leaveTypeName, style: const TextStyle(fontWeight: FontWeight.w600)),
|
|
subtitle: Text('${b.used} used of ${b.allocated}'),
|
|
trailing: Text('${b.available}',
|
|
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: AppColors.emerald)),
|
|
),
|
|
)),
|
|
const SizedBox(height: 16),
|
|
const Text('My requests', style: TextStyle(fontWeight: FontWeight.w600, color: AppColors.navy)),
|
|
const SizedBox(height: 8),
|
|
if (_requests.isEmpty)
|
|
const Text('No requests yet.', style: TextStyle(color: AppColors.muted))
|
|
else
|
|
..._requests.map((r) => Card(
|
|
margin: const EdgeInsets.only(bottom: 8),
|
|
child: ListTile(
|
|
title: Text('${r.leaveTypeName ?? 'Leave'} · ${r.days ?? ''} day(s)'),
|
|
subtitle: Text('${r.fromDate} → ${r.toDate}${r.reason != null ? '\n${r.reason}' : ''}'),
|
|
isThreeLine: r.reason != null,
|
|
trailing: _statusChip(r.status),
|
|
),
|
|
)),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
Widget _statusChip(String s) {
|
|
final c = s == 'APPROVED' ? AppColors.emerald : s == 'REJECTED' ? AppColors.danger : AppColors.gold;
|
|
return Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
|
decoration: BoxDecoration(color: c.withValues(alpha: 0.12), borderRadius: BorderRadius.circular(20)),
|
|
child: Text(s, style: TextStyle(fontSize: 11, fontWeight: FontWeight.w600, color: c)),
|
|
);
|
|
}
|
|
|
|
class _ApplySheet extends StatefulWidget {
|
|
final ApiService api;
|
|
final List<LeaveType> types;
|
|
const _ApplySheet({required this.api, required this.types});
|
|
|
|
@override
|
|
State<_ApplySheet> createState() => _ApplySheetState();
|
|
}
|
|
|
|
class _ApplySheetState extends State<_ApplySheet> {
|
|
String? _type;
|
|
DateTime? _from, _to;
|
|
final _reason = TextEditingController();
|
|
bool _busy = false;
|
|
|
|
Future<void> _pick(bool from) async {
|
|
final d = await showDatePicker(
|
|
context: context,
|
|
initialDate: DateTime.now(),
|
|
firstDate: DateTime.now().subtract(const Duration(days: 30)),
|
|
lastDate: DateTime.now().add(const Duration(days: 365)),
|
|
);
|
|
if (d != null) setState(() => from ? _from = d : _to = d);
|
|
}
|
|
|
|
Future<void> _submit() async {
|
|
if (_type == null || _from == null || _to == null) return;
|
|
setState(() => _busy = true);
|
|
final fmt = DateFormat('yyyy-MM-dd');
|
|
try {
|
|
await widget.api.applyLeave(_type!, fmt.format(_from!), fmt.format(_to!), _reason.text);
|
|
if (mounted) Navigator.pop(context, true);
|
|
} on ApiException catch (e) {
|
|
if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(e.message)));
|
|
setState(() => _busy = false);
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final fmt = DateFormat('dd MMM yyyy');
|
|
return Padding(
|
|
padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(20),
|
|
child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [
|
|
const Text('Apply for leave', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: AppColors.navy)),
|
|
const SizedBox(height: 16),
|
|
DropdownButtonFormField<String>(
|
|
initialValue: _type,
|
|
decoration: const InputDecoration(labelText: 'Leave type'),
|
|
items: widget.types.map((t) => DropdownMenuItem(value: t.gid, child: Text(t.name))).toList(),
|
|
onChanged: (v) => setState(() => _type = v),
|
|
),
|
|
const SizedBox(height: 12),
|
|
Row(children: [
|
|
Expanded(child: OutlinedButton(onPressed: () => _pick(true), child: Text(_from == null ? 'From' : fmt.format(_from!)))),
|
|
const SizedBox(width: 10),
|
|
Expanded(child: OutlinedButton(onPressed: () => _pick(false), child: Text(_to == null ? 'To' : fmt.format(_to!)))),
|
|
]),
|
|
const SizedBox(height: 12),
|
|
TextField(controller: _reason, decoration: const InputDecoration(labelText: 'Reason'), maxLines: 2),
|
|
const SizedBox(height: 16),
|
|
FilledButton(
|
|
onPressed: _busy ? null : _submit,
|
|
child: _busy
|
|
? const SizedBox(height: 20, width: 20, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white))
|
|
: const Text('Submit request'),
|
|
),
|
|
]),
|
|
),
|
|
);
|
|
}
|
|
}
|