init
This commit is contained in:
@@ -0,0 +1,185 @@
|
||||
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'),
|
||||
),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user