adding gui for the application
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
build
|
||||
dist
|
||||
node_modules
|
||||
*.tsbuildinfo
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"singleQuote": false,
|
||||
"semi": true,
|
||||
"trailingComma": "all",
|
||||
"printWidth": 100,
|
||||
"bracketSpacing": true,
|
||||
"bracketSameLine": false
|
||||
}
|
||||
@@ -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
@@ -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
@@ -10,22 +10,18 @@
|
||||
"react": "^19.3.0",
|
||||
"react-dom": "^19.3.0",
|
||||
"react-router": "^8.4.0",
|
||||
"react-scripts": "5.0.1",
|
||||
"sass": "^1.104.1",
|
||||
"web-vitals": "^2.1.4",
|
||||
"zustand": "^5.0.15"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "react-scripts start",
|
||||
"build": "react-scripts build",
|
||||
"test": "react-scripts test",
|
||||
"eject": "react-scripts eject"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": [
|
||||
"react-app",
|
||||
"react-app/jest"
|
||||
]
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"format": "prettier . --write",
|
||||
"format:check": "prettier . --check",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
@@ -40,7 +36,19 @@
|
||||
]
|
||||
},
|
||||
"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",
|
||||
"typescript": "4.9.5"
|
||||
"prettier": "^3.9.7",
|
||||
"typescript": "^6.0.0",
|
||||
"typescript-eslint": "^8.70.0",
|
||||
"vite": "^7.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+2416
-11392
File diff suppressed because it is too large
Load Diff
+2
-1
@@ -1,4 +1,5 @@
|
||||
allowBuilds:
|
||||
'@parcel/watcher': false
|
||||
"@parcel/watcher": false
|
||||
core-js: true
|
||||
core-js-pure: true
|
||||
esbuild: true
|
||||
|
||||
@@ -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
@@ -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;
|
||||
@@ -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
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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]
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
@@ -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",
|
||||
};
|
||||
@@ -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" },
|
||||
];
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -4,8 +4,16 @@
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
|
||||
'Segoe UI', Roboto, 'Helvetica Neue', Arial,
|
||||
font-family:
|
||||
Inter,
|
||||
ui-sans-serif,
|
||||
system-ui,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
"Segoe UI",
|
||||
Roboto,
|
||||
"Helvetica Neue",
|
||||
Arial,
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
const reportWebVitals = onPerfEntry => {
|
||||
import type { ReportHandler } from "web-vitals";
|
||||
|
||||
const reportWebVitals = (onPerfEntry?: ReportHandler) => {
|
||||
if (onPerfEntry && onPerfEntry instanceof Function) {
|
||||
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
|
||||
import("web-vitals").then(({ getCLS, getFCP, getLCP, getTTFB }) => {
|
||||
getCLS(onPerfEntry);
|
||||
getFID(onPerfEntry);
|
||||
getFCP(onPerfEntry);
|
||||
getLCP(onPerfEntry);
|
||||
getTTFB(onPerfEntry);
|
||||
@@ -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';
|
||||
@@ -0,0 +1 @@
|
||||
import "@testing-library/jest-dom";
|
||||
@@ -0,0 +1,8 @@
|
||||
export type BackendPreference = "rust" | "python";
|
||||
|
||||
export type BackendOption = {
|
||||
label: string;
|
||||
value: BackendPreference;
|
||||
baseUrl: string;
|
||||
swaggerUrl?: string;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
export type NavItem = {
|
||||
label: string;
|
||||
path: string;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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[];
|
||||
};
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user