diff --git a/.idea/libraries/Dart_Packages.xml b/.idea/libraries/Dart_Packages.xml
index 599e5219..1e1d277c 100644
--- a/.idea/libraries/Dart_Packages.xml
+++ b/.idea/libraries/Dart_Packages.xml
@@ -5,1286 +5,1310 @@
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
+
+
+
+
+
+
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/.idea/libraries/Dart_SDK.xml b/.idea/libraries/Dart_SDK.xml
index 2d486b53..ad1470cf 100644
--- a/.idea/libraries/Dart_SDK.xml
+++ b/.idea/libraries/Dart_SDK.xml
@@ -1,29 +1,29 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/.idea/workspace.xml b/.idea/workspace.xml
index ecb7d10a..8f21f709 100644
--- a/.idea/workspace.xml
+++ b/.idea/workspace.xml
@@ -5,14 +5,11 @@
-
+
-
-
-
-
-
-
+
+
+
@@ -64,29 +61,30 @@
- {
- "keyToString": {
- "Flutter.Unnamed.executor": "Run",
- "Flutter.main.dart.executor": "Run",
- "Flutter.main.executor": "Run",
- "RunOnceActivity.OpenProjectViewOnStart": "true",
- "RunOnceActivity.ShowReadmeOnStart": "true",
- "RunOnceActivity.cidr.known.project.marker": "true",
- "RunOnceActivity.git.unshallow": "true",
- "RunOnceActivity.readMode.enableVisualFormatting": "true",
- "cf.first.check.clang-format": "false",
- "cidr.known.project.marker": "true",
- "com.google.services.firebase.aqiPopupShown": "true",
- "dart.analysis.tool.window.visible": "false",
- "git-widget-placeholder": "master",
- "ignore.virus.scanning.warn.message": "true",
- "io.flutter.reload.alreadyRun": "true",
- "kotlin-language-version-configured": "true",
- "last_opened_file_path": "/Users/yatikajena/Desktop/iitgHABapp/frontend2/ios/Runner",
- "settings.editor.selected.configurable": "preferences.pluginManager",
- "show.migrate.to.gradle.popup": "false"
+
+}]]>
diff --git a/frontend2/lib/constants/endpoint.dart b/frontend2/lib/constants/endpoint.dart
index 26f23f62..95fc8670 100644
--- a/frontend2/lib/constants/endpoint.dart
+++ b/frontend2/lib/constants/endpoint.dart
@@ -1,6 +1,11 @@
const String baseUrl = "https://hab.codingclub.in/api";
const String authUrl = "https://hab.codingclub.in/api";
+class MessRebateEndpoints {
+ static const String sendApplication = '$baseUrl/leave/apply';
+ static const String getApplications = '$baseUrl/leave/my-applications';
+}
+
class NotificationEndpoints {
static const String registerToken = '$baseUrl/notification/register-token';
static const String welcome = '$baseUrl/notification/welcome';
@@ -27,11 +32,11 @@ class HostelEndpoint {
class AuthEndpoints {
// For initial Microsoft login - redirects through backend
static String get getAccess =>
- 'https://login.microsoftonline.com/850aa78d-94e1-4bc6-9cf3-8c11b530701c/oauth2/v2.0/authorize?client_id=2cdac4f3-1fda-4348-a057-9bb2e3d184a1&response_type=code&redirect_uri=$authUrl/auth/login/redirect/mobile&scope=offline_access%20User.Read&state=12345';
+ 'https://login.microsoftonline.com/850aa78d-94e1-4bc6-9cf3-8c11b530701c/oauth2/v2.0/authorize?client_id=2cdac4f3-1fda-4348-a057-9bb2e3d184a1&response_type=code&redirect_uri=$authUrl/auth/login/redirect/mobile&scope=offline_access%20User.Read&state=12345&prompt=select_account';
// For linking Microsoft account - uses same redirect URI as login but with state=link
static String get linkMicrosoft =>
- 'https://login.microsoftonline.com/850aa78d-94e1-4bc6-9cf3-8c11b530701c/oauth2/v2.0/authorize?client_id=2cdac4f3-1fda-4348-a057-9bb2e3d184a1&response_type=code&redirect_uri=$authUrl/auth/login/redirect/mobile&scope=offline_access%20User.Read&state=link';
+ 'https://login.microsoftonline.com/850aa78d-94e1-4bc6-9cf3-8c11b530701c/oauth2/v2.0/authorize?client_id=2cdac4f3-1fda-4348-a057-9bb2e3d184a1&response_type=code&redirect_uri=$authUrl/auth/login/redirect/mobile&scope=offline_access%20User.Read&state=link&prompt=select_account';
}
class Userendpoints {
diff --git a/frontend2/lib/main.dart b/frontend2/lib/main.dart
index 64e27d18..823b43fc 100644
--- a/frontend2/lib/main.dart
+++ b/frontend2/lib/main.dart
@@ -116,8 +116,8 @@ class _MyAppState extends State {
// Use `.map()` to transform the stream into a stream of ConnectivityResult
_connectivityStream = _connectivity.onConnectivityChanged.map(
- (List results) =>
- results.isNotEmpty ? results[0] : ConnectivityResult.none);
+ (List results) =>
+ results.isNotEmpty ? results[0] : ConnectivityResult.none);
_connectivityStream.listen((ConnectivityResult result) {
_handleConnectivityChange(result);
@@ -165,12 +165,13 @@ class _MyAppState extends State {
navigatorKey: navigatorKey,
home: widget.updateRequired
- ? const UpdateRequiredScreen()
- : (widget.isLoggedIn
- ? const MainNavigationScreen()
- : const LoginScreen()),
+ ? const UpdateRequiredScreen() :
+ (widget.isLoggedIn
+ ?
+ const MainNavigationScreen()
+ : const LoginScreen()),
- //home: ProfileScreen(),
+ // home: ProfileScreen(),
builder: EasyLoading.init(),
routes: {
'/home': (context) => const MainNavigationScreen(),
diff --git a/frontend2/lib/screens/home_screen.dart b/frontend2/lib/screens/home_screen.dart
index 6aee7ad1..7657abdf 100644
--- a/frontend2/lib/screens/home_screen.dart
+++ b/frontend2/lib/screens/home_screen.dart
@@ -15,6 +15,7 @@ import '../utilities/startupitem.dart';
import '../widgets/alerts_card.dart';
import '../widgets/microsoft_required_dialog.dart';
import 'mess_preference.dart';
+import 'leave_application_screen.dart';
class HomeScreen extends StatefulWidget {
final void Function(int)? onNavigateToTab;
@@ -223,6 +224,78 @@ class _HomeScreenState extends State {
),
),
),
+ const SizedBox(width: 16),
+
+ //Rebate
+ Expanded(
+ child: InkWell(
+ borderRadius: BorderRadius.circular(18),
+ onTap: () async {
+ final prefs = await SharedPreferences.getInstance();
+ final hasMicrosoftLinked =
+ prefs.getBool('hasMicrosoftLinked') ?? false;
+
+ if (!mounted) return;
+
+ if (!hasMicrosoftLinked) {
+ showDialog(
+ context: context,
+ builder: (context) => const MicrosoftRequiredDialog(
+ featureName: 'Mess Rebate',
+ ),
+ );
+ return;
+ }
+
+ Navigator.push(
+ context,
+ MaterialPageRoute(
+ builder: (context) => const LeaveApplicationScreen(),
+ ),
+ );
+ },
+ child: Container(
+ height: 90,
+ decoration: BoxDecoration(
+ // color: const Color(0xFFF6F6F6),
+ color: const Color(0xFFFFFFFF),
+ borderRadius: BorderRadius.circular(18),
+ ),
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ crossAxisAlignment: CrossAxisAlignment.center,
+ children: [
+ Container(
+ width: 40,
+ height: 40,
+ decoration: const BoxDecoration(
+ color: Color(0xFF3754DB),
+ shape: BoxShape.circle,
+ ),
+ child: Center(
+ child: SvgPicture.asset(
+ 'assets/icon/messicon.svg',
+ colorFilter: const ColorFilter.mode(
+ Colors.white, BlendMode.srcIn),
+ width: 22,
+ height: 22,
+ ),
+ ),
+ ),
+ const SizedBox(height: 8),
+ const Text(
+ "Mess Rebate",
+ style: TextStyle(
+ color: Colors.black,
+ fontWeight: FontWeight.w600,
+ fontSize: 15,
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
],
),
);
diff --git a/frontend2/lib/screens/leave_application_list_screen.dart b/frontend2/lib/screens/leave_application_list_screen.dart
new file mode 100644
index 00000000..58c615d5
--- /dev/null
+++ b/frontend2/lib/screens/leave_application_list_screen.dart
@@ -0,0 +1,186 @@
+import 'package:dio/dio.dart';
+import 'package:flutter/material.dart';
+import 'package:file_picker/file_picker.dart';
+import 'package:flutter_launcher_icons/constants.dart';
+import 'package:frontend2/apis/dio_client.dart';
+import 'package:frontend2/apis/protected.dart';
+import 'package:frontend2/constants/endpoint.dart';
+import 'package:frontend2/screens/leave_application_screen.dart';
+import 'package:intl/intl.dart';
+
+class LeaveApplicationListScreen extends StatefulWidget {
+ const LeaveApplicationListScreen({super.key});
+
+ @override
+ State createState() => _LeaveApplicationListScreenState();
+}
+
+class _LeaveApplicationListScreenState extends State {
+
+ var myApplications = [];
+ bool isLoading = true;
+
+
+ @override
+ void initState(){
+ super.initState();
+ _fetchHistory();
+ }
+
+ Future _fetchHistory ()async{
+ final accessToken = await getAccessToken();
+ if (accessToken == 'error') {
+ setState(() {
+ isLoading = false;
+ });
+ return;}
+ final dio = DioClient().dio;
+ final response = await dio.get(
+ MessRebateEndpoints.getApplications,
+ options: Options(headers: {'Authorization': 'Bearer $accessToken'}),
+ );
+ if (response.statusCode == 200) {
+ final data = response.data as Map;
+ setState(() {
+ myApplications = data['myApplications'] ?? [];
+ isLoading = false;
+ });
+ } else {
+ setState(() {
+ isLoading = false;
+ });
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return Scaffold(
+ backgroundColor: const Color(0xFFEDEDED),
+ appBar: AppBar(
+ elevation: 0,
+ backgroundColor: Colors.transparent,
+ iconTheme: const IconThemeData(color: Colors.black),
+ title: const Text(
+ "Applications List",
+ style: TextStyle(color: Colors.black, fontWeight: FontWeight.bold),
+ ),
+ leading: IconButton(
+ icon: const Icon(Icons.arrow_back),
+ onPressed: () {
+ Navigator.push(
+ context,
+ MaterialPageRoute(
+ builder: (context) => const LeaveApplicationScreen(),
+ ),
+ );
+ },
+ ),
+ ),
+
+ body: isLoading
+ ? const Center(child: CircularProgressIndicator())
+ : myApplications.isEmpty
+ ? Center(
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ Icon(
+ Icons.assignment_outlined,
+ size: 80,
+ color: Colors.grey[400],
+ ),
+ const SizedBox(height: 20),
+ const Text(
+ "No previous applications or requests",
+ textAlign: TextAlign.center,
+ style: TextStyle(
+ fontSize: 16,
+ fontWeight: FontWeight.w500,
+ color: Colors.black54,
+ ),
+ ),
+ const SizedBox(height: 8),
+ const Text(
+ "Your leave history will appear here.",
+ style: TextStyle(
+ fontSize: 14,
+ color: Colors.black38,
+ ),
+ ),
+ ],
+ ),
+ ) : ListView.builder(
+ padding: const EdgeInsets.all(12),
+ itemCount: myApplications.length,
+ itemBuilder: (context, index) {
+ final application = myApplications[index];
+
+ final startDate = DateFormat('dd MMM yyyy')
+ .format(DateTime.parse(application['startDate']));
+ final endDate = DateFormat('dd MMM yyyy')
+ .format(DateTime.parse(application['endDate']));
+
+ final status = application['status'] ?? '';
+
+ final fb = application['feedback']??'N/A';
+
+ Color statusColor;
+ if (status.toLowerCase() == 'approved') {
+ statusColor = Colors.green;
+ } else if (status.toLowerCase() == 'rejected') {
+ statusColor = Colors.red;
+ } else {
+ statusColor = Colors.grey;
+ }
+
+ return Card(
+ margin: const EdgeInsets.only(bottom: 12),
+ elevation: 3,
+ shape: RoundedRectangleBorder(
+ borderRadius: BorderRadius.circular(12),
+ ),
+ child: ListTile(
+ contentPadding:
+ const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
+
+ title: Text(
+ application['leaveType'] ?? 'Unknown',
+ style: const TextStyle(
+ fontSize: 16,
+ fontWeight: FontWeight.bold,
+ ),
+ ),
+
+ subtitle: Padding(
+ padding: const EdgeInsets.only(top: 6),
+ child: Text(
+ "$startDate → $endDate"+((status.toLowerCase() == 'rejected')?"\nFeedback: $fb":""),
+ style: TextStyle(
+ color: Colors.grey[700],
+ ),
+ ),
+ ),
+
+ trailing: Container(
+ padding: const EdgeInsets.symmetric(
+ horizontal: 12, vertical: 6),
+ decoration: BoxDecoration(
+ color: statusColor.withOpacity(0.15),
+ borderRadius: BorderRadius.circular(20),
+ ),
+ child: Text(
+ status.toUpperCase(),
+ style: TextStyle(
+ color: statusColor,
+ fontWeight: FontWeight.bold,
+ fontSize: 12,
+ ),
+ ),
+ ),
+ ),
+ );
+ },
+ ),
+ );
+ }
+}
diff --git a/frontend2/lib/screens/leave_application_screen.dart b/frontend2/lib/screens/leave_application_screen.dart
new file mode 100644
index 00000000..440db5a2
--- /dev/null
+++ b/frontend2/lib/screens/leave_application_screen.dart
@@ -0,0 +1,310 @@
+import 'package:dio/dio.dart';
+import 'package:flutter/material.dart';
+import 'package:file_picker/file_picker.dart';
+import 'package:flutter_launcher_icons/constants.dart';
+import 'package:frontend2/apis/dio_client.dart';
+import 'package:frontend2/apis/protected.dart';
+import 'package:frontend2/constants/endpoint.dart';
+import 'package:frontend2/screens/leave_application_list_screen.dart';
+import 'package:intl/intl.dart';
+import 'package:frontend2/screens/home_screen.dart';
+
+class LeaveApplicationScreen extends StatefulWidget {
+ const LeaveApplicationScreen({super.key});
+ @override
+ State createState() => _LeaveApplicationScreenState();
+}
+
+class _LeaveApplicationScreenState extends State {
+
+ int? _selectedValue;
+ DateTimeRange? _selectedDateRange;
+ PlatformFile? _pickedFile;
+ final TextEditingController _accountNumberController = TextEditingController();
+ final TextEditingController _ifscController = TextEditingController();
+ final TextEditingController _bankNameController = TextEditingController();
+ final TextEditingController _accountHolderController = TextEditingController();
+
+
+ Future _selectDateRange() async {
+ final DateTimeRange? picked = await showDateRangePicker(
+ context: context,
+ firstDate: DateTime.now(),
+ lastDate: DateTime(2027),
+ builder: (context, child) {
+ return Theme(data: ThemeData.light(), child: child!);
+ },
+ );
+ if (picked != null) {
+ setState(() => _selectedDateRange = picked);
+ }
+ }
+
+ Future _pickFile() async {
+ final result = await FilePicker.platform.pickFiles(
+ type: FileType.custom,
+ allowedExtensions: ['pdf', 'jpg', 'png'],
+ );
+ if (result != null) {
+ setState(() => _pickedFile = result.files.first);
+ }
+ }
+
+ Future _sendRequest({required int reason,
+ required DateTimeRange range,
+ required PlatformFile file,
+ }) async {
+
+ if (file.path == null) { return; }
+
+ final accessToken = await getAccessToken();
+
+ final dio = DioClient().dio;
+
+ try {
+ FormData formData = FormData.fromMap({
+ "leaveType": reason == 1 ? 'Academic' : 'Medical',
+ "startDate": DateFormat("yyyy-MM-dd").format(range.start),
+ "endDate": DateFormat("yyyy-MM-dd").format(range.end),
+ "proofDocument": await MultipartFile.fromFile(
+ file.path!,
+ filename: file.name,
+ ),
+ "bankAccountNumber": _accountNumberController.text,
+ "bankIFSCCode": _ifscController.text,
+ "bankName": _bankNameController.text,
+ "bankAccountHoldersName": _accountHolderController.text,
+ });
+
+ final response = await dio.post(
+ MessRebateEndpoints.sendApplication,
+ data: formData,
+ options: Options(
+ headers: {
+ 'Authorization': 'Bearer $accessToken',
+ "Content-Type": "multipart/form-data"
+ },
+ ),
+ );
+ if(response.statusCode==200) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ const SnackBar(content: Text("Application Request Successful!")),
+ );
+ }
+
+ } catch (e) {
+ print(e); // IMPORTANT for debugging
+ ScaffoldMessenger.of(context).showSnackBar(
+ const SnackBar(content: Text("Error")),
+ );
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return Scaffold(
+ backgroundColor: const Color(0xFFEDEDED),
+ appBar: AppBar(
+ elevation: 0,
+ backgroundColor: Colors.transparent,
+ iconTheme: const IconThemeData(color: Colors.black),
+ title: const Text(
+ "Mess Rebate",
+ style: TextStyle(color: Colors.black, fontWeight: FontWeight.bold),
+ ),
+ leading: IconButton(
+ icon: const Icon(Icons.arrow_back),
+ onPressed: () {
+ Navigator.push(
+ context,
+ MaterialPageRoute(
+ builder: (context) => const HomeScreen(),
+ ),
+ );
+ },
+ ),
+ ),
+ body: SingleChildScrollView(
+ padding: const EdgeInsets.all(16),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ _buildSection(
+ title: "Type of Leave",
+ child: Column(
+ children: [
+ RadioListTile(
+ title: const Text("Academic Leave"),
+ value: 1,
+ groupValue: _selectedValue,
+ onChanged: (val) => setState(() => _selectedValue = val),
+ ),
+ RadioListTile(
+ title: const Text("Medical Leave"),
+ value: 2,
+ groupValue: _selectedValue,
+ onChanged: (val) => setState(() => _selectedValue = val),
+ ),
+ ],
+ ),
+ ),
+ const SizedBox(height: 16),
+
+
+ _buildSection(
+ title: "Leave Duration",
+ child: ListTile(
+ leading: const Icon(Icons.calendar_month),
+ title: Text(_selectedDateRange == null
+ ? "Select Date Range"
+ : "${DateFormat('dd MMM').format(_selectedDateRange!.start)} - ${DateFormat('dd MMM').format(_selectedDateRange!.end)}"),
+ trailing: const Icon(Icons.edit),
+ onTap: _selectDateRange,
+ ),
+ ),
+ const SizedBox(height: 16),
+
+
+ _buildSection(
+ title: "Supporting Documents",
+ child: ListTile(
+ leading: const Icon(Icons.upload_file),
+ title: Text(_pickedFile == null ? "Upload File (PDF/IMG) (Max. Size - 5MB)" : _pickedFile!.name),
+ subtitle: _pickedFile != null ? Text("${(_pickedFile!.size / 1024).toStringAsFixed(2)} KB") : null,
+ trailing: const Icon(Icons.attach_file),
+ onTap: _pickFile,
+ ),
+ ),
+ const SizedBox(height: 16),
+
+ _buildSection(
+ title: "Bank Details",
+ child: Column(
+ children: [
+
+ TextField(
+ controller: _accountNumberController,
+ keyboardType: TextInputType.number,
+ decoration: const InputDecoration(
+ labelText: "Bank Account Number",
+ border: OutlineInputBorder(),
+ ),
+ ),
+
+ const SizedBox(height: 12),
+
+ TextField(
+ controller: _ifscController,
+ decoration: const InputDecoration(
+ labelText: "IFSC Code",
+ border: OutlineInputBorder(),
+ ),
+ ),
+
+ const SizedBox(height: 12),
+
+ TextField(
+ controller: _bankNameController,
+ decoration: const InputDecoration(
+ labelText: "Bank Name",
+ border: OutlineInputBorder(),
+ ),
+ ),
+
+ const SizedBox(height: 12),
+
+ TextField(
+ controller: _accountHolderController,
+ decoration: const InputDecoration(
+ labelText: "Account Holder Name",
+ border: OutlineInputBorder(),
+ ),
+ ),
+
+ ],
+ ),
+ ),
+
+ const SizedBox(height: 32),
+
+
+ ElevatedButton(
+ style: ElevatedButton.styleFrom(
+ padding: const EdgeInsets.symmetric(vertical: 16),
+ backgroundColor: Colors.blueAccent,
+ shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
+ ),
+ onPressed: () {
+
+ if (_selectedValue != null && _selectedDateRange != null && _pickedFile!=null&&_accountNumberController.text!=""&&_ifscController.text!=""&&_bankNameController.text!=""&&_accountHolderController.text!="") {
+ if ((_pickedFile!.size)/(1024*1024)>=5) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ const SnackBar(content: Text("File size limit - 5MB")),
+ );
+ }else if ((_selectedDateRange!.end.difference(_selectedDateRange!.start).inDays + 1)<5){
+ ScaffoldMessenger.of(context).showSnackBar(
+ const SnackBar(content: Text("Number of leave days must be >=5")),
+ );
+ }else {
+ _sendRequest(reason: _selectedValue!,
+ range: _selectedDateRange!,
+ file: _pickedFile!);
+ ScaffoldMessenger.of(context).showSnackBar(
+ const SnackBar(content: Text("Success!")),
+ );
+ }
+ } else {
+ ScaffoldMessenger.of(context).showSnackBar(
+ const SnackBar(content: Text("Please fill all fields")),
+ );
+ }
+ },
+ child: const Text("Submit Application", style: TextStyle(color: Colors.white, fontSize: 16)),
+ ),
+
+ const Divider(),
+
+ ElevatedButton(
+ style: ElevatedButton.styleFrom(
+ padding: const EdgeInsets.symmetric(vertical: 16),
+ backgroundColor: const Color(0xFFE3F2FD),
+ shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
+ ),
+ onPressed: () {
+ Navigator.push(
+ context,
+ MaterialPageRoute(
+ builder: (context) => const LeaveApplicationListScreen(),
+ ),
+ );
+ },
+ child: const Text("View history", style: TextStyle(color: Colors.blueAccent, fontSize: 14)),
+ ),
+
+ const SizedBox(height: 32),
+ ],
+ ),
+ ),
+ );
+ }
+
+
+ Widget _buildSection({required String title, required Widget child}) {
+ return Container(
+ decoration: BoxDecoration(
+ color: Colors.white,
+ borderRadius: BorderRadius.circular(12),
+ boxShadow: [BoxShadow(color: Colors.black.withOpacity(0.05), blurRadius: 5)],
+ ),
+ padding: const EdgeInsets.all(12),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(title, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
+ const Divider(),
+ child,
+ ],
+ ),
+ );
+ }
+}
\ No newline at end of file
diff --git a/frontend2/macos/Flutter/GeneratedPluginRegistrant.swift b/frontend2/macos/Flutter/GeneratedPluginRegistrant.swift
index 71cc0fe6..197588be 100644
--- a/frontend2/macos/Flutter/GeneratedPluginRegistrant.swift
+++ b/frontend2/macos/Flutter/GeneratedPluginRegistrant.swift
@@ -8,6 +8,7 @@ import Foundation
import connectivity_plus
import desktop_webview_window
import device_info_plus
+import file_picker
import file_selector_macos
import firebase_analytics
import firebase_auth
@@ -29,6 +30,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin"))
DesktopWebviewWindowPlugin.register(with: registry.registrar(forPlugin: "DesktopWebviewWindowPlugin"))
DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin"))
+ FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin"))
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
FirebaseAnalyticsPlugin.register(with: registry.registrar(forPlugin: "FirebaseAnalyticsPlugin"))
FLTFirebaseAuthPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAuthPlugin"))
diff --git a/frontend2/pubspec.lock b/frontend2/pubspec.lock
index 5f3ed86c..522d07dd 100644
--- a/frontend2/pubspec.lock
+++ b/frontend2/pubspec.lock
@@ -53,10 +53,10 @@ packages:
dependency: transitive
description:
name: characters
- sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
+ sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
url: "https://pub.dev"
source: hosted
- version: "1.4.0"
+ version: "1.4.1"
checked_yaml:
dependency: transitive
description:
@@ -225,6 +225,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "7.0.1"
+ file_picker:
+ dependency: "direct main"
+ description:
+ name: file_picker
+ sha256: ab13ae8ef5580a411c458d6207b6774a6c237d77ac37011b13994879f68a8810
+ url: "https://pub.dev"
+ source: hosted
+ version: "8.3.7"
file_selector_linux:
dependency: transitive
description:
@@ -724,18 +732,18 @@ packages:
dependency: transitive
description:
name: matcher
- sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
+ sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6"
url: "https://pub.dev"
source: hosted
- version: "0.12.17"
+ version: "0.12.18"
material_color_utilities:
dependency: transitive
description:
name: material_color_utilities
- sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
+ sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
url: "https://pub.dev"
source: hosted
- version: "0.11.1"
+ version: "0.13.0"
meta:
dependency: transitive
description:
@@ -1089,10 +1097,10 @@ packages:
dependency: transitive
description:
name: test_api
- sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
+ sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636"
url: "https://pub.dev"
source: hosted
- version: "0.7.7"
+ version: "0.7.9"
timezone:
dependency: transitive
description:
diff --git a/frontend2/pubspec.yaml b/frontend2/pubspec.yaml
index 590afacc..86aac5c3 100644
--- a/frontend2/pubspec.yaml
+++ b/frontend2/pubspec.yaml
@@ -37,6 +37,7 @@ dependencies:
flutter_easyloading: ^3.0.5
shared_preferences: ^2.3.2
http: ^1.2.2
+ file_picker: ^8.0.0
google_sign_in: ^6.2.2
firebase_auth: ^5.4.0
firebase_core: ^3.10.1
diff --git a/server/v1/.gitignore b/server/v1/.gitignore
index eaeffc46..d8eab98a 100644
--- a/server/v1/.gitignore
+++ b/server/v1/.gitignore
@@ -1,3 +1,4 @@
node_modules/
.env
-.secrets/
\ No newline at end of file
+.secrets/
+modules/leave/uploads
\ No newline at end of file
diff --git a/server/v1/index.js b/server/v1/index.js
index 850c07d9..e8134175 100644
--- a/server/v1/index.js
+++ b/server/v1/index.js
@@ -12,6 +12,7 @@ const feedbackRoute = require("./modules/feedback/feedbackRoute.js");
const hostelRoute = require("./modules/hostel/hostelRoute.js");
const notificationRoute = require("./modules/notification/notificationRoute.js");
const messRoute = require("./modules/mess/messRoute.js");
+const leaveRoute = require("./modules/leave/leaveRoute.js");
const logsRoute = require("./modules/mess/ScanLogsRoute.js");
const bugReportRoute = require("./modules/bug_report/bugReportRoute.js");
const cors = require("cors");
@@ -209,6 +210,9 @@ app.use("/api/notification", notificationRoute);
// Mess route
app.use("/api/mess", messRoute);
+//Mess rebate route
+app.use("/api/leave", leaveRoute);
+
//mess change route
app.use("/api/mess-change", messChangeRouter);
diff --git a/server/v1/modules/leave/leaveController.js b/server/v1/modules/leave/leaveController.js
new file mode 100644
index 00000000..3c6d08ce
--- /dev/null
+++ b/server/v1/modules/leave/leaveController.js
@@ -0,0 +1,519 @@
+const Leave = require("./leaveModel.js");
+const multer = require("multer");
+const path = require("path");
+const fs = require("fs");
+const mongoose = require("mongoose");
+
+const uploadDir = path.join(__dirname, ".", "uploads");
+
+if (!fs.existsSync(uploadDir)) {
+ fs.mkdirSync(uploadDir, { recursive: true });
+}
+
+// const storage = multer.diskStorage({
+// destination: (req, file, cb) => {
+// cb(null, uploadDir);
+// },
+// filename: (req, file, cb) => {
+// const timeStamp = Date.now();
+// cb(null, `leave-${req.user?req.user._id:req.hostel._id}-${timeStamp}-${file.originalname}`);
+// },
+// });
+
+const upload = multer({
+ storage: multer.memoryStorage(),
+ limits: { fileSize: 5 * 1024 * 1024 }, // 5MB limit
+ fileFilter: (req, file, cb) => {
+ const allowedTypes = /jpeg|jpg|png|pdf/;
+ const extname = allowedTypes.test(
+ path.extname(file.originalname).toLowerCase(),
+ );
+ const mimetype = allowedTypes.test(file.mimetype);
+
+ if (mimetype && extname) {
+ return cb(null, true);
+ } else {
+ cb(new Error("UNSUPPORTED_FILE_TYPE"));
+ }
+ },
+});
+
+const uploadMiddleware = async (req, res, next) => {
+ //console.log("Started uploading document to server");
+ upload.single("proofDocument")(req, res, (err) => {
+ if (err) {
+ if (err.message == "UNSUPPORTED_FILE_TYPE") {
+ res.status(400).json({
+ message: "Invalid file Type. Only jpg, pdf, png are allowed!",
+ error: err.message,
+ });
+ return;
+ }
+ }
+ //console.log("Passing file to Onedrive Uploader System");
+ next();
+ });
+};
+
+// Get all approved leaves for a specific month/year
+async function getRebateDaysForMonth(messHostelId, month, year) {
+ const startOfMonth = new Date(year, month - 1, 1);
+ const endOfMonth = new Date(year, month, 0, 23, 59, 59);
+ let query = {};
+
+
+ //Find approved leaves in leaves database
+ const leaves = await Leave.find({
+ messHostel: messHostelId,
+ status: "approved",
+ isEligibleForRebate: true,
+ $or: [{ startDate: { $lte: endOfMonth }, endDate: { $gte: startOfMonth } }],
+ }).populate("user", "name rollNumber -_id");
+
+ query.totalRebateDays = leaves.reduce(
+ (sum, leave) => sum + leave.eligibleDays,
+ 0,
+ );
+ query.eligibleApplications = leaves;
+
+ return query;
+}
+
+
+//Validation of presence of all fields before uploading file to onedrive
+const validateApply = async (req, res, next) => {
+ const fields = [
+ "leaveType",
+ "startDate",
+ "endDate",
+ "bankAccountNumber",
+ "bankIFSCCode",
+ "bankName",
+ "bankAccountHoldersName",
+ ]
+
+ const missingFields = fields.filter(field => !req.body[field]);
+
+ if(missingFields.length>0) {
+ return res.status(400).json({
+ message: "Fields cannot be empty",
+ emptyFields: missingFields,
+ })
+ }
+
+ //console.log(req.body)
+
+ next();
+};
+
+
+//Apply for leave(Student endpoint)
+const applyForLeave = async (req, res) => {
+ try {
+ const {
+ leaveType,
+ startDate,
+ endDate,
+ bankAccountNumber,
+ bankIFSCCode,
+ bankName,
+ bankAccountHoldersName,
+ } = req.body;
+ //No error handling required due to presence of validateApply()
+
+ //File handling
+ if (!req.file) {
+ res.status(400).json({
+ message: "Please upload proof document",
+ });
+ return;
+ }
+
+ //Obtain url and fileName generated by uploadToOnedrive.js
+ const proofDocumentUrl = req.file.leaveUrl;
+ const proofDocumentFilename = req.file.originalname;
+
+ //Genration of Date object
+ const [startYear, startMonth, startDay] = startDate.split("-").map(Number);
+ const [endYear, endMonth, endDay] = endDate.split("-").map(Number);
+ const start = new Date(startYear, startMonth - 1, startDay);
+ const end = new Date(endYear, endMonth - 1, endDay);
+
+ //Get difference between two dates
+ const diffBtwDates = Math.abs(end - start);
+ const numberOfDays = Math.floor(diffBtwDates / (1000 * 60 * 60 * 24));
+
+ //numberofdays business logic
+
+ if (numberOfDays < 5) {
+ res.status(400).json({
+ message: "Number of days must be greater than 5",
+ });
+ return;
+ }
+
+ let eligibleDays = 0;
+ //Calculation of eligible days
+ if (numberOfDays >= 5) {
+ eligibleDays = numberOfDays;
+ }
+
+ //Validation Rules / Business logic
+ if (!(leaveType == "Academic" || leaveType == "Medical")) {
+ res.status(400).json({
+ message: "Leave type is invalid",
+ });
+ return;
+ }
+
+ if (end - start < 0) {
+ res.status(400).json({
+ message: "Start, end date combination is invalid",
+ });
+ return;
+ }
+
+ //Autogenerated variables
+ const appliedAt = new Date(Date.now());
+
+ //Only if user exists
+ if (req.user && req.user.curr_subscribed_mess) {
+ const leaveApplication = new Leave({
+ user: req.user,
+ leaveType,
+ startDate: start,
+ endDate: end,
+ numberOfDays,
+ eligibleDays,
+ status: "pending",
+ proofDocumentUrl,
+ proofDocumentFilename,
+ appliedAt,
+ messHostel: req.user.curr_subscribed_mess,
+ bankAccountNumber,
+ bankIFSCCode,
+ bankName,
+ bankAccountHoldersName,
+ });
+
+ await leaveApplication.save();
+
+ res.status(201).json({
+ message: "Leave Application submited successfully",
+ leaveApplication: {
+ id: leaveApplication._id,
+ user: req.user,
+ eligibleDays,
+ status: leaveApplication.status,
+ appliedAt: leaveApplication.appliedAt,
+ },
+ });
+ } else {
+ if (req.user && !req.user.curr_subscribed_mess) {
+ res.status(400).json({
+ message: "Hostel not provided",
+ });
+ }
+ res.status(400).json({
+ message: "Please login first",
+ });
+ }
+ } catch (err) {
+ console.error("Error submitting leave application");
+ res.status(500).json({
+ message: "Error submitting leave application",
+ error: err.message,
+ });
+ }
+};
+
+const getApplications = async (req, res) => {
+ //Search by User ObjectID
+ const myApplications = await Leave.find({
+ user: req.user,
+ }).sort({
+ appliedAt: -1,
+ });
+
+ //For empty applications array
+ if (myApplications.length === 0) {
+ res.status(200).json({
+ message: "No past applications available",
+ });
+ return;
+ }
+
+ res.status(200).json({
+ message: "Retrieved applications successfully",
+ myApplications,
+ });
+ return;
+};
+
+const getApplicationByID = async (req, res) => {
+ const { id } = req.params;
+ //Search by Application ObjectID
+ if (!mongoose.Types.ObjectId.isValid(id)) {
+ res.status(400).json({
+ message: "Incorrect Object ID format",
+ });
+ return;
+ } else {
+ try {
+ let application = await Leave.findById(id);
+
+ if (!application.user.equals(req.user._id)) {
+ application = null;
+ }
+
+ //For empty appplication variable
+ if (application == null) {
+ res.status(404).json({
+ message: "There are no such leave applications",
+ });
+ } else {
+ res.status(200).json({
+ message: "Application retrieved successfully",
+ application,
+ });
+ }
+ } catch (err) {
+ res.status(500).json({
+ message: "Invalid request",
+ error: err.message,
+ });
+ }
+ }
+};
+
+const getApplicationProof = async (req, res) => {
+ const { id } = req.params;
+ //Retrieval of proofDocumentUrl from application ObjectID
+ if (!mongoose.Types.ObjectId.isValid(id)) {
+ res.status(400).json({
+ message: "Incorrect Object ID format",
+ });
+ return;
+ } else {
+ try {
+ const application = await Leave.findOne({
+ _id: id,
+ user: req.user,
+ });
+ if (application == null) {
+ res.status(404).json({
+ message: "There are no such leave applications",
+ });
+ } else {
+ res.status(200).json({
+ message: "Information retrieved successfully",
+ proofDocumentUrl: application.proofDocumentUrl,
+ });
+ }
+ } catch (err) {
+ res.status(500).json({
+ message: "Invalid request",
+ error: err.message,
+ });
+ }
+ }
+};
+
+const getAllPendingApplications = async (req, res) => {
+ const pendingApplications = await Leave.find({
+ messHostel: req.hostel,
+ status: "pending",
+ })
+ .sort({
+ appliedAt: -1,
+ })
+ .populate("user", "name rollNumber email -_id");
+
+ //For empty applications array
+ if (pendingApplications.length === 0) {
+ res.status(404).json({
+ message: "No applications available",
+ });
+ return;
+ }
+
+ res.status(200).json({
+ message: "Retrieved applications successfully",
+ pendingApplications,
+ });
+ return;
+};
+
+const filterApplications = async (req, res) => {
+ const query = {
+ messHostel: req.hostel,
+ };
+
+ if (req.query.status) {
+ query.status = req.query.status;
+ }
+
+
+ if (req.query.month && req.query.year) {
+ let month = parseInt(req.query.month);
+ let year = parseInt(req.query.year);
+ if(!(month>0 && month <=12)) {
+ return res.status(400).json({
+ message: "Wrong month format",
+ })
+ }
+ query.startDate = {
+ $gte: new Date(year, month - 1, 1),
+ $lt: new Date(year, month, 1),
+ };
+ } else if (req.query.year && !req.query.month) {
+ query.startDate = {
+ $gte: new Date(req.query.year, 0, 1),
+ $lt: new Date(req.query.year + 1, 0, 1),
+ };
+ }
+ else if (req.query.month && !req.query.year) {
+ res.status(404).json({
+ message: "Please input both year and month",
+ });
+ return;
+ }
+
+ try {
+ const filteredApplications = await Leave.find(query).sort({
+ appliedAt: -1,
+ });
+
+ if (filteredApplications.length === 0) {
+ res.status(404).json({
+ message: "No such applications could be found",
+ });
+ return;
+ }
+
+ res.status(200).json({
+ message: "Documents fetched successfully",
+ filteredApplications,
+ });
+ } catch (err) {
+ res.status(500).json({
+ message: "Error occured while retrieving leave applications",
+ error: err.message,
+ });
+ }
+};
+
+const approveApplication = async (req, res) => {
+ const { id } = req.params;
+
+ try {
+ let application = await Leave.findById(id);
+ if (!application.messHostel.equals(req.hostel._id)) {
+ application = null;
+ }
+
+ if (application !== null) {
+ const query = { status: "approved" };
+ if (req.body.feedback) {
+ query.feedback = req.body.feedback;
+ }
+
+ if (application.eligibleDays >= 5) {
+ console.log("Marking eligible for rebate = true");
+ query.isEligibleForRebate = true;
+ }
+
+ const updatedDoc = await Leave.findByIdAndUpdate(id, query, {new: true}).populate("user", "name rollNumber email -_id");
+
+ res.status(201).json({
+ message: `Approved application with ID ${id}`,
+ updatedApplication: updatedDoc,
+ });
+ } else {
+ res.status(401).json({
+ message: "You are not authorised to do that",
+ });
+ }
+ } catch (err) {
+ res.status(500).json({
+ message: "Error in approving the leave application",
+ error: err.message,
+ });
+ }
+};
+
+const rejectApplication = async (req, res) => {
+ const { id } = req.params;
+
+ try {
+ let application = await Leave.findById(id);
+ if (!application.messHostel.equals(req.hostel._id)) {
+ application = null;
+ }
+
+ if (application !== null) {
+ const query = { status: "rejected" };
+ if (req.body.feedback) {
+ query.feedback = req.body.feedback;
+ }
+
+ const updatedDoc = await Leave.findByIdAndUpdate(id, query, {new: true}).populate("user", "name rollNumber email -_id");
+
+ res.status(201).json({
+ message: `Rejected application with ID ${id}`,
+ updatedApplication: updatedDoc,
+ });
+ } else {
+ res.status(401).json({
+ message: "You are not authorised to do that",
+ });
+ }
+ } catch (err) {
+ res.status(500).json({
+ message: "Error in approving the leave application",
+ error: err.message,
+ });
+ }
+};
+
+const getRebateSummary = async (req, res) => {
+ const hostel = req.hostel._id;
+
+ if (!(req.query.month && req.query.year)) {
+ return res.status(400).json({
+ message: "Year or month not specified",
+ });
+ }
+
+ let year = parseInt(req.query.year);
+ let month = parseInt(req.query.month);
+
+ if(!(month>0 && month<=12)) {
+ return res.status(400).json({
+ message: "Invalid month format"
+ })
+ }
+
+ console.log(`month = ${month} and year = ${year}`);
+
+ const result = await getRebateDaysForMonth(hostel, month, year);
+
+ res.status(200).json({
+ message: "Rebate summary retrieved successfully",
+ rebateSummary: result,
+ });
+};
+
+module.exports = {
+ uploadMiddleware,
+ applyForLeave,
+ getApplications,
+ getApplicationByID,
+ getApplicationProof,
+ getAllPendingApplications,
+ filterApplications,
+ approveApplication,
+ rejectApplication,
+ getRebateSummary,
+ validateApply
+};
diff --git a/server/v1/modules/leave/leaveModel.js b/server/v1/modules/leave/leaveModel.js
new file mode 100644
index 00000000..7f0ad78c
--- /dev/null
+++ b/server/v1/modules/leave/leaveModel.js
@@ -0,0 +1,85 @@
+const mongoose = require("mongoose");
+
+//Define Leave Application Model
+const leaveSchema = new mongoose.Schema({
+ user: {
+ type: mongoose.Schema.Types.ObjectId,
+ ref: "User",
+ required: true,
+ },
+ leaveType: {
+ type: String,
+ enum: ["Academic", "Medical"],
+ required: true,
+ },
+ startDate: {
+ type: Date,
+ required: true,
+ },
+ endDate: {
+ type: Date,
+ required: true,
+ },
+ numberOfDays: {
+ type: Number,
+ required: true,
+ },
+ eligibleDays: {
+ type: Number,
+ required: true,
+ },
+ status: {
+ type: String,
+ enum: ["pending", "approved", "rejected"],
+ default: "pending",
+ required: true,
+ },
+ proofDocumentUrl: {
+ type: String,
+ required: true,
+ },
+ proofDocumentFilename: {
+ type: String,
+ required: true,
+ },
+ appliedAt: {
+ type: Date,
+ required: true,
+ },
+ feedback: {
+ type: String,
+ required: false,
+ },
+ messHostel: {
+ type: mongoose.Schema.Types.ObjectId,
+ ref: "Hostel",
+ required: true,
+ },
+ isEligibleForRebate: {
+ type: Boolean,
+ default: false,
+ required: true
+ },
+ bankAccountNumber: {
+ type: Number,
+ required: true,
+ },
+ bankIFSCCode: {
+ type: String,
+ required: true,
+ },
+ bankName: {
+ type: String,
+ required: true,
+ },
+ bankAccountHoldersName: {
+ type: String,
+ required: true,
+ },
+});
+
+
+const Leave = mongoose.model("Leave", leaveSchema);
+
+//Export leave application model
+module.exports = Leave;
\ No newline at end of file
diff --git a/server/v1/modules/leave/leaveRoute.js b/server/v1/modules/leave/leaveRoute.js
new file mode 100644
index 00000000..a0e488b9
--- /dev/null
+++ b/server/v1/modules/leave/leaveRoute.js
@@ -0,0 +1,47 @@
+const express = require("express");
+
+const {
+ authenticateJWT,
+ authenticateAdminJWT,
+} = require("../../middleware/authenticateJWT.js");
+
+const { uploadToOnedrive } = require('./uploadToOnedrive.js')
+
+const {
+ uploadMiddleware,
+ applyForLeave,
+ getApplications,
+ getApplicationByID,
+ getApplicationProof,
+ getAllPendingApplications,
+ filterApplications,
+ approveApplication,
+ rejectApplication,
+ getRebateSummary,
+ validateApply
+} = require("./leaveController.js");
+
+const leaveRouter = express.Router();
+
+//User/Student Endpoint
+
+leaveRouter.post('/apply', authenticateJWT, uploadMiddleware, validateApply, uploadToOnedrive, applyForLeave);
+
+leaveRouter.get('/my-applications', authenticateJWT, getApplications);
+
+leaveRouter.get('/:id', authenticateJWT, getApplicationByID);
+
+leaveRouter.get('/:id/proof', authenticateJWT, getApplicationProof);
+
+//Hostel Office Endpoints
+leaveRouter.get('/hostel/pending', authenticateAdminJWT, getAllPendingApplications);
+
+leaveRouter.get('/hostel/all', authenticateAdminJWT, filterApplications);
+
+leaveRouter.post('/:id/approve', authenticateAdminJWT, approveApplication);
+
+leaveRouter.post('/:id/reject', authenticateAdminJWT, rejectApplication);
+
+leaveRouter.get('/hostel/rebate-summary', authenticateAdminJWT, getRebateSummary);
+
+module.exports = leaveRouter;
diff --git a/server/v1/modules/leave/uploadToOnedrive.js b/server/v1/modules/leave/uploadToOnedrive.js
new file mode 100644
index 00000000..67ace0d9
--- /dev/null
+++ b/server/v1/modules/leave/uploadToOnedrive.js
@@ -0,0 +1,181 @@
+const axios = require('axios');
+const { getDelegatedAccessToken } = require('../../utils/delegatedGraphAuth.js');
+require('dotenv').config();
+
+const LEAVE_FOLDER_ID = process.env.ONEDRIVE_LEAVE_FOLDER_ID;
+
+async function requireDelegatedToken() {
+ const tok = await getDelegatedAccessToken();
+ if (!tok) {
+ throw new Error(
+ "Delegated token not available. Login as storage user and seed access+refresh tokens via /api/_debug/graph/delegated-token."
+ );
+ }
+ return tok;
+}
+
+async function graphGET(url, token, config = {}) {
+ const { data } = await axios.get(url, {
+ ...config,
+ headers: { ...(config.headers || {}), Authorization: `Bearer ${token}` },
+ });
+ return data;
+}
+
+async function graphPUT(url, token, body, headers = {}) {
+ const { data } = await axios.put(url, body, {
+ headers: { Authorization: `Bearer ${token}`, ...headers },
+ maxContentLength: Infinity,
+ maxBodyLength: Infinity,
+ });
+ return data;
+}
+
+async function graphPOST(url, token, body, headers = {}) {
+ const { data } = await axios.post(url, body, {
+ headers: { Authorization: `Bearer ${token}`, ...headers },
+ });
+ return data;
+}
+
+function extFromMime(mime) {
+ if (!mime) return ".jpg";
+ if (mime.includes("png")) return ".png";
+ if (mime.includes("jpeg")) return ".jpg";
+ if (mime.includes("jpg")) return ".jpg";
+ if (mime.includes("webp")) return ".webp";
+ return ".jpg";
+}
+
+async function getMe(token) {
+ return graphGET("https://graph.microsoft.com/v1.0/me", token);
+}
+
+async function getMyDrive(token) {
+ return graphGET("https://graph.microsoft.com/v1.0/me/drive", token);
+}
+
+async function getItemById(token, itemId) {
+ const url = `https://graph.microsoft.com/v1.0/me/drive/items/${itemId}?$select=id,name,parentReference,webUrl`;
+ return graphGET(url, token);
+}
+
+async function uploadToParentByName(
+ token,
+ parentId,
+ filename,
+ buffer,
+ mimeType
+) {
+ // console.log(filename, buffer, mimeType);
+ const url = `https://graph.microsoft.com/v1.0/me/drive/items/${parentId}:/${encodeURIComponent(
+ filename
+ )}:/content`;
+
+ const data = await graphPUT(url, token, buffer, {
+ "Content-Type": mimeType || "application/octet-stream",
+ });
+
+ return data; // driveItem
+}
+
+async function createOrganizationViewLink(token, itemId) {
+ const url = `https://graph.microsoft.com/v1.0/me/drive/items/${itemId}/createLink`;
+ const data = await graphPOST(
+ url,
+ token,
+ { type: "view", scope: "organization" },
+ { "Content-Type": "application/json" }
+ );
+
+ return data?.link?.webUrl;
+}
+
+
+
+async function uploadToOnedrive(req, res, next) {
+ try {
+ const file = req.file;
+ //console.log("File received by Onedrive Uploader");
+ if (!file) return res.status(400).json({ message: "No file uploaded" });
+ if (!LEAVE_FOLDER_ID) {
+ return res
+ .status(400)
+ .json({ message: "ONEDRIVE_LEAVE_FOLDER_ID is not configured" });
+ }
+
+ const ext = extFromMime(file.mimetype);
+ const timeStamp = Date.now()
+ const targetName = `leave-${req.user._id}-${timeStamp}-${file.originalname}`;
+
+ // Delegated token required to use /me/drive
+ const token = await requireDelegatedToken();
+
+ // Sanity checks: who am I? which drive? does folder exist?
+ let me, drive, parentItem;
+ try {
+ me = await getMe(token);
+ } catch (e) {}
+
+ try {
+ drive = await getMyDrive(token);
+ } catch (e) {}
+
+ try {
+ parentItem = await getItemById(token, LEAVE_FOLDER_ID);
+ if (
+ drive?.id &&
+ parentItem?.parentReference?.driveId &&
+ drive.id !== parentItem.parentReference.driveId
+ ) {
+ return res.status(400).json({
+ message:
+ "Configured folder belongs to a different drive than the token user's drive.",
+ });
+ }
+ } catch (e) {
+ // Parent folder lookup failed
+ return res.status(400).json({
+ message:
+ "Configured ONEDRIVE_LEAVE_FOLDER_ID not found or not accessible for this account.",
+ hint: "Fetch it with GET /v1.0/me/drive/root:/HAB%20App/rebate-requests and use the returned id.",
+ });
+ }
+ console.log(`Starting upload to onedrive to ${targetName} for user: ${req.user?.name}`)
+ // Upload new content to the parent folder with file name = roll.ext
+ const uploaded = await uploadToParentByName(
+ token,
+ LEAVE_FOLDER_ID,
+ targetName,
+ file.buffer,
+ file.mimetype
+ );
+
+ //console.log("Uplaoding to onedrive successful.");
+ //console.log("Creating organization view link");
+
+ // Create org-scoped view link (tenant must allow it)
+ let publicUrl = null;
+ try {
+ publicUrl = await createOrganizationViewLink(token, uploaded.id);
+ } catch (e) {
+ return res.status(401).json({
+ message: "Error in creating public link. Please try again"
+ })
+ }
+
+ req.file.leaveId = uploaded.id;
+ if (publicUrl) req.file.leaveUrl = publicUrl;
+
+ console.log("Uploading to onedrive successful");
+ next();
+ } catch (err) {
+ const status = err.response?.status;
+ const msg = err.response?.data?.error?.message || err.message;
+ return res
+ .status(status === 403 ? 403 : 500)
+ .json({ message: "Failed to upload leave application", error: msg, status });
+ }
+}
+
+module.exports = {uploadToOnedrive};
\ No newline at end of file
diff --git a/server/v1/package-lock.json b/server/v1/package-lock.json
index 6af07f8b..efdcfe92 100644
--- a/server/v1/package-lock.json
+++ b/server/v1/package-lock.json
@@ -3157,86 +3157,6 @@
"url": "https://opencollective.com/mongoose"
}
},
- "node_modules/mongoose/node_modules/agent-base": {
- "version": "6.0.2",
- "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
- "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
- "license": "MIT",
- "optional": true,
- "peer": true,
- "dependencies": {
- "debug": "4"
- },
- "engines": {
- "node": ">= 6.0.0"
- }
- },
- "node_modules/mongoose/node_modules/debug": {
- "version": "4.4.3",
- "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
- "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
- "license": "MIT",
- "optional": true,
- "peer": true,
- "dependencies": {
- "ms": "^2.1.3"
- },
- "engines": {
- "node": ">=6.0"
- },
- "peerDependenciesMeta": {
- "supports-color": {
- "optional": true
- }
- }
- },
- "node_modules/mongoose/node_modules/gaxios": {
- "version": "5.1.3",
- "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-5.1.3.tgz",
- "integrity": "sha512-95hVgBRgEIRQQQHIbnxBXeHbW4TqFk4ZDJW7wmVtvYar72FdhRIo1UGOLS2eRAKCPEdPBWu+M7+A33D9CdX9rA==",
- "license": "Apache-2.0",
- "optional": true,
- "peer": true,
- "dependencies": {
- "extend": "^3.0.2",
- "https-proxy-agent": "^5.0.0",
- "is-stream": "^2.0.0",
- "node-fetch": "^2.6.9"
- },
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/mongoose/node_modules/gcp-metadata": {
- "version": "5.3.0",
- "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-5.3.0.tgz",
- "integrity": "sha512-FNTkdNEnBdlqF2oatizolQqNANMrcqJt6AAYt99B3y1aLLC8Hc5IOBb+ZnnzllodEEf6xMBp6wRcBbc16fa65w==",
- "license": "Apache-2.0",
- "optional": true,
- "peer": true,
- "dependencies": {
- "gaxios": "^5.0.0",
- "json-bigint": "^1.0.0"
- },
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/mongoose/node_modules/https-proxy-agent": {
- "version": "5.0.1",
- "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
- "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
- "license": "MIT",
- "optional": true,
- "peer": true,
- "dependencies": {
- "agent-base": "6",
- "debug": "4"
- },
- "engines": {
- "node": ">= 6"
- }
- },
"node_modules/mongoose/node_modules/mongodb": {
"version": "6.18.0",
"resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.18.0.tgz",
diff --git a/server/v2/package-lock.json b/server/v2/package-lock.json
index 7ff856a4..7a21a1ae 100644
--- a/server/v2/package-lock.json
+++ b/server/v2/package-lock.json
@@ -3157,86 +3157,6 @@
"url": "https://opencollective.com/mongoose"
}
},
- "node_modules/mongoose/node_modules/agent-base": {
- "version": "6.0.2",
- "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
- "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
- "license": "MIT",
- "optional": true,
- "peer": true,
- "dependencies": {
- "debug": "4"
- },
- "engines": {
- "node": ">= 6.0.0"
- }
- },
- "node_modules/mongoose/node_modules/debug": {
- "version": "4.4.3",
- "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
- "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
- "license": "MIT",
- "optional": true,
- "peer": true,
- "dependencies": {
- "ms": "^2.1.3"
- },
- "engines": {
- "node": ">=6.0"
- },
- "peerDependenciesMeta": {
- "supports-color": {
- "optional": true
- }
- }
- },
- "node_modules/mongoose/node_modules/gaxios": {
- "version": "5.1.3",
- "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-5.1.3.tgz",
- "integrity": "sha512-95hVgBRgEIRQQQHIbnxBXeHbW4TqFk4ZDJW7wmVtvYar72FdhRIo1UGOLS2eRAKCPEdPBWu+M7+A33D9CdX9rA==",
- "license": "Apache-2.0",
- "optional": true,
- "peer": true,
- "dependencies": {
- "extend": "^3.0.2",
- "https-proxy-agent": "^5.0.0",
- "is-stream": "^2.0.0",
- "node-fetch": "^2.6.9"
- },
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/mongoose/node_modules/gcp-metadata": {
- "version": "5.3.0",
- "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-5.3.0.tgz",
- "integrity": "sha512-FNTkdNEnBdlqF2oatizolQqNANMrcqJt6AAYt99B3y1aLLC8Hc5IOBb+ZnnzllodEEf6xMBp6wRcBbc16fa65w==",
- "license": "Apache-2.0",
- "optional": true,
- "peer": true,
- "dependencies": {
- "gaxios": "^5.0.0",
- "json-bigint": "^1.0.0"
- },
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/mongoose/node_modules/https-proxy-agent": {
- "version": "5.0.1",
- "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
- "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
- "license": "MIT",
- "optional": true,
- "peer": true,
- "dependencies": {
- "agent-base": "6",
- "debug": "4"
- },
- "engines": {
- "node": ">= 6"
- }
- },
"node_modules/mongoose/node_modules/mongodb": {
"version": "6.18.0",
"resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.18.0.tgz",