updating the layout based on the endpoints

This commit is contained in:
2026-09-18 21:01:05 -04:00
parent 4c39253b81
commit 72590283d0
9 changed files with 352 additions and 35 deletions
+4 -6
View File
@@ -1,17 +1,15 @@
import { useState } from "react";
import { defaultBackendPreference } from "@api/backendConfig";
import { usePathname } from "@hooks/usePathname"; import { usePathname } from "@hooks/usePathname";
import { AppLayout } from "@layouts/AppLayout"; import { AppLayout } from "@layouts/AppLayout";
import { CompaniesPage } from "@pages/CompaniesPage"; import { CompaniesPage } from "@pages/CompaniesPage";
import { HomePage } from "@pages/HomePage"; import { HomePage } from "@pages/HomePage";
import { SchedulePage } from "@pages/SchedulePage"; import { SchedulePage } from "@pages/SchedulePage";
import { UsersPage } from "@pages/UsersPage"; import { UsersPage } from "@pages/UsersPage";
import type { BackendPreference } from "@/types/backend"; import { useBackendPreferenceStore } from "./store/backendPreferenceStore";
function App() { function App() {
const [pathname, navigate] = usePathname(); const [pathname, navigate] = usePathname();
const [backendPreference, setBackendPreference] = const backendPreference = useBackendPreferenceStore((state) => state.backendPreference);
useState<BackendPreference>(defaultBackendPreference); const setBackendPreference = useBackendPreferenceStore((state) => state.setBackendPreference);
const renderPage = () => { const renderPage = () => {
switch (pathname) { switch (pathname) {
@@ -22,7 +20,7 @@ function App() {
case "/companies": case "/companies":
return <CompaniesPage backendPreference={backendPreference} />; return <CompaniesPage backendPreference={backendPreference} />;
default: default:
return <HomePage backendPreference={backendPreference} />; return <HomePage backendPreference={backendPreference} onNavigate={navigate} />;
} }
}; };
+56 -4
View File
@@ -5,6 +5,58 @@ type ApiRequestOptions = RequestInit & {
backendPreference: BackendPreference; 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<string, unknown>;
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 { export function buildApiUrl(backendPreference: BackendPreference, endpoint: string): string {
const backend = getBackendOption(backendPreference); const backend = getBackendOption(backendPreference);
return `${backend.baseUrl}${endpoint}`; return `${backend.baseUrl}${endpoint}`;
@@ -22,12 +74,12 @@ export async function apiRequest<TResponse>(
}, },
}); });
if (!response.ok) {
throw new Error(`API request failed with status ${response.status}`);
}
const responseText = await response.text(); const responseText = await response.text();
if (!response.ok) {
throw new Error(getErrorMessage(responseText, response.status));
}
if (response.status === 204 || !responseText) { if (response.status === 204 || !responseText) {
return undefined as TResponse; return undefined as TResponse;
} }
+3
View File
@@ -6,6 +6,7 @@
padding: 12px 18px; padding: 12px 18px;
cursor: pointer; cursor: pointer;
font-weight: 700; font-weight: 700;
height: 40px;
white-space: nowrap; white-space: nowrap;
} }
@@ -43,6 +44,8 @@
background: #ffffff; background: #ffffff;
border-radius: 24px; border-radius: 24px;
box-shadow: 0 20px 50px rgba(17, 24, 39, 0.08); box-shadow: 0 20px 50px rgba(17, 24, 39, 0.08);
justify-content: space-between;
align-items: center;
h3, h3,
p { p {
+19
View File
@@ -5,10 +5,29 @@
} }
.card { .card {
width: 100%;
border: 0;
padding: 24px; padding: 24px;
text-align: left;
background: #ffffff; background: #ffffff;
border-radius: 24px; border-radius: 24px;
box-shadow: 0 20px 50px rgba(17, 24, 39, 0.08); 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 { span {
display: block; display: block;
+8 -7
View File
@@ -11,9 +11,10 @@ import styles from "./HomePage.module.scss";
type HomePageProps = { type HomePageProps = {
backendPreference: BackendPreference; backendPreference: BackendPreference;
onNavigate: (path: string) => void;
}; };
export function HomePage({ backendPreference }: HomePageProps) { export function HomePage({ backendPreference, onNavigate }: HomePageProps) {
const [companies, setCompanies] = useState<Company[]>([]); const [companies, setCompanies] = useState<Company[]>([]);
const [schedules, setSchedules] = useState<Schedule[]>([]); const [schedules, setSchedules] = useState<Schedule[]>([]);
const [users, setUsers] = useState<Employee[]>([]); const [users, setUsers] = useState<Employee[]>([]);
@@ -77,18 +78,18 @@ export function HomePage({ backendPreference }: HomePageProps) {
{error ? <p className={styles.error}>{error}</p> : null} {error ? <p className={styles.error}>{error}</p> : null}
{isLoading ? <p className={styles.muted}>Loading dashboard...</p> : null} {isLoading ? <p className={styles.muted}>Loading dashboard...</p> : null}
<section className={styles.statsGrid} aria-label="Dashboard overview"> <section className={styles.statsGrid} aria-label="Dashboard overview">
<article className={styles.card}> <button className={styles.card} onClick={() => onNavigate("/users")} type="button">
<span>Total users</span> <span>Total users</span>
<strong>{users.length}</strong> <strong>{users.length}</strong>
</article> </button>
<article className={styles.card}> <button className={styles.card} onClick={() => onNavigate("/schedule")} type="button">
<span>Working today</span> <span>Working today</span>
<strong>{workingToday}</strong> <strong>{workingToday}</strong>
</article> </button>
<article className={styles.card}> <button className={styles.card} onClick={() => onNavigate("/companies")} type="button">
<span>Companies</span> <span>Companies</span>
<strong>{companies.length}</strong> <strong>{companies.length}</strong>
</article> </button>
</section> </section>
</> </>
); );
+63
View File
@@ -18,6 +18,11 @@
background: #ffffff; background: #ffffff;
color: #172033; color: #172033;
box-shadow: inset 0 0 0 1px #d9e1ec; box-shadow: inset 0 0 0 1px #d9e1ec;
&:disabled {
cursor: not-allowed;
opacity: 0.45;
}
} }
.actionGroup { .actionGroup {
@@ -50,6 +55,34 @@
box-shadow: 0 20px 50px rgba(17, 24, 39, 0.08); 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 { .tableScroll {
overflow-x: auto; overflow-x: auto;
} }
@@ -58,6 +91,20 @@
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: collapse;
min-width: 760px; 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, th,
td { 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 { .selectedRow {
background: #eef4ff; background: #eef4ff;
} }
+177 -19
View File
@@ -18,20 +18,51 @@ type UsersPageProps = {
type UserStatus = "off-duty" | "working" | "worked"; type UserStatus = "off-duty" | "working" | "worked";
const workTimeOptions: WorkTime[] = ["morning", "afternoon", "evening"]; const workTimeOptions: WorkTime[] = ["morning", "afternoon", "evening"];
const statusOptions: UserStatus[] = ["off-duty", "working", "worked"];
const usersPerPage = 10;
function getUserStatus(schedules: Schedule[]): UserStatus { function isSameLocalDate(firstDate: Date, secondDate: Date): boolean {
const hasCheckIn = schedules.some((schedule) => schedule.enter_time); return (
const hasActiveShift = schedules.some((schedule) => schedule.enter_time && !schedule.out_time); firstDate.getFullYear() === secondDate.getFullYear() &&
firstDate.getMonth() === secondDate.getMonth() &&
if (hasActiveShift) { firstDate.getDate() === secondDate.getDate()
return "working"; );
} }
if (hasCheckIn) { 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 (latestSchedule.out_time) {
return "worked"; return "worked";
} }
return "off-duty"; return "working";
} }
function getStatusLabel(status: UserStatus): string { function getStatusLabel(status: UserStatus): string {
@@ -54,6 +85,30 @@ function formatDateTime(value: string | null): string {
return new Date(value).toLocaleString(); 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<string, Schedule[]>): Schedule[] {
return getEmployeeScheduleKeys(user).flatMap((key) => schedulesByUser[key] ?? []);
}
export function UsersPage({ backendPreference }: UsersPageProps) { export function UsersPage({ backendPreference }: UsersPageProps) {
const [companies, setCompanies] = useState<Company[]>([]); const [companies, setCompanies] = useState<Company[]>([]);
const [schedules, setSchedules] = useState<Schedule[]>([]); const [schedules, setSchedules] = useState<Schedule[]>([]);
@@ -71,20 +126,42 @@ export function UsersPage({ backendPreference }: UsersPageProps) {
const [savingActionUserId, setSavingActionUserId] = useState<number | null>(null); const [savingActionUserId, setSavingActionUserId] = useState<number | null>(null);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [currentPage, setCurrentPage] = useState(1);
const [selectedCompanyId, setSelectedCompanyId] = useState("");
const [selectedStatus, setSelectedStatus] = useState<UserStatus | "">("");
const selectedUser = users.find((user) => user.id === selectedUserId) ?? null; const selectedUser = users.find((user) => user.id === selectedUserId) ?? null;
const today = new Date();
const schedulesByUser = useMemo(() => { const schedulesByUser = useMemo(() => {
return schedules.reduce<Record<number, Schedule[]>>((accumulator, schedule) => { return schedules.reduce<Record<string, Schedule[]>>((accumulator, schedule) => {
if (typeof schedule.user_id !== "number") { const userKey = getScheduleUserKey(schedule);
if (!userKey) {
return accumulator; return accumulator;
} }
accumulator[schedule.user_id] = [...(accumulator[schedule.user_id] ?? []), schedule]; accumulator[userKey] = [...(accumulator[userKey] ?? []), schedule];
return accumulator; return accumulator;
}, {}); }, {});
}, [schedules]); }, [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 () => { const loadUsersPage = useCallback(async () => {
setError(""); setError("");
setIsLoading(true); setIsLoading(true);
@@ -110,6 +187,16 @@ export function UsersPage({ backendPreference }: UsersPageProps) {
void loadUsersPage(); void loadUsersPage();
}, [loadUsersPage]); }, [loadUsersPage]);
useEffect(() => {
if (currentPage > totalPages) {
setCurrentPage(totalPages);
}
}, [currentPage, totalPages]);
useEffect(() => {
setCurrentPage(1);
}, [selectedCompanyId, selectedStatus]);
function resetUserForm() { function resetUserForm() {
setCompanyId(""); setCompanyId("");
setFirstName(""); setFirstName("");
@@ -173,6 +260,20 @@ export function UsersPage({ backendPreference }: UsersPageProps) {
return days[new Date(value).getDay()]; 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) { function openCheckInModal(user: Employee) {
const now = new Date(); const now = new Date();
const local = new Date(now.getTime() - now.getTimezoneOffset() * 60000) const local = new Date(now.getTime() - now.getTimezoneOffset() * 60000)
@@ -196,6 +297,7 @@ export function UsersPage({ backendPreference }: UsersPageProps) {
await clockIn(backendPreference, checkingInUser.id, { await clockIn(backendPreference, checkingInUser.id, {
day_of_week: getDayOfWeekFromDateTime(checkInDateTime), day_of_week: getDayOfWeekFromDateTime(checkInDateTime),
enter_time: new Date(checkInDateTime).toISOString(), enter_time: new Date(checkInDateTime).toISOString(),
shift: getShiftFromDateTime(checkInDateTime),
}); });
setCheckingInUser(null); setCheckingInUser(null);
await loadUsersPage(); await loadUsersPage();
@@ -249,6 +351,34 @@ export function UsersPage({ backendPreference }: UsersPageProps) {
/> />
{error ? <p className={styles.error}>{error}</p> : null} {error ? <p className={styles.error}>{error}</p> : null}
{isLoading ? <p className={styles.muted}>Loading users...</p> : null} {isLoading ? <p className={styles.muted}>Loading users...</p> : null}
<div className={styles.usersToolbar}>
<label className={styles.companyFilter}>
<select
value={selectedCompanyId}
onChange={(event) => setSelectedCompanyId(event.target.value)}
>
<option value="">All companies</option>
{companies.map((company) => (
<option key={company.id} value={company.id}>
{company.name}
</option>
))}
</select>
</label>
<label className={styles.companyFilter}>
<select
value={selectedStatus}
onChange={(event) => setSelectedStatus(event.target.value as UserStatus | "")}
>
<option value="">All statuses</option>
{statusOptions.map((status) => (
<option key={status} value={status}>
{getStatusLabel(status)}
</option>
))}
</select>
</label>
</div>
<section className={styles.tableCard} aria-label="Users table"> <section className={styles.tableCard} aria-label="Users table">
<div className={styles.tableScroll}> <div className={styles.tableScroll}>
<table className={styles.table}> <table className={styles.table}>
@@ -265,13 +395,14 @@ export function UsersPage({ backendPreference }: UsersPageProps) {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{users.map((user) => { {paginatedUsers.map((user) => {
const userSchedules = schedulesByUser[user.id] ?? []; const userSchedules = getSchedulesForUser(user, schedulesByUser);
const status = getUserStatus(userSchedules); const todaySchedules = getTodaySchedules(userSchedules, today);
const activeSchedule = userSchedules.find( const status = getUserStatus(userSchedules, today);
(schedule) => schedule.enter_time && !schedule.out_time, const latestSchedule = getLatestSchedule(todaySchedules);
); const activeSchedule = latestSchedule?.enter_time && !latestSchedule.out_time
const latestSchedule = userSchedules.at(-1); ? latestSchedule
: undefined;
const company = companies.find((item) => item.id === user.company_id); const company = companies.find((item) => item.id === user.company_id);
const daysWorked = new Set( const daysWorked = new Set(
userSchedules userSchedules
@@ -306,7 +437,7 @@ export function UsersPage({ backendPreference }: UsersPageProps) {
<div className={styles.actionGroup}> <div className={styles.actionGroup}>
<button <button
className={styles.actionButton} className={styles.actionButton}
disabled={Boolean(activeSchedule) || savingActionUserId === user.id} disabled={status !== "off-duty" || savingActionUserId === user.id}
onClick={(event) => { onClick={(event) => {
event.stopPropagation(); event.stopPropagation();
openCheckInModal(user); openCheckInModal(user);
@@ -337,6 +468,33 @@ export function UsersPage({ backendPreference }: UsersPageProps) {
</tbody> </tbody>
</table> </table>
</div> </div>
<div className={styles.pagination}>
<span>
Showing {filteredUsers.length === 0 ? 0 : pageStartIndex + 1}-{pageEndIndex} of{" "}
{filteredUsers.length} users
</span>
<div className={styles.paginationActions}>
<button
className={styles.secondaryButton}
disabled={currentPage === 1}
onClick={() => setCurrentPage((page) => Math.max(1, page - 1))}
type="button"
>
Previous
</button>
<span>
Page {currentPage} of {totalPages}
</span>
<button
className={styles.secondaryButton}
disabled={currentPage === totalPages}
onClick={() => setCurrentPage((page) => Math.min(totalPages, page + 1))}
type="button"
>
Next
</button>
</div>
</div>
</section> </section>
{isAddUserOpen ? ( {isAddUserOpen ? (
+21
View File
@@ -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<BackendPreferenceState>()(
persist(
(set) => ({
backendPreference: defaultBackendPreference,
setBackendPreference: (backendPreference) => set({ backendPreference }),
}),
{
name: "manager-backend-preference",
},
),
);
+2
View File
@@ -14,6 +14,7 @@ export type Schedule = {
enter_time: string | null; enter_time: string | null;
day_of_week: DayOfWeek; day_of_week: DayOfWeek;
out_time: string | null; out_time: string | null;
shift?: WorkTime;
}; };
export type WeeklyScheduleResponse = { export type WeeklyScheduleResponse = {
@@ -30,6 +31,7 @@ export type CreateScheduleRequest = {
export type ClockInRequest = { export type ClockInRequest = {
day_of_week: DayOfWeek; day_of_week: DayOfWeek;
enter_time: string; enter_time: string;
shift: WorkTime;
}; };
export type ClockOutRequest = { export type ClockOutRequest = {