adding schedule by company

This commit is contained in:
2026-09-18 17:13:50 -04:00
parent 1911a973a0
commit 4c39253b81
9 changed files with 1268 additions and 1839 deletions
+1
View File
@@ -28,6 +28,7 @@
"@testing-library/user-event": "^13.5.0", "@testing-library/user-event": "^13.5.0",
"react": "^19.3.0", "react": "^19.3.0",
"react-dom": "^19.3.0", "react-dom": "^19.3.0",
"react-icons": "^5.7.0",
"react-router": "^8.4.0", "react-router": "^8.4.0",
"sass": "^1.104.1", "sass": "^1.104.1",
"web-vitals": "^2.1.4", "web-vitals": "^2.1.4",
+990 -1806
View File
File diff suppressed because it is too large Load Diff
+5 -5
View File
@@ -4,7 +4,6 @@ import {
assignEmployeeToCompany, assignEmployeeToCompany,
createCompany, createCompany,
deleteCompany, deleteCompany,
getCompanies,
getCompanyEmployeeNames, getCompanyEmployeeNames,
} from "@api/companiesApi"; } from "@api/companiesApi";
import { getUsers } from "@api/usersApi"; import { getUsers } from "@api/usersApi";
@@ -13,6 +12,7 @@ import { PageHeader } from "@components/PageHeader";
import type { BackendPreference } from "@/types/backend"; import type { BackendPreference } from "@/types/backend";
import type { Company } from "@/types/companies"; import type { Company } from "@/types/companies";
import type { Employee } from "@/types/users"; import type { Employee } from "@/types/users";
import { useCompaniesStore } from "../store/companiesStore";
import styles from "./CompaniesPage.module.scss"; import styles from "./CompaniesPage.module.scss";
type CompaniesPageProps = { type CompaniesPageProps = {
@@ -22,7 +22,6 @@ type CompaniesPageProps = {
type EmployeeNamesByCompany = Record<number, string[]>; type EmployeeNamesByCompany = Record<number, string[]>;
export function CompaniesPage({ backendPreference }: CompaniesPageProps) { export function CompaniesPage({ backendPreference }: CompaniesPageProps) {
const [companies, setCompanies] = useState<Company[]>([]);
const [employeeNamesByCompany, setEmployeeNamesByCompany] = useState<EmployeeNamesByCompany>({}); const [employeeNamesByCompany, setEmployeeNamesByCompany] = useState<EmployeeNamesByCompany>({});
const [users, setUsers] = useState<Employee[]>([]); const [users, setUsers] = useState<Employee[]>([]);
const [companyName, setCompanyName] = useState(""); const [companyName, setCompanyName] = useState("");
@@ -34,6 +33,8 @@ export function CompaniesPage({ backendPreference }: CompaniesPageProps) {
const [deletingCompanyId, setDeletingCompanyId] = useState<number | null>(null); const [deletingCompanyId, setDeletingCompanyId] = 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 companies = useCompaniesStore((state) => state.companies);
const refreshCompanies = useCompaniesStore((state) => state.loadCompanies);
const loadCompanies = useCallback(async () => { const loadCompanies = useCallback(async () => {
setError(""); setError("");
@@ -41,7 +42,7 @@ export function CompaniesPage({ backendPreference }: CompaniesPageProps) {
try { try {
const [companiesResponse, usersResponse] = await Promise.all([ const [companiesResponse, usersResponse] = await Promise.all([
getCompanies(backendPreference), refreshCompanies(backendPreference),
getUsers(backendPreference), getUsers(backendPreference),
]); ]);
const employeeNameEntries = await Promise.all( const employeeNameEntries = await Promise.all(
@@ -51,7 +52,6 @@ export function CompaniesPage({ backendPreference }: CompaniesPageProps) {
}), }),
); );
setCompanies(companiesResponse);
setUsers(usersResponse); setUsers(usersResponse);
setEmployeeNamesByCompany(Object.fromEntries(employeeNameEntries)); setEmployeeNamesByCompany(Object.fromEntries(employeeNameEntries));
} catch (loadError) { } catch (loadError) {
@@ -59,7 +59,7 @@ export function CompaniesPage({ backendPreference }: CompaniesPageProps) {
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
}, [backendPreference]); }, [backendPreference, refreshCompanies]);
useEffect(() => { useEffect(() => {
void loadCompanies(); void loadCompanies();
+4 -1
View File
@@ -61,7 +61,10 @@ export function HomePage({ backendPreference }: HomePageProps) {
const workingToday = new Set( const workingToday = new Set(
schedules schedules
.filter((schedule) => schedule.enter_time && !schedule.out_time) .filter(
(schedule): schedule is Schedule & { user_id: number } =>
typeof schedule.user_id === "number" && Boolean(schedule.enter_time && !schedule.out_time),
)
.map((schedule) => schedule.user_id), .map((schedule) => schedule.user_id),
).size; ).size;
+90
View File
@@ -1,3 +1,66 @@
.scheduleToolbar {
display: grid;
gap: 14px;
margin: 0 0 20px;
}
.weekNavigator {
display: flex;
align-items: center;
justify-content: center;
gap: 16px;
margin: 0;
h2 {
min-width: min(520px, 100%);
margin: 0;
text-align: center;
font-size: 1.2rem;
}
button {
display: inline-grid;
place-items: center;
width: 42px;
height: 42px;
border: 0;
border-radius: 999px;
color: #1f2937;
background: #ffffff;
box-shadow: 0 14px 35px rgba(17, 24, 39, 0.07);
cursor: pointer;
transition:
transform 0.18s ease,
box-shadow 0.18s ease;
&:hover {
transform: translateY(-1px);
box-shadow: 0 18px 38px rgba(17, 24, 39, 0.12);
}
}
}
.companyFilter {
display: grid;
gap: 8px;
justify-self: center;
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;
}
}
.calendar { .calendar {
display: grid; display: grid;
grid-template-columns: repeat(7, minmax(130px, 1fr)); grid-template-columns: repeat(7, minmax(130px, 1fr));
@@ -14,6 +77,14 @@
h3 { h3 {
margin: 0 0 14px; margin: 0 0 14px;
font-size: 1rem; font-size: 1rem;
span {
display: block;
margin-top: 4px;
color: #687487;
font-size: 0.8rem;
font-weight: 500;
}
} }
} }
@@ -50,6 +121,25 @@
} }
@media (max-width: 980px) { @media (max-width: 980px) {
.scheduleToolbar {
gap: 12px;
}
.weekNavigator {
gap: 10px;
h2 {
min-width: 0;
font-size: 1rem;
}
button {
width: 38px;
height: 38px;
flex: 0 0 auto;
}
}
.calendar { .calendar {
overflow-x: auto; overflow-x: auto;
} }
+121 -18
View File
@@ -1,10 +1,11 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { FaChevronLeft, FaChevronRight } from "react-icons/fa6";
import { getWeekSchedule } from "@api/scheduleApi"; import { getWeekSchedule } from "@api/scheduleApi";
import { getUsers } from "@api/usersApi";
import { PageHeader } from "@components/PageHeader"; import { PageHeader } from "@components/PageHeader";
import type { BackendPreference } from "@/types/backend"; import type { BackendPreference } from "@/types/backend";
import type { Schedule } from "@/types/schedule"; import type { Schedule } from "@/types/schedule";
import type { DayOfWeek, Employee } from "@/types/users"; import type { DayOfWeek } from "@/types/users";
import { useCompaniesStore } from "../store/companiesStore";
import styles from "./SchedulePage.module.scss"; import styles from "./SchedulePage.module.scss";
type SchedulePageProps = { type SchedulePageProps = {
@@ -25,6 +26,31 @@ function formatDay(day: DayOfWeek): string {
return day.charAt(0).toUpperCase() + day.slice(1); return day.charAt(0).toUpperCase() + day.slice(1);
} }
function getWeekStart(date: Date): Date {
const weekStart = new Date(date);
weekStart.setHours(0, 0, 0, 0);
weekStart.setDate(weekStart.getDate() - weekStart.getDay());
return weekStart;
}
function addDays(date: Date, days: number): Date {
const nextDate = new Date(date);
nextDate.setDate(nextDate.getDate() + days);
return nextDate;
}
function formatDate(value: Date): string {
return value.toLocaleDateString([], {
month: "short",
day: "numeric",
year: "numeric",
});
}
function formatShortDate(value: Date): string {
return value.toLocaleDateString([], { month: "short", day: "numeric" });
}
function formatTime(value: string | null): string { function formatTime(value: string | null): string {
if (!value) { if (!value) {
return "Not registered"; return "Not registered";
@@ -35,14 +61,47 @@ function formatTime(value: string | null): string {
export function SchedulePage({ backendPreference }: SchedulePageProps) { export function SchedulePage({ backendPreference }: SchedulePageProps) {
const [schedules, setSchedules] = useState<Schedule[]>([]); const [schedules, setSchedules] = useState<Schedule[]>([]);
const [users, setUsers] = useState<Employee[]>([]);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [selectedCompany, setSelectedCompany] = useState("");
const [selectedWeekStart, setSelectedWeekStart] = useState(() => getWeekStart(new Date()));
const companyNames = useCompaniesStore((state) => state.companyNames);
const companiesError = useCompaniesStore((state) => state.error);
const loadCompanies = useCompaniesStore((state) => state.loadCompanies);
const selectedWeekEnd = useMemo(() => addDays(selectedWeekStart, 6), [selectedWeekStart]);
const selectedWeekEndExclusive = useMemo(
() => addDays(selectedWeekStart, 7),
[selectedWeekStart],
);
const selectedWeekDays = useMemo(
() => weekDays.map((day, index) => ({ day, date: addDays(selectedWeekStart, index) })),
[selectedWeekStart],
);
const schedulesByDay = useMemo(() => { const schedulesByDay = useMemo(() => {
return schedules.reduce<Record<DayOfWeek, Schedule[]>>( return schedules.reduce<Record<DayOfWeek, Schedule[]>>(
(accumulator, schedule) => { (accumulator, schedule) => {
accumulator[schedule.day_of_week] = [...accumulator[schedule.day_of_week], schedule]; if (selectedCompany && schedule.company !== selectedCompany) {
return accumulator;
}
if (!schedule.enter_time) {
return accumulator;
}
const enterDate = new Date(schedule.enter_time);
if (
Number.isNaN(enterDate.getTime()) ||
enterDate < selectedWeekStart ||
enterDate >= selectedWeekEndExclusive
) {
return accumulator;
}
const day = weekDays[enterDate.getDay()];
accumulator[day] = [...accumulator[day], schedule];
return accumulator; return accumulator;
}, },
{ {
@@ -55,7 +114,15 @@ export function SchedulePage({ backendPreference }: SchedulePageProps) {
saturday: [], saturday: [],
}, },
); );
}, [schedules]); }, [schedules, selectedCompany, selectedWeekEndExclusive, selectedWeekStart]);
function showPreviousWeek() {
setSelectedWeekStart((currentWeekStart) => addDays(currentWeekStart, -7));
}
function showNextWeek() {
setSelectedWeekStart((currentWeekStart) => addDays(currentWeekStart, 7));
}
useEffect(() => { useEffect(() => {
let isMounted = true; let isMounted = true;
@@ -65,14 +132,10 @@ export function SchedulePage({ backendPreference }: SchedulePageProps) {
setIsLoading(true); setIsLoading(true);
try { try {
const [scheduleResponse, usersResponse] = await Promise.all([ const scheduleResponse = await getWeekSchedule(backendPreference);
getWeekSchedule(backendPreference),
getUsers(backendPreference),
]);
if (isMounted) { if (isMounted) {
setSchedules(scheduleResponse.schedules); setSchedules(scheduleResponse.schedules);
setUsers(usersResponse);
} }
} catch (loadError) { } catch (loadError) {
if (isMounted) { if (isMounted) {
@@ -92,6 +155,16 @@ export function SchedulePage({ backendPreference }: SchedulePageProps) {
}; };
}, [backendPreference]); }, [backendPreference]);
useEffect(() => {
void loadCompanies(backendPreference);
}, [backendPreference, loadCompanies]);
useEffect(() => {
if (selectedCompany && !companyNames.includes(selectedCompany)) {
setSelectedCompany("");
}
}, [companyNames, selectedCompany]);
return ( return (
<> <>
<PageHeader <PageHeader
@@ -99,26 +172,56 @@ export function SchedulePage({ backendPreference }: SchedulePageProps) {
description="Weekly scheduler calendar starts on Sunday and ends on Saturday so you can see who is working each day." description="Weekly scheduler calendar starts on Sunday and ends on Saturday so you can see who is working each day."
/> />
{error ? <p className={styles.error}>{error}</p> : null} {error ? <p className={styles.error}>{error}</p> : null}
{companiesError ? <p className={styles.error}>{companiesError}</p> : null}
{isLoading ? <p className={styles.muted}>Loading schedule...</p> : null} {isLoading ? <p className={styles.muted}>Loading schedule...</p> : null}
<div className={styles.scheduleToolbar}>
<div className={styles.weekNavigator} aria-label="Select schedule week">
<button type="button" onClick={showPreviousWeek} aria-label="Previous week">
<FaChevronLeft aria-hidden="true" />
</button>
<h2>
Week from {formatDate(selectedWeekStart)} to {formatDate(selectedWeekEnd)}
</h2>
<button type="button" onClick={showNextWeek} aria-label="Next week">
<FaChevronRight aria-hidden="true" />
</button>
<label className={styles.companyFilter}>
<select
value={selectedCompany}
onChange={(event) => setSelectedCompany(event.target.value)}
>
<option value="">All companies</option>
{companyNames.map((company) => (
<option key={company} value={company}>
{company}
</option>
))}
</select>
</label>
</div>
</div>
<section className={styles.calendar} aria-label="Weekly schedule from Sunday to Saturday"> <section className={styles.calendar} aria-label="Weekly schedule from Sunday to Saturday">
{weekDays.map((day) => ( {selectedWeekDays.map(({ day, date }) => (
<article className={styles.dayCard} key={day}> <article className={styles.dayCard} key={day}>
<h3>{formatDay(day)}</h3> <h3>
{formatDay(day)}
<span>{formatShortDate(date)}</span>
</h3>
<div className={styles.shiftList}> <div className={styles.shiftList}>
{schedulesByDay[day].length > 0 ? ( {schedulesByDay[day].length > 0 ? (
schedulesByDay[day].map((schedule) => { schedulesByDay[day].map((schedule) => (
const user = users.find((item) => item.id === schedule.user_id);
return (
<div className={styles.shift} key={schedule.id}> <div className={styles.shift} key={schedule.id}>
<strong> <strong>
{user ? `${user.name} ${user.last_name}` : `User #${schedule.user_id}`} {schedule.user
? `${schedule.user.name} ${schedule.user.last_name}`
: "Unknown user"}
</strong> </strong>
<span>{schedule.company ?? "No company"}</span>
<span> <span>
{formatTime(schedule.enter_time)} - {formatTime(schedule.out_time)} {formatTime(schedule.enter_time)} - {formatTime(schedule.out_time)}
</span> </span>
</div> </div>
); ))
})
) : ( ) : (
<p className={styles.muted}>No shifts scheduled</p> <p className={styles.muted}>No shifts scheduled</p>
)} )}
+4
View File
@@ -76,6 +76,10 @@ export function UsersPage({ backendPreference }: UsersPageProps) {
const schedulesByUser = useMemo(() => { const schedulesByUser = useMemo(() => {
return schedules.reduce<Record<number, Schedule[]>>((accumulator, schedule) => { return schedules.reduce<Record<number, Schedule[]>>((accumulator, schedule) => {
if (typeof schedule.user_id !== "number") {
return accumulator;
}
accumulator[schedule.user_id] = [...(accumulator[schedule.user_id] ?? []), schedule]; accumulator[schedule.user_id] = [...(accumulator[schedule.user_id] ?? []), schedule];
return accumulator; return accumulator;
}, {}); }, {});
+36
View File
@@ -0,0 +1,36 @@
import { create } from "zustand";
import { getCompanies } from "@api/companiesApi";
import type { BackendPreference } from "@/types/backend";
import type { Company } from "@/types/companies";
type CompaniesState = {
companies: Company[];
companyNames: string[];
error: string;
isLoading: boolean;
loadCompanies: (backendPreference: BackendPreference) => Promise<Company[]>;
};
export const useCompaniesStore = create<CompaniesState>((set) => ({
companies: [],
companyNames: [],
error: "",
isLoading: false,
async loadCompanies(backendPreference) {
set({ error: "", isLoading: true });
try {
const companies = await getCompanies(backendPreference);
const companyNames = companies
.map((company) => company.name)
.sort((firstCompany, secondCompany) => firstCompany.localeCompare(secondCompany));
set({ companies, companyNames, isLoading: false });
return companies;
} catch (loadError) {
const error = loadError instanceof Error ? loadError.message : "Unable to load companies";
set({ error, isLoading: false });
throw loadError;
}
},
}));
+10 -2
View File
@@ -1,8 +1,16 @@
import type { DayOfWeek } from "@/types/users"; import type { DayOfWeek, WorkTime } from "@/types/users";
export type ScheduleUser = {
name: string;
last_name: string;
preference: WorkTime;
};
export type Schedule = { export type Schedule = {
id: number; id: number;
user_id: number; user_id?: number;
user?: ScheduleUser;
company?: string;
enter_time: string | null; enter_time: string | null;
day_of_week: DayOfWeek; day_of_week: DayOfWeek;
out_time: string | null; out_time: string | null;