adding gui for the application
This commit is contained in:
@@ -0,0 +1,126 @@
|
||||
.primaryButton,
|
||||
.secondaryButton {
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
padding: 12px 18px;
|
||||
cursor: pointer;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.primaryButton {
|
||||
background: #2563eb;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.secondaryButton {
|
||||
background: #ffffff;
|
||||
color: #172033;
|
||||
box-shadow: inset 0 0 0 1px #d9e1ec;
|
||||
}
|
||||
|
||||
.companyGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.companyCard {
|
||||
padding: 22px;
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
background: #ffffff;
|
||||
border-radius: 24px;
|
||||
box-shadow: 0 20px 50px rgba(17, 24, 39, 0.08);
|
||||
|
||||
h3,
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
p {
|
||||
color: #687487;
|
||||
}
|
||||
}
|
||||
|
||||
.companyHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.deleteButton {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
flex: 0 0 auto;
|
||||
background: #fee2e2;
|
||||
color: #b91c1c;
|
||||
cursor: pointer;
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
fill: currentColor;
|
||||
}
|
||||
}
|
||||
|
||||
.employeeList {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.pill {
|
||||
border-radius: 999px;
|
||||
padding: 7px 11px;
|
||||
background: #eef4ff;
|
||||
color: #1d4ed8;
|
||||
font-size: 0.84rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.form {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
|
||||
label {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
color: #293548;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
input,
|
||||
select {
|
||||
border: 1px solid #d9e1ec;
|
||||
border-radius: 14px;
|
||||
padding: 12px 14px;
|
||||
color: #172033;
|
||||
font: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #b91c1c;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: #687487;
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.companyGrid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import type { FormEvent } from "react";
|
||||
import {
|
||||
assignEmployeeToCompany,
|
||||
createCompany,
|
||||
deleteCompany,
|
||||
getCompanies,
|
||||
getCompanyEmployeeNames,
|
||||
} from "@api/companiesApi";
|
||||
import { getUsers } 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 { Employee } from "@/types/users";
|
||||
import styles from "./CompaniesPage.module.scss";
|
||||
|
||||
type CompaniesPageProps = {
|
||||
backendPreference: BackendPreference;
|
||||
};
|
||||
|
||||
type EmployeeNamesByCompany = Record<number, string[]>;
|
||||
|
||||
export function CompaniesPage({ backendPreference }: CompaniesPageProps) {
|
||||
const [companies, setCompanies] = useState<Company[]>([]);
|
||||
const [employeeNamesByCompany, setEmployeeNamesByCompany] = useState<EmployeeNamesByCompany>({});
|
||||
const [users, setUsers] = useState<Employee[]>([]);
|
||||
const [companyName, setCompanyName] = useState("");
|
||||
const [selectedCompany, setSelectedCompany] = useState<Company | null>(null);
|
||||
const [selectedEmployeeId, setSelectedEmployeeId] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [isAddCompanyOpen, setIsAddCompanyOpen] = useState(false);
|
||||
const [deletingCompanyId, setDeletingCompanyId] = useState<number | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const loadCompanies = useCallback(async () => {
|
||||
setError("");
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const [companiesResponse, usersResponse] = await Promise.all([
|
||||
getCompanies(backendPreference),
|
||||
getUsers(backendPreference),
|
||||
]);
|
||||
const employeeNameEntries = await Promise.all(
|
||||
companiesResponse.map(async (company) => {
|
||||
const response = await getCompanyEmployeeNames(backendPreference, company.name);
|
||||
return [company.id, response.employees] as const;
|
||||
}),
|
||||
);
|
||||
|
||||
setCompanies(companiesResponse);
|
||||
setUsers(usersResponse);
|
||||
setEmployeeNamesByCompany(Object.fromEntries(employeeNameEntries));
|
||||
} catch (loadError) {
|
||||
setError(loadError instanceof Error ? loadError.message : "Unable to load companies");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [backendPreference]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadCompanies();
|
||||
}, [loadCompanies]);
|
||||
|
||||
async function handleCreateCompany(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setIsSaving(true);
|
||||
setError("");
|
||||
|
||||
try {
|
||||
await createCompany(backendPreference, { name: companyName });
|
||||
setCompanyName("");
|
||||
setIsAddCompanyOpen(false);
|
||||
await loadCompanies();
|
||||
} catch (saveError) {
|
||||
setError(saveError instanceof Error ? saveError.message : "Unable to create company");
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAssignEmployee(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
|
||||
if (!selectedCompany || !selectedEmployeeId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
setError("");
|
||||
|
||||
try {
|
||||
await assignEmployeeToCompany(
|
||||
backendPreference,
|
||||
selectedCompany.id,
|
||||
Number(selectedEmployeeId),
|
||||
);
|
||||
setSelectedCompany(null);
|
||||
setSelectedEmployeeId("");
|
||||
await loadCompanies();
|
||||
} catch (saveError) {
|
||||
setError(saveError instanceof Error ? saveError.message : "Unable to assign employee");
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteCompany(company: Company) {
|
||||
const shouldDelete = window.confirm(`Delete ${company.name}?`);
|
||||
|
||||
if (!shouldDelete) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDeletingCompanyId(company.id);
|
||||
setError("");
|
||||
|
||||
try {
|
||||
await deleteCompany(backendPreference, company.id);
|
||||
await loadCompanies();
|
||||
} catch (deleteError) {
|
||||
setError(deleteError instanceof Error ? deleteError.message : "Unable to delete company");
|
||||
} finally {
|
||||
setDeletingCompanyId(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Companies"
|
||||
description="Create companies, view company names, and see the users assigned to each company."
|
||||
actions={
|
||||
<button className={styles.primaryButton} onClick={() => setIsAddCompanyOpen(true)}>
|
||||
Add company
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
{error ? <p className={styles.error}>{error}</p> : null}
|
||||
{isLoading ? <p className={styles.muted}>Loading companies...</p> : null}
|
||||
<section className={styles.companyGrid} aria-label="Company list">
|
||||
{companies.map((company) => (
|
||||
<article className={styles.companyCard} key={company.id}>
|
||||
<div className={styles.companyHeader}>
|
||||
<div>
|
||||
<h3>{company.name}</h3>
|
||||
<p>Created {new Date(company.date_created).toLocaleDateString()}</p>
|
||||
</div>
|
||||
<button
|
||||
className={styles.deleteButton}
|
||||
disabled={deletingCompanyId === company.id}
|
||||
onClick={() => void handleDeleteCompany(company)}
|
||||
type="button"
|
||||
aria-label={`Delete ${company.name}`}
|
||||
>
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" focusable="false">
|
||||
<path d="M9 3h6l1 2h4v2H4V5h4l1-2Zm1 6h2v9h-2V9Zm4 0h2v9h-2V9ZM7 9h2v10h6V9h2v12H7V9Z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className={styles.employeeList} aria-label={`${company.name} users`}>
|
||||
{(employeeNamesByCompany[company.id] ?? []).length > 0 ? (
|
||||
employeeNamesByCompany[company.id].map((user) => (
|
||||
<span className={styles.pill} key={user}>
|
||||
{user}
|
||||
</span>
|
||||
))
|
||||
) : (
|
||||
<span className={styles.muted}>No users assigned</span>
|
||||
)}
|
||||
</div>
|
||||
<button className={styles.secondaryButton} onClick={() => setSelectedCompany(company)}>
|
||||
Add users
|
||||
</button>
|
||||
</article>
|
||||
))}
|
||||
</section>
|
||||
|
||||
{isAddCompanyOpen ? (
|
||||
<Modal title="Add company" onClose={() => setIsAddCompanyOpen(false)}>
|
||||
<form className={styles.form} onSubmit={handleCreateCompany}>
|
||||
<label>
|
||||
Company name
|
||||
<input
|
||||
value={companyName}
|
||||
onChange={(event) => setCompanyName(event.target.value)}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<button className={styles.primaryButton} disabled={isSaving} type="submit">
|
||||
{isSaving ? "Saving..." : "Create company"}
|
||||
</button>
|
||||
</form>
|
||||
</Modal>
|
||||
) : null}
|
||||
|
||||
{selectedCompany ? (
|
||||
<Modal
|
||||
title={`Add users to ${selectedCompany.name}`}
|
||||
onClose={() => setSelectedCompany(null)}
|
||||
>
|
||||
<form className={styles.form} onSubmit={handleAssignEmployee}>
|
||||
<label>
|
||||
Existing user
|
||||
<select
|
||||
value={selectedEmployeeId}
|
||||
onChange={(event) => setSelectedEmployeeId(event.target.value)}
|
||||
required
|
||||
>
|
||||
<option value="">Select a user</option>
|
||||
{users.map((user) => (
|
||||
<option key={user.id} value={user.id}>
|
||||
{user.name} {user.last_name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<button className={styles.primaryButton} disabled={isSaving} type="submit">
|
||||
{isSaving ? "Saving..." : "Assign user"}
|
||||
</button>
|
||||
</form>
|
||||
</Modal>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
.statsGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 24px;
|
||||
background: #ffffff;
|
||||
border-radius: 24px;
|
||||
box-shadow: 0 20px 50px rgba(17, 24, 39, 0.08);
|
||||
|
||||
span {
|
||||
display: block;
|
||||
color: #6b7280;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
strong {
|
||||
font-size: 2.25rem;
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #b91c1c;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: #687487;
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.statsGrid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { getCompanies } from "@api/companiesApi";
|
||||
import { getWeekSchedule } from "@api/scheduleApi";
|
||||
import { getUsers } from "@api/usersApi";
|
||||
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 } from "@/types/users";
|
||||
import styles from "./HomePage.module.scss";
|
||||
|
||||
type HomePageProps = {
|
||||
backendPreference: BackendPreference;
|
||||
};
|
||||
|
||||
export function HomePage({ backendPreference }: HomePageProps) {
|
||||
const [companies, setCompanies] = useState<Company[]>([]);
|
||||
const [schedules, setSchedules] = useState<Schedule[]>([]);
|
||||
const [users, setUsers] = useState<Employee[]>([]);
|
||||
const [error, setError] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
async function loadDashboard() {
|
||||
setError("");
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const [companiesResponse, usersResponse, scheduleResponse] = await Promise.all([
|
||||
getCompanies(backendPreference),
|
||||
getUsers(backendPreference),
|
||||
getWeekSchedule(backendPreference),
|
||||
]);
|
||||
|
||||
if (isMounted) {
|
||||
setCompanies(companiesResponse);
|
||||
setUsers(usersResponse);
|
||||
setSchedules(scheduleResponse.schedules);
|
||||
}
|
||||
} catch (loadError) {
|
||||
if (isMounted) {
|
||||
setError(
|
||||
loadError instanceof Error ? loadError.message : "Unable to load dashboard data",
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (isMounted) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void loadDashboard();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [backendPreference]);
|
||||
|
||||
const workingToday = new Set(
|
||||
schedules
|
||||
.filter((schedule) => schedule.enter_time && !schedule.out_time)
|
||||
.map((schedule) => schedule.user_id),
|
||||
).size;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Employee Manager"
|
||||
description="Track employee attendance, company teams, and weekly schedules from one dashboard."
|
||||
/>
|
||||
{error ? <p className={styles.error}>{error}</p> : null}
|
||||
{isLoading ? <p className={styles.muted}>Loading dashboard...</p> : null}
|
||||
<section className={styles.statsGrid} aria-label="Dashboard overview">
|
||||
<article className={styles.card}>
|
||||
<span>Total users</span>
|
||||
<strong>{users.length}</strong>
|
||||
</article>
|
||||
<article className={styles.card}>
|
||||
<span>Working today</span>
|
||||
<strong>{workingToday}</strong>
|
||||
</article>
|
||||
<article className={styles.card}>
|
||||
<span>Companies</span>
|
||||
<strong>{companies.length}</strong>
|
||||
</article>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
.calendar {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, minmax(130px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.dayCard {
|
||||
min-height: 210px;
|
||||
padding: 16px;
|
||||
border-radius: 20px;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 14px 35px rgba(17, 24, 39, 0.07);
|
||||
|
||||
h3 {
|
||||
margin: 0 0 14px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.shiftList {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.shift {
|
||||
border-radius: 14px;
|
||||
padding: 10px;
|
||||
background: #f3f6fb;
|
||||
|
||||
strong,
|
||||
span {
|
||||
display: block;
|
||||
}
|
||||
|
||||
span {
|
||||
color: #687487;
|
||||
font-size: 0.86rem;
|
||||
margin-top: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #b91c1c;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: #687487;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.calendar {
|
||||
overflow-x: auto;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { getWeekSchedule } from "@api/scheduleApi";
|
||||
import { getUsers } from "@api/usersApi";
|
||||
import { PageHeader } from "@components/PageHeader";
|
||||
import type { BackendPreference } from "@/types/backend";
|
||||
import type { Schedule } from "@/types/schedule";
|
||||
import type { DayOfWeek, Employee } from "@/types/users";
|
||||
import styles from "./SchedulePage.module.scss";
|
||||
|
||||
type SchedulePageProps = {
|
||||
backendPreference: BackendPreference;
|
||||
};
|
||||
|
||||
const weekDays: DayOfWeek[] = [
|
||||
"sunday",
|
||||
"monday",
|
||||
"tuesday",
|
||||
"wednesday",
|
||||
"thursday",
|
||||
"friday",
|
||||
"saturday",
|
||||
];
|
||||
|
||||
function formatDay(day: DayOfWeek): string {
|
||||
return day.charAt(0).toUpperCase() + day.slice(1);
|
||||
}
|
||||
|
||||
function formatTime(value: string | null): string {
|
||||
if (!value) {
|
||||
return "Not registered";
|
||||
}
|
||||
|
||||
return new Date(value).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||||
}
|
||||
|
||||
export function SchedulePage({ backendPreference }: SchedulePageProps) {
|
||||
const [schedules, setSchedules] = useState<Schedule[]>([]);
|
||||
const [users, setUsers] = useState<Employee[]>([]);
|
||||
const [error, setError] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
const schedulesByDay = useMemo(() => {
|
||||
return schedules.reduce<Record<DayOfWeek, Schedule[]>>(
|
||||
(accumulator, schedule) => {
|
||||
accumulator[schedule.day_of_week] = [...accumulator[schedule.day_of_week], schedule];
|
||||
return accumulator;
|
||||
},
|
||||
{
|
||||
sunday: [],
|
||||
monday: [],
|
||||
tuesday: [],
|
||||
wednesday: [],
|
||||
thursday: [],
|
||||
friday: [],
|
||||
saturday: [],
|
||||
},
|
||||
);
|
||||
}, [schedules]);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
async function loadSchedule() {
|
||||
setError("");
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const [scheduleResponse, usersResponse] = await Promise.all([
|
||||
getWeekSchedule(backendPreference),
|
||||
getUsers(backendPreference),
|
||||
]);
|
||||
|
||||
if (isMounted) {
|
||||
setSchedules(scheduleResponse.schedules);
|
||||
setUsers(usersResponse);
|
||||
}
|
||||
} catch (loadError) {
|
||||
if (isMounted) {
|
||||
setError(loadError instanceof Error ? loadError.message : "Unable to load schedule");
|
||||
}
|
||||
} finally {
|
||||
if (isMounted) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void loadSchedule();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [backendPreference]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Schedule"
|
||||
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}
|
||||
{isLoading ? <p className={styles.muted}>Loading schedule...</p> : null}
|
||||
<section className={styles.calendar} aria-label="Weekly schedule from Sunday to Saturday">
|
||||
{weekDays.map((day) => (
|
||||
<article className={styles.dayCard} key={day}>
|
||||
<h3>{formatDay(day)}</h3>
|
||||
<div className={styles.shiftList}>
|
||||
{schedulesByDay[day].length > 0 ? (
|
||||
schedulesByDay[day].map((schedule) => {
|
||||
const user = users.find((item) => item.id === schedule.user_id);
|
||||
return (
|
||||
<div className={styles.shift} key={schedule.id}>
|
||||
<strong>
|
||||
{user ? `${user.name} ${user.last_name}` : `User #${schedule.user_id}`}
|
||||
</strong>
|
||||
<span>
|
||||
{formatTime(schedule.enter_time)} - {formatTime(schedule.out_time)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<p className={styles.muted}>No shifts scheduled</p>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
.button {
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
padding: 12px 18px;
|
||||
cursor: pointer;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.primaryButton {
|
||||
composes: button;
|
||||
background: #2563eb;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.secondaryButton {
|
||||
composes: button;
|
||||
background: #ffffff;
|
||||
color: #172033;
|
||||
box-shadow: inset 0 0 0 1px #d9e1ec;
|
||||
}
|
||||
|
||||
.tableCard {
|
||||
overflow: hidden;
|
||||
background: #ffffff;
|
||||
border-radius: 24px;
|
||||
box-shadow: 0 20px 50px rgba(17, 24, 39, 0.08);
|
||||
}
|
||||
|
||||
.tableScroll {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
min-width: 760px;
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: 16px 18px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #edf1f6;
|
||||
}
|
||||
|
||||
th {
|
||||
color: #6b7280;
|
||||
font-size: 0.78rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
td {
|
||||
color: #293548;
|
||||
}
|
||||
|
||||
tbody tr {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.selectedRow {
|
||||
background: #eef4ff;
|
||||
}
|
||||
|
||||
.status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border-radius: 999px;
|
||||
padding: 6px 10px;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
color: #047857;
|
||||
background: #d1fae5;
|
||||
}
|
||||
|
||||
.working {
|
||||
color: #047857;
|
||||
background: #d1fae5;
|
||||
}
|
||||
|
||||
.worked {
|
||||
color: #1d4ed8;
|
||||
background: #dbeafe;
|
||||
}
|
||||
|
||||
.off-duty {
|
||||
color: #b45309;
|
||||
background: #fef3c7;
|
||||
}
|
||||
|
||||
.form {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
|
||||
label {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
color: #293548;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
input,
|
||||
select {
|
||||
border: 1px solid #d9e1ec;
|
||||
border-radius: 14px;
|
||||
padding: 12px 14px;
|
||||
color: #172033;
|
||||
font: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #b91c1c;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: #687487;
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import type { FormEvent } from "react";
|
||||
import { getCompanies } from "@api/companiesApi";
|
||||
import { 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"];
|
||||
|
||||
function getUserStatus(schedules: Schedule[]): UserStatus {
|
||||
const hasCheckIn = schedules.some((schedule) => schedule.enter_time);
|
||||
const hasActiveShift = schedules.some((schedule) => schedule.enter_time && !schedule.out_time);
|
||||
|
||||
if (hasActiveShift) {
|
||||
return "working";
|
||||
}
|
||||
|
||||
if (hasCheckIn) {
|
||||
return "worked";
|
||||
}
|
||||
|
||||
return "off-duty";
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
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 [isLoading, setIsLoading] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const selectedUser = users.find((user) => user.id === selectedUserId) ?? null;
|
||||
|
||||
const schedulesByUser = useMemo(() => {
|
||||
return schedules.reduce<Record<number, Schedule[]>>((accumulator, schedule) => {
|
||||
accumulator[schedule.user_id] = [...(accumulator[schedule.user_id] ?? []), schedule];
|
||||
return accumulator;
|
||||
}, {});
|
||||
}, [schedules]);
|
||||
|
||||
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]);
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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}
|
||||
<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>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map((user) => {
|
||||
const userSchedules = schedulesByUser[user.id] ?? [];
|
||||
const status = getUserStatus(userSchedules);
|
||||
const latestSchedule = userSchedules.at(-1);
|
||||
const company = companies.find((item) => item.id === user.company_id);
|
||||
const daysWorked = new Set(
|
||||
userSchedules
|
||||
.filter((schedule) => schedule.enter_time)
|
||||
.map((schedule) => schedule.day_of_week),
|
||||
).size;
|
||||
|
||||
return (
|
||||
<tr
|
||||
className={selectedUserId === user.id ? styles.selectedRow : ""}
|
||||
key={user.id}
|
||||
onClick={() => setSelectedUserId(user.id)}
|
||||
>
|
||||
<td>
|
||||
{user.name} {user.last_name}
|
||||
</td>
|
||||
<td>{company?.name ?? `Company #${user.company_id}`}</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>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user