diff --git a/guard_app/src/components/PayrollFilterModal.tsx b/guard_app/src/components/PayrollFilterModal.tsx new file mode 100644 index 000000000..c98085fdb --- /dev/null +++ b/guard_app/src/components/PayrollFilterModal.tsx @@ -0,0 +1,346 @@ +// src/components/PayrollFilterModal.tsx + +import DateTimePicker, { type DateTimePickerEvent } from '@react-native-community/datetimepicker'; +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + Alert, + Modal, + Platform, + StyleSheet, + Text, + TextInput, + TouchableOpacity, + View, +} from 'react-native'; + +import { type PayrollPeriodType } from '../api/payroll'; +import { useAppTheme } from '../theme'; +import { AppColors } from '../theme/colors'; + +export interface FilterPayrollProps { + visible: boolean; + onClose: () => void; + onFilter: ( + start: Date, + end: Date, + period: PayrollPeriodType, + guardId?: string, + department?: string, + ) => void; +} + +export default function PayRollFilterModal({ visible, onClose, onFilter }: FilterPayrollProps) { + /* + Check before Submission: + - Conditional Types + - Unused styles, functions, etc + - Colours using hexcode not colors.whatever + - ESLint warnings + - Typos + - Validation matches specification + - Sort: + - Imports + - Constants + - Functions + */ + const { colors } = useAppTheme(); + const styles = getStyles(colors); + const { t } = useTranslation(); + + const [activePicker, setActivePicker] = useState<'start' | 'end' | null>(null); + const [department, setDepartment] = useState(''); + const [endDate, setEndDate] = useState(null); + const [guardID, setGuardID] = useState(''); + const [selectedPeriodType, setSelectedPeriodType] = useState('daily'); + const [showDropdown, setShowDropdown] = useState(false); + const [showOptional, setShowOptional] = useState(false); + const [startDate, setStartDate] = useState(null); + + const PERIOD_TYPES = [ + { id: 'daily', label: t('payroll.types.daily') }, + { id: 'weekly', label: t('payroll.types.weekly') }, + { id: 'monthly', label: t('payroll.types.monthly') }, + ]; + const handleCancel = () => { + resetState(); + onClose(); + }; + + const handleFilter = () => { + if (!startDate || !endDate) { + Alert.alert(t('payroll.missingAlertHead'), t('payroll.missingAlertMsg')); + return; + } + + if (startDate > endDate) { + Alert.alert(t('payroll.invalidAlertHead'), t('payroll.invalidAlertMsg')); + return; + } + + onFilter(startDate, endDate, selectedPeriodType, guardID, department); + resetState(); + onClose(); + }; + + const openPicker = (kind: 'start' | 'end') => { + setActivePicker(kind); + }; + + const handlePickerChange = (event: DateTimePickerEvent, selected?: Date) => { + if (event.type === 'dismissed') { + setActivePicker(null); + return; + } + + if (!selected) return; + + if (activePicker === 'start') { + setStartDate(selected); + } else if (activePicker === 'end') { + setEndDate(selected); + } + + if (Platform.OS === 'android') { + setActivePicker(null); + } + }; + + const resetState = () => { + setStartDate(null); + setEndDate(null); + setShowDropdown(false); + setShowOptional(false); + setSelectedPeriodType('daily'); + }; + + const selectedPeriodTypeLabel = PERIOD_TYPES.find((pd) => pd.id === selectedPeriodType)?.label; + + return ( + + + + {t('payroll.filter')} + + {t('payroll.startDateHead')} + openPicker('start')}> + {startDate ? startDate.toDateString() : t('payroll.selectStart')} + + + {t('payroll.endDateHead')} + openPicker('end')}> + {endDate ? endDate.toDateString() : t('payroll.selectEnd')} + + + {t('payroll.periodHead')} + setShowDropdown(!showDropdown)}> + + {selectedPeriodTypeLabel || t('payroll.selectPeriod')} + + {showDropdown ? '▲' : '▼'} + + + {showDropdown && ( + + {PERIOD_TYPES.map((periodType) => ( + { + if (periodType.id == 'daily') setSelectedPeriodType('daily'); + else if (periodType.id == 'weekly') setSelectedPeriodType('weekly'); + else setSelectedPeriodType('monthly'); + setShowDropdown(false); + }} + > + + {periodType.label} + + + ))} + + )} + + setShowOptional(!showOptional)} + > + {t('payroll.optional')} + {showOptional ? '▲' : '▼'} + + + {t('payroll.ID')} + setGuardID(s)} + /> + + {t('payroll.departmentHead')} + setDepartment(s)} + /> + + + + {t('payroll.cancel')} + + + + {t('payroll.filter')} + + + + + {activePicker && ( + + )} + + + ); +} + +const getStyles = (colors: AppColors) => + StyleSheet.create({ + backdrop: { + flex: 1, + backgroundColor: 'rgba(0,0,0,0.35)', + justifyContent: 'center', + padding: 16, + }, + card: { backgroundColor: 'white', borderRadius: 12, padding: 16 }, + title: { fontSize: 18, fontWeight: 'bold', marginBottom: 12 }, + label: { fontWeight: '600', marginBottom: 4 }, + inputLike: { + borderWidth: 1, + borderColor: colors.border, + borderRadius: 8, + paddingHorizontal: 10, + paddingVertical: 8, + marginBottom: 12, + }, + row: { flexDirection: 'row', justifyContent: 'space-between', gap: 8 }, + column: { flex: 1 }, + dropdown: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + backgroundColor: colors.card, + borderWidth: 1, + borderColor: colors.border, + borderRadius: 8, + padding: 14, + marginBottom: 12, + }, + dropdownTextPlaceholder: { + fontSize: 15, + color: colors.muted, + }, + dropdownTextSelected: { + fontSize: 15, + color: colors.text, + fontWeight: '500', + }, + dropdownIcon: { + fontSize: 12, + color: colors.muted, + }, + dropdownMenu: { + backgroundColor: colors.card, + borderRadius: 8, + borderWidth: 1, + borderColor: colors.border, + marginTop: -25, + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.1, + shadowRadius: 8, + elevation: 5, + maxHeight: 340, + }, + dropdownItem: { + padding: 14, + borderBottomWidth: 1, + borderBottomColor: colors.border, + }, + dropdownItemSelected: { + backgroundColor: colors.primarySoft, + }, + dropdownItemText: { + fontSize: 15, + color: colors.text, + }, + dropdownItemTextSelected: { + color: colors.primary, + fontWeight: '600', + }, + input: { + fontSize: 14, + color: colors.text, + borderWidth: 1, + borderColor: colors.border, + borderRadius: 8, + marginBottom: 12, + }, + optionalSelect: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + backgroundColor: colors.card, + padding: 7, + }, + optionalSectionGone: { + display: 'none', + }, + optionalSectionShow: { + marginHorizontal: 13, + }, + buttonsRow: { flexDirection: 'row', marginTop: 8, justifyContent: 'flex-end', gap: 8 }, + primaryButton: { + paddingVertical: 10, + paddingHorizontal: 16, + borderRadius: 8, + backgroundColor: '#003f88', + }, + primaryButtonText: { color: '#fff', fontWeight: '600' }, + secondaryButton: { + paddingVertical: 10, + paddingHorizontal: 16, + borderRadius: 8, + borderWidth: 1, + borderColor: '#ccc', + backgroundColor: '#f5f5f5', + }, + secondaryButtonText: { color: '#333', fontWeight: '500' }, + }); diff --git a/guard_app/src/locales/en.json b/guard_app/src/locales/en.json index 796ba4a78..3e12ed627 100644 --- a/guard_app/src/locales/en.json +++ b/guard_app/src/locales/en.json @@ -286,5 +286,36 @@ "testNotif": "Test Notification", "notifTitle": "Shift Assigned", "notifBody": "You have been assigned to Hospital Complex shift." + }, + "payroll": { + "period": "Period:", + "guardID": "GuardID:", + "site": "Site:", + "department": "Department:", + "notFound": "No payroll found", + "error": "Failed to load payroll history", + "types": { + "daily": "Daily", + "weekly": "Weekly", + "monthly": "Monthly" + }, + "filter": "Filter", + "startDateHead": "Start Date*", + "selectStart": "Select start date", + "endDateHead": "End Date*", + "selectEnd": "Select end date", + "periodHead": "Period Type*", + "selectPeriod": "Select period", + "optional": "Optional", + "ID": "ID", + "siteHead": "Site", + "siteHint": "Site name", + "departmentHead": "Department", + "departmentHint": "Department name", + "missingAlertHead": "Missing info", + "missingAlertMsg": "Please select a start and end date as well as a period type.", + "invalidAlertHead": "Invalid info", + "invalidAlertMsg": "Start date must be before end date.", + "cancel": "Cancel" } } diff --git a/guard_app/src/navigation/AppNavigator.tsx b/guard_app/src/navigation/AppNavigator.tsx index 0bb8140fe..a3064a3f4 100644 --- a/guard_app/src/navigation/AppNavigator.tsx +++ b/guard_app/src/navigation/AppNavigator.tsx @@ -8,6 +8,7 @@ import EditProfileScreen from '../screen/EditProfileScreen'; import LoginScreen from '../screen/loginscreen'; import MessagesScreen from '../screen/MessagesScreen'; import NotificationsScreen from '../screen/notifications'; +import PayrollScreen from '../screen/PayrollScreen'; import PrivacyPolicyScreen from '../screen/PrivacyPolicyScreen'; import SettingsScreen from '../screen/SettingsScreen'; import ShiftDetailsScreen from '../screen/ShiftDetailsScreen'; @@ -23,6 +24,7 @@ export type RootStackParamList = { Signup: undefined; Documents: undefined; Settings: undefined; + Payroll: undefined; PrivacyPolicy: undefined; EditProfile: undefined; Messages: @@ -87,6 +89,11 @@ export default function AppNavigator() { component={NotificationsScreen} options={{ headerShown: true, title: t('nav.notifications') }} /> + ( + navigation.navigate('Payroll')} + style={{ paddingHorizontal: 8 }} + > + + navigation.navigate('Messages')} style={{ paddingHorizontal: 8 }} diff --git a/guard_app/src/screen/PayrollScreen.tsx b/guard_app/src/screen/PayrollScreen.tsx new file mode 100644 index 000000000..4e93ab51e --- /dev/null +++ b/guard_app/src/screen/PayrollScreen.tsx @@ -0,0 +1,186 @@ +// src/screen/PayrollScreen.tsx + +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/PayrollFilterModal'; +import { useAppTheme } from '../theme'; +import { AppColors } from '../theme/colors'; + +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 fetchData = useCallback(async () => { + try { + setModalVisible(true); + setError(''); + } catch (e) { + console.error(e); + setError(t('payroll.error')); + } 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); + 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')); + } finally { + setLoading(false); + } + }; + + return ( + + + setModalVisible(true)}> + + + fetchData()}> + + + + + {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} + /> + + )} + + ); +} + +const getStyles = (colors: AppColors) => + StyleSheet.create({ + scroll: { + padding: 16, + }, + filterBtn: { + width: 44, + height: 44, + alignItems: 'center', + justifyContent: 'center', + }, + filterRow: { + flexDirection: 'row-reverse', + paddingHorizontal: 30, + paddingTop: 10, + marginBottom: 10, + }, + mainView: { + paddingBottom: 95, + }, + payList: { + paddingBottom: 95, + }, + payCard: { + 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, + }, + periodCard: { + fontSize: 18, + }, + payRow: { + flexDirection: 'row', + alignItems: 'center', + }, + payHead: { + fontSize: 15, + fontWeight: '600', + color: colors.text, + marginBottom: 2, + }, + payMeta: { + fontSize: 13, + color: colors.muted, + }, + payMetaDot: { + fontSize: 13, + color: colors.muted, + marginHorizontal: 6, + }, + emptyText: { + textAlign: 'center', + color: colors.muted, + marginTop: 40, + fontSize: 24, + fontWeight: 600, + }, + errorText: { + textAlign: 'center', + color: colors.status.rejected, + marginTop: 40, + fontSize: 24, + fontWeight: 600, + }, + });