From 72590283d0659fee8a1b817ca547238f8f5d68ab Mon Sep 17 00:00:00 2001 From: Carlos Gutierrez Date: Fri, 18 Sep 2026 21:01:05 -0400 Subject: [PATCH] updating the layout based on the endpoints --- src/App.tsx | 10 +- src/api/apiClient.ts | 60 ++++++++- src/pages/CompaniesPage.module.scss | 3 + src/pages/HomePage.module.scss | 19 +++ src/pages/HomePage.tsx | 15 ++- src/pages/UsersPage.module.scss | 63 +++++++++ src/pages/UsersPage.tsx | 194 +++++++++++++++++++++++++--- src/store/backendPreferenceStore.ts | 21 +++ src/types/schedule.ts | 2 + 9 files changed, 352 insertions(+), 35 deletions(-) create mode 100644 src/store/backendPreferenceStore.ts diff --git a/src/App.tsx b/src/App.tsx index 35a519b..dc4f1d7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,17 +1,15 @@ -import { useState } from "react"; -import { defaultBackendPreference } from "@api/backendConfig"; import { usePathname } from "@hooks/usePathname"; import { AppLayout } from "@layouts/AppLayout"; import { CompaniesPage } from "@pages/CompaniesPage"; import { HomePage } from "@pages/HomePage"; import { SchedulePage } from "@pages/SchedulePage"; import { UsersPage } from "@pages/UsersPage"; -import type { BackendPreference } from "@/types/backend"; +import { useBackendPreferenceStore } from "./store/backendPreferenceStore"; function App() { const [pathname, navigate] = usePathname(); - const [backendPreference, setBackendPreference] = - useState(defaultBackendPreference); + const backendPreference = useBackendPreferenceStore((state) => state.backendPreference); + const setBackendPreference = useBackendPreferenceStore((state) => state.setBackendPreference); const renderPage = () => { switch (pathname) { @@ -22,7 +20,7 @@ function App() { case "/companies": return ; default: - return ; + return ; } }; diff --git a/src/api/apiClient.ts b/src/api/apiClient.ts index 3379317..bbdb533 100644 --- a/src/api/apiClient.ts +++ b/src/api/apiClient.ts @@ -5,6 +5,58 @@ type ApiRequestOptions = RequestInit & { backendPreference: BackendPreference; }; +function formatErrorValue(value: unknown): string | null { + if (typeof value === "string") { + return value; + } + + if (Array.isArray(value)) { + return value.map(formatErrorValue).filter(Boolean).join(", ") || null; + } + + if (value && typeof value === "object") { + return Object.entries(value) + .map(([key, entryValue]) => { + const formattedValue = formatErrorValue(entryValue); + + return formattedValue ? `${key}: ${formattedValue}` : null; + }) + .filter(Boolean) + .join(", ") || null; + } + + if (value == null) { + return null; + } + + return String(value); +} + +function getErrorMessage(responseText: string, status: number): string { + if (!responseText) { + return `API request failed with status ${status}`; + } + + try { + const parsedResponse = JSON.parse(responseText) as unknown; + + if (parsedResponse && typeof parsedResponse === "object" && !Array.isArray(parsedResponse)) { + const errorResponse = parsedResponse as Record; + const message = + formatErrorValue(errorResponse.message) ?? + formatErrorValue(errorResponse.error) ?? + formatErrorValue(errorResponse.detail) ?? + formatErrorValue(errorResponse); + + return message ?? `API request failed with status ${status}`; + } + + return formatErrorValue(parsedResponse) ?? `API request failed with status ${status}`; + } catch { + return responseText; + } +} + export function buildApiUrl(backendPreference: BackendPreference, endpoint: string): string { const backend = getBackendOption(backendPreference); return `${backend.baseUrl}${endpoint}`; @@ -22,12 +74,12 @@ export async function apiRequest( }, }); - if (!response.ok) { - throw new Error(`API request failed with status ${response.status}`); - } - const responseText = await response.text(); + if (!response.ok) { + throw new Error(getErrorMessage(responseText, response.status)); + } + if (response.status === 204 || !responseText) { return undefined as TResponse; } diff --git a/src/pages/CompaniesPage.module.scss b/src/pages/CompaniesPage.module.scss index faf20e1..a5aa3e9 100644 --- a/src/pages/CompaniesPage.module.scss +++ b/src/pages/CompaniesPage.module.scss @@ -6,6 +6,7 @@ padding: 12px 18px; cursor: pointer; font-weight: 700; + height: 40px; white-space: nowrap; } @@ -43,6 +44,8 @@ background: #ffffff; border-radius: 24px; box-shadow: 0 20px 50px rgba(17, 24, 39, 0.08); + justify-content: space-between; + align-items: center; h3, p { diff --git a/src/pages/HomePage.module.scss b/src/pages/HomePage.module.scss index cd2b57b..cc6e22b 100644 --- a/src/pages/HomePage.module.scss +++ b/src/pages/HomePage.module.scss @@ -5,10 +5,29 @@ } .card { + width: 100%; + border: 0; padding: 24px; + text-align: left; background: #ffffff; border-radius: 24px; box-shadow: 0 20px 50px rgba(17, 24, 39, 0.08); + cursor: pointer; + font: inherit; + transition: + transform 0.18s ease, + box-shadow 0.18s ease; + + &:hover, + &:focus-visible { + transform: translateY(-2px); + box-shadow: 0 24px 58px rgba(17, 24, 39, 0.12); + } + + &:focus-visible { + outline: 3px solid #93c5fd; + outline-offset: 3px; + } span { display: block; diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx index 9a11224..dee8242 100644 --- a/src/pages/HomePage.tsx +++ b/src/pages/HomePage.tsx @@ -11,9 +11,10 @@ import styles from "./HomePage.module.scss"; type HomePageProps = { backendPreference: BackendPreference; + onNavigate: (path: string) => void; }; -export function HomePage({ backendPreference }: HomePageProps) { +export function HomePage({ backendPreference, onNavigate }: HomePageProps) { const [companies, setCompanies] = useState([]); const [schedules, setSchedules] = useState([]); const [users, setUsers] = useState([]); @@ -77,18 +78,18 @@ export function HomePage({ backendPreference }: HomePageProps) { {error ?

