adding scheduler

This commit is contained in:
2026-09-18 12:27:51 -04:00
parent 256e87c8c8
commit f4f3f76d6c
7 changed files with 273 additions and 35 deletions
+3
View File
@@ -2,6 +2,7 @@ import { apiRequest } from "./apiClient";
import { endpoints } from "./endpoints"; import { endpoints } from "./endpoints";
import type { BackendPreference } from "@/types/backend"; import type { BackendPreference } from "@/types/backend";
import type { import type {
ClockInRequest,
ClockOutRequest, ClockOutRequest,
GenerateWeeklyScheduleRequest, GenerateWeeklyScheduleRequest,
Schedule, Schedule,
@@ -28,10 +29,12 @@ export function generateWeeklySchedule(
export function clockIn( export function clockIn(
backendPreference: BackendPreference, backendPreference: BackendPreference,
employeeId: number, employeeId: number,
request: ClockInRequest,
): Promise<Schedule> { ): Promise<Schedule> {
return apiRequest<Schedule>(endpoints.employeeClockIn(employeeId), { return apiRequest<Schedule>(endpoints.employeeClockIn(employeeId), {
backendPreference, backendPreference,
method: "POST", method: "POST",
body: JSON.stringify(request),
}); });
} }
+30 -1
View File
@@ -1,5 +1,6 @@
.primaryButton, .primaryButton,
.secondaryButton { .secondaryButton,
.dangerButton {
border: 0; border: 0;
border-radius: 999px; border-radius: 999px;
padding: 12px 18px; padding: 12px 18px;
@@ -19,6 +20,16 @@
box-shadow: inset 0 0 0 1px #d9e1ec; box-shadow: inset 0 0 0 1px #d9e1ec;
} }
.dangerButton {
background: #dc2626;
color: #ffffff;
&:disabled {
cursor: not-allowed;
opacity: 0.65;
}
}
.companyGrid { .companyGrid {
display: grid; display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(3, minmax(0, 1fr));
@@ -110,6 +121,24 @@
} }
} }
.confirmation {
display: grid;
gap: 18px;
p {
margin: 0;
color: #293548;
line-height: 1.6;
}
}
.confirmationActions {
display: flex;
justify-content: flex-end;
gap: 10px;
flex-wrap: wrap;
}
.error { .error {
color: #b91c1c; color: #b91c1c;
font-weight: 700; font-weight: 700;
+54 -10
View File
@@ -26,6 +26,7 @@ export function CompaniesPage({ backendPreference }: CompaniesPageProps) {
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("");
const [companyToDelete, setCompanyToDelete] = useState<Company | null>(null);
const [selectedCompany, setSelectedCompany] = useState<Company | null>(null); const [selectedCompany, setSelectedCompany] = useState<Company | null>(null);
const [selectedEmployeeId, setSelectedEmployeeId] = useState(""); const [selectedEmployeeId, setSelectedEmployeeId] = useState("");
const [error, setError] = useState(""); const [error, setError] = useState("");
@@ -107,18 +108,17 @@ export function CompaniesPage({ backendPreference }: CompaniesPageProps) {
} }
} }
async function handleDeleteCompany(company: Company) { async function handleDeleteCompany() {
const shouldDelete = window.confirm(`Delete ${company.name}?`); if (!companyToDelete) {
if (!shouldDelete) {
return; return;
} }
setDeletingCompanyId(company.id); setDeletingCompanyId(companyToDelete.id);
setError(""); setError("");
try { try {
await deleteCompany(backendPreference, company.id); await deleteCompany(backendPreference, companyToDelete.id);
setCompanyToDelete(null);
await loadCompanies(); await loadCompanies();
} catch (deleteError) { } catch (deleteError) {
setError(deleteError instanceof Error ? deleteError.message : "Unable to delete company"); setError(deleteError instanceof Error ? deleteError.message : "Unable to delete company");
@@ -151,7 +151,7 @@ export function CompaniesPage({ backendPreference }: CompaniesPageProps) {
<button <button
className={styles.deleteButton} className={styles.deleteButton}
disabled={deletingCompanyId === company.id} disabled={deletingCompanyId === company.id}
onClick={() => void handleDeleteCompany(company)} onClick={() => setCompanyToDelete(company)}
type="button" type="button"
aria-label={`Delete ${company.name}`} aria-label={`Delete ${company.name}`}
> >
@@ -196,12 +196,22 @@ export function CompaniesPage({ backendPreference }: CompaniesPageProps) {
</Modal> </Modal>
) : null} ) : null}
{selectedCompany ? ( {selectedCompany
? (() => {
const assignedNames = new Set(employeeNamesByCompany[selectedCompany.id] ?? []);
const availableUsers = users.filter(
(user) => !assignedNames.has(`${user.name} ${user.last_name}`),
);
return (
<Modal <Modal
title={`Add users to ${selectedCompany.name}`} title={`Add users to ${selectedCompany.name}`}
onClose={() => setSelectedCompany(null)} onClose={() => setSelectedCompany(null)}
> >
<form className={styles.form} onSubmit={handleAssignEmployee}> <form className={styles.form} onSubmit={handleAssignEmployee}>
{availableUsers.length === 0 ? (
<p className={styles.muted}>All users are in a company</p>
) : (
<label> <label>
Existing user Existing user
<select <select
@@ -210,18 +220,52 @@ export function CompaniesPage({ backendPreference }: CompaniesPageProps) {
required required
> >
<option value="">Select a user</option> <option value="">Select a user</option>
{users.map((user) => ( {availableUsers.map((user) => (
<option key={user.id} value={user.id}> <option key={user.id} value={user.id}>
{user.name} {user.last_name} {user.name} {user.last_name}
</option> </option>
))} ))}
</select> </select>
</label> </label>
<button className={styles.primaryButton} disabled={isSaving} type="submit"> )}
<button
className={styles.primaryButton}
disabled={isSaving || availableUsers.length === 0}
type="submit"
>
{isSaving ? "Saving..." : "Assign user"} {isSaving ? "Saving..." : "Assign user"}
</button> </button>
</form> </form>
</Modal> </Modal>
);
})()
: null}
{companyToDelete ? (
<Modal title="Delete company" onClose={() => setCompanyToDelete(null)}>
<div className={styles.confirmation}>
<p>
Delete <strong>{companyToDelete.name}</strong>? This action cannot be undone.
</p>
<div className={styles.confirmationActions}>
<button
className={styles.secondaryButton}
onClick={() => setCompanyToDelete(null)}
type="button"
>
Cancel
</button>
<button
className={styles.dangerButton}
disabled={deletingCompanyId === companyToDelete.id}
onClick={() => void handleDeleteCompany()}
type="button"
>
{deletingCompanyId === companyToDelete.id ? "Deleting..." : "Delete company"}
</button>
</div>
</div>
</Modal>
) : null} ) : null}
</> </>
); );
+23
View File
@@ -20,6 +20,29 @@
box-shadow: inset 0 0 0 1px #d9e1ec; box-shadow: inset 0 0 0 1px #d9e1ec;
} }
.actionGroup {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.actionButton {
border: 0;
border-radius: 999px;
padding: 8px 11px;
background: #eef4ff;
color: #1d4ed8;
cursor: pointer;
font-size: 0.82rem;
font-weight: 700;
white-space: nowrap;
&:disabled {
cursor: not-allowed;
opacity: 0.45;
}
}
.tableCard { .tableCard {
overflow: hidden; overflow: hidden;
background: #ffffff; background: #ffffff;
+136 -2
View File
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import type { FormEvent } from "react"; import type { FormEvent } from "react";
import { getCompanies } from "@api/companiesApi"; import { getCompanies } from "@api/companiesApi";
import { getWeekSchedule } from "@api/scheduleApi"; import { clockIn, clockOut, getWeekSchedule } from "@api/scheduleApi";
import { createUser, getUsers, updateUserPreference } from "@api/usersApi"; import { createUser, getUsers, updateUserPreference } from "@api/usersApi";
import { Modal } from "@components/Modal"; import { Modal } from "@components/Modal";
import { PageHeader } from "@components/PageHeader"; import { PageHeader } from "@components/PageHeader";
@@ -66,6 +66,9 @@ export function UsersPage({ backendPreference }: UsersPageProps) {
const [error, setError] = useState(""); const [error, setError] = useState("");
const [isAddUserOpen, setIsAddUserOpen] = useState(false); const [isAddUserOpen, setIsAddUserOpen] = useState(false);
const [isPreferenceOpen, setIsPreferenceOpen] = useState(false); const [isPreferenceOpen, setIsPreferenceOpen] = useState(false);
const [checkingInUser, setCheckingInUser] = useState<Employee | null>(null);
const [checkInDateTime, setCheckInDateTime] = useState("");
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);
@@ -153,6 +156,69 @@ export function UsersPage({ backendPreference }: UsersPageProps) {
} }
} }
function getDayOfWeekFromDateTime(value: string): import("@/types/users").DayOfWeek {
const days: import("@/types/users").DayOfWeek[] = [
"sunday",
"monday",
"tuesday",
"wednesday",
"thursday",
"friday",
"saturday",
];
return days[new Date(value).getDay()];
}
function openCheckInModal(user: Employee) {
const now = new Date();
const local = new Date(now.getTime() - now.getTimezoneOffset() * 60000)
.toISOString()
.slice(0, 16);
setCheckingInUser(user);
setCheckInDateTime(local);
}
async function handleClockIn(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!checkingInUser || !checkInDateTime) {
return;
}
setError("");
setSavingActionUserId(checkingInUser.id);
try {
await clockIn(backendPreference, checkingInUser.id, {
day_of_week: getDayOfWeekFromDateTime(checkInDateTime),
enter_time: new Date(checkInDateTime).toISOString(),
});
setCheckingInUser(null);
await loadUsersPage();
} catch (saveError) {
setError(saveError instanceof Error ? saveError.message : "Unable to check in user");
} finally {
setSavingActionUserId(null);
}
}
async function handleClockOut(userId: number, activeSchedule: Schedule) {
setError("");
setSavingActionUserId(userId);
try {
await clockOut(backendPreference, userId, {
day_of_week: activeSchedule.day_of_week,
out_time: new Date().toISOString(),
});
await loadUsersPage();
} catch (saveError) {
setError(saveError instanceof Error ? saveError.message : "Unable to check out user");
} finally {
setSavingActionUserId(null);
}
}
return ( return (
<> <>
<PageHeader <PageHeader
@@ -191,12 +257,16 @@ export function UsersPage({ backendPreference }: UsersPageProps) {
<th>Check in</th> <th>Check in</th>
<th>Check out</th> <th>Check out</th>
<th>Days worked</th> <th>Days worked</th>
<th>Actions</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{users.map((user) => { {users.map((user) => {
const userSchedules = schedulesByUser[user.id] ?? []; const userSchedules = schedulesByUser[user.id] ?? [];
const status = getUserStatus(userSchedules); const status = getUserStatus(userSchedules);
const activeSchedule = userSchedules.find(
(schedule) => schedule.enter_time && !schedule.out_time,
);
const latestSchedule = userSchedules.at(-1); const latestSchedule = userSchedules.at(-1);
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(
@@ -210,11 +280,15 @@ export function UsersPage({ backendPreference }: UsersPageProps) {
className={selectedUserId === user.id ? styles.selectedRow : ""} className={selectedUserId === user.id ? styles.selectedRow : ""}
key={user.id} key={user.id}
onClick={() => setSelectedUserId(user.id)} onClick={() => setSelectedUserId(user.id)}
title="Double click to edit user information"
> >
<td> <td>
{user.name} {user.last_name} {user.name} {user.last_name}
</td> </td>
<td>{company?.name ?? `Company #${user.company_id}`}</td> <td>
{company?.name ??
(user.company_id ? `Company #${user.company_id}` : "Unassigned")}
</td>
<td>{user.preference}</td> <td>{user.preference}</td>
<td> <td>
<span className={`${styles.status} ${styles[status]}`}> <span className={`${styles.status} ${styles[status]}`}>
@@ -224,6 +298,35 @@ export function UsersPage({ backendPreference }: UsersPageProps) {
<td>{formatDateTime(latestSchedule?.enter_time ?? null)}</td> <td>{formatDateTime(latestSchedule?.enter_time ?? null)}</td>
<td>{formatDateTime(latestSchedule?.out_time ?? null)}</td> <td>{formatDateTime(latestSchedule?.out_time ?? null)}</td>
<td>{daysWorked}</td> <td>{daysWorked}</td>
<td>
<div className={styles.actionGroup}>
<button
className={styles.actionButton}
disabled={Boolean(activeSchedule) || savingActionUserId === user.id}
onClick={(event) => {
event.stopPropagation();
openCheckInModal(user);
}}
type="button"
>
Check in
</button>
<button
className={styles.actionButton}
disabled={!activeSchedule || savingActionUserId === user.id}
onClick={(event) => {
event.stopPropagation();
if (activeSchedule) {
void handleClockOut(user.id, activeSchedule);
}
}}
type="button"
>
Check out
</button>
</div>
</td>
</tr> </tr>
); );
})} })}
@@ -314,6 +417,37 @@ export function UsersPage({ backendPreference }: UsersPageProps) {
</form> </form>
</Modal> </Modal>
) : null} ) : null}
{checkingInUser ? (
<Modal
title={`Check in ${checkingInUser.name} ${checkingInUser.last_name}`}
onClose={() => setCheckingInUser(null)}
>
<form className={styles.form} onSubmit={handleClockIn}>
<label>
Date and time
<input
type="datetime-local"
value={checkInDateTime}
onChange={(event) => setCheckInDateTime(event.target.value)}
required
/>
</label>
<p className={styles.muted}>
Day of week will be derived as{" "}
{checkInDateTime ? getDayOfWeekFromDateTime(checkInDateTime) : "--"} from the selected
date.
</p>
<button
className={styles.primaryButton}
disabled={savingActionUserId === checkingInUser.id}
type="submit"
>
{savingActionUserId === checkingInUser.id ? "Saving..." : "Check in"}
</button>
</form>
</Modal>
) : null}
</> </>
); );
} }
+5
View File
@@ -19,6 +19,11 @@ export type CreateScheduleRequest = {
out_time: string; out_time: string;
}; };
export type ClockInRequest = {
day_of_week: DayOfWeek;
enter_time: string;
};
export type ClockOutRequest = { export type ClockOutRequest = {
day_of_week: DayOfWeek; day_of_week: DayOfWeek;
out_time: string; out_time: string;
+1 -1
View File
@@ -2,7 +2,7 @@ export type WorkTime = "morning" | "afternoon" | "evening";
export type Employee = { export type Employee = {
id: number; id: number;
company_id: number; company_id: number | null;
name: string; name: string;
last_name: string; last_name: string;
preference: WorkTime; preference: WorkTime;