diff --git a/src/api/scheduleApi.ts b/src/api/scheduleApi.ts index 4a93b3a..348b790 100644 --- a/src/api/scheduleApi.ts +++ b/src/api/scheduleApi.ts @@ -2,6 +2,7 @@ import { apiRequest } from "./apiClient"; import { endpoints } from "./endpoints"; import type { BackendPreference } from "@/types/backend"; import type { + ClockInRequest, ClockOutRequest, GenerateWeeklyScheduleRequest, Schedule, @@ -28,10 +29,12 @@ export function generateWeeklySchedule( export function clockIn( backendPreference: BackendPreference, employeeId: number, + request: ClockInRequest, ): Promise { return apiRequest(endpoints.employeeClockIn(employeeId), { backendPreference, method: "POST", + body: JSON.stringify(request), }); } diff --git a/src/pages/CompaniesPage.module.scss b/src/pages/CompaniesPage.module.scss index 58db83d..faf20e1 100644 --- a/src/pages/CompaniesPage.module.scss +++ b/src/pages/CompaniesPage.module.scss @@ -1,5 +1,6 @@ .primaryButton, -.secondaryButton { +.secondaryButton, +.dangerButton { border: 0; border-radius: 999px; padding: 12px 18px; @@ -19,6 +20,16 @@ box-shadow: inset 0 0 0 1px #d9e1ec; } +.dangerButton { + background: #dc2626; + color: #ffffff; + + &:disabled { + cursor: not-allowed; + opacity: 0.65; + } +} + .companyGrid { display: grid; 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 { color: #b91c1c; font-weight: 700; diff --git a/src/pages/CompaniesPage.tsx b/src/pages/CompaniesPage.tsx index e30ee8e..d6520d2 100644 --- a/src/pages/CompaniesPage.tsx +++ b/src/pages/CompaniesPage.tsx @@ -26,6 +26,7 @@ export function CompaniesPage({ backendPreference }: CompaniesPageProps) { const [employeeNamesByCompany, setEmployeeNamesByCompany] = useState({}); const [users, setUsers] = useState([]); const [companyName, setCompanyName] = useState(""); + const [companyToDelete, setCompanyToDelete] = useState(null); const [selectedCompany, setSelectedCompany] = useState(null); const [selectedEmployeeId, setSelectedEmployeeId] = useState(""); const [error, setError] = useState(""); @@ -107,18 +108,17 @@ export function CompaniesPage({ backendPreference }: CompaniesPageProps) { } } - async function handleDeleteCompany(company: Company) { - const shouldDelete = window.confirm(`Delete ${company.name}?`); - - if (!shouldDelete) { + async function handleDeleteCompany() { + if (!companyToDelete) { return; } - setDeletingCompanyId(company.id); + setDeletingCompanyId(companyToDelete.id); setError(""); try { - await deleteCompany(backendPreference, company.id); + await deleteCompany(backendPreference, companyToDelete.id); + setCompanyToDelete(null); await loadCompanies(); } catch (deleteError) { setError(deleteError instanceof Error ? deleteError.message : "Unable to delete company"); @@ -151,7 +151,7 @@ export function CompaniesPage({ backendPreference }: CompaniesPageProps) { - +
+ {availableUsers.length === 0 ? ( +

All users are in a company

+ ) : ( + + )} + +
+ + ); + })() + : null} + + {companyToDelete ? ( + setCompanyToDelete(null)}> +
+

+ Delete {companyToDelete.name}? This action cannot be undone. +

+
+ + +
+
) : null} diff --git a/src/pages/UsersPage.module.scss b/src/pages/UsersPage.module.scss index c4b3daa..9b51569 100644 --- a/src/pages/UsersPage.module.scss +++ b/src/pages/UsersPage.module.scss @@ -20,6 +20,29 @@ 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 { overflow: hidden; background: #ffffff; diff --git a/src/pages/UsersPage.tsx b/src/pages/UsersPage.tsx index 578ca4e..2230617 100644 --- a/src/pages/UsersPage.tsx +++ b/src/pages/UsersPage.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import type { FormEvent } from "react"; 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 { Modal } from "@components/Modal"; import { PageHeader } from "@components/PageHeader"; @@ -66,6 +66,9 @@ export function UsersPage({ backendPreference }: UsersPageProps) { const [error, setError] = useState(""); const [isAddUserOpen, setIsAddUserOpen] = useState(false); const [isPreferenceOpen, setIsPreferenceOpen] = useState(false); + const [checkingInUser, setCheckingInUser] = useState(null); + const [checkInDateTime, setCheckInDateTime] = useState(""); + const [savingActionUserId, setSavingActionUserId] = useState(null); const [isLoading, setIsLoading] = useState(true); 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) { + 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 ( <> Check in Check out Days worked + Actions {users.map((user) => { const userSchedules = schedulesByUser[user.id] ?? []; const status = getUserStatus(userSchedules); + const activeSchedule = userSchedules.find( + (schedule) => schedule.enter_time && !schedule.out_time, + ); const latestSchedule = userSchedules.at(-1); const company = companies.find((item) => item.id === user.company_id); const daysWorked = new Set( @@ -210,11 +280,15 @@ export function UsersPage({ backendPreference }: UsersPageProps) { className={selectedUserId === user.id ? styles.selectedRow : ""} key={user.id} onClick={() => setSelectedUserId(user.id)} + title="Double click to edit user information" > {user.name} {user.last_name} - {company?.name ?? `Company #${user.company_id}`} + + {company?.name ?? + (user.company_id ? `Company #${user.company_id}` : "Unassigned")} + {user.preference} @@ -224,6 +298,35 @@ export function UsersPage({ backendPreference }: UsersPageProps) { {formatDateTime(latestSchedule?.enter_time ?? null)} {formatDateTime(latestSchedule?.out_time ?? null)} {daysWorked} + +
+ + +
+ ); })} @@ -314,6 +417,37 @@ export function UsersPage({ backendPreference }: UsersPageProps) { ) : null} + + {checkingInUser ? ( + setCheckingInUser(null)} + > +
+ +

+ Day of week will be derived as{" "} + {checkInDateTime ? getDayOfWeekFromDateTime(checkInDateTime) : "--"} from the selected + date. +

+ +
+
+ ) : null} ); } diff --git a/src/types/schedule.ts b/src/types/schedule.ts index bac800d..ff06b10 100644 --- a/src/types/schedule.ts +++ b/src/types/schedule.ts @@ -19,6 +19,11 @@ export type CreateScheduleRequest = { out_time: string; }; +export type ClockInRequest = { + day_of_week: DayOfWeek; + enter_time: string; +}; + export type ClockOutRequest = { day_of_week: DayOfWeek; out_time: string; diff --git a/src/types/users.ts b/src/types/users.ts index a35e4cd..7e6c504 100644 --- a/src/types/users.ts +++ b/src/types/users.ts @@ -2,7 +2,7 @@ export type WorkTime = "morning" | "afternoon" | "evening"; export type Employee = { id: number; - company_id: number; + company_id: number | null; name: string; last_name: string; preference: WorkTime;