{error}

: null} {isLoading ?

Loading dashboard...

: null}
-
+
-
+ +
-
+ +
+
); diff --git a/src/pages/UsersPage.module.scss b/src/pages/UsersPage.module.scss index 9b51569..c92cf51 100644 --- a/src/pages/UsersPage.module.scss +++ b/src/pages/UsersPage.module.scss @@ -18,6 +18,11 @@ background: #ffffff; color: #172033; box-shadow: inset 0 0 0 1px #d9e1ec; + + &:disabled { + cursor: not-allowed; + opacity: 0.45; + } } .actionGroup { @@ -50,6 +55,34 @@ box-shadow: 0 20px 50px rgba(17, 24, 39, 0.08); } +.usersToolbar { + display: flex; + flex-wrap: wrap; + gap: 12px; + justify-content: flex-end; + margin: 0 0 20px; +} + +.companyFilter { + display: grid; + gap: 8px; + min-width: min(320px, 100%); + color: #687487; + font-size: 0.88rem; + font-weight: 700; + + select { + width: 100%; + border: 0; + border-radius: 14px; + padding: 11px 14px; + color: #1f2937; + background: #ffffff; + box-shadow: 0 14px 35px rgba(17, 24, 39, 0.07); + font: inherit; + } +} + .tableScroll { overflow-x: auto; } @@ -58,6 +91,20 @@ width: 100%; border-collapse: collapse; min-width: 760px; + table-layout: fixed; + + thead, + tbody tr { + display: table; + width: 100%; + table-layout: fixed; + } + + tbody { + display: block; + max-height: 520px; + overflow-y: auto; + } th, td { @@ -83,6 +130,22 @@ } } +.pagination { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 16px 18px; + color: #687487; + font-weight: 700; +} + +.paginationActions { + display: flex; + align-items: center; + gap: 12px; +} + .selectedRow { background: #eef4ff; } diff --git a/src/pages/UsersPage.tsx b/src/pages/UsersPage.tsx index c7dcdc4..cc9710a 100644 --- a/src/pages/UsersPage.tsx +++ b/src/pages/UsersPage.tsx @@ -18,20 +18,51 @@ type UsersPageProps = { type UserStatus = "off-duty" | "working" | "worked"; const workTimeOptions: WorkTime[] = ["morning", "afternoon", "evening"]; +const statusOptions: UserStatus[] = ["off-duty", "working", "worked"]; +const usersPerPage = 10; -function getUserStatus(schedules: Schedule[]): UserStatus { - const hasCheckIn = schedules.some((schedule) => schedule.enter_time); - const hasActiveShift = schedules.some((schedule) => schedule.enter_time && !schedule.out_time); +function isSameLocalDate(firstDate: Date, secondDate: Date): boolean { + return ( + firstDate.getFullYear() === secondDate.getFullYear() && + firstDate.getMonth() === secondDate.getMonth() && + firstDate.getDate() === secondDate.getDate() + ); +} - if (hasActiveShift) { - return "working"; +function getTodaySchedules(schedules: Schedule[], today: Date): Schedule[] { + return schedules.filter((schedule) => { + if (!schedule.enter_time) { + return false; + } + + const enterDate = new Date(schedule.enter_time); + + return !Number.isNaN(enterDate.getTime()) && isSameLocalDate(enterDate, today); + }); +} + +function getLatestSchedule(schedules: Schedule[]): Schedule | undefined { + return [...schedules].sort((firstSchedule, secondSchedule) => { + const firstTime = firstSchedule.enter_time ? new Date(firstSchedule.enter_time).getTime() : 0; + const secondTime = secondSchedule.enter_time ? new Date(secondSchedule.enter_time).getTime() : 0; + + return firstTime - secondTime; + }).at(-1); +} + +function getUserStatus(schedules: Schedule[], today: Date): UserStatus { + const todaySchedules = getTodaySchedules(schedules, today); + const latestSchedule = getLatestSchedule(todaySchedules); + + if (!latestSchedule?.enter_time) { + return "off-duty"; } - if (hasCheckIn) { + if (latestSchedule.out_time) { return "worked"; } - return "off-duty"; + return "working"; } function getStatusLabel(status: UserStatus): string { @@ -54,6 +85,30 @@ function formatDateTime(value: string | null): string { return new Date(value).toLocaleString(); } +function getNameScheduleKey(name: string, lastName: string): string { + return `name:${name.trim().toLowerCase()}:${lastName.trim().toLowerCase()}`; +} + +function getEmployeeScheduleKeys(user: Employee): string[] { + return [`id:${user.id}`, getNameScheduleKey(user.name, user.last_name)]; +} + +function getScheduleUserKey(schedule: Schedule): string | null { + if (typeof schedule.user_id === "number") { + return `id:${schedule.user_id}`; + } + + if (schedule.user) { + return getNameScheduleKey(schedule.user.name, schedule.user.last_name); + } + + return null; +} + +function getSchedulesForUser(user: Employee, schedulesByUser: Record): Schedule[] { + return getEmployeeScheduleKeys(user).flatMap((key) => schedulesByUser[key] ?? []); +} + export function UsersPage({ backendPreference }: UsersPageProps) { const [companies, setCompanies] = useState([]); const [schedules, setSchedules] = useState([]); @@ -71,20 +126,42 @@ export function UsersPage({ backendPreference }: UsersPageProps) { const [savingActionUserId, setSavingActionUserId] = useState(null); const [isLoading, setIsLoading] = useState(true); const [isSaving, setIsSaving] = useState(false); + const [currentPage, setCurrentPage] = useState(1); + const [selectedCompanyId, setSelectedCompanyId] = useState(""); + const [selectedStatus, setSelectedStatus] = useState(""); const selectedUser = users.find((user) => user.id === selectedUserId) ?? null; + const today = new Date(); const schedulesByUser = useMemo(() => { - return schedules.reduce>((accumulator, schedule) => { - if (typeof schedule.user_id !== "number") { + return schedules.reduce>((accumulator, schedule) => { + const userKey = getScheduleUserKey(schedule); + + if (!userKey) { return accumulator; } - accumulator[schedule.user_id] = [...(accumulator[schedule.user_id] ?? []), schedule]; + accumulator[userKey] = [...(accumulator[userKey] ?? []), schedule]; return accumulator; }, {}); }, [schedules]); + const filteredUsers = users.filter((user) => { + if (selectedCompanyId && user.company_id !== Number(selectedCompanyId)) { + return false; + } + + if (selectedStatus && getUserStatus(getSchedulesForUser(user, schedulesByUser), today) !== selectedStatus) { + return false; + } + + return true; + }); + const totalPages = Math.max(1, Math.ceil(filteredUsers.length / usersPerPage)); + const pageStartIndex = (currentPage - 1) * usersPerPage; + const paginatedUsers = filteredUsers.slice(pageStartIndex, pageStartIndex + usersPerPage); + const pageEndIndex = pageStartIndex + paginatedUsers.length; + const loadUsersPage = useCallback(async () => { setError(""); setIsLoading(true); @@ -110,6 +187,16 @@ export function UsersPage({ backendPreference }: UsersPageProps) { void loadUsersPage(); }, [loadUsersPage]); + useEffect(() => { + if (currentPage > totalPages) { + setCurrentPage(totalPages); + } + }, [currentPage, totalPages]); + + useEffect(() => { + setCurrentPage(1); + }, [selectedCompanyId, selectedStatus]); + function resetUserForm() { setCompanyId(""); setFirstName(""); @@ -173,6 +260,20 @@ export function UsersPage({ backendPreference }: UsersPageProps) { return days[new Date(value).getDay()]; } + function getShiftFromDateTime(value: string): WorkTime { + const hour = new Date(value).getHours(); + + if (hour < 12) { + return "morning"; + } + + if (hour < 19) { + return "evening"; + } + + return "afternoon"; + } + function openCheckInModal(user: Employee) { const now = new Date(); const local = new Date(now.getTime() - now.getTimezoneOffset() * 60000) @@ -196,6 +297,7 @@ export function UsersPage({ backendPreference }: UsersPageProps) { await clockIn(backendPreference, checkingInUser.id, { day_of_week: getDayOfWeekFromDateTime(checkInDateTime), enter_time: new Date(checkInDateTime).toISOString(), + shift: getShiftFromDateTime(checkInDateTime), }); setCheckingInUser(null); await loadUsersPage(); @@ -249,6 +351,34 @@ export function UsersPage({ backendPreference }: UsersPageProps) { /> {error ?

