diff --git a/guard_app/package-lock.json b/guard_app/package-lock.json index 02752fca3..483c7948e 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": "~19.0.22", "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": "~15.0.8", + "expo-sharing": "~14.0.8", "expo-status-bar": "~3.0.9", "i18next": "^26.0.6", "react": "19.1.0", @@ -3451,7 +3454,7 @@ "version": "19.1.17", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.17.tgz", "integrity": "sha512-Qec1E3mhALmaspIrhWt9jkQMNdw6bReVu64mjvhbhq2NFPftLPVr+l1SZgmw/66WwBNpDh7ao5AT6gF5v41PFA==", - "dev": true, + "devOptional": true, "dependencies": { "csstype": "^3.0.2" } @@ -5391,7 +5394,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true + "devOptional": true }, "node_modules/data-urls": { "version": "3.0.2", @@ -6925,6 +6928,16 @@ "react-native": "*" } }, + "node_modules/expo-print": { + "version": "15.0.8", + "resolved": "https://registry.npmjs.org/expo-print/-/expo-print-15.0.8.tgz", + "integrity": "sha512-4O0Qzm0On5AmJIl9d+BT+ieTipFp658nHI4aX7vKEFPfj3dfQxG6rDJJpca+rrc9c4Ha8ZFYGvxJG5+4lFq2Pw==", + "license": "MIT", + "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 +6946,15 @@ "node": ">=20.16.0" } }, + "node_modules/expo-sharing": { + "version": "14.0.8", + "resolved": "https://registry.npmjs.org/expo-sharing/-/expo-sharing-14.0.8.tgz", + "integrity": "sha512-A1pPr2iBrxypFDCWVAESk532HK+db7MFXbvO2sCV9ienaFXAk7lIBm6bkqgE6vzRd9O3RGdEGzYx80cYlc089Q==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-status-bar": { "version": "3.0.9", "resolved": "https://registry.npmjs.org/expo-status-bar/-/expo-status-bar-3.0.9.tgz", @@ -13940,7 +13962,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, + "devOptional": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/guard_app/package.json b/guard_app/package.json index 1d160d41a..2205e26b6 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": "~19.0.22", "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": "~15.0.8", + "expo-sharing": "~14.0.8", "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/PayrollScreen.tsx b/guard_app/src/screen/PayrollScreen.tsx index d86c3dafb..7340a6781 100644 --- a/guard_app/src/screen/PayrollScreen.tsx +++ b/guard_app/src/screen/PayrollScreen.tsx @@ -1,186 +1,378 @@ -// src/screen/PayrollScreen.tsx +import { AxiosError } from 'axios'; +import React, { useState } from 'react'; +import { + ActivityIndicator, + Alert, + Pressable, + ScrollView, + StyleSheet, + Text, + TextInput, + View, +} from 'react-native'; -import { Ionicons } from '@expo/vector-icons'; -import { useFocusEffect } from '@react-navigation/native'; -import React, { useCallback, useState } from 'react'; -import { useTranslation } from 'react-i18next'; -import { FlatList, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; - -import { getPayroll, type PayrollResponse, PayrollPeriodType } from '../api/payroll'; -import PayrollFilterModal from '../components/modal/PayrollFilterModal'; +import { + exportPayrollCsv, + exportPayrollPdf, + getPayrollSummary, + PayrollPeriodType, + PayrollResponse, +} from '../api/payroll'; import { useAppTheme } from '../theme'; import { AppColors } from '../theme/colors'; +const DATE_REGEX = /^\d{4}-\d{2}-\d{2}$/; + +function fmtHours(value?: number) { + return Number(value ?? 0).toFixed(2); +} + +function getDefaultStartDate() { + const date = new Date(); + date.setDate(1); + return date.toISOString().split('T')[0]; +} + +function getDefaultEndDate() { + return new Date().toISOString().split('T')[0]; +} + export default function PayrollScreen() { const { colors } = useAppTheme(); const styles = getStyles(colors); - const { t } = useTranslation(); - const [modalVisible, setModalVisible] = useState(true); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(''); - const [rows, setRows] = useState(); + const [startDate, setStartDate] = useState(getDefaultStartDate()); + const [endDate, setEndDate] = useState(getDefaultEndDate()); + const [periodType, setPeriodType] = useState('weekly'); + const [payroll, setPayroll] = useState(null); + const [loading, setLoading] = useState(false); + const [exportingCsv, setExportingCsv] = useState(false); + const [exportingPdf, setExportingPdf] = useState(false); + + const params = { startDate, endDate, periodType }; + + const validateFilters = () => { + if (!startDate || !endDate) { + Alert.alert('Missing dates', 'Please enter both start date and end date.'); + return false; + } + + if (!DATE_REGEX.test(startDate) || !DATE_REGEX.test(endDate)) { + Alert.alert('Invalid date format', 'Dates must be in YYYY-MM-DD format.'); + return false; + } + + if (new Date(startDate) > new Date(endDate)) { + Alert.alert('Invalid date range', 'Start date cannot be after end date.'); + return false; + } + + return true; + }; + + const handleError = (error: unknown, fallback: string) => { + if (error instanceof AxiosError) { + Alert.alert('Error', error.response?.data?.message ?? error.message ?? fallback); + return; + } + + Alert.alert('Error', error instanceof Error ? error.message : fallback); + }; + + const onGeneratePayroll = async () => { + if (!validateFilters()) return; - const fetchData = useCallback(async () => { try { - setModalVisible(true); - setError(''); - } catch (e) { - console.error(e); - setError(t('payroll.error')); + setLoading(true); + const data = await getPayrollSummary(params); + setPayroll(data); + } catch (error) { + handleError(error, 'Failed to load payroll summary.'); } finally { setLoading(false); } - }, []); - - useFocusEffect(useCallback(() => void fetchData(), [fetchData])); - - const handleAddFilter = async ( - start: Date, - end: Date, - period: PayrollPeriodType, - id?: string, - department?: string, - ) => { - console.log(start); - setLoading(true); + }; + + const onExportCsv = async () => { + if (!validateFilters()) return; + try { - const mapped = await getPayroll({ - startDate: start.toDateString(), - endDate: end.toDateString(), - periodType: period, - guardId: id, - department: department, - }); - setRows(mapped); - setError(''); - } catch (e) { - console.log(e); - setError(t('payroll.error')); + setExportingCsv(true); + await exportPayrollCsv(params); + Alert.alert('Export ready', 'Payroll CSV export has been generated.'); + } catch (error) { + handleError(error, 'Failed to export payroll CSV.'); } finally { - setLoading(false); + setExportingCsv(false); + } + }; + + const onExportPdf = async () => { + if (!payroll) { + Alert.alert('No payroll data', 'Please generate payroll before exporting PDF.'); + return; + } + + try { + setExportingPdf(true); + + const html = ` + + +

Payroll Summary

+

Date Range: ${startDate} to ${endDate}

+

Period Type: ${periodType}

+ +

Summary

+
    +
  • Completed Shifts: ${payroll.summary.totalCompletedShifts}
  • +
  • Total Hours: ${fmtHours(payroll.summary.totalHours)}
  • +
  • Overtime Hours: ${fmtHours(payroll.summary.totalOvertimeHours)}
  • +
  • Pending Approval: ${payroll.summary.totalPendingApproval}
  • +
+ +

Period Breakdown

+ + + + + + + + + ${(payroll.periods ?? []) + .map( + (period) => ` + + + + + + + + `, + ) + .join('')} +
PeriodShiftsTotal HoursOvertimePending
${period.periodLabel}${period.totalShifts}${fmtHours(period.totalHours)}${fmtHours(period.overtimeHours)}${period.pendingApproval}
+ + + `; + + await exportPayrollPdf(html); + Alert.alert('Export ready', 'Payroll PDF export has been generated.'); + } catch (error) { + handleError(error, 'Failed to export payroll PDF.'); + } finally { + setExportingPdf(false); } }; return ( - - - setModalVisible(true)}> - - - fetchData()}> - - + + Payroll + Generate payroll summaries and export payroll reports. + + + Filters + + Start Date + + + End Date + + + Period Type + + {(['daily', 'weekly', 'monthly'] as PayrollPeriodType[]).map((type) => ( + setPeriodType(type)} + style={[styles.periodButton, periodType === type && styles.periodButtonActive]} + > + + {type} + + + ))} + + + + {loading ? 'Generating...' : 'Generate'} + + + + + + {exportingCsv ? 'Exporting...' : 'Export CSV'} + + + + + + {exportingPdf ? 'Exporting...' : 'Export PDF'} + + + - {error && {error}} - - {!error && !loading && ( - - ( - - - {item.period.endDate} - {item.period.endDate} - - - - {t('payroll.period')} {item.period.type} - - - - {t('payroll.guardID')} {item.guard ?? 'Not found'} - - - - )} - ListEmptyComponent={{t('payroll.notFound')}} - /> - - setModalVisible(false)} - onFilter={handleAddFilter} - /> + {loading ? ( + + + Loading payroll summary... + + ) : null} + + {payroll ? ( + + Summary + + + Completed Shifts: {payroll.summary.totalCompletedShifts} + + + Total Hours: {fmtHours(payroll.summary.totalHours)} + + + Overtime Hours: {fmtHours(payroll.summary.totalOvertimeHours)} + + + Pending Approval: {payroll.summary.totalPendingApproval} + + ) : ( + No payroll summary generated yet. )} - + ); } const getStyles = (colors: AppColors) => StyleSheet.create({ - scroll: { - padding: 16, - }, - filterBtn: { - width: 44, - height: 44, - alignItems: 'center', - justifyContent: 'center', + container: { + flex: 1, + backgroundColor: colors.bg, }, - filterRow: { - flexDirection: 'row-reverse', - paddingHorizontal: 30, - paddingTop: 10, - marginBottom: 10, + content: { + padding: 16, + paddingBottom: 28, }, - mainView: { - paddingBottom: 95, + heading: { + fontSize: 26, + fontWeight: '900', + color: colors.text, + marginBottom: 6, }, - payList: { - paddingBottom: 95, + subText: { + color: colors.muted, + fontWeight: '600', + marginBottom: 14, }, - payCard: { + card: { backgroundColor: colors.card, - borderRadius: 12, - marginHorizontal: 10, - marginVertical: 5, - padding: 10, - borderWidth: 1, borderColor: colors.border, - shadowColor: '#000', - shadowOffset: { width: 0, height: 1 }, - shadowOpacity: 0.05, - shadowRadius: 4, - elevation: 2, + borderRadius: 16, + borderWidth: 1, + marginBottom: 16, + padding: 16, }, - periodCard: { + cardTitle: { + color: colors.text, fontSize: 18, + fontWeight: '900', + marginBottom: 12, + }, + label: { + color: colors.text, + fontWeight: '800', + marginBottom: 6, }, - payRow: { + input: { + backgroundColor: colors.bg, + borderColor: colors.border, + borderRadius: 10, + borderWidth: 1, + color: colors.text, + marginBottom: 12, + paddingHorizontal: 12, + paddingVertical: 10, + }, + periodRow: { flexDirection: 'row', + gap: 8, + marginBottom: 14, + }, + periodButton: { alignItems: 'center', + borderColor: colors.border, + borderRadius: 999, + borderWidth: 1, + flex: 1, + paddingVertical: 10, }, - payHead: { - fontSize: 15, - fontWeight: '600', - color: colors.text, - marginBottom: 2, + periodButtonActive: { + backgroundColor: colors.primarySoft, + borderColor: colors.primary, }, - payMeta: { - fontSize: 13, + periodText: { color: colors.muted, + fontWeight: '800', + textTransform: 'capitalize', }, - payMetaDot: { - fontSize: 13, - color: colors.muted, - marginHorizontal: 6, + periodTextActive: { + color: colors.primary, }, - emptyText: { - textAlign: 'center', - color: colors.muted, - marginTop: 40, - fontSize: 24, - fontWeight: 600, + primaryButton: { + alignItems: 'center', + backgroundColor: colors.primary, + borderRadius: 12, + paddingVertical: 12, + }, + primaryButtonText: { + color: '#FFFFFF', + fontWeight: '900', + }, + exportRow: { + flexDirection: 'row', + gap: 10, + marginTop: 10, + }, + secondaryButton: { + alignItems: 'center', + borderColor: colors.primary, + borderRadius: 12, + borderWidth: 1, + flex: 1, + paddingVertical: 12, + }, + secondaryButtonText: { + color: colors.primary, + fontWeight: '900', }, - errorText: { + loadingBox: { + alignItems: 'center', + marginVertical: 16, + }, + summaryText: { + color: colors.text, + fontWeight: '700', + marginBottom: 8, + }, + empty: { + color: colors.muted, + fontWeight: '700', textAlign: 'center', - color: colors.status.rejected, - marginTop: 40, - fontSize: 24, - fontWeight: 600, + marginTop: 12, }, });