diff --git a/__tests__/AttendanceAction.break-rules.test.jsx b/__tests__/AttendanceAction.break-rules.test.jsx index 87d25ba..f3f7471 100644 --- a/__tests__/AttendanceAction.break-rules.test.jsx +++ b/__tests__/AttendanceAction.break-rules.test.jsx @@ -59,13 +59,15 @@ jest.mock("@expo/vector-icons", () => { }; }); +let focusListener = null; + const mockNavigation = { setOptions: jest.fn(), goBack: jest.fn(), navigate: jest.fn(), addListener: jest.fn((event, cb) => { if (event === "focus" && typeof cb === "function") { - // Keep tests deterministic: do not auto-trigger focus callback. + focusListener = cb; } return jest.fn(); @@ -109,17 +111,23 @@ const createStore = (attendanceOverrides = {}) => const renderScreen = (attendanceOverrides = {}) => { const store = createStore(attendanceOverrides); - return render( + const utils = render( , ); + + return { + ...utils, + store, + }; }; describe("AttendanceAction break rules", () => { beforeEach(async () => { jest.clearAllMocks(); jest.useRealTimers(); + focusListener = null; await AsyncStorage.clear(); await AsyncStorage.setItem("restrict_location", "0"); @@ -145,6 +153,113 @@ describe("AttendanceAction break rules", () => { }); }); + it("keeps checkin start time stable after focus refresh", async () => { + const stableCheckinTime = new Date("2026-04-25T08:30:00.000Z").getTime(); + + await AsyncStorage.setItem("checkinStartTime", String(stableCheckinTime)); + + attendanceService.getAttendanceStatus.mockResolvedValue({ + custom_in: 1, + checkin_time: String(stableCheckinTime), + }); + + const screen = renderScreen({ + checkin: true, + checkinTime: stableCheckinTime, + }); + + await waitFor(() => { + expect(focusListener).toEqual(expect.any(Function)); + }); + + await act(async () => { + await focusListener(); + }); + + await waitFor(() => { + expect(screen.store.getState().attendance.checkinTime).toBe( + stableCheckinTime, + ); + }); + }); + + it("keeps newer local checkin start when backend timestamp is stale", async () => { + const staleBackendCheckinTime = new Date( + "2026-04-25T08:30:00.000Z", + ).getTime(); + const freshLocalCheckinTime = new Date( + "2026-04-25T10:00:00.000Z", + ).getTime(); + + await AsyncStorage.setItem( + "checkinStartTime", + String(freshLocalCheckinTime), + ); + + attendanceService.getAttendanceStatus.mockResolvedValue({ + custom_in: 1, + checkin_time: String(staleBackendCheckinTime), + }); + + const screen = renderScreen({ + checkin: true, + checkinTime: freshLocalCheckinTime, + }); + + await waitFor(() => { + expect(focusListener).toEqual(expect.any(Function)); + }); + + await act(async () => { + await focusListener(); + }); + + await waitFor(() => { + expect(screen.store.getState().attendance.checkinTime).toBe( + freshLocalCheckinTime, + ); + }); + }); + + it("ignores status checkin time older than last local checkout", async () => { + jest.useFakeTimers(); + + const now = new Date("2026-04-25T10:00:00.000Z"); + jest.setSystemTime(now); + + const staleBackendCheckinTime = new Date( + "2026-04-25T08:30:00.000Z", + ).getTime(); + const lastCheckoutTime = new Date("2026-04-25T09:45:00.000Z").getTime(); + + await AsyncStorage.removeItem("checkinStartTime"); + await AsyncStorage.setItem("lastCheckoutTime", String(lastCheckoutTime)); + + attendanceService.getAttendanceStatus.mockResolvedValue({ + custom_in: 1, + checkin_time: String(staleBackendCheckinTime), + }); + + const screen = renderScreen({ + checkin: false, + checkinTime: null, + }); + + await waitFor(() => { + expect(focusListener).toEqual(expect.any(Function)); + }); + + await act(async () => { + await focusListener(); + }); + + await waitFor(() => { + expect(screen.store.getState().attendance.checkinTime).toBe( + now.getTime(), + ); + }); + }); + it("disables break after a completed break for the day", async () => { attendanceService.getTodayBreaks.mockResolvedValue({ total_break_minutes: 25, diff --git a/components/AttendanceAction/AttendanceActionForm.jsx b/components/AttendanceAction/AttendanceActionForm.jsx new file mode 100644 index 0000000..6c6cf09 --- /dev/null +++ b/components/AttendanceAction/AttendanceActionForm.jsx @@ -0,0 +1,97 @@ +import React from "react"; +import { View, Text, TouchableOpacity } from "react-native"; +import { MaterialCommunityIcons } from "@expo/vector-icons"; +import { COLORS } from "../../constants"; + +function AttendanceActionForm({ + dateTime, + locationStatusText, + distanceInfo, + restrictLocation, + checkin, + actionLoading, + isLocationBlocked, + onCheckInOutPress, + onBreakPress, + breakDisabled, + breakButtonLabel, + breakButtonToneClass, + monthlyCapMessage, +}) { + return ( + + + + DATE AND TIME * + + + {dateTime} + + + + + LOCATION * + + + + {locationStatusText} + + + + + {restrictLocation === "1" && distanceInfo && ( + + + Distance: {distanceInfo.distance} m | Allowed:{" "} + {distanceInfo.radius} m + + + )} + + + + {checkin ? "CHECK-OUT" : "CHECK-IN"} + + + + {checkin && ( + + + + {breakButtonLabel} + + + + {!!monthlyCapMessage && ( + + + {monthlyCapMessage} + + + )} + + )} + + + ); +} + +export default React.memo(AttendanceActionForm); diff --git a/components/AttendanceAction/BreakInProgressBanner.jsx b/components/AttendanceAction/BreakInProgressBanner.jsx new file mode 100644 index 0000000..c57f39f --- /dev/null +++ b/components/AttendanceAction/BreakInProgressBanner.jsx @@ -0,0 +1,23 @@ +import React from "react"; +import { View, Text } from "react-native"; + +function BreakInProgressBanner({ liveBreakTime }) { + return ( + + + BREAK IN PROGRESS + + + {liveBreakTime || "00:00:00"} + + + Auto-ends at 02:00:00 + + + ); +} + +export default React.memo(BreakInProgressBanner); diff --git a/components/AttendanceAction/DevBreakTools.jsx b/components/AttendanceAction/DevBreakTools.jsx new file mode 100644 index 0000000..dcde699 --- /dev/null +++ b/components/AttendanceAction/DevBreakTools.jsx @@ -0,0 +1,62 @@ +import React from "react"; +import { View, Text, TouchableOpacity } from "react-native"; + +const PRESETS = [ + { key: "idle-0", label: "Idle 00:00", tone: "bg-slate-700" }, + { key: "idle-45", label: "Idle 00:45", tone: "bg-slate-700" }, + { key: "running-30", label: "Running +30m", tone: "bg-amber-700" }, + { key: "cap-120", label: "Cap 02:00", tone: "bg-gray-700" }, + { key: "completed", label: "Completed 1/day", tone: "bg-indigo-700" }, + { key: "monthly-cap", label: "Monthly Cap 8h", tone: "bg-rose-700" }, +]; + +function DevBreakTools({ + devBreakMockMode, + onToggleDevBreakMockMode, + onInvalidateAccessToken, + onApplyPreset, +}) { + return ( + + + + DEV: Invalidate access token + + + + + DEV: Break UI presets + + + + + DEV local break flow: {devBreakMockMode ? "ON" : "OFF"} + + + + + {PRESETS.map((preset) => ( + onApplyPreset(preset.key)} + > + + {preset.label} + + + ))} + + + ); +} + +export default React.memo(DevBreakTools); diff --git a/hooks/attendanceAction/helpers.js b/hooks/attendanceAction/helpers.js new file mode 100644 index 0000000..363d9f8 --- /dev/null +++ b/hooks/attendanceAction/helpers.js @@ -0,0 +1,30 @@ +export const BREAK_LIMIT_MS = 2 * 60 * 60 * 1000; // 2 hours + +export const getTodayString = () => + new Date().toLocaleDateString("en-GB").replace(/\//g, "-"); + +export const isBreakCompleted = (breakData) => { + if (!breakData?.breaks?.length) return false; + + const hasIn = breakData.breaks.some((b) => b.start); + const hasOut = breakData.breaks.some((b) => b.end); + + return hasIn && hasOut; +}; + +export const findOpenBreak = (breakData) => { + const breaks = breakData?.breaks ?? []; + for (let i = breaks.length - 1; i >= 0; i -= 1) { + const currentBreak = breaks[i]; + if (!currentBreak?.end) return currentBreak; + } + return null; +}; + +export const formatSecondsToHms = (seconds) => { + const hrs = String(Math.floor(seconds / 3600)).padStart(2, "0"); + const mins = String(Math.floor((seconds % 3600) / 60)).padStart(2, "0"); + const secs = String(seconds % 60).padStart(2, "0"); + + return `${hrs}:${mins}:${secs}`; +}; diff --git a/hooks/attendanceAction/useAttendanceBreakFlow.js b/hooks/attendanceAction/useAttendanceBreakFlow.js new file mode 100644 index 0000000..4efaa75 --- /dev/null +++ b/hooks/attendanceAction/useAttendanceBreakFlow.js @@ -0,0 +1,402 @@ +import { useEffect, useState, useRef, useCallback } from "react"; +import { Alert } from "react-native"; +import { useDispatch } from "react-redux"; +import Toast from "react-native-toast-message"; +import AsyncStorage from "@react-native-async-storage/async-storage"; +import { + setCheckin, + setBreakMinutes, + setBreakStatus, +} from "../../redux/Slices/AttendanceSlice"; +import { + employeeBreak, + getAttendanceStatus, + getTodayBreaks, +} from "../../services/api/attendance.service"; +import { + BREAK_LIMIT_MS, + findOpenBreak, + formatSecondsToHms, + getTodayString, + isBreakCompleted, +} from "./helpers"; + +function useAttendanceBreakFlow({ + navigation, + employeeCode, + checkin, + breakMinutes, + isLocationBlocked, + isMountedRef, + refreshAttendanceData, + syncCheckinFromStatus, + setActionLoading, +}) { + const dispatch = useDispatch(); + + const [onBreak, setOnBreak] = useState(false); + const [liveBreakTime, setLiveBreakTime] = useState("00:00:00"); + const [breakStartTime, setBreakStartTime] = useState(null); + const [breakCompleted, setBreakCompleted] = useState(false); + const [monthlyCapMessage, setMonthlyCapMessage] = useState(""); + const [devBreakMockMode, setDevBreakMockMode] = useState(false); + + const breakTriggeredRef = useRef(false); + + const syncBreakState = useCallback(async (breakData) => { + const openBreak = findOpenBreak(breakData); + + if (!openBreak) { + setOnBreak(false); + setBreakStartTime(null); + await AsyncStorage.removeItem("breakStartTime"); + return; + } + + const isOpen = !openBreak.end || openBreak.end === ""; + + setOnBreak(isOpen); + + if (isOpen) breakTriggeredRef.current = false; + + const savedTime = await AsyncStorage.getItem("breakStartTime"); + + if (savedTime) { + setBreakStartTime(parseInt(savedTime, 10)); + } else { + const backendTime = new Date(openBreak.start).getTime(); + setBreakStartTime(backendTime); + await AsyncStorage.setItem("breakStartTime", backendTime.toString()); + } + }, []); + + useEffect(() => { + const unsubscribe = navigation.addListener("focus", async () => { + try { + if (!employeeCode) return; + + const status = await getAttendanceStatus(); + + if (!isMountedRef.current) return; + await syncCheckinFromStatus(status); + + const breakData = await refreshAttendanceData(); + + if (!isMountedRef.current) return; + setBreakCompleted(isBreakCompleted(breakData)); + + await syncBreakState(breakData); + } catch (error) { + console.log("Focus sync error:", error); + } + }); + + return unsubscribe; + }, [ + navigation, + employeeCode, + isMountedRef, + refreshAttendanceData, + syncBreakState, + syncCheckinFromStatus, + ]); + + useEffect(() => { + const loadBreak = async () => { + if (!employeeCode) return; + + const breakData = await getTodayBreaks(employeeCode, getTodayString()); + + if (!isMountedRef.current) return; + setBreakCompleted(isBreakCompleted(breakData)); + + await syncBreakState(breakData); + }; + + loadBreak(); + }, [employeeCode, isMountedRef, syncBreakState]); + + useEffect(() => { + if (!onBreak || !breakStartTime) { + setLiveBreakTime("00:00:00"); + return; + } + + const interval = setInterval(async () => { + const diff = Date.now() - breakStartTime; + const currentBreakSeconds = Math.floor(diff / 1000); + + setLiveBreakTime(formatSecondsToHms(currentBreakSeconds)); + + if (diff >= BREAK_LIMIT_MS && !breakTriggeredRef.current) { + breakTriggeredRef.current = true; + + try { + await employeeBreak({ employeeCode, type: "OUT" }); + await AsyncStorage.removeItem("breakStartTime"); + + if (!isMountedRef.current) return; + setOnBreak(false); + setBreakStartTime(null); + setBreakCompleted(true); + + const breakData = await getTodayBreaks( + employeeCode, + getTodayString(), + ); + + if (!isMountedRef.current) return; + dispatch(setBreakMinutes(breakData?.total_break_minutes ?? 0)); + + Alert.alert( + "Break Ended", + "2-hour break limit reached. Break automatically stopped.", + ); + } catch { + Alert.alert("Error", "Auto break end failed"); + } + } + }, 1000); + + return () => clearInterval(interval); + }, [breakStartTime, dispatch, employeeCode, isMountedRef, onBreak]); + + const applyDevBreakPreset = useCallback( + async (preset) => { + const now = Date.now(); + + const setIdleState = async (minutes, completed = false) => { + setOnBreak(false); + setBreakStartTime(null); + setBreakCompleted(completed); + setMonthlyCapMessage(""); + breakTriggeredRef.current = false; + dispatch(setBreakMinutes(minutes)); + dispatch( + setBreakStatus({ + onBreak: false, + breakStartTime: null, + }), + ); + await AsyncStorage.removeItem("breakStartTime"); + }; + + dispatch( + setCheckin({ + checkinTime: now, + location: null, + }), + ); + + if (preset === "idle-0") { + await setIdleState(0, false); + } + + if (preset === "idle-45") { + await setIdleState(45, false); + } + + if (preset === "running-30") { + const startTime = now - 30 * 60 * 1000; + setOnBreak(true); + setBreakStartTime(startTime); + setBreakCompleted(false); + breakTriggeredRef.current = false; + dispatch(setBreakMinutes(45)); + dispatch( + setBreakStatus({ + onBreak: true, + breakStartTime: startTime, + }), + ); + await AsyncStorage.setItem("breakStartTime", String(startTime)); + } + + if (preset === "cap-120") { + await setIdleState(120, false); + } + + if (preset === "completed") { + await setIdleState(60, true); + } + + if (preset === "monthly-cap") { + await setIdleState(30, true); + setMonthlyCapMessage("Monthly break limit reached (8h)"); + Toast.show({ + type: "error", + text1: "Monthly break limit reached (8h)", + }); + } + + if (__DEV__) { + setDevBreakMockMode(true); + } + + Toast.show({ + type: "success", + text1: `DEV preset applied: ${preset}`, + }); + }, + [dispatch], + ); + + const toggleDevBreakMockMode = useCallback(() => { + setDevBreakMockMode((prev) => !prev); + }, []); + + const handleBreak = useCallback(async () => { + if (!checkin) { + Toast.show({ type: "error", text1: "Please check-in first" }); + return; + } + + if (isLocationBlocked) { + Toast.show({ + type: "error", + text1: "You are out of allowed location", + }); + return; + } + + if (__DEV__ && devBreakMockMode) { + try { + setActionLoading(true); + + if (!onBreak) { + const startTime = Date.now(); + setOnBreak(true); + setBreakStartTime(startTime); + setBreakCompleted(false); + setMonthlyCapMessage(""); + breakTriggeredRef.current = false; + + dispatch( + setBreakStatus({ + onBreak: true, + breakStartTime: startTime, + }), + ); + await AsyncStorage.setItem("breakStartTime", String(startTime)); + + Toast.show({ type: "success", text1: "DEV break started (local)" }); + return; + } + + const elapsedMinutes = Math.max( + 0, + Math.floor((Date.now() - breakStartTime) / 60000), + ); + const nextTotal = Math.min(120, (breakMinutes ?? 0) + elapsedMinutes); + + setOnBreak(false); + setBreakStartTime(null); + setBreakCompleted(true); + setMonthlyCapMessage(""); + dispatch(setBreakMinutes(nextTotal)); + dispatch( + setBreakStatus({ + onBreak: false, + breakStartTime: null, + }), + ); + await AsyncStorage.removeItem("breakStartTime"); + + Toast.show({ + type: "success", + text1: `DEV break ended (total: ${nextTotal}m)`, + }); + return; + } finally { + setActionLoading(false); + } + } + + const breakDataCheck = await getTodayBreaks(employeeCode, getTodayString()); + + if (isBreakCompleted(breakDataCheck)) { + Toast.show({ + type: "error", + text1: "Break already completed for today", + }); + return; + } + + const type = onBreak ? "OUT" : "IN"; + + try { + setActionLoading(true); + const response = await employeeBreak({ employeeCode, type }); + + if (!response.allowed) { + if (response.message?.includes("Monthly break limit")) { + setBreakCompleted(true); + setMonthlyCapMessage(response.message); + } else { + setMonthlyCapMessage(""); + } + + Toast.show({ type: "error", text1: response.message }); + return; + } + + if (type === "IN") { + const startTime = Date.now(); + + setOnBreak(true); + setBreakStartTime(startTime); + setMonthlyCapMessage(""); + + await AsyncStorage.setItem("breakStartTime", startTime.toString()); + } else { + setOnBreak(false); + setBreakStartTime(null); + setMonthlyCapMessage(""); + + await AsyncStorage.removeItem("breakStartTime"); + } + + const breakData = await getTodayBreaks(employeeCode, getTodayString()); + + setBreakCompleted(isBreakCompleted(breakData)); + dispatch(setBreakMinutes(breakData?.total_break_minutes ?? 0)); + + await syncBreakState(breakData); + + Toast.show({ type: "success", text1: response.message }); + } catch (error) { + Toast.show({ + type: "error", + text1: "Break failed", + text2: error.message, + }); + } finally { + setActionLoading(false); + } + }, [ + breakMinutes, + breakStartTime, + checkin, + devBreakMockMode, + dispatch, + employeeCode, + isLocationBlocked, + onBreak, + setActionLoading, + syncBreakState, + ]); + + return { + applyDevBreakPreset, + breakCompleted, + devBreakMockMode, + handleBreak, + liveBreakTime, + monthlyCapMessage, + onBreak, + syncBreakState, + toggleDevBreakMockMode, + }; +} + +export default useAttendanceBreakFlow; diff --git a/hooks/attendanceAction/useAttendanceCheckInOut.js b/hooks/attendanceAction/useAttendanceCheckInOut.js new file mode 100644 index 0000000..0e67a64 --- /dev/null +++ b/hooks/attendanceAction/useAttendanceCheckInOut.js @@ -0,0 +1,148 @@ +import { useCallback } from "react"; +import { useDispatch } from "react-redux"; +import Toast from "react-native-toast-message"; +import AsyncStorage from "@react-native-async-storage/async-storage"; +import { setCheckin, setCheckout } from "../../redux/Slices/AttendanceSlice"; +import { + userCheckIn, + employeeBreak, +} from "../../services/api/attendance.service"; + +const CHECKIN_START_STORAGE_KEY = "checkinStartTime"; +const CHECKOUT_TIME_STORAGE_KEY = "lastCheckoutTime"; + +function useAttendanceCheckInOut({ + navigation, + checkin, + employeeCode, + distanceInfo, + restrictLocation, + onBreak, + refreshAttendanceData, + syncBreakState, + setActionLoading, +}) { + const dispatch = useDispatch(); + + const handleDirectCheckInOut = useCallback( + async (type) => { + try { + setActionLoading(true); + const response = await userCheckIn({ + employeeCode, + type, + locationData: distanceInfo, + }); + + if (!response.allowed) { + Toast.show({ + type: "error", + text1: ":warning: Action blocked", + text2: response.message, + }); + return; + } + + if (type === "IN") { + const checkinStartTime = Date.now(); + await AsyncStorage.removeItem(CHECKOUT_TIME_STORAGE_KEY); + await AsyncStorage.setItem( + CHECKIN_START_STORAGE_KEY, + String(checkinStartTime), + ); + + dispatch({ type: "attendance/setSelectedLocation", payload: null }); + dispatch( + setCheckin({ + checkinTime: checkinStartTime, + location: restrictLocation === "1" ? response.location : null, + }), + ); + } else { + const checkoutTime = Date.now(); + + if (onBreak) { + const breakRes = await employeeBreak({ + employeeCode, + type: "OUT", + }); + if (!breakRes?.allowed) { + console.log("Break already ended from backend"); + } + } + + await AsyncStorage.removeItem(CHECKIN_START_STORAGE_KEY); + await AsyncStorage.setItem( + CHECKOUT_TIME_STORAGE_KEY, + String(checkoutTime), + ); + dispatch(setCheckout({ checkoutTime })); + dispatch({ type: "attendance/setSelectedLocation", payload: null }); + } + + const breakData = await refreshAttendanceData(); + await syncBreakState(breakData); + + Toast.show({ + type: "success", + text1: type === "IN" ? "Checked in!" : "Checked out!", + }); + } catch (error) { + console.log("AttendanceAction.handleDirectCheckInOut error:", { + errorMessage: error?.message, + status: error?.response?.status, + responseData: error?.response?.data, + }); + + Toast.show({ + type: "error", + text1: ":warning: Failed", + text2: + error?.response?.data?.message || + error?.response?.data || + error.message || + "Request failed", + }); + } finally { + setActionLoading(false); + } + }, + [ + dispatch, + distanceInfo, + employeeCode, + onBreak, + refreshAttendanceData, + restrictLocation, + setActionLoading, + syncBreakState, + ], + ); + + const handleCheckInOutPress = useCallback(async () => { + try { + const photoValue = await AsyncStorage.getItem("photo"); + const actionType = checkin ? "OUT" : "IN"; + + if (photoValue !== "1") { + await handleDirectCheckInOut(actionType); + } else { + navigation.navigate("Attendance camera", { + type: actionType, + }); + } + } catch (error) { + Toast.show({ + type: "error", + text1: ":warning: Action failed", + text2: error.message, + }); + } + }, [checkin, handleDirectCheckInOut, navigation]); + + return { + handleCheckInOutPress, + }; +} + +export default useAttendanceCheckInOut; diff --git a/hooks/attendanceAction/useAttendanceDevActions.js b/hooks/attendanceAction/useAttendanceDevActions.js new file mode 100644 index 0000000..bb26bf1 --- /dev/null +++ b/hooks/attendanceAction/useAttendanceDevActions.js @@ -0,0 +1,41 @@ +import { useCallback } from "react"; +import Toast from "react-native-toast-message"; +import AsyncStorage from "@react-native-async-storage/async-storage"; +import { saveTokens } from "../../services/api/apiClient"; + +function useAttendanceDevActions() { + const handleInvalidateAccessToken = useCallback(async () => { + try { + const refreshToken = await AsyncStorage.getItem("refresh_token"); + + if (!refreshToken) { + Toast.show({ + type: "error", + text1: "Refresh token missing", + text2: "Cannot invalidate access token without a refresh token.", + }); + return; + } + + await saveTokens("invalid-access-token-123", refreshToken); + const maskedRefresh = `${refreshToken.slice(0, 6)}...${refreshToken.slice(-4)}`; + Toast.show({ + type: "success", + text1: "Dev token invalidated", + text2: `Refresh token preserved: ${maskedRefresh}`, + }); + } catch (error) { + Toast.show({ + type: "error", + text1: "Dev token reset failed", + text2: error.message || "Unable to invalidate access token.", + }); + } + }, []); + + return { + handleInvalidateAccessToken, + }; +} + +export default useAttendanceDevActions; diff --git a/hooks/attendanceAction/useAttendanceMeta.js b/hooks/attendanceAction/useAttendanceMeta.js new file mode 100644 index 0000000..c5aefa2 --- /dev/null +++ b/hooks/attendanceAction/useAttendanceMeta.js @@ -0,0 +1,288 @@ +import { useEffect, useState, useCallback, useMemo } from "react"; +import { useDispatch, useSelector } from "react-redux"; +import Toast from "react-native-toast-message"; +import AsyncStorage from "@react-native-async-storage/async-storage"; +import { + setCheckin, + resetCheckin, + setBreakMinutes, + setTodayHours, + setMonthlyHours, + selectCheckinTime, +} from "../../redux/Slices/AttendanceSlice"; +import { updateDateTime } from "../../utils/TimeServices"; +import { + getOfficeLocation, + getAttendanceStatus, + getDailyWorkedHours, + getMonthlyWorkedHours, + getServerTime, + getTodayBreaks, +} from "../../services/api/attendance.service"; +import { getTodayString } from "./helpers"; + +const CHECKIN_START_STORAGE_KEY = "checkinStartTime"; +const CHECKOUT_TIME_STORAGE_KEY = "lastCheckoutTime"; + +const parseTimestampToMs = (value) => { + if (value === null || value === undefined || value === "") return null; + + const normalizeEpochMs = (epoch) => + epoch < 1_000_000_000_000 ? epoch * 1000 : epoch; + + if (typeof value === "number" && Number.isFinite(value)) { + return normalizeEpochMs(value); + } + + if (typeof value === "string") { + const trimmed = value.trim(); + if (/^\d+$/.test(trimmed)) { + const asNumber = Number(trimmed); + return Number.isFinite(asNumber) ? normalizeEpochMs(asNumber) : null; + } + } + + const parsed = new Date(value).getTime(); + return Number.isFinite(parsed) ? parsed : null; +}; + +const pickBestCheckinStartMs = (...candidates) => { + const now = Date.now(); + const MAX_FUTURE_SKEW_MS = 5 * 60 * 1000; + + const normalized = candidates.filter( + (value) => Number.isFinite(value) && value > 0, + ); + + if (normalized.length === 0) return now; + + const plausible = normalized.filter( + (value) => value <= now + MAX_FUTURE_SKEW_MS, + ); + + const pool = plausible.length ? plausible : normalized; + return Math.max(...pool); +}; + +const dropIfNotAfterCheckout = (candidate, lastCheckoutMs) => { + if (!Number.isFinite(candidate)) return null; + if (!Number.isFinite(lastCheckoutMs)) return candidate; + return candidate > lastCheckoutMs ? candidate : null; +}; + +function useAttendanceMeta({ employeeCode, isMountedRef }) { + const dispatch = useDispatch(); + const existingCheckinTime = useSelector(selectCheckinTime); + + const [refresh, setRefresh] = useState(false); + const [dateTime, setDateTime] = useState(null); + const [inTarget, setInTarget] = useState(true); + const [ready, setReady] = useState(false); + const [distanceInfo, setDistanceInfo] = useState(null); + const [restrictLocation, setRestrictLocation] = useState("0"); + const [restrictionLoaded, setRestrictionLoaded] = useState(false); + + const isLocationBlocked = restrictLocation === "1" && !inTarget; + + const locationStatusText = useMemo(() => { + if (restrictLocation === "0") return "Not Required"; + if (!ready) return "Getting Location..."; + return inTarget ? "In bound" : "Out of bound"; + }, [restrictLocation, ready, inTarget]); + + useEffect(() => { + const loadRestriction = async () => { + const stored = await AsyncStorage.getItem("restrict_location"); + if (!isMountedRef.current) return; + setRestrictLocation(stored === "1" ? "1" : "0"); + setRestrictionLoaded(true); + }; + + loadRestriction(); + }, [isMountedRef]); + + const syncCheckinFromStatus = useCallback( + async (status) => { + const isCheckedIn = + status?.custom_in === 1 || + status?.custom_in === "1" || + status?.custom_in === true || + `${status?.log_type || ""}`.toUpperCase() === "IN"; + + if (isCheckedIn) { + const statusStartMs = parseTimestampToMs( + status?.checkin_time ?? + status?.timestamp ?? + status?.creation ?? + status?.time ?? + status?.checkinTime, + ); + const storedStartRaw = await AsyncStorage.getItem( + CHECKIN_START_STORAGE_KEY, + ); + const lastCheckoutRaw = await AsyncStorage.getItem( + CHECKOUT_TIME_STORAGE_KEY, + ); + + const storedStartMs = parseTimestampToMs(storedStartRaw); + const existingStartMs = parseTimestampToMs(existingCheckinTime); + const lastCheckoutMs = parseTimestampToMs(lastCheckoutRaw); + + const sanitizedStatusStartMs = dropIfNotAfterCheckout( + statusStartMs, + lastCheckoutMs, + ); + const sanitizedStoredStartMs = dropIfNotAfterCheckout( + storedStartMs, + lastCheckoutMs, + ); + const sanitizedExistingStartMs = dropIfNotAfterCheckout( + existingStartMs, + lastCheckoutMs, + ); + + const checkinStartTime = pickBestCheckinStartMs( + sanitizedStatusStartMs, + sanitizedStoredStartMs, + sanitizedExistingStartMs, + ); + + await AsyncStorage.setItem( + CHECKIN_START_STORAGE_KEY, + String(checkinStartTime), + ); + + if ( + Number.isFinite(lastCheckoutMs) && + Number.isFinite(checkinStartTime) && + checkinStartTime > lastCheckoutMs + ) { + await AsyncStorage.removeItem(CHECKOUT_TIME_STORAGE_KEY); + } + + dispatch( + setCheckin({ + checkinTime: checkinStartTime, + location: null, + }), + ); + return; + } + + await AsyncStorage.removeItem(CHECKIN_START_STORAGE_KEY); + dispatch(resetCheckin()); + }, + [dispatch, existingCheckinTime], + ); + + useEffect(() => { + const loadCheckinStatus = async () => { + try { + const status = await getAttendanceStatus(); + + if (!isMountedRef.current) return; + await syncCheckinFromStatus(status); + } catch (error) { + console.log("Status sync error:", error); + } + }; + + if (employeeCode) { + loadCheckinStatus(); + } + }, [employeeCode, isMountedRef, syncCheckinFromStatus]); + + const fetchStatusAndLocation = useCallback(async () => { + setReady(false); + + try { + if (restrictLocation === "0") { + setInTarget(true); + setDistanceInfo(null); + return; + } + + const nearest = await getOfficeLocation(employeeCode); + if (!isMountedRef.current) return; + setInTarget(nearest.withinRadius); + setDistanceInfo(nearest); + } catch (error) { + if (!isMountedRef.current) return; + Toast.show({ + type: "error", + text1: ":warning: Location error", + text2: error.message, + }); + setInTarget(false); + } finally { + if (!isMountedRef.current) return; + setReady(true); + } + }, [employeeCode, isMountedRef, restrictLocation]); + + const onRefresh = useCallback(() => { + setRefresh(true); + fetchStatusAndLocation().finally(() => { + if (!isMountedRef.current) return; + setRefresh(false); + }); + }, [fetchStatusAndLocation, isMountedRef]); + + useEffect(() => { + if (restrictionLoaded && employeeCode) { + fetchStatusAndLocation(); + } + }, [employeeCode, fetchStatusAndLocation, restrictionLoaded]); + + useEffect(() => { + const loadServerTime = async () => { + const server = await getServerTime(); + if (!isMountedRef.current) return; + if (server) setDateTime(updateDateTime(server)); + }; + + loadServerTime(); + const intervalId = setInterval(loadServerTime, 10000); + + return () => clearInterval(intervalId); + }, [isMountedRef]); + + const refreshAttendanceData = useCallback(async () => { + const todayStr = getTodayString(); + const now = new Date(); + + const [todayWorked, monthlyWorked, breakData] = await Promise.all([ + getDailyWorkedHours(employeeCode, todayStr), + getMonthlyWorkedHours( + employeeCode, + now.getMonth() + 1, + now.getFullYear(), + ), + getTodayBreaks(employeeCode, todayStr), + ]); + + dispatch(setTodayHours(todayWorked ?? "00:00")); + dispatch(setMonthlyHours(monthlyWorked ?? "00:00")); + dispatch(setBreakMinutes(breakData?.total_break_minutes ?? 0)); + + return breakData; + }, [dispatch, employeeCode]); + + return { + dateTime, + distanceInfo, + fetchStatusAndLocation, + inTarget, + isLocationBlocked, + locationStatusText, + onRefresh, + ready, + refresh, + refreshAttendanceData, + restrictLocation, + restrictionLoaded, + syncCheckinFromStatus, + }; +} + +export default useAttendanceMeta; diff --git a/hooks/useAttendanceActionController.js b/hooks/useAttendanceActionController.js new file mode 100644 index 0000000..84c2403 --- /dev/null +++ b/hooks/useAttendanceActionController.js @@ -0,0 +1,114 @@ +import { useEffect, useState, useRef } from "react"; +import { useSelector } from "react-redux"; +import useAttendanceMeta from "./attendanceAction/useAttendanceMeta"; +import useAttendanceBreakFlow from "./attendanceAction/useAttendanceBreakFlow"; +import useAttendanceCheckInOut from "./attendanceAction/useAttendanceCheckInOut"; +import useAttendanceDevActions from "./attendanceAction/useAttendanceDevActions"; + +function useAttendanceActionController({ navigation }) { + const checkin = useSelector((state) => state.attendance.checkin); + const userDetails = useSelector((state) => state.user.userDetails); + const breakMinutes = useSelector((state) => state.attendance.breakMinutes); + const employeeCode = userDetails?.employeeCode; + + const [actionLoading, setActionLoading] = useState(false); + const isMountedRef = useRef(true); + + useEffect(() => { + isMountedRef.current = true; + return () => { + isMountedRef.current = false; + }; + }, []); + + const { + dateTime, + distanceInfo, + isLocationBlocked, + locationStatusText, + onRefresh, + refresh, + refreshAttendanceData, + restrictLocation, + restrictionLoaded, + syncCheckinFromStatus, + } = useAttendanceMeta({ employeeCode, isMountedRef }); + + const { + applyDevBreakPreset, + breakCompleted, + devBreakMockMode, + handleBreak, + liveBreakTime, + monthlyCapMessage, + onBreak, + syncBreakState, + toggleDevBreakMockMode, + } = useAttendanceBreakFlow({ + navigation, + employeeCode, + checkin, + breakMinutes, + isLocationBlocked, + isMountedRef, + refreshAttendanceData, + syncCheckinFromStatus, + setActionLoading, + }); + + const { handleCheckInOutPress } = useAttendanceCheckInOut({ + navigation, + checkin, + employeeCode, + distanceInfo, + restrictLocation, + onBreak, + refreshAttendanceData, + syncBreakState, + setActionLoading, + }); + + const { handleInvalidateAccessToken } = useAttendanceDevActions(); + + const breakDisabled = + actionLoading || isLocationBlocked || breakCompleted || breakMinutes >= 120; + + const breakButtonLabel = breakDisabled + ? "BREAK NOT ALLOWED" + : onBreak + ? "END BREAK" + : "TAKE BREAK"; + + const breakButtonToneClass = breakDisabled + ? "bg-gray-400" + : onBreak + ? "bg-slate-500" + : "bg-blue-400"; + + return { + actionLoading, + applyDevBreakPreset, + breakButtonLabel, + breakButtonToneClass, + breakDisabled, + checkin, + dateTime, + devBreakMockMode, + distanceInfo, + handleBreak, + handleCheckInOutPress, + handleInvalidateAccessToken, + isLocationBlocked, + liveBreakTime, + locationStatusText, + monthlyCapMessage, + onBreak, + onRefresh, + refresh, + restrictLocation, + restrictionLoaded, + toggleDevBreakMockMode, + }; +} + +export default useAttendanceActionController; diff --git a/redux/Slices/AttendanceSlice.js b/redux/Slices/AttendanceSlice.js index 076c4e4..0b6fd6d 100644 --- a/redux/Slices/AttendanceSlice.js +++ b/redux/Slices/AttendanceSlice.js @@ -12,7 +12,6 @@ const initialState = { breakTakenToday: false, onBreak: false, breakStartTime: null, - }; export const AttendanceSlice = createSlice({ @@ -27,6 +26,7 @@ export const AttendanceSlice = createSlice({ }, setCheckout: (state, action) => { state.checkin = false; + state.checkinTime = null; state.checkoutTime = action.payload.checkoutTime; state.location = null; // clear location on checkout }, diff --git a/screens/AttendanceAction.jsx b/screens/AttendanceAction.jsx index cbac067..3e57edc 100644 --- a/screens/AttendanceAction.jsx +++ b/screens/AttendanceAction.jsx @@ -1,10 +1,4 @@ -import React, { - useEffect, - useLayoutEffect, - useState, - useRef, - useCallback, -} from "react"; +import React, { useLayoutEffect } from "react"; import { View, Text, @@ -12,86 +6,48 @@ import { ActivityIndicator, ScrollView, RefreshControl, - Alert, StyleSheet, } from "react-native"; -import { Entypo, MaterialCommunityIcons } from "@expo/vector-icons"; -import { useDispatch, useSelector } from "react-redux"; -import Toast from "react-native-toast-message"; +import { Entypo } from "@expo/vector-icons"; import { useNavigation } from "@react-navigation/native"; -import AsyncStorage from "@react-native-async-storage/async-storage"; -import { - setCheckin, - setCheckout, - resetCheckin, - setBreakMinutes, - setBreakStatus, - setTodayHours, - setMonthlyHours, -} from "../redux/Slices/AttendanceSlice"; import { COLORS, SIZES } from "../constants"; import WelcomeCard from "../components/AttendanceAction/WelcomeCard"; -import { updateDateTime } from "../utils/TimeServices"; -import { saveTokens } from "../services/api/apiClient"; -import { - getOfficeLocation, - userCheckIn, - getAttendanceStatus, - getDailyWorkedHours, - getMonthlyWorkedHours, - getServerTime, - employeeBreak, - getTodayBreaks, -} from "../services/api/attendance.service"; +import BreakInProgressBanner from "../components/AttendanceAction/BreakInProgressBanner"; +import DevBreakTools from "../components/AttendanceAction/DevBreakTools"; +import AttendanceActionForm from "../components/AttendanceAction/AttendanceActionForm"; +import useAttendanceActionController from "../hooks/useAttendanceActionController"; import { SafeAreaView, useSafeAreaInsets, } from "react-native-safe-area-context"; -const BREAK_LIMIT_MS = 2 * 60 * 60 * 1000; // 2 hours - -/** Returns today's date formatted as DD-MM-YYYY */ -const getTodayString = () => - new Date().toLocaleDateString("en-GB").replace(/\//g, "-"); - function AttendanceAction() { const insets = useSafeAreaInsets(); const navigation = useNavigation(); - const dispatch = useDispatch(); - const checkin = useSelector((state) => state.attendance.checkin); - const userDetails = useSelector((state) => state.user.userDetails); - const breakMinutes = useSelector((state) => state.attendance.breakMinutes); - const employeeCode = userDetails?.employeeCode; - const [refresh, setRefresh] = useState(false); - const [dateTime, setDateTime] = useState(null); - const [inTarget, setInTarget] = useState(true); - const [ready, setReady] = useState(false); - const [distanceInfo, setDistanceInfo] = useState(null); - const [actionLoading, setActionLoading] = useState(false); - const [restrictLocation, setRestrictLocation] = useState("0"); - const [restrictionLoaded, setRestrictionLoaded] = useState(false); - const [onBreak, setOnBreak] = useState(false); - const [liveBreakTime, setLiveBreakTime] = useState("00:00:00"); - const [breakStartTime, setBreakStartTime] = useState(null); - const breakTriggeredRef = useRef(false); - const isMountedRef = useRef(true); - const [breakCompleted, setBreakCompleted] = useState(false); - const [monthlyCapMessage, setMonthlyCapMessage] = useState(""); - const [devBreakMockMode, setDevBreakMockMode] = useState(false); - const isBreakCompleted = (breakData) => { - if (!breakData?.breaks?.length) return false; - - const hasIn = breakData.breaks.some((b) => b.start); - const hasOut = breakData.breaks.some((b) => b.end); - - return hasIn && hasOut; - }; - useEffect(() => { - isMountedRef.current = true; - return () => { - isMountedRef.current = false; - }; - }, []); + const { + actionLoading, + applyDevBreakPreset, + breakButtonLabel, + breakButtonToneClass, + breakDisabled, + checkin, + dateTime, + devBreakMockMode, + distanceInfo, + handleBreak, + handleCheckInOutPress, + handleInvalidateAccessToken, + isLocationBlocked, + liveBreakTime, + locationStatusText, + monthlyCapMessage, + onBreak, + onRefresh, + refresh, + restrictLocation, + restrictionLoaded, + toggleDevBreakMockMode, + } = useAttendanceActionController({ navigation }); useLayoutEffect(() => { navigation.setOptions({ @@ -112,593 +68,6 @@ function AttendanceAction() { }); }, [navigation]); - // Load location restriction - useEffect(() => { - const loadRestriction = async () => { - const r = await AsyncStorage.getItem("restrict_location"); - if (!isMountedRef.current) return; - setRestrictLocation(r === "1" ? "1" : "0"); - setRestrictionLoaded(true); - }; - loadRestriction(); - }, []); - useEffect(() => { - const loadCheckinStatus = async () => { - try { - const res = await getAttendanceStatus(); - - if (!isMountedRef.current) return; - - if (res.custom_in === 1) { - dispatch( - setCheckin({ - checkinTime: Date.now(), - location: null, - }), - ); - } else { - // dispatch(setCheckout({ checkoutTime: null })); - dispatch(resetCheckin()); - } - } catch (e) { - console.log("Status sync error:", e); - } - }; - - if (employeeCode) { - loadCheckinStatus(); - } - }, [employeeCode]); - - const fetchStatusAndLocation = useCallback(async () => { - try { - setReady(false); - - if (restrictLocation === "0") { - setInTarget(true); - setDistanceInfo(null); - setReady(true); - return; - } - - const nearest = await getOfficeLocation(employeeCode); - if (!isMountedRef.current) return; - setInTarget(nearest.withinRadius); - setDistanceInfo(nearest); - setReady(true); - } catch (error) { - if (!isMountedRef.current) return; - Toast.show({ - type: "error", - text1: ":warning: Location error", - text2: error.message, - }); - setInTarget(false); - setReady(true); - } - }, [restrictLocation, employeeCode]); - - // Fetch GPS on mount - useEffect(() => { - if (restrictionLoaded && employeeCode) fetchStatusAndLocation(); - }, [restrictionLoaded, employeeCode, fetchStatusAndLocation]); - - // Update date & time every 10 seconds - useEffect(() => { - const loadServerTime = async () => { - const server = await getServerTime(); - if (!isMountedRef.current) return; - if (server) setDateTime(updateDateTime(server)); - }; - - loadServerTime(); - const intervalId = setInterval(loadServerTime, 10000); - return () => clearInterval(intervalId); - }, []); - - /** - * Dispatches today + monthly worked hours and break minutes to Redux. - * Avoids duplicating three identical Promise.all blocks across handlers. - */ - const refreshAttendanceData = useCallback(async () => { - const todayStr = getTodayString(); - const now = new Date(); - const [todayWorked, monthlyWorked, breakData] = await Promise.all([ - getDailyWorkedHours(employeeCode, todayStr), - getMonthlyWorkedHours( - employeeCode, - now.getMonth() + 1, - now.getFullYear(), - ), - getTodayBreaks(employeeCode, todayStr), - ]); - - dispatch(setTodayHours(todayWorked ?? "00:00")); - dispatch(setMonthlyHours(monthlyWorked ?? "00:00")); - dispatch(setBreakMinutes(breakData?.total_break_minutes ?? 0)); - - return breakData; - }, [dispatch, employeeCode]); - - /** - * Syncs onBreak / breakStartTime state from a breakData response object. - */ - - const syncBreakState = useCallback(async (breakData) => { - const lastBreak = breakData?.breaks?.find((b) => !b.end || b.end === null); - - if (!lastBreak) { - setOnBreak(false); - setBreakStartTime(null); - await AsyncStorage.removeItem("breakStartTime"); // ✅ keep storage clean - return; - } - - const isOpen = - !lastBreak.end || lastBreak.end === "" || lastBreak.end === null; - - setOnBreak(isOpen); - - if (isOpen) breakTriggeredRef.current = false; - - const savedTime = await AsyncStorage.getItem("breakStartTime"); - - if (savedTime) { - setBreakStartTime(parseInt(savedTime)); - } else { - const backendTime = new Date(lastBreak.start).getTime(); - setBreakStartTime(backendTime); - - await AsyncStorage.setItem("breakStartTime", backendTime.toString()); - } - }, []); - - useEffect(() => { - const unsubscribe = navigation.addListener("focus", async () => { - try { - if (!employeeCode) return; - - // ✅ 1. FIRST: sync attendance status - const res = await getAttendanceStatus(); - - console.log("FOCUS STATUS:", res.custom_in); - - if (!isMountedRef.current) return; - - if (res.custom_in === 1) { - dispatch( - setCheckin({ - checkinTime: Date.now(), - location: null, - }), - ); - } else { - dispatch(resetCheckin()); - } - - // ✅ 2. THEN: fetch totals - const breakData = await refreshAttendanceData(); - - if (!isMountedRef.current) return; - setBreakCompleted(isBreakCompleted(breakData)); - - syncBreakState(breakData); - } catch (e) { - console.log("Focus sync error:", e); - } - }); - - return unsubscribe; - }, [navigation, employeeCode]); - - useEffect(() => { - const loadBreak = async () => { - const saved = await AsyncStorage.getItem("breakStartTime"); - const breakData = await getTodayBreaks(employeeCode, getTodayString()); - setBreakCompleted(isBreakCompleted(breakData)); - - if (saved) { - const parsedTime = parseInt(saved); - - setBreakStartTime(parsedTime); - setOnBreak(true); - } - }; - - loadBreak(); - }, []); - - useEffect(() => { - if (!onBreak || !breakStartTime) { - setLiveBreakTime("00:00:00"); - return; - } - - const interval = setInterval(async () => { - const diff = Date.now() - breakStartTime; - const currentBreakSeconds = Math.floor(diff / 1000); - - const hrs = String(Math.floor(currentBreakSeconds / 3600)).padStart( - 2, - "0", - ); - const mins = String( - Math.floor((currentBreakSeconds % 3600) / 60), - ).padStart(2, "0"); - const secs = String(currentBreakSeconds % 60).padStart(2, "0"); - setLiveBreakTime(`${hrs}:${mins}:${secs}`); - - if (diff >= BREAK_LIMIT_MS && !breakTriggeredRef.current) { - breakTriggeredRef.current = true; - - try { - await employeeBreak({ employeeCode, type: "OUT" }); - await AsyncStorage.removeItem("breakStartTime"); - - if (!isMountedRef.current) return; - setOnBreak(false); - setBreakStartTime(null); - setBreakCompleted(true); - - const breakData = await getTodayBreaks( - employeeCode, - getTodayString(), - ); - if (!isMountedRef.current) return; - dispatch(setBreakMinutes(breakData?.total_break_minutes ?? 0)); - - Alert.alert( - "Break Ended", - "2-hour break limit reached. Break automatically stopped.", - ); - } catch { - Alert.alert("Error", "Auto break end failed"); - } - } - }, 1000); - console.log("BREAK START TIME:", breakStartTime); - console.log("NOW:", Date.now()); - console.log("DIFF MIN:", (Date.now() - breakStartTime) / 60000); - - return () => clearInterval(interval); - }, [onBreak, breakStartTime, dispatch, employeeCode]); - - const handleDirectCheckInOut = useCallback( - async (type) => { - try { - setActionLoading(true); - const response = await userCheckIn({ - employeeCode, - type, - locationData: distanceInfo, - }); - - if (!response.allowed) { - Toast.show({ - type: "error", - text1: ":warning: Action blocked", - text2: response.message, - }); - return; - } - - if (type === "IN") { - dispatch({ type: "attendance/setSelectedLocation", payload: null }); - dispatch( - setCheckin({ - checkinTime: Date.now(), - location: restrictLocation === "1" ? response.location : null, - }), - ); - } else { - if (onBreak) { - const breakRes = await employeeBreak({ - employeeCode, - type: "OUT", - }); - if (!breakRes?.allowed) { - console.log("Break already ended from backend"); - } - } - dispatch(setCheckout({ checkoutTime: Date.now() })); - dispatch({ type: "attendance/setSelectedLocation", payload: null }); - } - - const breakData = await refreshAttendanceData(); - syncBreakState(breakData); - - Toast.show({ - type: "success", - text1: type === "IN" ? "Checked in!" : "Checked out!", - }); - } catch (error) { - console.log("AttendanceAction.handleDirectCheckInOut error:", { - errorMessage: error?.message, - status: error?.response?.status, - responseData: error?.response?.data, - }); - - Toast.show({ - type: "error", - text1: ":warning: Failed", - text2: - error?.response?.data?.message || - error?.response?.data || - error.message || - "Request failed", - }); - } finally { - setActionLoading(false); - } - }, - [ - employeeCode, - distanceInfo, - restrictLocation, - onBreak, - dispatch, - refreshAttendanceData, - syncBreakState, - ], - ); - - const handleInvalidateAccessToken = useCallback(async () => { - try { - const refreshToken = await AsyncStorage.getItem("refresh_token"); - - if (!refreshToken) { - Toast.show({ - type: "error", - text1: "Refresh token missing", - text2: "Cannot invalidate access token without a refresh token.", - }); - return; - } - - await saveTokens("invalid-access-token-123", refreshToken); - const maskedRefresh = `${refreshToken.slice(0, 6)}...${refreshToken.slice(-4)}`; - Toast.show({ - type: "success", - text1: "Dev token invalidated", - text2: `Refresh token preserved: ${maskedRefresh}`, - }); - } catch (error) { - Toast.show({ - type: "error", - text1: "Dev token reset failed", - text2: error.message || "Unable to invalidate access token.", - }); - } - }, []); - - const applyDevBreakPreset = useCallback( - async (preset) => { - const now = Date.now(); - const setIdleState = async (minutes, completed = false) => { - setOnBreak(false); - setBreakStartTime(null); - setBreakCompleted(completed); - setMonthlyCapMessage(""); - breakTriggeredRef.current = false; - dispatch(setBreakMinutes(minutes)); - dispatch( - setBreakStatus({ - onBreak: false, - breakStartTime: null, - }), - ); - await AsyncStorage.removeItem("breakStartTime"); - }; - - dispatch( - setCheckin({ - checkinTime: now, - location: null, - }), - ); - - if (preset === "idle-0") { - await setIdleState(0, false); - } - - if (preset === "idle-45") { - await setIdleState(45, false); - } - - if (preset === "running-30") { - const startTime = now - 30 * 60 * 1000; - setOnBreak(true); - setBreakStartTime(startTime); - setBreakCompleted(false); - breakTriggeredRef.current = false; - dispatch(setBreakMinutes(45)); - dispatch( - setBreakStatus({ - onBreak: true, - breakStartTime: startTime, - }), - ); - await AsyncStorage.setItem("breakStartTime", String(startTime)); - } - - if (preset === "cap-120") { - await setIdleState(120, false); - } - - if (preset === "completed") { - await setIdleState(60, true); - } - - if (preset === "monthly-cap") { - await setIdleState(30, true); - setMonthlyCapMessage("Monthly break limit reached (8h)"); - Toast.show({ - type: "error", - text1: "Monthly break limit reached (8h)", - }); - } - - if (__DEV__) { - setDevBreakMockMode(true); - } - - Toast.show({ - type: "success", - text1: `DEV preset applied: ${preset}`, - }); - }, - [dispatch], - ); - - const handleBreak = useCallback(async () => { - if (!checkin) { - Toast.show({ type: "error", text1: "Please check-in first" }); - return; - } - - if (restrictLocation === "1" && !inTarget) { - Toast.show({ - type: "error", - text1: "You are out of allowed location", - }); - return; - } - - if (__DEV__ && devBreakMockMode) { - try { - setActionLoading(true); - - if (!onBreak) { - const startTime = Date.now(); - setOnBreak(true); - setBreakStartTime(startTime); - setBreakCompleted(false); - setMonthlyCapMessage(""); - breakTriggeredRef.current = false; - - dispatch( - setBreakStatus({ - onBreak: true, - breakStartTime: startTime, - }), - ); - await AsyncStorage.setItem("breakStartTime", String(startTime)); - - Toast.show({ type: "success", text1: "DEV break started (local)" }); - return; - } - - const elapsedMinutes = Math.max( - 0, - Math.floor((Date.now() - breakStartTime) / 60000), - ); - const nextTotal = Math.min(120, (breakMinutes ?? 0) + elapsedMinutes); - - setOnBreak(false); - setBreakStartTime(null); - setBreakCompleted(true); - setMonthlyCapMessage(""); - dispatch(setBreakMinutes(nextTotal)); - dispatch( - setBreakStatus({ - onBreak: false, - breakStartTime: null, - }), - ); - await AsyncStorage.removeItem("breakStartTime"); - - Toast.show({ - type: "success", - text1: `DEV break ended (total: ${nextTotal}m)`, - }); - return; - } finally { - setActionLoading(false); - } - } - - // ✅ FIRST check from backend (important) - const breakDataCheck = await getTodayBreaks(employeeCode, getTodayString()); - - if (isBreakCompleted(breakDataCheck)) { - Toast.show({ - type: "error", - text1: "Break already completed for today", - }); - return; - } - - const type = onBreak ? "OUT" : "IN"; - - try { - setActionLoading(true); - - // const response = await employeeBreak({ employeeCode, type }); - - // if (!response.allowed) { - // Toast.show({ type: "error", text1: response.message }); - // return; - // } - const response = await employeeBreak({ employeeCode, type }); - - if (!response.allowed) { - // ✅ Handle monthly limit from backend - if (response.message?.includes("Monthly break limit")) { - setBreakCompleted(true); // disable button - setMonthlyCapMessage(response.message); - } else { - setMonthlyCapMessage(""); - } - - Toast.show({ type: "error", text1: response.message }); - return; - } - - if (type === "IN") { - const startTime = Date.now(); - - setOnBreak(true); - setBreakStartTime(startTime); - setMonthlyCapMessage(""); - - await AsyncStorage.setItem("breakStartTime", startTime.toString()); - } else { - setOnBreak(false); - setBreakStartTime(null); - setMonthlyCapMessage(""); - - await AsyncStorage.removeItem("breakStartTime"); - } - - // ✅ Fetch latest break data - const breakData = await getTodayBreaks(employeeCode, getTodayString()); - - setBreakCompleted(isBreakCompleted(breakData)); - dispatch(setBreakMinutes(breakData?.total_break_minutes ?? 0)); - - syncBreakState(breakData); - - Toast.show({ type: "success", text1: response.message }); - } catch (error) { - Toast.show({ - type: "error", - text1: "Break failed", - text2: error.message, - }); - } finally { - setActionLoading(false); - } - }, [ - checkin, - restrictLocation, - inTarget, - onBreak, - devBreakMockMode, - breakMinutes, - breakStartTime, - dispatch, - ]); - // Temporary loading screen if (!restrictionLoaded) { return ( @@ -747,242 +116,38 @@ function AttendanceAction() { paddingBottom: Math.max(insets.bottom, 16), }} refreshControl={ - { - setRefresh(true); - fetchStatusAndLocation().finally(() => setRefresh(false)); - }} - /> + } > - {onBreak && ( - - - BREAK IN PROGRESS - - - {liveBreakTime || "00:00:00"} - - - Auto-ends at 02:00:00 - - - )} - - {__DEV__ && ( - - - - DEV: Invalidate access token - - - - - DEV: Break UI presets - - - setDevBreakMockMode((prev) => !prev)} - > - - DEV local break flow: {devBreakMockMode ? "ON" : "OFF"} - - - - - applyDevBreakPreset("idle-0")} - > - - Idle 00:00 - - - - applyDevBreakPreset("idle-45")} - > - - Idle 00:45 - - + {onBreak && } - applyDevBreakPreset("running-30")} - > - - Running +30m - - - - applyDevBreakPreset("cap-120")} - > - - Cap 02:00 - - - - applyDevBreakPreset("completed")} - > - - Completed 1/day - - + - applyDevBreakPreset("monthly-cap")} - > - - Monthly Cap 8h - - - - + {__DEV__ && ( + )} - - - {/* DATE & TIME */} - - DATE AND TIME * - - - - {dateTime} - - - - {/* LOCATION */} - - LOCATION * - - - - {restrictLocation === "0" - ? "Not Required" - : !ready - ? "Getting Location..." - : // : distanceInfo?.locationName - // ? distanceInfo.locationName - inTarget - ? "In bound" - : "Out of bound"} - - - - {restrictLocation === "1" && distanceInfo && ( - - - Distance: {distanceInfo.distance} m | Allowed:{" "} - {distanceInfo.radius} m - - - )} - - {/* CHECK-IN / CHECK-OUT BUTTON */} - { - try { - const photoValue = await AsyncStorage.getItem("photo"); - const actionType = checkin ? "OUT" : "IN"; - - if (photoValue !== "1") { - await handleDirectCheckInOut(actionType); - } else { - navigation.navigate("Attendance camera", { - type: actionType, - }); - } - } catch (error) { - Toast.show({ - type: "error", - text1: ":warning: Action failed", - text2: error.message, - }); - } - }} - > - - {checkin ? "CHECK-OUT" : "CHECK-IN"} - - - {/* BREAK BUTTON */} - {checkin && ( - - = 120 - ? "bg-gray-400" // ✅ disabled color - : onBreak - ? "bg-slate-500" // break running - : "bg-blue-400" // normal - }`} - disabled={ - actionLoading || - (restrictLocation === "1" && !inTarget) || - breakCompleted || - breakMinutes >= 120 - } - onPress={handleBreak} - > - - {actionLoading || - (restrictLocation === "1" && !inTarget) || - breakCompleted || - breakMinutes >= 120 - ? "BREAK NOT ALLOWED" - : onBreak - ? "END BREAK" - : "TAKE BREAK"} - - - - {!!monthlyCapMessage && ( - - - {monthlyCapMessage} - - - )} - - )} - - + diff --git a/screens/AttendanceCamera.jsx b/screens/AttendanceCamera.jsx index 07e7571..20792e1 100644 --- a/screens/AttendanceCamera.jsx +++ b/screens/AttendanceCamera.jsx @@ -32,6 +32,9 @@ import { getOfficeLocation, } from "../services/api"; +const CHECKIN_START_STORAGE_KEY = "checkinStartTime"; +const CHECKOUT_TIME_STORAGE_KEY = "lastCheckoutTime"; + function AttendanceCamera() { const navigation = useNavigation(); const dispatch = useDispatch(); @@ -137,18 +140,31 @@ function AttendanceCamera() { await uploadPicture(docname); // Redux update if (custom_in === 1) { + const checkinStartTime = Date.now(); + await AsyncStorage.removeItem(CHECKOUT_TIME_STORAGE_KEY); + await AsyncStorage.setItem( + CHECKIN_START_STORAGE_KEY, + String(checkinStartTime), + ); + dispatch( setCheckin({ - checkinTime: new Date().toISOString(), + checkinTime: checkinStartTime, location: { locationName: locationData?.locationName || "Office", latitude: locationData?.latitude, longitude: locationData?.longitude, radius: locationData?.radius, }, - }) + }), ); } else { + const checkoutTime = Date.now(); + await AsyncStorage.removeItem(CHECKIN_START_STORAGE_KEY); + await AsyncStorage.setItem( + CHECKOUT_TIME_STORAGE_KEY, + String(checkoutTime), + ); dispatch(setCheckout({ checkoutTime: new Date().toISOString() })); } diff --git a/screens/Profile.jsx b/screens/Profile.jsx index 95b8c9c..a0c18a4 100644 --- a/screens/Profile.jsx +++ b/screens/Profile.jsx @@ -296,7 +296,7 @@ function Profile() { Employee account - Version {appVersion}-APR-28 + Version {appVersion}-APR-29-exp {deviceName} diff --git a/services/api/attendance.service.js b/services/api/attendance.service.js index 4192a58..6b88157 100644 --- a/services/api/attendance.service.js +++ b/services/api/attendance.service.js @@ -295,8 +295,22 @@ export const getAttendanceStatus = async () => { console.log("LATEST RECORD:", latest); + const isCustomIn = + latest?.custom_in === 1 || + latest?.custom_in === "1" || + latest?.custom_in === true; + const isLogIn = `${latest?.log_type || ""}`.toUpperCase() === "IN"; + const isCheckedIn = isCustomIn || isLogIn; + return { - custom_in: latest?.custom_in === 1 || latest?.log_type === "IN" ? 1 : 0, + custom_in: isCheckedIn ? 1 : 0, + checkin_time: isCheckedIn + ? (latest?.checkin_time ?? + latest?.timestamp ?? + latest?.time ?? + latest?.creation ?? + null) + : null, }; } catch (e) { console.log("STATUS ERROR:", e);