643 lines
21 KiB
TypeScript
643 lines
21 KiB
TypeScript
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
import type { FormEvent } from "react";
|
|
import { getCompanies } from "@api/companiesApi";
|
|
import { clockIn, clockOut, getWeekSchedule } from "@api/scheduleApi";
|
|
import { createUser, getUsers, updateUserPreference } from "@api/usersApi";
|
|
import { Modal } from "@components/Modal";
|
|
import { PageHeader } from "@components/PageHeader";
|
|
import type { BackendPreference } from "@/types/backend";
|
|
import type { Company } from "@/types/companies";
|
|
import type { Schedule } from "@/types/schedule";
|
|
import type { Employee, WorkTime } from "@/types/users";
|
|
import styles from "./UsersPage.module.scss";
|
|
|
|
type UsersPageProps = {
|
|
backendPreference: BackendPreference;
|
|
};
|
|
|
|
type UserStatus = "off-duty" | "working" | "worked";
|
|
|
|
const workTimeOptions: WorkTime[] = ["morning", "afternoon", "evening"];
|
|
const statusOptions: UserStatus[] = ["off-duty", "working", "worked"];
|
|
const usersPerPage = 10;
|
|
|
|
function isSameLocalDate(firstDate: Date, secondDate: Date): boolean {
|
|
return (
|
|
firstDate.getFullYear() === secondDate.getFullYear() &&
|
|
firstDate.getMonth() === secondDate.getMonth() &&
|
|
firstDate.getDate() === secondDate.getDate()
|
|
);
|
|
}
|
|
|
|
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 isScheduleInWeek(schedule: Schedule, weekStart: Date, weekEndExclusive: Date): boolean {
|
|
if (!schedule.enter_time) {
|
|
return false;
|
|
}
|
|
|
|
const enterDate = new Date(schedule.enter_time);
|
|
|
|
return (
|
|
!Number.isNaN(enterDate.getTime()) && enterDate >= weekStart && enterDate < weekEndExclusive
|
|
);
|
|
}
|
|
|
|
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 "working";
|
|
}
|
|
|
|
function getStatusLabel(status: UserStatus): string {
|
|
if (status === "working") {
|
|
return "Working";
|
|
}
|
|
|
|
if (status === "worked") {
|
|
return "Worked";
|
|
}
|
|
|
|
return "Off duty";
|
|
}
|
|
|
|
function formatDateTime(value: string | null): string {
|
|
if (!value) {
|
|
return "--";
|
|
}
|
|
|
|
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) {
|
|
const [companies, setCompanies] = useState<Company[]>([]);
|
|
const [schedules, setSchedules] = useState<Schedule[]>([]);
|
|
const [selectedUserId, setSelectedUserId] = useState<number | null>(null);
|
|
const [users, setUsers] = useState<Employee[]>([]);
|
|
const [companyId, setCompanyId] = useState("");
|
|
const [firstName, setFirstName] = useState("");
|
|
const [lastName, setLastName] = useState("");
|
|
const [preference, setPreference] = useState<WorkTime>("morning");
|
|
const [error, setError] = useState("");
|
|
const [isAddUserOpen, setIsAddUserOpen] = 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 [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 today = new Date();
|
|
const currentWeekStart = getWeekStart(today);
|
|
const currentWeekEndExclusive = addDays(currentWeekStart, 7);
|
|
|
|
const schedulesByUser = useMemo(() => {
|
|
return schedules.reduce<Record<string, Schedule[]>>((accumulator, schedule) => {
|
|
const userKey = getScheduleUserKey(schedule);
|
|
|
|
if (!userKey) {
|
|
return accumulator;
|
|
}
|
|
|
|
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);
|
|
|
|
try {
|
|
const [companiesResponse, usersResponse, scheduleResponse] = await Promise.all([
|
|
getCompanies(backendPreference),
|
|
getUsers(backendPreference),
|
|
getWeekSchedule(backendPreference),
|
|
]);
|
|
|
|
setCompanies(companiesResponse);
|
|
setUsers(usersResponse);
|
|
setSchedules(scheduleResponse.schedules);
|
|
} catch (loadError) {
|
|
setError(loadError instanceof Error ? loadError.message : "Unable to load users");
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
}, [backendPreference]);
|
|
|
|
useEffect(() => {
|
|
void loadUsersPage();
|
|
}, [loadUsersPage]);
|
|
|
|
useEffect(() => {
|
|
if (currentPage > totalPages) {
|
|
setCurrentPage(totalPages);
|
|
}
|
|
}, [currentPage, totalPages]);
|
|
|
|
useEffect(() => {
|
|
setCurrentPage(1);
|
|
}, [selectedCompanyId, selectedStatus]);
|
|
|
|
function resetUserForm() {
|
|
setCompanyId("");
|
|
setFirstName("");
|
|
setLastName("");
|
|
setPreference("morning");
|
|
}
|
|
|
|
async function handleCreateUser(event: FormEvent<HTMLFormElement>) {
|
|
event.preventDefault();
|
|
setError("");
|
|
setIsSaving(true);
|
|
|
|
try {
|
|
await createUser(backendPreference, {
|
|
company_id: Number(companyId),
|
|
name: firstName,
|
|
last_name: lastName,
|
|
preference,
|
|
});
|
|
resetUserForm();
|
|
setIsAddUserOpen(false);
|
|
await loadUsersPage();
|
|
} catch (saveError) {
|
|
setError(saveError instanceof Error ? saveError.message : "Unable to add user");
|
|
} finally {
|
|
setIsSaving(false);
|
|
}
|
|
}
|
|
|
|
async function handleUpdatePreference(event: FormEvent<HTMLFormElement>) {
|
|
event.preventDefault();
|
|
|
|
if (!selectedUser) {
|
|
return;
|
|
}
|
|
|
|
setError("");
|
|
setIsSaving(true);
|
|
|
|
try {
|
|
await updateUserPreference(backendPreference, selectedUser.id, { preference });
|
|
setIsPreferenceOpen(false);
|
|
await loadUsersPage();
|
|
} catch (saveError) {
|
|
setError(saveError instanceof Error ? saveError.message : "Unable to update preference");
|
|
} finally {
|
|
setIsSaving(false);
|
|
}
|
|
}
|
|
|
|
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 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)
|
|
.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(),
|
|
shift: getShiftFromDateTime(checkInDateTime),
|
|
});
|
|
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 (
|
|
<>
|
|
<PageHeader
|
|
title="Users"
|
|
description="Review employees, assigned companies, active work status, check-in times, and check-out times."
|
|
actions={
|
|
<>
|
|
<button className={styles.primaryButton} onClick={() => setIsAddUserOpen(true)}>
|
|
Add user
|
|
</button>
|
|
{selectedUser ? (
|
|
<button
|
|
className={styles.secondaryButton}
|
|
onClick={() => {
|
|
setPreference(selectedUser.preference);
|
|
setIsPreferenceOpen(true);
|
|
}}
|
|
>
|
|
Update preference
|
|
</button>
|
|
) : null}
|
|
</>
|
|
}
|
|
/>
|
|
{error ? <p className={styles.error}>{error}</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">
|
|
<div className={styles.tableScroll}>
|
|
<table className={styles.table}>
|
|
<thead>
|
|
<tr>
|
|
<th>Name</th>
|
|
<th>Company</th>
|
|
<th>Preference</th>
|
|
<th>Status</th>
|
|
<th>Check in</th>
|
|
<th>Check out</th>
|
|
<th>Days worked</th>
|
|
<th>Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{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
|
|
.filter((schedule) => isScheduleInWeek(schedule, currentWeekStart, currentWeekEndExclusive))
|
|
.map((schedule) => new Date(schedule.enter_time as string).getDay()),
|
|
).size;
|
|
|
|
return (
|
|
<tr
|
|
className={selectedUserId === user.id ? styles.selectedRow : ""}
|
|
key={user.id}
|
|
onClick={() => setSelectedUserId(user.id)}
|
|
title="Double click to edit user information"
|
|
>
|
|
<td>
|
|
{user.name} {user.last_name}
|
|
</td>
|
|
<td>
|
|
{company?.name ??
|
|
(user.company_id ? `Company #${user.company_id}` : "Unassigned")}
|
|
</td>
|
|
<td>{user.preference}</td>
|
|
<td>
|
|
<span className={`${styles.status} ${styles[status]}`}>
|
|
{getStatusLabel(status)}
|
|
</span>
|
|
</td>
|
|
<td>{formatDateTime(latestSchedule?.enter_time ?? null)}</td>
|
|
<td>{formatDateTime(latestSchedule?.out_time ?? null)}</td>
|
|
<td>{daysWorked}</td>
|
|
<td>
|
|
<div className={styles.actionGroup}>
|
|
<button
|
|
className={styles.actionButton}
|
|
disabled={status !== "off-duty" || 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>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</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>
|
|
|
|
{isAddUserOpen ? (
|
|
<Modal
|
|
title="Add user"
|
|
onClose={() => {
|
|
setIsAddUserOpen(false);
|
|
resetUserForm();
|
|
}}
|
|
>
|
|
<form className={styles.form} onSubmit={handleCreateUser}>
|
|
<label>
|
|
Company
|
|
<select
|
|
value={companyId}
|
|
onChange={(event) => setCompanyId(event.target.value)}
|
|
required
|
|
>
|
|
<option value="">Select a company</option>
|
|
{companies.map((company) => (
|
|
<option key={company.id} value={company.id}>
|
|
{company.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
<label>
|
|
First name
|
|
<input
|
|
value={firstName}
|
|
onChange={(event) => setFirstName(event.target.value)}
|
|
required
|
|
/>
|
|
</label>
|
|
<label>
|
|
Last name
|
|
<input
|
|
value={lastName}
|
|
onChange={(event) => setLastName(event.target.value)}
|
|
required
|
|
/>
|
|
</label>
|
|
<label>
|
|
Preference
|
|
<select
|
|
value={preference}
|
|
onChange={(event) => setPreference(event.target.value as WorkTime)}
|
|
>
|
|
{workTimeOptions.map((option) => (
|
|
<option key={option} value={option}>
|
|
{option}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
<button className={styles.primaryButton} disabled={isSaving} type="submit">
|
|
{isSaving ? "Saving..." : "Create user"}
|
|
</button>
|
|
</form>
|
|
</Modal>
|
|
) : null}
|
|
|
|
{isPreferenceOpen && selectedUser ? (
|
|
<Modal title="Update preference" onClose={() => setIsPreferenceOpen(false)}>
|
|
<form className={styles.form} onSubmit={handleUpdatePreference}>
|
|
<label>
|
|
Preference for {selectedUser.name} {selectedUser.last_name}
|
|
<select
|
|
value={preference}
|
|
onChange={(event) => setPreference(event.target.value as WorkTime)}
|
|
>
|
|
{workTimeOptions.map((option) => (
|
|
<option key={option} value={option}>
|
|
{option}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
<button className={styles.primaryButton} disabled={isSaving} type="submit">
|
|
{isSaving ? "Saving..." : "Save preference"}
|
|
</button>
|
|
</form>
|
|
</Modal>
|
|
) : 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}
|
|
</>
|
|
);
|
|
}
|