adding gui for the application

This commit is contained in:
2026-09-18 11:11:59 -04:00
parent ff7aa065e3
commit 256e87c8c8
47 changed files with 4398 additions and 12055 deletions
+4
View File
@@ -0,0 +1,4 @@
build
dist
node_modules
*.tsbuildinfo
+8
View File
@@ -0,0 +1,8 @@
{
"singleQuote": false,
"semi": true,
"trailingComma": "all",
"printWidth": 100,
"bracketSpacing": true,
"bracketSameLine": false
}
+44
View File
@@ -0,0 +1,44 @@
import js from "@eslint/js";
import prettierConfig from "eslint-config-prettier/flat";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
import globals from "globals";
import tseslint from "typescript-eslint";
export default tseslint.config(
{
ignores: ["build", "dist", "node_modules", "*.tsbuildinfo"],
},
js.configs.recommended,
...tseslint.configs.recommended,
{
files: ["**/*.{ts,tsx}"],
languageOptions: {
ecmaVersion: 2022,
globals: globals.browser,
},
plugins: {
"react-hooks": reactHooks,
"react-refresh": reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
"@typescript-eslint/consistent-type-definitions": ["error", "type"],
"@typescript-eslint/no-unused-vars": [
"error",
{
argsIgnorePattern: "^_",
caughtErrorsIgnorePattern: "^_",
varsIgnorePattern: "^_",
},
],
"brace-style": ["error", "1tbs", { allowSingleLine: false }],
curly: ["error", "all"],
"no-undef": "off",
quotes: ["error", "double", { avoidEscape: true }],
"react-hooks/set-state-in-effect": "off",
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
},
},
prettierConfig,
);
+18
View File
@@ -0,0 +1,18 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#111827" />
<meta name="description" content="Employee manager dashboard" />
<link rel="apple-touch-icon" href="/logo192.png" />
<link rel="manifest" href="/manifest.json" />
<title>Manager GUI</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+20 -12
View File
@@ -10,22 +10,18 @@
"react": "^19.3.0", "react": "^19.3.0",
"react-dom": "^19.3.0", "react-dom": "^19.3.0",
"react-router": "^8.4.0", "react-router": "^8.4.0",
"react-scripts": "5.0.1",
"sass": "^1.104.1", "sass": "^1.104.1",
"web-vitals": "^2.1.4", "web-vitals": "^2.1.4",
"zustand": "^5.0.15" "zustand": "^5.0.15"
}, },
"scripts": { "scripts": {
"dev": "react-scripts start", "dev": "vite",
"build": "react-scripts build", "build": "tsc -b && vite build",
"test": "react-scripts test", "lint": "eslint .",
"eject": "react-scripts eject" "lint:fix": "eslint . --fix",
}, "format": "prettier . --write",
"eslintConfig": { "format:check": "prettier . --check",
"extends": [ "preview": "vite preview"
"react-app",
"react-app/jest"
]
}, },
"browserslist": { "browserslist": {
"production": [ "production": [
@@ -40,7 +36,19 @@
] ]
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^10.0.1",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^5.0.0",
"eslint": "^10.10.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.7",
"globals": "^17.12.0",
"identity-obj-proxy": "^3.0.0", "identity-obj-proxy": "^3.0.0",
"typescript": "4.9.5" "prettier": "^3.9.7",
"typescript": "^6.0.0",
"typescript-eslint": "^8.70.0",
"vite": "^7.0.0"
} }
} }
+2416 -11392
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -1,4 +1,5 @@
allowBuilds: allowBuilds:
'@parcel/watcher': false "@parcel/watcher": false
core-js: true core-js: true
core-js-pure: true core-js-pure: true
esbuild: true
-43
View File
@@ -1,43 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="Web site created using create-react-app"
/>
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>React App</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>
-274
View File
@@ -1,274 +0,0 @@
import { useEffect, useState } from 'react';
import styles from './App.module.scss';
const navItems = [
{ label: 'Home', path: '/' },
{ label: 'Users', path: '/users' },
{ label: 'Schedule', path: '/schedule' },
{ label: 'Companies', path: '/companies' },
];
const users = [
{
name: 'Maya Chen',
company: 'Northwind Logistics',
working: true,
checkIn: '08:00 AM',
checkOut: '04:00 PM',
role: 'Shift Lead',
},
{
name: 'Carlos Rivera',
company: 'BluePeak Services',
working: true,
checkIn: '09:00 AM',
checkOut: '05:00 PM',
role: 'Technician',
},
{
name: 'Nora Patel',
company: 'Greenfield Foods',
working: false,
checkIn: '--',
checkOut: '--',
role: 'Coordinator',
},
{
name: 'Ethan Brooks',
company: 'Northwind Logistics',
working: true,
checkIn: '07:30 AM',
checkOut: '03:30 PM',
role: 'Warehouse Associate',
},
];
const companies = [
{
name: 'Northwind Logistics',
users: ['Maya Chen', 'Ethan Brooks'],
note: 'Morning and fulfillment teams',
},
{
name: 'BluePeak Services',
users: ['Carlos Rivera'],
note: 'Field support coverage',
},
{
name: 'Greenfield Foods',
users: ['Nora Patel'],
note: 'Operations coordination',
},
];
const weekSchedule = [
{ day: 'Sunday', shifts: [{ name: 'Maya Chen', time: '8 AM - 4 PM' }] },
{ day: 'Monday', shifts: [{ name: 'Carlos Rivera', time: '9 AM - 5 PM' }, { name: 'Ethan Brooks', time: '7:30 AM - 3:30 PM' }] },
{ day: 'Tuesday', shifts: [{ name: 'Nora Patel', time: '10 AM - 6 PM' }] },
{ day: 'Wednesday', shifts: [{ name: 'Maya Chen', time: '8 AM - 4 PM' }, { name: 'Carlos Rivera', time: '9 AM - 5 PM' }] },
{ day: 'Thursday', shifts: [{ name: 'Ethan Brooks', time: '7:30 AM - 3:30 PM' }] },
{ day: 'Friday', shifts: [{ name: 'Nora Patel', time: '10 AM - 6 PM' }, { name: 'Maya Chen', time: '8 AM - 4 PM' }] },
{ day: 'Saturday', shifts: [{ name: 'Carlos Rivera', time: '9 AM - 1 PM' }] },
];
function usePathname() {
const [pathname, setPathname] = useState(window.location.pathname);
useEffect(() => {
const handlePopState = () => setPathname(window.location.pathname);
window.addEventListener('popstate', handlePopState);
return () => window.removeEventListener('popstate', handlePopState);
}, []);
const navigate = (path) => {
window.history.pushState(null, '', path);
setPathname(path);
};
return [pathname, navigate];
}
function PageHeader({ title, description, actions }) {
return (
<header className={styles.pageHeader}>
<div>
<h2>{title}</h2>
<p>{description}</p>
</div>
{actions ? <div className={styles.buttonGroup}>{actions}</div> : null}
</header>
);
}
function HomePage() {
const workingToday = users.filter((user) => user.working).length;
return (
<>
<PageHeader
title="Employee Manager"
description="Track employee attendance, company teams, and weekly schedules from one dashboard."
/>
<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>
</>
);
}
function UsersPage() {
return (
<>
<PageHeader
title="Users"
description="Review all employees, assigned companies, active work status, check-in times, and check-out times."
actions={(
<>
<button className={styles.primaryButton}>Add user</button>
<button className={styles.secondaryButton}>Update preference</button>
</>
)}
/>
<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>Role</th>
<th>Working</th>
<th>Check in</th>
<th>Check out</th>
</tr>
</thead>
<tbody>
{users.map((user) => (
<tr key={user.name}>
<td>{user.name}</td>
<td>{user.company}</td>
<td>{user.role}</td>
<td>
<span className={`${styles.status} ${user.working ? '' : styles.statusOff}`}>
{user.working ? 'Working' : 'Off duty'}
</span>
</td>
<td>{user.checkIn}</td>
<td>{user.checkOut}</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
</>
);
}
function CompaniesPage() {
return (
<>
<PageHeader
title="Companies"
description="Create companies, view company names, and see the users assigned to each company."
actions={<button className={styles.primaryButton}>Add company</button>}
/>
<section className={styles.companyGrid} aria-label="Company list">
{companies.map((company) => (
<article className={styles.companyCard} key={company.name}>
<div>
<h3>{company.name}</h3>
<p>{company.note}</p>
</div>
<div className={styles.employeeList} aria-label={`${company.name} users`}>
{company.users.map((user) => (
<span className={styles.pill} key={user}>{user}</span>
))}
</div>
<button className={styles.secondaryButton}>Add users</button>
</article>
))}
</section>
</>
);
}
function SchedulePage() {
return (
<>
<PageHeader
title="Schedule"
description="Weekly scheduler calendar starts on Sunday and ends on Saturday so you can see who is working each day."
/>
<section className={styles.calendar} aria-label="Weekly schedule from Sunday to Saturday">
{weekSchedule.map((day) => (
<article className={styles.dayCard} key={day.day}>
<h3>{day.day}</h3>
<div className={styles.shiftList}>
{day.shifts.map((shift) => (
<div className={styles.shift} key={`${day.day}-${shift.name}`}>
<strong>{shift.name}</strong>
<span>{shift.time}</span>
</div>
))}
</div>
</article>
))}
</section>
</>
);
}
function App() {
const [pathname, navigate] = usePathname();
const renderPage = () => {
switch (pathname) {
case '/users':
return <UsersPage />;
case '/schedule':
return <SchedulePage />;
case '/companies':
return <CompaniesPage />;
default:
return <HomePage />;
}
};
return (
<div className={styles.shell}>
<aside className={styles.sidebar}>
<div className={styles.brand}>
<h1>Manager GUI</h1>
<p>Employees, companies, and schedules</p>
</div>
<nav className={styles.nav} aria-label="Sidebar navigation">
{navItems.map((item) => (
<button
className={`${styles.navItem} ${pathname === item.path ? styles.active : ''}`}
key={item.path}
onClick={() => navigate(item.path)}
type="button"
>
{item.label}
</button>
))}
</nav>
</aside>
<main className={styles.main}>{renderPage()}</main>
</div>
);
}
export default App;
-306
View File
@@ -1,306 +0,0 @@
.shell {
min-height: 100vh;
display: grid;
grid-template-columns: 280px 1fr;
background: #eef2f6;
color: #172033;
}
.sidebar {
position: sticky;
top: 0;
height: 100vh;
padding: 28px 22px;
background: #111827;
color: #ffffff;
display: flex;
flex-direction: column;
gap: 28px;
}
.brand {
display: grid;
gap: 6px;
h1 {
margin: 0;
font-size: 1.35rem;
letter-spacing: -0.03em;
}
p {
margin: 0;
color: #aeb8c8;
font-size: 0.9rem;
}
}
.nav {
display: grid;
gap: 10px;
}
.navItem {
border: 0;
border-radius: 14px;
padding: 14px 16px;
background: transparent;
color: #d9e0eb;
cursor: pointer;
font: inherit;
text-align: left;
transition: background 160ms ease, color 160ms ease;
&:hover,
&.active {
background: #2563eb;
color: #ffffff;
}
}
.main {
padding: 32px;
overflow: auto;
}
.pageHeader {
display: flex;
justify-content: space-between;
gap: 18px;
align-items: flex-start;
margin-bottom: 24px;
h2 {
margin: 0 0 8px;
font-size: clamp(1.8rem, 4vw, 3.25rem);
letter-spacing: -0.05em;
}
p {
margin: 0;
max-width: 680px;
color: #5f6b7a;
line-height: 1.6;
}
}
.buttonGroup {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.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;
}
.statsGrid,
.companyGrid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 18px;
}
.card,
.tableCard,
.companyCard,
.scheduleCard {
background: #ffffff;
border-radius: 24px;
box-shadow: 0 20px 50px rgba(17, 24, 39, 0.08);
}
.card {
padding: 24px;
span {
display: block;
color: #6b7280;
margin-bottom: 12px;
}
strong {
font-size: 2.25rem;
letter-spacing: -0.04em;
}
}
.tableCard {
overflow: hidden;
}
.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;
}
}
.status {
display: inline-flex;
align-items: center;
border-radius: 999px;
padding: 6px 10px;
font-size: 0.82rem;
font-weight: 700;
color: #047857;
background: #d1fae5;
}
.statusOff {
color: #b45309;
background: #fef3c7;
}
.companyCard {
padding: 22px;
display: grid;
gap: 18px;
h3,
p {
margin: 0;
}
p {
color: #687487;
}
}
.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;
}
.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;
}
}
@media (max-width: 980px) {
.shell {
grid-template-columns: 1fr;
}
.sidebar {
position: static;
height: auto;
}
.nav {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.navItem {
text-align: center;
}
.statsGrid,
.companyGrid {
grid-template-columns: 1fr;
}
.calendar {
overflow-x: auto;
}
}
@media (max-width: 640px) {
.main,
.sidebar {
padding: 20px;
}
.pageHeader {
display: grid;
}
.nav {
grid-template-columns: 1fr 1fr;
}
}
+41
View File
@@ -0,0 +1,41 @@
import { useState } from "react";
import { defaultBackendPreference } from "@api/backendConfig";
import { usePathname } from "@hooks/usePathname";
import { AppLayout } from "@layouts/AppLayout";
import { CompaniesPage } from "@pages/CompaniesPage";
import { HomePage } from "@pages/HomePage";
import { SchedulePage } from "@pages/SchedulePage";
import { UsersPage } from "@pages/UsersPage";
import type { BackendPreference } from "@/types/backend";
function App() {
const [pathname, navigate] = usePathname();
const [backendPreference, setBackendPreference] =
useState<BackendPreference>(defaultBackendPreference);
const renderPage = () => {
switch (pathname) {
case "/users":
return <UsersPage backendPreference={backendPreference} />;
case "/schedule":
return <SchedulePage backendPreference={backendPreference} />;
case "/companies":
return <CompaniesPage backendPreference={backendPreference} />;
default:
return <HomePage backendPreference={backendPreference} />;
}
};
return (
<AppLayout
backendPreference={backendPreference}
onBackendPreferenceChange={setBackendPreference}
pathname={pathname}
onNavigate={navigate}
>
{renderPage()}
</AppLayout>
);
}
export default App;
+36
View File
@@ -0,0 +1,36 @@
import { getBackendOption } from "./backendConfig";
import type { BackendPreference } from "@/types/backend";
type ApiRequestOptions = RequestInit & {
backendPreference: BackendPreference;
};
export function buildApiUrl(backendPreference: BackendPreference, endpoint: string): string {
const backend = getBackendOption(backendPreference);
return `${backend.baseUrl}${endpoint}`;
}
export async function apiRequest<TResponse>(
endpoint: string,
{ backendPreference, headers, ...requestOptions }: ApiRequestOptions,
): Promise<TResponse> {
const response = await fetch(buildApiUrl(backendPreference, endpoint), {
...requestOptions,
headers: {
"Content-Type": "application/json",
...headers,
},
});
if (!response.ok) {
throw new Error(`API request failed with status ${response.status}`);
}
const responseText = await response.text();
if (response.status === 204 || !responseText) {
return undefined as TResponse;
}
return JSON.parse(responseText) as TResponse;
}
+24
View File
@@ -0,0 +1,24 @@
import type { BackendOption, BackendPreference } from "@/types/backend";
export const backendOptions: BackendOption[] = [
{
label: "Rust",
value: "rust",
baseUrl: "http://localhost:5005",
swaggerUrl: "http://localhost:5005/swagger-ui/#/",
},
{
label: "Python",
value: "python",
baseUrl: "http://localhost:5006",
swaggerUrl: "http://localhost:5006/swagger-ui/#/",
},
];
export const defaultBackendPreference: BackendPreference = "rust";
export function getBackendOption(preference: BackendPreference): BackendOption {
return (
backendOptions.find((backendOption) => backendOption.value === preference) ?? backendOptions[0]
);
}
+54
View File
@@ -0,0 +1,54 @@
import { apiRequest } from "./apiClient";
import { endpoints } from "./endpoints";
import type { BackendPreference } from "@/types/backend";
import type {
Company,
CompanyUser,
CreateCompanyRequest,
EmployeeNamesResponse,
} from "@/types/companies";
export function getCompanies(backendPreference: BackendPreference): Promise<Company[]> {
return apiRequest<Company[]>(endpoints.companies, { backendPreference });
}
export function createCompany(
backendPreference: BackendPreference,
request: CreateCompanyRequest,
): Promise<Company> {
return apiRequest<Company>(endpoints.companies, {
backendPreference,
method: "POST",
body: JSON.stringify(request),
});
}
export function deleteCompany(
backendPreference: BackendPreference,
companyId: number,
): Promise<Company> {
return apiRequest<Company>(endpoints.company(companyId), {
backendPreference,
method: "DELETE",
});
}
export function getCompanyEmployeeNames(
backendPreference: BackendPreference,
companyName: string,
): Promise<EmployeeNamesResponse> {
return apiRequest<EmployeeNamesResponse>(endpoints.companyEmployeeNames(companyName), {
backendPreference,
});
}
export function assignEmployeeToCompany(
backendPreference: BackendPreference,
companyId: number,
employeeId: number,
): Promise<CompanyUser> {
return apiRequest<CompanyUser>(endpoints.assignEmployeeToCompany(companyId, employeeId), {
backendPreference,
method: "POST",
});
}
+18
View File
@@ -0,0 +1,18 @@
export const endpoints = {
health: "/health",
companies: "/companies",
company: (id: number) => `/companies/${id}`,
assignEmployeeToCompany: (companyId: number, employeeId: number) =>
`/companies/${companyId}/employees/${employeeId}`,
companyEmployeeNames: (companyName: string) =>
`/companies/${encodeURIComponent(companyName)}/employees/names`,
employees: "/employees",
employee: (id: number) => `/employees/${id}`,
employeePreference: (id: number) => `/employees/${id}/preference`,
employeeWeeklyPreferences: (id: number) => `/employees/${id}/weekly-preferences`,
employeeClockIn: (id: number) => `/employees/${id}/clock-in`,
employeeClockOut: (id: number) => `/employees/${id}/clock-out`,
schedules: "/schedules",
weeklySchedule: "/schedules/weekly",
generateWeeklySchedule: "/schedules/weekly/generate",
};
+8
View File
@@ -0,0 +1,8 @@
import type { NavItem } from "@/types/navigation";
export const getNavigationItems = (): NavItem[] => [
{ label: "Home", path: "/" },
{ label: "Users", path: "/users" },
{ label: "Schedule", path: "/schedule" },
{ label: "Companies", path: "/companies" },
];
+48
View File
@@ -0,0 +1,48 @@
import { apiRequest } from "./apiClient";
import { endpoints } from "./endpoints";
import type { BackendPreference } from "@/types/backend";
import type {
ClockOutRequest,
GenerateWeeklyScheduleRequest,
Schedule,
WeeklyScheduleResponse,
} from "@/types/schedule";
export function getWeekSchedule(
backendPreference: BackendPreference,
): Promise<WeeklyScheduleResponse> {
return apiRequest<WeeklyScheduleResponse>(endpoints.weeklySchedule, { backendPreference });
}
export function generateWeeklySchedule(
backendPreference: BackendPreference,
request: GenerateWeeklyScheduleRequest,
): Promise<WeeklyScheduleResponse> {
return apiRequest<WeeklyScheduleResponse>(endpoints.generateWeeklySchedule, {
backendPreference,
method: "POST",
body: JSON.stringify(request),
});
}
export function clockIn(
backendPreference: BackendPreference,
employeeId: number,
): Promise<Schedule> {
return apiRequest<Schedule>(endpoints.employeeClockIn(employeeId), {
backendPreference,
method: "POST",
});
}
export function clockOut(
backendPreference: BackendPreference,
employeeId: number,
request: ClockOutRequest,
): Promise<Schedule> {
return apiRequest<Schedule>(endpoints.employeeClockOut(employeeId), {
backendPreference,
method: "PUT",
body: JSON.stringify(request),
});
}
+48
View File
@@ -0,0 +1,48 @@
import { apiRequest } from "./apiClient";
import { endpoints } from "./endpoints";
import type { BackendPreference } from "@/types/backend";
import type {
CreateEmployeeRequest,
Employee,
RegisterPreferenceRequest,
WeeklyPreferenceRequest,
} from "@/types/users";
export function getUsers(backendPreference: BackendPreference): Promise<Employee[]> {
return apiRequest<Employee[]>(endpoints.employees, { backendPreference });
}
export function createUser(
backendPreference: BackendPreference,
request: CreateEmployeeRequest,
): Promise<Employee> {
return apiRequest<Employee>(endpoints.employees, {
backendPreference,
method: "POST",
body: JSON.stringify(request),
});
}
export function updateUserPreference(
backendPreference: BackendPreference,
employeeId: number,
request: RegisterPreferenceRequest,
): Promise<void> {
return apiRequest<void>(endpoints.employeePreference(employeeId), {
backendPreference,
method: "PUT",
body: JSON.stringify(request),
});
}
export function updateUserWeeklyPreferences(
backendPreference: BackendPreference,
employeeId: number,
request: WeeklyPreferenceRequest,
): Promise<Employee> {
return apiRequest<Employee>(endpoints.employeeWeeklyPreferences(employeeId), {
backendPreference,
method: "PUT",
body: JSON.stringify(request),
});
}
+40
View File
@@ -0,0 +1,40 @@
.overlay {
position: fixed;
inset: 0;
z-index: 20;
display: grid;
place-items: center;
padding: 20px;
background: rgba(15, 23, 42, 0.56);
}
.modal {
width: min(520px, 100%);
border-radius: 24px;
padding: 22px;
background: #ffffff;
box-shadow: 0 30px 70px rgba(15, 23, 42, 0.28);
}
.header {
display: flex;
justify-content: space-between;
gap: 16px;
align-items: center;
margin-bottom: 18px;
h3 {
margin: 0;
font-size: 1.35rem;
}
button {
border: 0;
border-radius: 999px;
padding: 9px 12px;
background: #eef2f6;
color: #172033;
cursor: pointer;
font-weight: 700;
}
}
+24
View File
@@ -0,0 +1,24 @@
import type { ReactNode } from "react";
import styles from "./Modal.module.scss";
type ModalProps = {
children: ReactNode;
onClose: () => void;
title: string;
};
export function Modal({ children, onClose, title }: ModalProps) {
return (
<div className={styles.overlay} role="presentation">
<section className={styles.modal} role="dialog" aria-modal="true" aria-label={title}>
<header className={styles.header}>
<h3>{title}</h3>
<button type="button" onClick={onClose} aria-label="Close modal">
Close
</button>
</header>
{children}
</section>
</div>
);
}
+32
View File
@@ -0,0 +1,32 @@
.pageHeader {
display: flex;
justify-content: space-between;
gap: 18px;
align-items: flex-start;
margin-bottom: 24px;
h2 {
margin: 0 0 8px;
font-size: clamp(1.8rem, 4vw, 3.25rem);
letter-spacing: -0.05em;
}
p {
margin: 0;
max-width: 680px;
color: #5f6b7a;
line-height: 1.6;
}
}
.buttonGroup {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
@media (max-width: 640px) {
.pageHeader {
display: grid;
}
}
+20
View File
@@ -0,0 +1,20 @@
import type { ReactNode } from "react";
import styles from "./PageHeader.module.scss";
type PageHeaderProps = {
title: string;
description: string;
actions?: ReactNode;
};
export function PageHeader({ title, description, actions }: PageHeaderProps) {
return (
<header className={styles.pageHeader}>
<div>
<h2>{title}</h2>
<p>{description}</p>
</div>
{actions ? <div className={styles.buttonGroup}>{actions}</div> : null}
</header>
);
}
+18
View File
@@ -0,0 +1,18 @@
import { useEffect, useState } from "react";
export function usePathname(): [string, (path: string) => void] {
const [pathname, setPathname] = useState(window.location.pathname);
useEffect(() => {
const handlePopState = () => setPathname(window.location.pathname);
window.addEventListener("popstate", handlePopState);
return () => window.removeEventListener("popstate", handlePopState);
}, []);
const navigate = (path: string) => {
window.history.pushState(null, "", path);
setPathname(path);
};
return [pathname, navigate];
}
+10 -2
View File
@@ -4,8 +4,16 @@
body { body {
margin: 0; margin: 0;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, font-family:
'Segoe UI', Roboto, 'Helvetica Neue', Arial, Inter,
ui-sans-serif,
system-ui,
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
Roboto,
"Helvetica Neue",
Arial,
sans-serif; sans-serif;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
-17
View File
@@ -1,17 +0,0 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
import reportWebVitals from './reportWebVitals';
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);
// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();
+121
View File
@@ -0,0 +1,121 @@
.shell {
min-height: 100vh;
display: grid;
grid-template-columns: 280px 1fr;
background: #eef2f6;
color: #172033;
}
.sidebar {
position: sticky;
top: 0;
height: 100vh;
padding: 28px 22px;
background: #111827;
color: #ffffff;
display: flex;
flex-direction: column;
gap: 28px;
}
.brand {
display: grid;
gap: 6px;
h1 {
margin: 0;
font-size: 1.35rem;
letter-spacing: -0.03em;
}
p {
margin: 0;
color: #aeb8c8;
font-size: 0.9rem;
}
}
.nav {
display: grid;
gap: 10px;
}
.navItem {
border: 0;
border-radius: 14px;
padding: 14px 16px;
background: transparent;
color: #d9e0eb;
cursor: pointer;
font: inherit;
text-align: left;
transition:
background 160ms ease,
color 160ms ease;
&:hover,
&.active {
background: #2563eb;
color: #ffffff;
}
}
.backendPreference {
display: grid;
gap: 10px;
margin-top: auto;
label {
color: #aeb8c8;
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
select {
width: 100%;
border: 1px solid #344154;
border-radius: 14px;
padding: 12px 14px;
background: #1f2937;
color: #ffffff;
cursor: pointer;
font: inherit;
}
}
.main {
padding: 32px;
overflow: auto;
}
@media (max-width: 980px) {
.shell {
grid-template-columns: 1fr;
}
.sidebar {
position: static;
height: auto;
}
.nav {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.navItem {
text-align: center;
}
}
@media (max-width: 640px) {
.main,
.sidebar {
padding: 20px;
}
.nav {
grid-template-columns: 1fr 1fr;
}
}
+61
View File
@@ -0,0 +1,61 @@
import type { ReactNode } from "react";
import { backendOptions } from "@api/backendConfig";
import { getNavigationItems } from "@api/navigationApi";
import type { BackendPreference } from "@/types/backend";
import styles from "./AppLayout.module.scss";
type AppLayoutProps = {
backendPreference: BackendPreference;
children: ReactNode;
onBackendPreferenceChange: (backendPreference: BackendPreference) => void;
pathname: string;
onNavigate: (path: string) => void;
};
const navItems = getNavigationItems();
export function AppLayout({
backendPreference,
children,
onBackendPreferenceChange,
pathname,
onNavigate,
}: AppLayoutProps) {
return (
<div className={styles.shell}>
<aside className={styles.sidebar}>
<div className={styles.brand}>
<h1>Manager GUI</h1>
<p>Employees, companies, and schedules</p>
</div>
<nav className={styles.nav} aria-label="Sidebar navigation">
{navItems.map((item) => (
<button
className={`${styles.navItem} ${pathname === item.path ? styles.active : ""}`}
key={item.path}
onClick={() => onNavigate(item.path)}
type="button"
>
{item.label}
</button>
))}
</nav>
<div className={styles.backendPreference}>
<label htmlFor="backend-preference">Backend preference</label>
<select
id="backend-preference"
value={backendPreference}
onChange={(event) => onBackendPreferenceChange(event.target.value as BackendPreference)}
>
{backendOptions.map((backendOption) => (
<option key={backendOption.value} value={backendOption.value}>
{backendOption.label}
</option>
))}
</select>
</div>
</aside>
<main className={styles.main}>{children}</main>
</div>
);
}
+20
View File
@@ -0,0 +1,20 @@
import React from "react";
import ReactDOM from "react-dom/client";
import "./index.css";
import App from "./App";
import reportWebVitals from "./reportWebVitals";
const rootElement = document.getElementById("root");
if (!rootElement) {
throw new Error("Root element not found");
}
const root = ReactDOM.createRoot(rootElement);
root.render(
<React.StrictMode>
<App />
</React.StrictMode>,
);
reportWebVitals();
+126
View File
@@ -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;
}
}
+228
View File
@@ -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}
</>
);
}
+38
View File
@@ -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;
}
}
+92
View File
@@ -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>
</>
);
}
+56
View File
@@ -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;
}
}
+131
View File
@@ -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>
</>
);
}
+121
View File
@@ -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;
}
+319
View File
@@ -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}
</>
);
}
@@ -1,8 +1,9 @@
const reportWebVitals = onPerfEntry => { import type { ReportHandler } from "web-vitals";
const reportWebVitals = (onPerfEntry?: ReportHandler) => {
if (onPerfEntry && onPerfEntry instanceof Function) { if (onPerfEntry && onPerfEntry instanceof Function) {
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { import("web-vitals").then(({ getCLS, getFCP, getLCP, getTTFB }) => {
getCLS(onPerfEntry); getCLS(onPerfEntry);
getFID(onPerfEntry);
getFCP(onPerfEntry); getFCP(onPerfEntry);
getLCP(onPerfEntry); getLCP(onPerfEntry);
getTTFB(onPerfEntry); getTTFB(onPerfEntry);
-5
View File
@@ -1,5 +0,0 @@
// jest-dom adds custom jest matchers for asserting on DOM nodes.
// allows you to do things like:
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom
import '@testing-library/jest-dom';
+1
View File
@@ -0,0 +1 @@
import "@testing-library/jest-dom";
+8
View File
@@ -0,0 +1,8 @@
export type BackendPreference = "rust" | "python";
export type BackendOption = {
label: string;
value: BackendPreference;
baseUrl: string;
swaggerUrl?: string;
};
+22
View File
@@ -0,0 +1,22 @@
export type Company = {
id: number;
name: string;
date_created: string;
date_updated: string;
};
export type CreateCompanyRequest = {
name: string;
};
export type EmployeeNamesResponse = {
employees: string[];
};
export type CompanyUser = {
id: number;
user_id: number;
company_id: number;
date_created: string;
date_updated: string;
};
+4
View File
@@ -0,0 +1,4 @@
export type NavItem = {
label: string;
path: string;
};
+29
View File
@@ -0,0 +1,29 @@
import type { DayOfWeek } from "@/types/users";
export type Schedule = {
id: number;
user_id: number;
enter_time: string | null;
day_of_week: DayOfWeek;
out_time: string | null;
};
export type WeeklyScheduleResponse = {
schedules: Schedule[];
};
export type CreateScheduleRequest = {
user_id: number;
enter_time: string;
day_of_week: DayOfWeek;
out_time: string;
};
export type ClockOutRequest = {
day_of_week: DayOfWeek;
out_time: string;
};
export type GenerateWeeklyScheduleRequest = {
company_id: number;
};
+34
View File
@@ -0,0 +1,34 @@
export type WorkTime = "morning" | "afternoon" | "evening";
export type Employee = {
id: number;
company_id: number;
name: string;
last_name: string;
preference: WorkTime;
};
export type CreateEmployeeRequest = {
company_id: number;
name: string;
last_name: string;
preference: WorkTime;
};
export type RegisterPreferenceRequest = {
preference: WorkTime;
};
export type DayOfWeek =
"sunday" | "monday" | "tuesday" | "wednesday" | "thursday" | "friday" | "saturday";
export type DayPreferenceRequest = {
day_of_week: DayOfWeek;
first: WorkTime;
second: WorkTime;
third: WorkTime;
};
export type WeeklyPreferenceRequest = {
preferences: DayPreferenceRequest[];
};
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+30
View File
@@ -0,0 +1,30 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["DOM", "DOM.Iterable", "ES2022"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"ignoreDeprecations": "6.0",
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"baseUrl": ".",
"paths": {
"@api/*": ["src/api/*"],
"@components/*": ["src/components/*"],
"@hooks/*": ["src/hooks/*"],
"@layouts/*": ["src/layouts/*"],
"@pages/*": ["src/pages/*"],
"@/types/*": ["src/types/*"]
},
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": ["src", "vite.config.ts"]
}
+19
View File
@@ -0,0 +1,19 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
"@api": new URL("./src/api", import.meta.url).pathname,
"@components": new URL("./src/components", import.meta.url).pathname,
"@hooks": new URL("./src/hooks", import.meta.url).pathname,
"@layouts": new URL("./src/layouts", import.meta.url).pathname,
"@pages": new URL("./src/pages", import.meta.url).pathname,
"@/types": new URL("./src/types", import.meta.url).pathname,
},
},
server: {
port: 3000,
},
});