diff --git a/guard_app/package-lock.json b/guard_app/package-lock.json index 02752fca3..9dabba89d 100644 --- a/guard_app/package-lock.json +++ b/guard_app/package-lock.json @@ -22,11 +22,14 @@ "expo-dev-client": "~6.0.21", "expo-device": "~8.0.10", "expo-document-picker": "~14.0.8", + "expo-file-system": "^56.0.7", "expo-image-picker": "~17.0.11", "expo-localization": "~17.0.8", "expo-location": "~19.0.8", "expo-modules-autolinking": "~3.0.22", "expo-notifications": "~0.32.17", + "expo-print": "^56.0.3", + "expo-sharing": "^56.0.13", "expo-status-bar": "~3.0.9", "i18next": "^26.0.6", "react": "19.1.0", @@ -6785,9 +6788,9 @@ } }, "node_modules/expo-file-system": { - "version": "19.0.22", - "resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-19.0.22.tgz", - "integrity": "sha512-l9pgahSc7sJD0bP9vBNeXvZjy8QKDpVHVxWmei/ESQOrzmoj5BidziqLVsyZdxsi+PfdbTtttLTAmddH/JafYA==", + "version": "56.0.7", + "resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-56.0.7.tgz", + "integrity": "sha512-dcKzo8ShPloM7jgfnMcJStgQebhP8owVjCkNI/aX6NMFV1CYB8bxKGMdnzJ3mXk5nfaiW+F/lSKr2UIJ02WAUA==", "peerDependencies": { "expo": "*", "react-native": "*" @@ -6925,6 +6928,15 @@ "react-native": "*" } }, + "node_modules/expo-print": { + "version": "56.0.3", + "resolved": "https://registry.npmjs.org/expo-print/-/expo-print-56.0.3.tgz", + "integrity": "sha512-ByGKQuvYk/hDjqDGD81lUepSyAyd037XHGL90zZDMLC9vcRyGyaW5iX6QruK39JOZsF90rxV7Eu4syQxhBxSvQ==", + "peerDependencies": { + "expo": "*", + "react-native": "*" + } + }, "node_modules/expo-server": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/expo-server/-/expo-server-1.0.6.tgz", @@ -6933,6 +6945,94 @@ "node": ">=20.16.0" } }, + "node_modules/expo-sharing": { + "version": "56.0.13", + "resolved": "https://registry.npmjs.org/expo-sharing/-/expo-sharing-56.0.13.tgz", + "integrity": "sha512-Yz6mBSqbU5dM6UzCjkKr1+B0JKADRuGj4Dokgs2fLysRTyjvO4mbmSTyY7AGQx3VYz0IUMoyPg4zqvsWPEFeDg==", + "dependencies": { + "@expo/config-plugins": "^56.0.8", + "@expo/config-types": "^56.0.5", + "@expo/plist": "^0.7.0" + }, + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" + } + }, + "node_modules/expo-sharing/node_modules/@expo/config-plugins": { + "version": "56.0.8", + "resolved": "https://registry.npmjs.org/@expo/config-plugins/-/config-plugins-56.0.8.tgz", + "integrity": "sha512-phTuyBhgVLfqUHMjQkAfRtbyoY6yTxoKja1awtpVnEkoJDxPJuXx1KX5uvq1eZtt4bJQ08OBJ6P95INqRSHpRg==", + "dependencies": { + "@expo/config-types": "^56.0.5", + "@expo/json-file": "~10.2.0", + "@expo/plist": "^0.7.0", + "@expo/require-utils": "^56.1.3", + "@expo/sdk-runtime-versions": "^1.0.0", + "chalk": "^4.1.2", + "debug": "^4.3.5", + "getenv": "^2.0.0", + "glob": "^13.0.0", + "semver": "^7.5.4", + "slugify": "^1.6.6", + "xcode": "^3.0.1", + "xml2js": "0.6.0" + } + }, + "node_modules/expo-sharing/node_modules/@expo/config-types": { + "version": "56.0.5", + "resolved": "https://registry.npmjs.org/@expo/config-types/-/config-types-56.0.5.tgz", + "integrity": "sha512-GsAHO/MwW9ZRdgnmyfRXqVGLCP/zejD6rWnp5OROp8mBGRObKm4HfrjlUyT1skjMwCj1OrURx9ZfIc6yeBAkIA==" + }, + "node_modules/expo-sharing/node_modules/@expo/json-file": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/@expo/json-file/-/json-file-10.2.0.tgz", + "integrity": "sha512-S6XzKe3R9GQeHiUPXc3xJjOv2VJhOEwFYf7xdC2z2cUqt3kZJ9mSO877sNQloVdnW/SUCtPY3bexlM7nwq+CAQ==", + "dependencies": { + "@babel/code-frame": "^7.20.0", + "json5": "^2.2.3" + } + }, + "node_modules/expo-sharing/node_modules/@expo/plist": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@expo/plist/-/plist-0.7.0.tgz", + "integrity": "sha512-vrpryU1GoqSIRNqRB2D3IjXDmzNYfiQpEF6AH/xknlD7eiYmEDt3mb26V7cLcedcPG8PY/1xWHdBXVQJfEAh6Q==", + "dependencies": { + "@xmldom/xmldom": "^0.8.8", + "base64-js": "^1.5.1", + "xmlbuilder": "^15.1.1" + } + }, + "node_modules/expo-sharing/node_modules/@expo/require-utils": { + "version": "56.1.3", + "resolved": "https://registry.npmjs.org/@expo/require-utils/-/require-utils-56.1.3.tgz", + "integrity": "sha512-KyLeOn/zzQSvuPpV5YhB/FPKnpQytno4luN918bGdPDssLBoS3N/0UbC3W0rJAn9kSFu+XpfR81eABRVsSdfgQ==", + "dependencies": { + "@babel/code-frame": "^7.20.0", + "@babel/core": "^7.25.2", + "@babel/plugin-transform-modules-commonjs": "^7.24.8" + }, + "peerDependencies": { + "typescript": "^5.0.0 || ^5.0.0-0 || ^6.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/expo-sharing/node_modules/semver": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", + "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/expo-status-bar": { "version": "3.0.9", "resolved": "https://registry.npmjs.org/expo-status-bar/-/expo-status-bar-3.0.9.tgz", @@ -6987,6 +7087,15 @@ "react-native": "*" } }, + "node_modules/expo/node_modules/expo-file-system": { + "version": "19.0.22", + "resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-19.0.22.tgz", + "integrity": "sha512-l9pgahSc7sJD0bP9vBNeXvZjy8QKDpVHVxWmei/ESQOrzmoj5BidziqLVsyZdxsi+PfdbTtttLTAmddH/JafYA==", + "peerDependencies": { + "expo": "*", + "react-native": "*" + } + }, "node_modules/expo/node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", diff --git a/guard_app/package.json b/guard_app/package.json index 1d160d41a..ffe42ceb0 100644 --- a/guard_app/package.json +++ b/guard_app/package.json @@ -36,11 +36,14 @@ "expo-dev-client": "~6.0.21", "expo-device": "~8.0.10", "expo-document-picker": "~14.0.8", + "expo-file-system": "^56.0.7", "expo-image-picker": "~17.0.11", "expo-localization": "~17.0.8", "expo-location": "~19.0.8", "expo-modules-autolinking": "~3.0.22", "expo-notifications": "~0.32.17", + "expo-print": "^56.0.3", + "expo-sharing": "^56.0.13", "expo-status-bar": "~3.0.9", "i18next": "^26.0.6", "react": "19.1.0", diff --git a/guard_app/src/api/payroll.ts b/guard_app/src/api/payroll.ts index a62e1837a..a86b6a1c4 100644 --- a/guard_app/src/api/payroll.ts +++ b/guard_app/src/api/payroll.ts @@ -1,130 +1,126 @@ -// src/api/payroll.ts -import http from '../lib/http'; +import * as FileSystem from 'expo-file-system'; +import * as Print from 'expo-print'; +import * as Sharing from 'expo-sharing'; +import { Platform } from 'react-native'; + +import http, { API_BASE_URL, API_PATH } from '../lib/http'; +import { LocalStorage } from '../lib/localStorage'; export type PayrollPeriodType = 'daily' | 'weekly' | 'monthly'; -export type PayrollStatus = 'PENDING' | 'APPROVED' | 'PROCESSED'; -export type PayrollExportFormat = 'csv' | 'pdf'; - -export interface PayrollEntry { - shift: string; - attendance: string | null; - shiftDate: string; - scheduledHours: number; - actualHours: number; - regularHours: number; - overtimeHours: number; - payRate: number; - regularPay: number; - overtimePay: number; - totalPay: number; - hasAttendanceRecord: boolean; - attendanceStatus: 'present' | 'absent' | 'incomplete' | 'scheduled' | 'no_record'; -} -export interface PayrollRecord { - _id: string; - guard: string; - period: { - type: PayrollPeriodType; - startDate: string; - endDate: string; - }; - entries: PayrollEntry[]; - totalScheduledHours: number; - totalWorkedHours: number; - totalRegularHours: number; - totalOvertimeHours: number; - grossPay: number; - status: PayrollStatus; - approvedBy?: any; - approvedAt?: string; - processedBy?: any; - processedAt?: string; - guardName: string; - guardEmail: string; - guardRole: string; - guardDepartment?: string; - createdAt: string; - updatedAt: string; -} +export type PayrollSummaryParams = { + startDate: string; + endDate: string; + periodType: PayrollPeriodType; +}; -export interface PayrollSummary { +export type PayrollSummary = { + totalCompletedShifts: number; + totalAttendanceRecords: number; totalGuards: number; - totalWorkedHours: number; + totalHours: number; totalOvertimeHours: number; - totalGrossPay: number; -} + totalPendingApproval: number; +}; -export interface PayrollResponse { - period: { - type: PayrollPeriodType; - startDate: string; - endDate: string; - }; +export type PayrollResponse = { + message: string; summary: PayrollSummary; - records: PayrollRecord[]; -} - -export interface ShiftAttendanceRecord { - _id: string; - shift: string; - guard: any; - clockIn: string | null; - clockOut: string | null; - scheduledStart: string; - scheduledEnd: string; - hoursWorked: number; - status: 'scheduled' | 'present' | 'incomplete' | 'absent'; - notes?: string; - recordedBy?: any; - createdAt: string; - updatedAt: string; -} - -export interface ShiftAttendanceListResponse { - shiftId: string; - count: number; - records: ShiftAttendanceRecord[]; -} + guards: { + guardId: string | null; + guardName: string | null; + totalShifts: number; + totalHours: number; + overtimeHours: number; + underworkedShifts: number; + pendingApproval: number; + }[]; + periods: { + periodLabel: string; + totalShifts: number; + totalHours: number; + overtimeHours: number; + underworkedShifts: number; + pendingApproval: number; + }[]; +}; -/** - * Retrieve (and generate / refresh) payroll summaries. - */ -export async function getPayroll(params: { - startDate: string; - endDate: string; - periodType: PayrollPeriodType; - guardId?: string; - department?: string; -}) { +export async function getPayrollSummary(params: PayrollSummaryParams) { const { data } = await http.get('/payroll', { params }); return data; } -/** - * Export payroll data as CSV or PDF. - * Note: For mobile, you might want to handle the blob or return the URL. - */ -export async function exportPayroll(params: { - startDate: string; - endDate: string; - periodType: PayrollPeriodType; - format?: PayrollExportFormat; - guardId?: string; - department?: string; - status?: PayrollStatus; -}) { - const { data } = await http.get('/payroll/export', { - params, - responseType: 'blob', +export async function exportPayrollCsv(params: PayrollSummaryParams) { + const token = await LocalStorage.getToken(); + + const searchParams = new URLSearchParams({ + startDate: params.startDate, + endDate: params.endDate, + periodType: params.periodType, + }).toString(); + + const url = `${API_BASE_URL}${API_PATH}/payroll/export?${searchParams}`; + + const response = await fetch(url, { + method: 'GET', + headers: token + ? { + Authorization: `Bearer ${token}`, + } + : undefined, + }); + + if (!response.ok) { + throw new Error('Failed to export payroll CSV'); + } + + if (Platform.OS === 'web') { + const blob = await response.blob(); + const downloadUrl = window.URL.createObjectURL(blob); + const link = document.createElement('a'); + + link.href = downloadUrl; + link.download = `payroll-export-${params.startDate}-to-${params.endDate}.csv`; + + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + + window.URL.revokeObjectURL(downloadUrl); + return; + } + + const csvContent = await response.text(); + const fileUri = `${FileSystem.Paths.cache.uri}payroll-export-${params.startDate}-to-${params.endDate}.csv`; + + const file = new FileSystem.File(fileUri); + await file.write(csvContent); + + const canShare = await Sharing.isAvailableAsync(); + + if (!canShare) { + throw new Error('Sharing is not available on this device.'); + } + + await Sharing.shareAsync(fileUri, { + mimeType: 'text/csv', + dialogTitle: 'Export Payroll CSV', + UTI: 'public.comma-separated-values-text', }); - return data; } -/** - * Get attendance records for a specific shift. - */ -export async function getAttendanceForShift(shiftId: string) { - const { data } = await http.get(`/payroll/attendance/${shiftId}`); - return data; +export async function exportPayrollPdf(html: string) { + const { uri } = await Print.printToFileAsync({ html }); + + const canShare = await Sharing.isAvailableAsync(); + + if (!canShare) { + throw new Error('Sharing is not available on this device.'); + } + + await Sharing.shareAsync(uri, { + mimeType: 'application/pdf', + dialogTitle: 'Export Payroll PDF', + UTI: 'com.adobe.pdf', + }); } diff --git a/guard_app/src/screen/HomeScreen.tsx b/guard_app/src/screen/HomeScreen.tsx index 068cfa14e..d7c52088f 100644 --- a/guard_app/src/screen/HomeScreen.tsx +++ b/guard_app/src/screen/HomeScreen.tsx @@ -1,12 +1,9 @@ /* eslint-disable react-native/no-inline-styles */ // src/screen/HomeScreen.tsx -import { Ionicons, Feather, MaterialCommunityIcons } from '@expo/vector-icons'; +import { Feather, Ionicons, MaterialCommunityIcons } from '@expo/vector-icons'; import { useFocusEffect, useNavigation } from '@react-navigation/native'; import { NativeStackNavigationProp } from '@react-navigation/native-stack'; import { useCallback, useLayoutEffect, useState } from 'react'; -import { useTranslation } from 'react-i18next'; -import { fetchGuardScore, GuardScore } from '../api/guardScore'; -import { getUserProfile } from '../api/profile'; import { Button, Dimensions, @@ -24,6 +21,7 @@ import http from '../lib/http'; import { RootStackParamList } from '../navigation/AppNavigator'; import { useAppTheme } from '../theme'; import { AppColors } from '../theme/colors'; +import { showLocalNotification } from '../utils/notificationHelpers'; type Nav = NativeStackNavigationProp; @@ -114,7 +112,6 @@ export default function HomeScreen() { const navigation = useNavigation