{error}

: null} {isLoading ?

Loading users...

: null} +
+ + +
@@ -265,13 +395,14 @@ export function UsersPage({ backendPreference }: UsersPageProps) { - {users.map((user) => { - const userSchedules = schedulesByUser[user.id] ?? []; - const status = getUserStatus(userSchedules); - const activeSchedule = userSchedules.find( - (schedule) => schedule.enter_time && !schedule.out_time, - ); - const latestSchedule = userSchedules.at(-1); + {paginatedUsers.map((user) => { + const userSchedules = getSchedulesForUser(user, schedulesByUser); + const todaySchedules = getTodaySchedules(userSchedules, today); + const status = getUserStatus(userSchedules, today); + const latestSchedule = getLatestSchedule(todaySchedules); + const activeSchedule = latestSchedule?.enter_time && !latestSchedule.out_time + ? latestSchedule + : undefined; const company = companies.find((item) => item.id === user.company_id); const daysWorked = new Set( userSchedules @@ -306,7 +437,7 @@ export function UsersPage({ backendPreference }: UsersPageProps) {
+
+ + Showing {filteredUsers.length === 0 ? 0 : pageStartIndex + 1}-{pageEndIndex} of{" "} + {filteredUsers.length} users + +
+ + + Page {currentPage} of {totalPages} + + +
+
{isAddUserOpen ? ( diff --git a/src/store/backendPreferenceStore.ts b/src/store/backendPreferenceStore.ts new file mode 100644 index 0000000..a7c8619 --- /dev/null +++ b/src/store/backendPreferenceStore.ts @@ -0,0 +1,21 @@ +import { create } from "zustand"; +import { persist } from "zustand/middleware"; +import { defaultBackendPreference } from "@api/backendConfig"; +import type { BackendPreference } from "@/types/backend"; + +type BackendPreferenceState = { + backendPreference: BackendPreference; + setBackendPreference: (backendPreference: BackendPreference) => void; +}; + +export const useBackendPreferenceStore = create()( + persist( + (set) => ({ + backendPreference: defaultBackendPreference, + setBackendPreference: (backendPreference) => set({ backendPreference }), + }), + { + name: "manager-backend-preference", + }, + ), +); diff --git a/src/types/schedule.ts b/src/types/schedule.ts index 288e3a8..38c8045 100644 --- a/src/types/schedule.ts +++ b/src/types/schedule.ts @@ -14,6 +14,7 @@ export type Schedule = { enter_time: string | null; day_of_week: DayOfWeek; out_time: string | null; + shift?: WorkTime; }; export type WeeklyScheduleResponse = { @@ -30,6 +31,7 @@ export type CreateScheduleRequest = { export type ClockInRequest = { day_of_week: DayOfWeek; enter_time: string; + shift: WorkTime; }; export type ClockOutRequest = {