adding scheduler
This commit is contained in:
@@ -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),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
+75
-31
@@ -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,31 +196,75 @@ export function CompaniesPage({ backendPreference }: CompaniesPageProps) {
|
|||||||
</Modal>
|
</Modal>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{selectedCompany ? (
|
{selectedCompany
|
||||||
<Modal
|
? (() => {
|
||||||
title={`Add users to ${selectedCompany.name}`}
|
const assignedNames = new Set(employeeNamesByCompany[selectedCompany.id] ?? []);
|
||||||
onClose={() => setSelectedCompany(null)}
|
const availableUsers = users.filter(
|
||||||
>
|
(user) => !assignedNames.has(`${user.name} ${user.last_name}`),
|
||||||
<form className={styles.form} onSubmit={handleAssignEmployee}>
|
);
|
||||||
<label>
|
|
||||||
Existing user
|
return (
|
||||||
<select
|
<Modal
|
||||||
value={selectedEmployeeId}
|
title={`Add users to ${selectedCompany.name}`}
|
||||||
onChange={(event) => setSelectedEmployeeId(event.target.value)}
|
onClose={() => setSelectedCompany(null)}
|
||||||
required
|
|
||||||
>
|
>
|
||||||
<option value="">Select a user</option>
|
<form className={styles.form} onSubmit={handleAssignEmployee}>
|
||||||
{users.map((user) => (
|
{availableUsers.length === 0 ? (
|
||||||
<option key={user.id} value={user.id}>
|
<p className={styles.muted}>All users are in a company</p>
|
||||||
{user.name} {user.last_name}
|
) : (
|
||||||
</option>
|
<label>
|
||||||
))}
|
Existing user
|
||||||
</select>
|
<select
|
||||||
</label>
|
value={selectedEmployeeId}
|
||||||
<button className={styles.primaryButton} disabled={isSaving} type="submit">
|
onChange={(event) => setSelectedEmployeeId(event.target.value)}
|
||||||
{isSaving ? "Saving..." : "Assign user"}
|
required
|
||||||
</button>
|
>
|
||||||
</form>
|
<option value="">Select a user</option>
|
||||||
|
{availableUsers.map((user) => (
|
||||||
|
<option key={user.id} value={user.id}>
|
||||||
|
{user.name} {user.last_name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
className={styles.primaryButton}
|
||||||
|
disabled={isSaving || availableUsers.length === 0}
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
{isSaving ? "Saving..." : "Assign user"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</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>
|
</Modal>
|
||||||
) : null}
|
) : null}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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
@@ -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}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user