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": "^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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+2416
-11392
File diff suppressed because it is too large
Load Diff
+2
-1
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
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;
|
||||||
|
|||||||
@@ -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) {
|
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);
|
||||||
@@ -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