Initial commit
All checks were successful
Docker Build and Publish / build-and-push (push) Successful in 2m17s
All checks were successful
Docker Build and Publish / build-and-push (push) Successful in 2m17s
This commit is contained in:
24
frontend/.gitignore
vendored
Normal file
24
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
16
frontend/README.md
Normal file
16
frontend/README.md
Normal file
@@ -0,0 +1,16 @@
|
||||
# React + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||
|
||||
## React Compiler
|
||||
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.
|
||||
29
frontend/eslint.config.js
Normal file
29
frontend/eslint.config.js
Normal file
@@ -0,0 +1,29 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{js,jsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
ecmaFeatures: { jsx: true },
|
||||
sourceType: 'module',
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
|
||||
},
|
||||
},
|
||||
])
|
||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>frontend</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
3674
frontend/package-lock.json
generated
Normal file
3674
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
33
frontend/package.json
Normal file
33
frontend/package.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"pocketbase": "^0.26.5",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-router-dom": "^7.12.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@tailwindcss/postcss": "^4.1.18",
|
||||
"@types/react": "^19.2.5",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"autoprefixer": "^10.4.23",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"globals": "^16.5.0",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"vite": "^7.2.4"
|
||||
}
|
||||
}
|
||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
1
frontend/public/vite.svg
Normal file
1
frontend/public/vite.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
32
frontend/seed.js
Normal file
32
frontend/seed.js
Normal file
@@ -0,0 +1,32 @@
|
||||
import PocketBase from 'pocketbase';
|
||||
|
||||
const pb = new PocketBase('http://127.0.0.1:8090');
|
||||
|
||||
async function seed() {
|
||||
try {
|
||||
// Check if user exists
|
||||
try {
|
||||
await pb.collection('users').authWithPassword('test@example.com', 'password123');
|
||||
console.log('User already exists');
|
||||
return;
|
||||
} catch (e) {
|
||||
// User likely doesn't exist
|
||||
}
|
||||
|
||||
const data = {
|
||||
"username": "testuser",
|
||||
"email": "test@example.com",
|
||||
"emailVisibility": true,
|
||||
"password": "password123",
|
||||
"passwordConfirm": "password123",
|
||||
"name": "Test User"
|
||||
};
|
||||
|
||||
const record = await pb.collection('users').create(data);
|
||||
console.log('User created:', record.id);
|
||||
} catch (error) {
|
||||
console.error('Error seeding user:', error);
|
||||
}
|
||||
}
|
||||
|
||||
seed();
|
||||
42
frontend/src/App.css
Normal file
42
frontend/src/App.css
Normal file
@@ -0,0 +1,42 @@
|
||||
#root {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 6em;
|
||||
padding: 1.5em;
|
||||
will-change: filter;
|
||||
transition: filter 300ms;
|
||||
}
|
||||
.logo:hover {
|
||||
filter: drop-shadow(0 0 2em #646cffaa);
|
||||
}
|
||||
.logo.react:hover {
|
||||
filter: drop-shadow(0 0 2em #61dafbaa);
|
||||
}
|
||||
|
||||
@keyframes logo-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
a:nth-of-type(2) .logo {
|
||||
animation: logo-spin infinite 20s linear;
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 2em;
|
||||
}
|
||||
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
}
|
||||
105
frontend/src/App.jsx
Normal file
105
frontend/src/App.jsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import React, { useContext } from 'react';
|
||||
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { AuthProvider, AuthContext } from './contexts/AuthContext';
|
||||
import Login from './components/Auth/Login';
|
||||
import Register from './components/Auth/Register';
|
||||
import LandingPage from './pages/LandingPage';
|
||||
|
||||
const PrivateRoute = ({ children }) => {
|
||||
const { user } = useContext(AuthContext);
|
||||
return user ? children : <Navigate to="/login" />;
|
||||
};
|
||||
|
||||
import EntryForm from './components/TimeEntry/EntryForm';
|
||||
import TimeList from './components/TimeEntry/TimeList';
|
||||
import BalanceCard from './components/TimeEntry/BalanceCard';
|
||||
import RedeemForm from './components/TimeEntry/RedeemForm';
|
||||
import ProfileSettings from './components/Profile/ProfileSettings';
|
||||
import SupervisorDashboard from './components/Supervisor/SupervisorDashboard';
|
||||
import GoCLayout from './components/Layout/GoCLayout';
|
||||
import { LanguageProvider } from './contexts/LanguageContext';
|
||||
import { pb } from './lib/pocketbase';
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
const Dashboard = () => {
|
||||
const { user, logout } = useContext(AuthContext);
|
||||
const [entries, setEntries] = useState([]);
|
||||
const [balance, setBalance] = useState(0);
|
||||
const [refresh, setRefresh] = useState(0);
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
// Load recent entries
|
||||
const records = await pb.collection('time_entries').getList(1, 50, {
|
||||
sort: '-date',
|
||||
});
|
||||
setEntries(records.items);
|
||||
|
||||
// Load balance (banked hours)
|
||||
const allBanked = await pb.collection('time_entries').getFullList({
|
||||
filter: 'is_banked = true',
|
||||
requestKey: null // Disable auto-cancellation to ensure it runs
|
||||
});
|
||||
const total = allBanked.reduce((acc, curr) => acc + (curr.calculated_hours || 0), 0);
|
||||
setBalance(total);
|
||||
|
||||
} catch (error) {
|
||||
console.error("Error loading data", error);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [refresh]);
|
||||
|
||||
const handleEntryAdded = () => {
|
||||
setRefresh(prev => prev + 1);
|
||||
};
|
||||
|
||||
return (
|
||||
<GoCLayout>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
{/* Supervisor Dashboard (Only visible if is_supervisor) */}
|
||||
{user.is_supervisor && (
|
||||
<SupervisorDashboard user={user} />
|
||||
)}
|
||||
|
||||
<EntryForm onEntryAdded={handleEntryAdded} />
|
||||
<TimeList entries={entries} />
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
<BalanceCard balance={balance} />
|
||||
<RedeemForm onRedeem={handleEntryAdded} />
|
||||
<ProfileSettings user={user} />
|
||||
</div>
|
||||
</div>
|
||||
</GoCLayout>
|
||||
);
|
||||
};
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<LanguageProvider>
|
||||
<Router>
|
||||
<Routes>
|
||||
<Route path="/" element={<LandingPage />} />
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/register" element={<Register />} />
|
||||
<Route
|
||||
path="/dashboard"
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<Dashboard />
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</Router>
|
||||
</LanguageProvider>
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
BIN
frontend/src/assets/landing-infographic.png
Normal file
BIN
frontend/src/assets/landing-infographic.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 325 KiB |
1
frontend/src/assets/react.svg
Normal file
1
frontend/src/assets/react.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
76
frontend/src/components/Auth/Login.jsx
Normal file
76
frontend/src/components/Auth/Login.jsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import React, { useState, useContext } from 'react';
|
||||
import { AuthContext } from '../../contexts/AuthContext';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
import GoCLayout from '../Layout/GoCLayout';
|
||||
|
||||
const Login = () => {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const { login, loading } = useContext(AuthContext);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
try {
|
||||
await login(email, password);
|
||||
navigate('/');
|
||||
} catch (err) {
|
||||
setError('Failed to log in. Please check your credentials.');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<GoCLayout>
|
||||
<div className="flex items-center justify-center p-4">
|
||||
<div className="bg-white p-8 rounded-lg shadow-md w-full max-w-md border border-gray-200">
|
||||
<h2 className="text-2xl font-bold mb-6 text-center text-gray-800">Sign In</h2>
|
||||
{error && <div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">{error}</div>}
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="mb-4">
|
||||
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="email">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-6">
|
||||
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="password">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 mb-3 leading-tight focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between flex-col gap-4">
|
||||
<button
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline w-full transition duration-300"
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? 'Logging in...' : 'Sign In'}
|
||||
</button>
|
||||
<Link to="/register" className="text-blue-600 hover:text-blue-800 text-sm">
|
||||
Don't have an account? Register here
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</GoCLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default Login;
|
||||
127
frontend/src/components/Auth/Register.jsx
Normal file
127
frontend/src/components/Auth/Register.jsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import React, { useState, useContext } from 'react';
|
||||
import { AuthContext } from '../../contexts/AuthContext';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
import { pb } from '../../lib/pocketbase';
|
||||
import GoCLayout from '../Layout/GoCLayout';
|
||||
|
||||
const Register = () => {
|
||||
const [name, setName] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [passwordConfirm, setPasswordConfirm] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const { login } = useContext(AuthContext);
|
||||
const navigate = useNavigate();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
|
||||
if (password !== passwordConfirm) {
|
||||
return setError('Passwords do not match');
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
// Create user
|
||||
const data = {
|
||||
"username": `user_${Math.random().toString(36).slice(2, 12)}`, // Generate random username if required or let PB handle it if not
|
||||
"email": email,
|
||||
"emailVisibility": true,
|
||||
"password": password,
|
||||
"passwordConfirm": passwordConfirm,
|
||||
"name": name
|
||||
};
|
||||
|
||||
await pb.collection('users').create(data);
|
||||
|
||||
// Log in immediately after creation
|
||||
await login(email, password);
|
||||
navigate('/');
|
||||
} catch (err) {
|
||||
console.error("Registration error:", err);
|
||||
setError('Failed to create account. Please try again.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<GoCLayout>
|
||||
<div className="flex items-center justify-center p-4">
|
||||
<div className="bg-white p-8 rounded-lg shadow-md w-full max-w-md border border-gray-200">
|
||||
<h2 className="text-2xl font-bold mb-6 text-center text-gray-800">Create Account</h2>
|
||||
{error && <div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">{error}</div>}
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="mb-4">
|
||||
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="name">
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="email">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="password">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 mb-3 leading-tight focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-6">
|
||||
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="passwordConfirm">
|
||||
Confirm Password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
id="passwordConfirm"
|
||||
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 mb-3 leading-tight focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
value={passwordConfirm}
|
||||
onChange={(e) => setPasswordConfirm(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between flex-col gap-4">
|
||||
<button
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline w-full transition duration-300"
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? 'Creating Account...' : 'Register'}
|
||||
</button>
|
||||
<Link to="/" className="text-blue-600 hover:text-blue-800 text-sm">
|
||||
Already have an account? Sign In
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</GoCLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default Register;
|
||||
91
frontend/src/components/Layout/GoCLayout.jsx
Normal file
91
frontend/src/components/Layout/GoCLayout.jsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import React, { useContext } from 'react';
|
||||
import { AuthContext } from '../../contexts/AuthContext';
|
||||
import { LanguageContext } from '../../contexts/LanguageContext';
|
||||
|
||||
const GoCLayout = ({ children }) => {
|
||||
const { user, logout } = useContext(AuthContext);
|
||||
const { t, toggleLanguage, language } = useContext(LanguageContext);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col min-h-screen font-sans text-gray-800">
|
||||
{/* Top Bar (Language/Auth) */}
|
||||
<div className="bg-white border-b border-gray-200">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex justify-end py-1 space-x-4 text-sm text-goc-link underline">
|
||||
{/* Language Toggle */}
|
||||
<button onClick={toggleLanguage} className="hover:text-goc-link-hover" lang={language === 'en' ? 'fr' : 'en'}>
|
||||
{t('lang.switch')}
|
||||
</button>
|
||||
|
||||
{user ? (
|
||||
<button onClick={logout} className="hover:text-goc-link-hover">{t('nav.sign_out')}</button>
|
||||
) : (
|
||||
<a href="/login" className="hover:text-goc-link-hover">{t('nav.sign_in')}</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Header (Wordmark) */}
|
||||
<header className="bg-white py-6 border-b-4 border-goc-blue">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 flex justify-between items-center">
|
||||
<div className="flex items-center">
|
||||
{/* Wordmark (Using text for now, should be SVG) */}
|
||||
<div className="flex items-center space-x-2">
|
||||
<img src="https://www.canada.ca/etc/designs/canada/wet-boew/assets/sig-blk-en.svg" alt="Government of Canada" className="h-8" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Breadcrumbs (Placeholder) */}
|
||||
<div className="bg-white border-b border-gray-200 py-3">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 text-sm text-gray-500">
|
||||
Canada.ca > Time Tracker > <span className="text-gray-900">Dashboard</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="flex-grow bg-white">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{/* Title Area */}
|
||||
<div className="mb-8 border-b border-goc-red pb-2">
|
||||
<h1 className="text-3xl font-bold font-sans">{t('app.title')}</h1>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="bg-goc-blue text-white mt-auto">
|
||||
{/* Main Footer Links */}
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
<div>
|
||||
<h3 className="font-bold mb-4">{t('footer.about')}</h3>
|
||||
<ul className="space-y-2 text-sm text-white opacity-90">
|
||||
<li><a href="#" className="hover:underline">{t('footer.terms')}</a></li>
|
||||
<li><a href="#" className="hover:underline">{t('footer.privacy')}</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold mb-4">{t('footer.contact')}</h3>
|
||||
<ul className="space-y-2 text-sm text-white opacity-90">
|
||||
<li><a href="#" className="hover:underline">{t('footer.dept')}</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Canada Wordmark Footer */}
|
||||
<div className="bg-goc-blue border-t border-gray-600">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 flex justify-end">
|
||||
<img src="https://www.canada.ca/etc/designs/canada/wet-boew/assets/wmms-blk.svg" alt="Symbol of the Government of Canada" className="h-8 filter invert grayscale brightness-200" />
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GoCLayout;
|
||||
103
frontend/src/components/Profile/ProfileSettings.jsx
Normal file
103
frontend/src/components/Profile/ProfileSettings.jsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import React, { useState, useEffect, useContext } from 'react';
|
||||
import { pb } from '../../lib/pocketbase';
|
||||
import { LanguageContext } from '../../contexts/LanguageContext';
|
||||
|
||||
const ProfileSettings = ({ user, onDelete }) => {
|
||||
const { t } = useContext(LanguageContext);
|
||||
const [isSupervisor, setIsSupervisor] = useState(user.is_supervisor || false);
|
||||
const [supervisorId, setSupervisorId] = useState(user.supervisor || '');
|
||||
const [availableSupervisors, setAvailableSupervisors] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [msg, setMsg] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const loadSupervisors = async () => {
|
||||
try {
|
||||
// Find all users who are supervisors
|
||||
const result = await pb.collection('users').getList(1, 100, {
|
||||
filter: 'is_supervisor = true',
|
||||
});
|
||||
// Exclude self
|
||||
setAvailableSupervisors(result.items.filter(u => u.id !== user.id));
|
||||
} catch (e) {
|
||||
console.error("Error loading supervisors", e);
|
||||
}
|
||||
};
|
||||
loadSupervisors();
|
||||
}, [user.id]);
|
||||
|
||||
const handleSave = async (e) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
try {
|
||||
await pb.collection('users').update(user.id, {
|
||||
is_supervisor: isSupervisor,
|
||||
supervisor: supervisorId
|
||||
});
|
||||
setMsg(t('profile.updated'));
|
||||
// Refresh auth store might be needed or page reload
|
||||
// pb.authStore.model is updated automatically on successful update?
|
||||
// Actually it is usually good practice to refresh.
|
||||
// But we will let the user navigate.
|
||||
|
||||
// To be safe, reload window or trigger parent refresh
|
||||
window.location.reload();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setMsg(t('profile.error'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white p-6 rounded-lg shadow-md mb-6">
|
||||
<h3 className="text-lg font-semibold mb-4 text-gray-800">{t('profile.title')}</h3>
|
||||
{msg && <div className="mb-4 p-2 bg-green-100 text-green-700 rounded block">{msg}</div>}
|
||||
|
||||
<form onSubmit={handleSave} className="space-y-4">
|
||||
|
||||
{/* Supervisor Toggle */}
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="is_supervisor"
|
||||
checked={isSupervisor}
|
||||
onChange={(e) => setIsSupervisor(e.target.checked)}
|
||||
className="h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-gray-300 rounded"
|
||||
/>
|
||||
<label htmlFor="is_supervisor" className="ml-2 block text-sm text-gray-900">
|
||||
{t('profile.supervisor_check')}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Supervisor Selection */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">{t('profile.my_supervisor')}</label>
|
||||
<select
|
||||
value={supervisorId}
|
||||
onChange={(e) => setSupervisorId(e.target.value)}
|
||||
className="mt-1 block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md"
|
||||
>
|
||||
<option value="">{t('profile.select_supervisor')}</option>
|
||||
{availableSupervisors.map(s => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.name || s.email}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="bg-indigo-600 text-white px-4 py-2 rounded-md hover:bg-indigo-700 disabled:opacity-50"
|
||||
>
|
||||
{loading ? t('profile.saving') : t('profile.save')}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProfileSettings;
|
||||
105
frontend/src/components/Supervisor/SupervisorDashboard.jsx
Normal file
105
frontend/src/components/Supervisor/SupervisorDashboard.jsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import React, { useState, useEffect, useContext } from 'react';
|
||||
import { pb } from '../../lib/pocketbase';
|
||||
import TimeList from '../TimeEntry/TimeList';
|
||||
import BalanceCard from '../TimeEntry/BalanceCard';
|
||||
import { LanguageContext } from '../../contexts/LanguageContext';
|
||||
|
||||
const SupervisorDashboard = ({ user }) => {
|
||||
const { t } = useContext(LanguageContext);
|
||||
const [employees, setEmployees] = useState([]);
|
||||
const [selectedEmployeeId, setSelectedEmployeeId] = useState(null);
|
||||
const [employeeEntries, setEmployeeEntries] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
const loadEmployees = async () => {
|
||||
try {
|
||||
// Find users who have assigned ME as supervisor
|
||||
const result = await pb.collection('users').getList(1, 50, {
|
||||
filter: `supervisor = "${user.id}"`,
|
||||
});
|
||||
setEmployees(result.items);
|
||||
} catch (err) {
|
||||
console.error("Error loading employees", err);
|
||||
}
|
||||
};
|
||||
if (user.is_supervisor) {
|
||||
loadEmployees();
|
||||
}
|
||||
}, [user.id, user.is_supervisor]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedEmployeeId) {
|
||||
const loadEntries = async () => {
|
||||
try {
|
||||
const result = await pb.collection('time_entries').getList(1, 50, {
|
||||
filter: `user = "${selectedEmployeeId}"`,
|
||||
sort: '-date',
|
||||
});
|
||||
setEmployeeEntries(result.items);
|
||||
} catch (err) {
|
||||
console.error("Error loading employee entries", err);
|
||||
}
|
||||
};
|
||||
loadEntries();
|
||||
} else {
|
||||
setEmployeeEntries([]);
|
||||
}
|
||||
}, [selectedEmployeeId]);
|
||||
|
||||
if (!user.is_supervisor) return null;
|
||||
|
||||
return (
|
||||
<div className="bg-white p-6 rounded-lg shadow-md mb-6">
|
||||
<h2 className="text-xl font-bold mb-4 text-indigo-700">{t('sup.title')}</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
|
||||
{/* Employee List */}
|
||||
<div className="md:col-span-1 border-r border-gray-200 pr-4">
|
||||
<h3 className="font-semibold text-gray-700 mb-2">{t('sup.my_team')}</h3>
|
||||
{employees.length === 0 ? (
|
||||
<p className="text-sm text-gray-500">{t('sup.no_employees')}</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{employees.map(emp => (
|
||||
<li key={emp.id}>
|
||||
<button
|
||||
onClick={() => setSelectedEmployeeId(emp.id)}
|
||||
className={`w-full text-left px-3 py-2 rounded-md text-sm transition ${selectedEmployeeId === emp.id
|
||||
? 'bg-indigo-100 text-indigo-700 font-medium'
|
||||
: 'hover:bg-gray-50 text-gray-600'
|
||||
}`}
|
||||
>
|
||||
{emp.name || emp.email}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Employee Details */}
|
||||
<div className="md:col-span-3">
|
||||
{selectedEmployeeId ? (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center bg-gray-50 p-4 rounded-lg">
|
||||
<h3 className="font-bold text-gray-800">
|
||||
{t('dash.employee_viewing')}: {employees.find(e => e.id === selectedEmployeeId)?.name || 'Employee'}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<BalanceCard userId={selectedEmployeeId} />
|
||||
|
||||
<TimeList entries={employeeEntries} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-full flex items-center justify-center text-gray-400 border-2 border-dashed border-gray-200 rounded-lg">
|
||||
{t('sup.select_prompt')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SupervisorDashboard;
|
||||
43
frontend/src/components/TimeEntry/BalanceCard.jsx
Normal file
43
frontend/src/components/TimeEntry/BalanceCard.jsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import React, { useState, useEffect, useContext } from 'react';
|
||||
import { pb } from '../../lib/pocketbase';
|
||||
import { LanguageContext } from '../../contexts/LanguageContext';
|
||||
|
||||
const BalanceCard = ({ balance: propBalance, userId }) => {
|
||||
const { t } = useContext(LanguageContext);
|
||||
// If balance prop is provided (from App.jsx), use it.
|
||||
// If userId is provided (supervisor view), fetch it.
|
||||
|
||||
const [fetchedBalance, setFetchedBalance] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (userId) {
|
||||
const fetchBalance = async () => {
|
||||
try {
|
||||
const records = await pb.collection('time_entries').getFullList({
|
||||
filter: `is_banked = true && user = "${userId}"`,
|
||||
requestKey: null
|
||||
});
|
||||
const total = records.reduce((acc, curr) => acc + (curr.calculated_hours || 0), 0);
|
||||
setFetchedBalance(total);
|
||||
} catch (err) {
|
||||
console.error("Error fetching balance for user", userId, err);
|
||||
}
|
||||
};
|
||||
fetchBalance();
|
||||
}
|
||||
}, [userId]);
|
||||
|
||||
const displayBalance = userId ? (fetchedBalance || 0) : (propBalance || 0);
|
||||
|
||||
return (
|
||||
<div className="bg-gradient-to-r from-indigo-500 to-purple-600 p-6 rounded-lg shadow-md text-white">
|
||||
<h3 className="text-lg font-semibold opacity-90 mb-1">
|
||||
{userId ? t('balance.employee_title') : t('balance.title')}
|
||||
</h3>
|
||||
<div className="text-4xl font-bold">{displayBalance.toFixed(2)} <span className="text-sm font-normal opacity-75">hrs</span></div>
|
||||
<p className="text-xs mt-2 opacity-75">{t('balance.available')}</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BalanceCard;
|
||||
303
frontend/src/components/TimeEntry/EntryForm.jsx
Normal file
303
frontend/src/components/TimeEntry/EntryForm.jsx
Normal file
@@ -0,0 +1,303 @@
|
||||
import React, { useState, useEffect, useContext } from 'react';
|
||||
import { pb } from '../../lib/pocketbase';
|
||||
import { calculateBankedHours, generateStandbyEntries, getMaxStandbyHours, detectDayType } from '../../lib/time-rules';
|
||||
import { LanguageContext } from '../../contexts/LanguageContext';
|
||||
|
||||
const EntryForm = ({ onEntryAdded }) => {
|
||||
const { t } = useContext(LanguageContext);
|
||||
|
||||
// Form Mode: 'single' or 'range'
|
||||
const [entryMode, setEntryMode] = useState('single');
|
||||
|
||||
// Fields
|
||||
const [date, setDate] = useState(new Date().toISOString().split('T')[0]);
|
||||
const [dateEnd, setDateEnd] = useState(new Date().toISOString().split('T')[0]); // For range
|
||||
|
||||
const [duration, setDuration] = useState('');
|
||||
const [type, setType] = useState('overtime');
|
||||
const [dayType, setDayType] = useState('workday');
|
||||
const [notes, setNotes] = useState('');
|
||||
const [isBanked, setIsBanked] = useState(true);
|
||||
|
||||
// Calculated / UI State
|
||||
const [preview, setPreview] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [bulkPreview, setBulkPreview] = useState(null); // { count, totalHours }
|
||||
|
||||
// Reset mode if type changes away from standby (optional, but good UX)
|
||||
useEffect(() => {
|
||||
if (type !== 'standby') {
|
||||
setEntryMode('single');
|
||||
}
|
||||
}, [type]);
|
||||
|
||||
// Recalculate Single Preview
|
||||
useEffect(() => {
|
||||
if (entryMode === 'single') {
|
||||
const calculated = calculateBankedHours(duration, type, dayType);
|
||||
setPreview(calculated);
|
||||
}
|
||||
}, [duration, type, dayType, entryMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (entryMode === 'range' && type === 'standby') {
|
||||
const entries = generateStandbyEntries(date, dateEnd);
|
||||
let total = 0;
|
||||
entries.forEach(e => {
|
||||
const banked = calculateBankedHours(e.duration, e.type, e.dayType); // Should handle 16->2, 24->3 (if formula is correct)
|
||||
total += banked;
|
||||
});
|
||||
setBulkPreview({ count: entries.length, totalHours: total });
|
||||
}
|
||||
}, [date, dateEnd, entryMode, type]);
|
||||
|
||||
// Update max duration or hint based on Standby rules
|
||||
const maxDuration = (type === 'standby') ? getMaxStandbyHours(dayType) : 24;
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
if (entryMode === 'single') {
|
||||
await createSingleEntry();
|
||||
} else {
|
||||
await createBulkEntries();
|
||||
}
|
||||
// Reset
|
||||
setDuration('');
|
||||
setNotes('');
|
||||
onEntryAdded();
|
||||
} catch (error) {
|
||||
console.error("Error creating entry:", error);
|
||||
alert("Failed to save entry");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const createSingleEntry = async () => {
|
||||
// Validation for Max Standby
|
||||
if (type === 'standby' && parseFloat(duration) > maxDuration) {
|
||||
if (!window.confirm(`You entered ${duration}h for Standby on a ${dayType}. Max is usually ${maxDuration}h. Continue?`)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const data = {
|
||||
user: pb.authStore.model.id,
|
||||
date,
|
||||
duration: parseFloat(duration),
|
||||
type,
|
||||
day_type: dayType,
|
||||
multiplier: preview > 0 && parseFloat(duration) > 0 ? (preview / parseFloat(duration)) : 0,
|
||||
notes,
|
||||
is_banked: isBanked,
|
||||
calculated_hours: preview,
|
||||
manual_override: false
|
||||
};
|
||||
|
||||
await pb.collection('time_entries').create(data);
|
||||
};
|
||||
|
||||
const createBulkEntries = async () => {
|
||||
const entries = generateStandbyEntries(date, dateEnd);
|
||||
|
||||
// Parallel create? Or sequential? Sequential is safer for order if that matters, but parallel is faster.
|
||||
// PocketBase creates return promises.
|
||||
const promises = entries.map(entry => {
|
||||
const calculated = calculateBankedHours(entry.duration, entry.type, entry.dayType);
|
||||
const multiplier = entry.duration > 0 ? (calculated / entry.duration) : 0;
|
||||
|
||||
return pb.collection('time_entries').create({
|
||||
user: pb.authStore.model.id,
|
||||
date: entry.date,
|
||||
duration: entry.duration,
|
||||
type: entry.type,
|
||||
day_type: entry.dayType,
|
||||
multiplier: multiplier,
|
||||
notes: notes || 'Bulk Standby Entry', // Use user notes or default
|
||||
is_banked: true,
|
||||
calculated_hours: calculated,
|
||||
manual_override: false
|
||||
}, { requestKey: `standby_${entry.date}` });
|
||||
});
|
||||
|
||||
await Promise.all(promises);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white p-6 rounded-lg shadow-md mb-6">
|
||||
<h3 className="text-lg font-semibold mb-4 text-gray-800">{t('entry.title')}</h3>
|
||||
|
||||
{/* Type Selection First - Logic flows from here */}
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700">{t('entry.type')}</label>
|
||||
<select
|
||||
className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2"
|
||||
value={type}
|
||||
onChange={e => setType(e.target.value)}
|
||||
>
|
||||
<option value="overtime">{t('type.overtime')}</option>
|
||||
<option value="regular">{t('type.regular')}</option>
|
||||
<option value="standby">{t('type.standby')}</option>
|
||||
<option value="callback">{t('type.callback')}</option>
|
||||
<option value="non_contiguous">{t('type.non_contiguous')}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Mode Switch (Only for Standby) */}
|
||||
{type === 'standby' && (
|
||||
<div className="flex space-x-4 mb-4 border-b pb-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEntryMode('single')}
|
||||
className={`text-sm font-medium ${entryMode === 'single' ? 'text-blue-600 border-b-2 border-blue-600' : 'text-gray-500'}`}
|
||||
>
|
||||
{t('entry.mode.single')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEntryMode('range')}
|
||||
className={`text-sm font-medium ${entryMode === 'range' ? 'text-blue-600 border-b-2 border-blue-600' : 'text-gray-500'}`}
|
||||
>
|
||||
{t('entry.mode.range')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
|
||||
{/* Date Inputs */}
|
||||
{entryMode === 'single' ? (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">{t('entry.date')}</label>
|
||||
<input
|
||||
type="date"
|
||||
required
|
||||
className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2"
|
||||
value={date}
|
||||
onChange={e => setDate(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">{t('entry.date_start')}</label>
|
||||
<input
|
||||
type="date"
|
||||
required
|
||||
className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2"
|
||||
value={date}
|
||||
onChange={e => setDate(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">{t('entry.date_end')}</label>
|
||||
<input
|
||||
type="date"
|
||||
required
|
||||
className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2"
|
||||
value={dateEnd}
|
||||
onChange={e => setDateEnd(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Single Mode Fields */}
|
||||
{entryMode === 'single' && (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
{t('entry.duration')}
|
||||
{type === 'standby' && <span className="text-xs text-gray-400 ml-1">(Max {maxDuration}h)</span>}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.25"
|
||||
required
|
||||
min="0.25"
|
||||
max={type === 'standby' ? maxDuration : undefined}
|
||||
className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2"
|
||||
value={duration}
|
||||
onChange={e => setDuration(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">{t('entry.day_type')}</label>
|
||||
<select
|
||||
className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2"
|
||||
value={dayType}
|
||||
onChange={e => setDayType(e.target.value)}
|
||||
>
|
||||
<option value="workday">{t('day.workday')}</option>
|
||||
<option value="rest_day_1">{t('day.rest_day_1')}</option>
|
||||
<option value="rest_day_2">{t('day.rest_day_2')}</option>
|
||||
<option value="holiday">{t('day.holiday')}</option>
|
||||
</select>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Common Fields */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">{t('entry.notes')}</label>
|
||||
<textarea
|
||||
className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2"
|
||||
rows="2"
|
||||
value={notes}
|
||||
onChange={e => setNotes(e.target.value)}
|
||||
placeholder={entryMode === 'range' ? "Applied to all generated entries" : ""}
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
{/* Calculation / Preview */}
|
||||
<div className="flex items-center justify-between bg-blue-50 p-3 rounded">
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isBanked}
|
||||
onChange={e => setIsBanked(e.target.checked)}
|
||||
disabled={type === 'standby'} // Standby usually defaults to banked? Or paid? Let's leave editable if single, but range forces banked?
|
||||
// Plan said: "auto-populates... isBanked: true".
|
||||
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
||||
/>
|
||||
<label className="ml-2 block text-sm text-gray-900">
|
||||
{t('entry.bank_time')}
|
||||
</label>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
{entryMode === 'single' ? (
|
||||
<>
|
||||
<span className="text-sm text-gray-500 block">{t('entry.calc_value')}:</span>
|
||||
<span className="text-lg font-bold text-blue-700">{preview.toFixed(2)} hrs</span>
|
||||
</>
|
||||
) : (
|
||||
bulkPreview && (
|
||||
<span className="text-sm text-blue-700">
|
||||
{t('entry.bulk_preview')
|
||||
.replace('{count}', bulkPreview.count)
|
||||
.replace('{hours}', bulkPreview.totalHours.toFixed(2))}
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full bg-indigo-600 text-white p-2 rounded-md hover:bg-indigo-700 transition"
|
||||
>
|
||||
{loading ? t('entry.saving') : t('entry.save')}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EntryForm;
|
||||
82
frontend/src/components/TimeEntry/RedeemForm.jsx
Normal file
82
frontend/src/components/TimeEntry/RedeemForm.jsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import React, { useState, useContext } from 'react';
|
||||
import { pb } from '../../lib/pocketbase';
|
||||
import { LanguageContext } from '../../contexts/LanguageContext';
|
||||
|
||||
const RedeemForm = ({ onRedeem }) => {
|
||||
const { t } = useContext(LanguageContext);
|
||||
const [amount, setAmount] = useState('');
|
||||
const [date, setDate] = useState(new Date().toISOString().split('T')[0]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleRedeem = async (e) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
|
||||
const hoursToDeduct = parseFloat(amount);
|
||||
if (hoursToDeduct <= 0) return;
|
||||
|
||||
const data = {
|
||||
user: pb.authStore.model.id,
|
||||
date,
|
||||
duration: hoursToDeduct,
|
||||
type: 'regular', // or 'leave'
|
||||
day_type: 'workday',
|
||||
multiplier: -1, // Negative multiplier or just negative calculated hours?
|
||||
// Let's set calculated_hours directly to negative
|
||||
is_banked: true,
|
||||
calculated_hours: -hoursToDeduct,
|
||||
notes: 'Time Off Redeemed',
|
||||
manual_override: true
|
||||
};
|
||||
|
||||
try {
|
||||
await pb.collection('time_entries').create(data);
|
||||
setAmount('');
|
||||
onRedeem();
|
||||
} catch (error) {
|
||||
console.error("Error redeeming:", error);
|
||||
alert("Failed to redeem hours");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white p-6 rounded-lg shadow-md">
|
||||
<h3 className="text-lg font-semibold mb-4 text-gray-800">{t('redeem.title')}</h3>
|
||||
<form onSubmit={handleRedeem} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">{t('redeem.date')}</label>
|
||||
<input
|
||||
type="date"
|
||||
required
|
||||
className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2"
|
||||
value={date}
|
||||
onChange={e => setDate(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">{t('redeem.hours')}</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.25"
|
||||
min="0.25"
|
||||
required
|
||||
className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2"
|
||||
value={amount}
|
||||
onChange={e => setAmount(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full bg-green-600 text-white p-2 rounded-md hover:bg-green-700 transition"
|
||||
>
|
||||
{loading ? t('redeem.processing') : t('redeem.submit')}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RedeemForm;
|
||||
66
frontend/src/components/TimeEntry/TimeList.jsx
Normal file
66
frontend/src/components/TimeEntry/TimeList.jsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import React, { useContext } from 'react';
|
||||
import { LanguageContext } from '../../contexts/LanguageContext';
|
||||
|
||||
const TimeList = ({ entries }) => {
|
||||
const { t } = useContext(LanguageContext);
|
||||
|
||||
if (!entries || !entries.length) {
|
||||
return (
|
||||
<div className="bg-white p-6 rounded-lg shadow-md text-center text-gray-500">
|
||||
{t('list.no_entries')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-md overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-gray-200">
|
||||
<h3 className="text-lg font-semibold text-gray-800">{t('list.title')}</h3>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{t('list.header.date')}</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{t('list.header.type')}</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{t('list.header.duration')}</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{t('list.header.calc')}</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{t('list.header.status')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{entries.map((entry) => (
|
||||
<tr key={entry.id}>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{entry.date ? new Date(entry.date).toLocaleDateString(undefined, { timeZone: 'UTC' }) : '-'}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 capitalize">
|
||||
{t(`type.${entry.type}`) || entry.type} <span className="text-xs text-gray-400">({t(`day.${entry.day_type}`) || entry.day_type})</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{entry.duration}h
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-blue-600">
|
||||
{entry.calculated_hours ? entry.calculated_hours.toFixed(2) : '-'}h
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{entry.is_banked ? (
|
||||
<span className="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800">
|
||||
{t('list.banked')}
|
||||
</span>
|
||||
) : (
|
||||
<span className="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-gray-100 text-gray-800">
|
||||
{t('list.paid_out')}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div >
|
||||
);
|
||||
};
|
||||
|
||||
export default TimeList;
|
||||
43
frontend/src/contexts/AuthContext.jsx
Normal file
43
frontend/src/contexts/AuthContext.jsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import React, { createContext, useState, useEffect } from 'react';
|
||||
import { pb } from '../lib/pocketbase';
|
||||
|
||||
export const AuthContext = createContext();
|
||||
|
||||
export const AuthProvider = ({ children }) => {
|
||||
const [user, setUser] = useState(pb.authStore.model);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Subscribe to auth state changes
|
||||
const unsubscribe = pb.authStore.onChange((token, model) => {
|
||||
setUser(model);
|
||||
});
|
||||
|
||||
return () => {
|
||||
// Unsubscribe (authStore.onChange returns a compiled unsubscribe function)
|
||||
unsubscribe();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const login = async (email, password) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await pb.collection('users').authWithPassword(email, password);
|
||||
} catch (error) {
|
||||
console.error("Login failed:", error);
|
||||
throw error;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
pb.authStore.clear();
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, login, logout, loading }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
};
|
||||
29
frontend/src/contexts/LanguageContext.jsx
Normal file
29
frontend/src/contexts/LanguageContext.jsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import React, { createContext, useState, useEffect } from 'react';
|
||||
import { translations } from '../locales/translations';
|
||||
|
||||
export const LanguageContext = createContext();
|
||||
|
||||
export const LanguageProvider = ({ children }) => {
|
||||
const [language, setLanguage] = useState('en');
|
||||
|
||||
useEffect(() => {
|
||||
const stored = localStorage.getItem('app_language');
|
||||
if (stored) setLanguage(stored);
|
||||
}, []);
|
||||
|
||||
const toggleLanguage = () => {
|
||||
const newLang = language === 'en' ? 'fr' : 'en';
|
||||
setLanguage(newLang);
|
||||
localStorage.setItem('app_language', newLang);
|
||||
};
|
||||
|
||||
const t = (key) => {
|
||||
return translations[language][key] || key;
|
||||
};
|
||||
|
||||
return (
|
||||
<LanguageContext.Provider value={{ language, toggleLanguage, t }}>
|
||||
{children}
|
||||
</LanguageContext.Provider>
|
||||
);
|
||||
};
|
||||
10
frontend/src/index.css
Normal file
10
frontend/src/index.css
Normal file
@@ -0,0 +1,10 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Lato:wght@300;400;700&family=Noto+Sans:wght@300;400;600;700&display=swap');
|
||||
@import "tailwindcss";
|
||||
|
||||
body {
|
||||
@apply bg-slate-50 text-slate-900 antialiased;
|
||||
}
|
||||
|
||||
#root {
|
||||
@apply w-full min-h-screen;
|
||||
}
|
||||
3
frontend/src/lib/pocketbase.js
Normal file
3
frontend/src/lib/pocketbase.js
Normal file
@@ -0,0 +1,3 @@
|
||||
import PocketBase from 'pocketbase';
|
||||
|
||||
export const pb = new PocketBase('http://127.0.0.1:8090');
|
||||
234
frontend/src/lib/time-rules.js
Normal file
234
frontend/src/lib/time-rules.js
Normal file
@@ -0,0 +1,234 @@
|
||||
/**
|
||||
* usage: calculatedHours = calculateHours(duration, type, dayType)
|
||||
*
|
||||
* Rules based on IT Group Collective Agreement (IT-01 to IT-04):
|
||||
* - Workday OT: 1.5x for first 7.5h, 2.0x thereafter.
|
||||
* - Rest Day 1 (Sat): 1.5x for first 7.5h, 2.0x thereafter.
|
||||
* - Rest Day 2 (Sun): 2.0x for all hours.
|
||||
* - Holiday: 1.5x for first 7.5h, 2.0x thereafter (plus regular pay if applicable, but we track OT here).
|
||||
* - Call-back: Minimum 3h pay (at applicable rate).
|
||||
* - Reporting Pay: Minimum 4h pay (at straight time) or worked time at OT rate, whichever is greater.
|
||||
* (Simplified: If worked < 3h? IT rules say 3h min for Callback. 4h min for Reporting on Rest Day).
|
||||
*
|
||||
* Assumptions:
|
||||
* - This function returns the "Banked Equivalent" hours.
|
||||
* e.g., 1h worked at 1.5x = 1.5 banked hours.
|
||||
* - Non-Contiguous Hours (Workday): Greater of (Actual * OT) or (2h * Straight Time).
|
||||
*/
|
||||
|
||||
export const calculateBankedHours = (duration, type, dayType) => {
|
||||
let multiplier = 1.0;
|
||||
let earned = 0;
|
||||
|
||||
// Convert duration to number
|
||||
const hours = parseFloat(duration) || 0;
|
||||
if (hours <= 0) return 0;
|
||||
|
||||
// --- STANDBY ---
|
||||
if (type === 'standby') {
|
||||
// Article 11.01: 0.5h pay for each 4h period. => 0.125 multiplier.
|
||||
// Or we can just store it as 1h for every 8.
|
||||
// Let's use exact calculation: hours * (0.5 / 4)
|
||||
return hours * 0.125;
|
||||
}
|
||||
|
||||
// --- CALL-BACK ---
|
||||
if (type === 'callback') {
|
||||
// Article 10.01: Greater of 3h pay or time worked at OT rate.
|
||||
// We calculate OT rate first.
|
||||
let workedValue = calculateOvertimeValue(hours, dayType);
|
||||
|
||||
// Clause 10.01 (a) says "three (3) hours' pay at the *applicable rate for overtime*"
|
||||
const applicableRate = getBaseOvertimeRate(dayType);
|
||||
const minPay = 3 * applicableRate;
|
||||
|
||||
// If we work long enough to hit 2.0x threshold on a 1.5x day, we need exact calculation
|
||||
return Math.max(minPay, workedValue);
|
||||
}
|
||||
|
||||
// --- NON-CONTIGUOUS HOURS ---
|
||||
if (type === 'non_contiguous') {
|
||||
// Article 9.01 (4): Greater of "time actually worked"(at OT rate) OR "minimum of two (2) hours' pay at straight time".
|
||||
// Note: 9.01(4) generally applies "on that day" (workday).
|
||||
// 2h pay at straight time = 2.0 banked hours.
|
||||
|
||||
let workedValue = calculateOvertimeValue(hours, dayType);
|
||||
|
||||
// Minimum is 2h pay at STRAIGHT time (1.0x).
|
||||
const minPay = 2.0;
|
||||
|
||||
return Math.max(minPay, workedValue);
|
||||
}
|
||||
|
||||
// --- REGULAR / OVERTIME ---
|
||||
if (type === 'overtime' || type === 'regular') {
|
||||
// "Regular" on a Time Tracker for Overtime usually implies "Regular Overtime" unless it is standard hours?
|
||||
// If it's standard 7.5h workday, multiplier is 1.0.
|
||||
// But the user tool is for "Overtime and Back hours".
|
||||
// If type is 'regular' and day is 'workday', it's just 1.0x?
|
||||
// Let's assume 'regular' means 1.0x (standard work).
|
||||
if (type === 'regular') {
|
||||
return hours * 1.0;
|
||||
}
|
||||
|
||||
// Overtime Logic
|
||||
return calculateOvertimeValue(hours, dayType);
|
||||
}
|
||||
|
||||
return hours;
|
||||
};
|
||||
|
||||
function getBaseOvertimeRate(dayType) {
|
||||
if (dayType === 'rest_day_2') return 2.0;
|
||||
return 1.5;
|
||||
}
|
||||
|
||||
function calculateOvertimeValue(hours, dayType) {
|
||||
// Second Day of Rest (Sunday) = All 2.0x
|
||||
if (dayType === 'rest_day_2') {
|
||||
return hours * 2.0;
|
||||
}
|
||||
|
||||
// Workday, Rest Day 1, Holiday
|
||||
// First 7.5h @ 1.5x
|
||||
// Remaining @ 2.0x
|
||||
if (hours <= 7.5) {
|
||||
return hours * 1.5;
|
||||
} else {
|
||||
const firstPart = 7.5 * 1.5;
|
||||
const secondPart = (hours - 7.5) * 2.0;
|
||||
return firstPart + secondPart;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the maximum allowed standby hours based on the day type.
|
||||
* - Workday: 16 hours (24 - 8 regular work).
|
||||
* - Rest Days / Holidays: 24 hours.
|
||||
*/
|
||||
export const getMaxStandbyHours = (dayType) => {
|
||||
if (dayType === 'workday') return 16;
|
||||
return 24;
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper to check if a date is a Canadian Statutory Holiday.
|
||||
* Covers fixed dates and major variable dates (Good Friday, Victoria Day, Labour Day, Thanksgiving, Easter Monday, Civic Holiday).
|
||||
*/
|
||||
export const isCanadianHoliday = (dateObj) => {
|
||||
const year = dateObj.getFullYear();
|
||||
const month = dateObj.getMonth(); // 0-indexed
|
||||
const date = dateObj.getDate();
|
||||
const day = dateObj.getDay(); // 0 = Sun, 1 = Mon, etc.
|
||||
|
||||
// --- Fixed Date Holidays ---
|
||||
// New Year's Day (Jan 1)
|
||||
if (month === 0 && date === 1) return true;
|
||||
// Canada Day (Jul 1)
|
||||
if (month === 6 && date === 1) return true;
|
||||
// Remembrance Day (Nov 11)
|
||||
if (month === 10 && date === 11) return true;
|
||||
// Christmas Day (Dec 25)
|
||||
if (month === 11 && date === 25) return true;
|
||||
// Boxing Day (Dec 26)
|
||||
if (month === 11 && date === 26) return true;
|
||||
|
||||
// --- Variable Date Holidays ---
|
||||
|
||||
// Good Friday & Easter Monday
|
||||
// Easter calculation (Anonymous / Meeus/Jones/Butcher's algorithm)
|
||||
const a = year % 19;
|
||||
const b = Math.floor(year / 100);
|
||||
const c = year % 100;
|
||||
const d = Math.floor(b / 4);
|
||||
const e = b % 4;
|
||||
const f = Math.floor((b + 8) / 25);
|
||||
const g = Math.floor((b - f + 1) / 3);
|
||||
const h = (19 * a + b - d - g + 15) % 30;
|
||||
const i = Math.floor(c / 4);
|
||||
const k = c % 4;
|
||||
const l = (32 + 2 * e + 2 * i - h - k) % 7;
|
||||
const m = Math.floor((a + 11 * h + 22 * l) / 451);
|
||||
const easterMonth = Math.floor((h + l - 7 * m + 114) / 31) - 1; // 0-indexed
|
||||
const easterDay = ((h + l - 7 * m + 114) % 31) + 1;
|
||||
|
||||
const easterDate = new Date(year, easterMonth, easterDay);
|
||||
|
||||
// Good Friday (2 days before Easter)
|
||||
const goodFriday = new Date(easterDate);
|
||||
goodFriday.setDate(easterDate.getDate() - 2);
|
||||
if (month === goodFriday.getMonth() && date === goodFriday.getDate()) return true;
|
||||
|
||||
// Easter Monday (1 day after Easter) - specific to some agreements but often treated as holiday in fed gov
|
||||
const easterMonday = new Date(easterDate);
|
||||
easterMonday.setDate(easterDate.getDate() + 1);
|
||||
if (month === easterMonday.getMonth() && date === easterMonday.getDate()) return true;
|
||||
|
||||
// Victoria Day (Money before May 25)
|
||||
if (month === 4 && day === 1 && date >= 18 && date <= 24) return true;
|
||||
|
||||
// Canada Day Observed (if Jul 1 is Sun, then Jul 2 is holiday? - simplified, usually if falling on weekend)
|
||||
// Checking only standard dates for now as per "statutory holidays".
|
||||
|
||||
// Civic Holiday (First Monday in August) - Not statutory everywhere but often observed
|
||||
if (month === 7 && day === 1 && date <= 7) return true;
|
||||
|
||||
// Labour Day (First Monday in September)
|
||||
if (month === 8 && day === 1 && date <= 7) return true;
|
||||
|
||||
// Thanksgiving (Second Monday in October)
|
||||
if (month === 9 && day === 1 && date >= 8 && date <= 14) return true;
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper to detect day type from a Date object (for auto-filling).
|
||||
* Includes holiday detection.
|
||||
*/
|
||||
export const detectDayType = (dateObj) => {
|
||||
if (isCanadianHoliday(dateObj)) return 'holiday';
|
||||
|
||||
const day = dateObj.getDay(); // 0 = Sun, 6 = Sat
|
||||
if (day === 0) return 'rest_day_2'; // Sunday
|
||||
if (day === 6) return 'rest_day_1'; // Saturday
|
||||
return 'workday';
|
||||
};
|
||||
|
||||
/**
|
||||
* Generates a list of daily entries for a given range.
|
||||
* Used for Bulk Standby Entry.
|
||||
*/
|
||||
export const generateStandbyEntries = (startDateStr, endDateStr) => {
|
||||
const entries = [];
|
||||
const [sy, sm, sd] = startDateStr.split('-').map(Number);
|
||||
const [ey, em, ed] = endDateStr.split('-').map(Number);
|
||||
// Use Noon UTC to avoid timezone rollover issues
|
||||
const start = new Date(Date.UTC(sy, sm - 1, sd, 12, 0, 0));
|
||||
const end = new Date(Date.UTC(ey, em - 1, ed, 12, 0, 0));
|
||||
|
||||
// Loop from start to end
|
||||
for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) {
|
||||
// Create local date object for holiday checking (using getFullYear/getMonth/getDate)
|
||||
// We used UTC above for iteration safety, but need local context or consistent context for day checks.
|
||||
// detectDayType uses local methods.
|
||||
// Let's create a new Date object from the UTC components to treat it as "Local Noon" for checking.
|
||||
// Actually, detectDayType uses .getDay() which is local.
|
||||
// If we want consistency, we should ensure we are checking the "intended" date.
|
||||
// The simplistic approach:
|
||||
const checkDate = new Date(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate(), 12, 0, 0);
|
||||
|
||||
const dateStr = d.toISOString().split('T')[0];
|
||||
const dayType = detectDayType(checkDate);
|
||||
const maxHours = getMaxStandbyHours(dayType);
|
||||
|
||||
entries.push({
|
||||
date: dateStr,
|
||||
dayType: dayType,
|
||||
duration: maxHours,
|
||||
type: 'standby',
|
||||
isBanked: true // Default to banked for standby? Usually yes.
|
||||
});
|
||||
}
|
||||
return entries;
|
||||
};
|
||||
168
frontend/src/locales/translations.js
Normal file
168
frontend/src/locales/translations.js
Normal file
@@ -0,0 +1,168 @@
|
||||
export const translations = {
|
||||
en: {
|
||||
// Layout
|
||||
'app.title': 'Time Tracking Application',
|
||||
'nav.sign_in': 'Sign in',
|
||||
'nav.sign_out': 'Sign out',
|
||||
'footer.about': 'About this site',
|
||||
'footer.terms': 'Terms and conditions',
|
||||
'footer.privacy': 'Privacy',
|
||||
'footer.contact': 'Contact us',
|
||||
'footer.dept': 'Department of Time',
|
||||
'lang.switch': 'Français',
|
||||
|
||||
// Dashboard
|
||||
'dash.welcome': 'Welcome',
|
||||
'dash.employee_viewing': 'Viewing',
|
||||
|
||||
// Entry Form
|
||||
'entry.title': 'Add Time Entry',
|
||||
'entry.date': 'Date',
|
||||
'entry.duration': 'Duration (Hours)',
|
||||
'entry.type': 'Type',
|
||||
'entry.day_type': 'Day Type',
|
||||
'entry.notes': 'Notes (Reason)',
|
||||
'entry.bank_time': 'Bank this time',
|
||||
'entry.calc_value': 'Calculated Value',
|
||||
'entry.save': 'Save Entry',
|
||||
'entry.saving': 'Saving...',
|
||||
'entry.mode.single': 'Single Day',
|
||||
'entry.mode.range': 'Date Range',
|
||||
'entry.date_start': 'From Date',
|
||||
'entry.date_end': 'To Date',
|
||||
'entry.bulk_preview': 'Will generate {count} entries totalling {hours} banked hours.',
|
||||
|
||||
// Types
|
||||
'type.overtime': 'Overtime',
|
||||
'type.regular': 'Regular',
|
||||
'type.standby': 'Standby',
|
||||
'type.callback': 'Call-back',
|
||||
'type.non_contiguous': 'Non-Contiguous Hours',
|
||||
|
||||
// Day Types
|
||||
'day.workday': 'Normal Workday',
|
||||
'day.rest_day_1': 'First Day of Rest (Sat)',
|
||||
'day.rest_day_2': 'Second Day of Rest (Sun)',
|
||||
'day.holiday': 'Designated Paid Holiday',
|
||||
|
||||
// List
|
||||
'list.title': 'Recent Entries',
|
||||
'list.no_entries': 'No entries found.',
|
||||
'list.header.date': 'Date',
|
||||
'list.header.type': 'Type',
|
||||
'list.header.duration': 'Duration',
|
||||
'list.header.calc': 'Calculated',
|
||||
'list.header.status': 'Status',
|
||||
'list.banked': 'Banked',
|
||||
'list.paid_out': 'Paid Out',
|
||||
|
||||
// Balance & Redeem
|
||||
'balance.title': 'Banked Hours',
|
||||
'balance.employee_title': 'Employee Balance',
|
||||
'balance.available': 'Available for time off',
|
||||
'redeem.title': 'Use Banked Hours',
|
||||
'redeem.date': 'Date Taken',
|
||||
'redeem.hours': 'Hours to Use',
|
||||
'redeem.submit': 'Take Time Off',
|
||||
'redeem.processing': 'Processing...',
|
||||
|
||||
// Profile
|
||||
'profile.title': 'Profile Settings',
|
||||
'profile.supervisor_check': 'I am a Supervisor',
|
||||
'profile.my_supervisor': 'My Supervisor',
|
||||
'profile.select_supervisor': '-- Select Supervisor --',
|
||||
'profile.save': 'Save Settings',
|
||||
'profile.saving': 'Saving...',
|
||||
'profile.updated': 'Profile updated!',
|
||||
'profile.error': 'Error saving profile',
|
||||
|
||||
// Supervisor
|
||||
'sup.title': 'Supervisor Dashboard',
|
||||
'sup.my_team': 'My Team',
|
||||
'sup.no_employees': 'No employees assigned.',
|
||||
'sup.select_prompt': 'Select an employee to view details',
|
||||
},
|
||||
fr: {
|
||||
// Layout
|
||||
'app.title': 'Application de suivi du temps',
|
||||
'nav.sign_in': 'Connexion',
|
||||
'nav.sign_out': 'Déconnexion',
|
||||
'footer.about': 'À propos de ce site',
|
||||
'footer.terms': 'Avis',
|
||||
'footer.privacy': 'Confidentialité',
|
||||
'footer.contact': 'Contactez-nous',
|
||||
'footer.dept': 'Ministère du Temps',
|
||||
'lang.switch': 'English',
|
||||
|
||||
// Dashboard
|
||||
'dash.welcome': 'Bienvenue',
|
||||
'dash.employee_viewing': 'Visualisation',
|
||||
|
||||
// Entry Form
|
||||
'entry.title': 'Ajouter une entrée',
|
||||
'entry.date': 'Date',
|
||||
'entry.duration': 'Durée (Heures)',
|
||||
'entry.type': 'Type',
|
||||
'entry.day_type': 'Type de jour',
|
||||
'entry.notes': 'Notes (Raison)',
|
||||
'entry.bank_time': 'Stocker ce temps',
|
||||
'entry.calc_value': 'Valeur calculée',
|
||||
'entry.save': 'Enregistrer',
|
||||
'entry.saving': 'Enregistrement...',
|
||||
'entry.mode.single': 'Jour unique',
|
||||
'entry.mode.range': 'Plage de dates',
|
||||
'entry.date_start': 'Date de début',
|
||||
'entry.date_end': 'Date de fin',
|
||||
'entry.bulk_preview': 'Générera {count} entrées totalisant {hours} heures stockées.',
|
||||
|
||||
// Types
|
||||
'type.overtime': 'Heures supplémentaires',
|
||||
'type.regular': 'Régulier',
|
||||
'type.standby': 'Disponibilité',
|
||||
'type.callback': 'Rappel au travail',
|
||||
'type.non_contiguous': 'Heures non contiguës',
|
||||
|
||||
// Day Types
|
||||
'day.workday': 'Jour normal',
|
||||
'day.rest_day_1': 'Premier jour de repos (Sam)',
|
||||
'day.rest_day_2': 'Deuxième jour de repos (Dim)',
|
||||
'day.holiday': 'Jour férié désigné',
|
||||
|
||||
// List
|
||||
'list.title': 'Entrées récentes',
|
||||
'list.no_entries': 'Aucune entrée trouvée.',
|
||||
'list.header.date': 'Date',
|
||||
'list.header.type': 'Type',
|
||||
'list.header.duration': 'Durée',
|
||||
'list.header.calc': 'Calculé',
|
||||
'list.header.status': 'État',
|
||||
'list.banked': 'Stocké',
|
||||
'list.paid_out': 'Payé',
|
||||
|
||||
// Balance & Redeem
|
||||
'balance.title': 'Heures stockées',
|
||||
'balance.employee_title': 'Solde employé',
|
||||
'balance.available': 'Disponible pour congé',
|
||||
'redeem.title': 'Utiliser heures stockées',
|
||||
'redeem.date': 'Date prise',
|
||||
'redeem.hours': 'Heures à utiliser',
|
||||
'redeem.submit': 'Prendre congé',
|
||||
'redeem.processing': 'Traitement...',
|
||||
|
||||
// Profile
|
||||
'profile.title': 'Paramètres de profil',
|
||||
'profile.supervisor_check': 'Je suis superviseur',
|
||||
'profile.my_supervisor': 'Mon superviseur',
|
||||
'profile.select_supervisor': '-- Sélectionner superviseur --',
|
||||
'profile.save': 'Enregistrer',
|
||||
'profile.saving': 'Enregistrement...',
|
||||
'profile.updated': 'Profil mis à jour !',
|
||||
'profile.error': 'Erreur lors de l\'enregistrement',
|
||||
|
||||
// Supervisor
|
||||
'sup.title': 'Tableau de bord superviseur',
|
||||
'sup.my_team': 'Mon équipe',
|
||||
'sup.no_employees': 'Aucun employé assigné.',
|
||||
'sup.select_prompt': 'Sélectionnez un employé pour voir les détails',
|
||||
}
|
||||
};
|
||||
10
frontend/src/main.jsx
Normal file
10
frontend/src/main.jsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.jsx'
|
||||
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
124
frontend/src/pages/LandingPage.jsx
Normal file
124
frontend/src/pages/LandingPage.jsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import React, { useState, useContext } from 'react';
|
||||
import { AuthContext } from '../contexts/AuthContext';
|
||||
import { useNavigate, Link, Navigate } from 'react-router-dom';
|
||||
import landingInfographic from '../assets/landing-infographic.png';
|
||||
import GoCLayout from '../components/Layout/GoCLayout';
|
||||
|
||||
const LandingPage = () => {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const { login, loading, user } = useContext(AuthContext);
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Redirect if already logged in
|
||||
if (user) {
|
||||
return <Navigate to="/dashboard" />;
|
||||
}
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
try {
|
||||
await login(email, password);
|
||||
navigate('/dashboard');
|
||||
} catch (err) {
|
||||
setError('Failed to log in. Please check your credentials.');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<GoCLayout>
|
||||
<div className="flex flex-col md:flex-row bg-gray-50 rounded-lg overflow-hidden shadow-sm border border-gray-200">
|
||||
{/* Left/Top Side - Info & Graphics */}
|
||||
<div className="w-full md:w-1/2 p-8 md:p-12 flex flex-col justify-center items-center bg-blue-50">
|
||||
<div className="max-w-md space-y-8 text-center md:text-left">
|
||||
<h1 className="text-4xl md:text-5xl font-bold text-gray-900 mb-4">
|
||||
Time Tracker
|
||||
</h1>
|
||||
<p className="text-xl text-gray-600 mb-8">
|
||||
Track time worked and use it to take time off. Manage your hours efficiently and balance your work-life schedule with ease.
|
||||
</p>
|
||||
<div className="rounded-xl overflow-hidden shadow-lg transform hover:scale-105 transition duration-300 bg-white p-4">
|
||||
<img
|
||||
src={landingInfographic}
|
||||
alt="Time management infographic"
|
||||
className="w-full h-auto object-contain"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right/Bottom Side - Login Form */}
|
||||
<div className="w-full md:w-1/2 p-8 flex items-center justify-center bg-white">
|
||||
<div className="w-full max-w-md space-y-8">
|
||||
<div className="text-center">
|
||||
<h2 className="text-3xl font-extrabold text-gray-900">
|
||||
Welcome Back
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-gray-600">
|
||||
Please sign in to your account
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && <div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded">{error}</div>}
|
||||
|
||||
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
|
||||
<div className="rounded-md shadow-sm -space-y-px">
|
||||
<div className="mb-4">
|
||||
<label htmlFor="email-address" className="sr-only">Email address</label>
|
||||
<input
|
||||
id="email-address"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-t-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm"
|
||||
placeholder="Email address"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="password" className="sr-only">Password</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-b-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm"
|
||||
placeholder="Password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
>
|
||||
{loading ? 'Signing in...' : 'Sign in'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="text-center mt-4">
|
||||
<p className="text-sm text-gray-600">
|
||||
Don't have an account?{' '}
|
||||
<Link to="/register" className="font-medium text-blue-600 hover:text-blue-500">
|
||||
Register here
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</GoCLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default LandingPage;
|
||||
23
frontend/tailwind.config.js
Normal file
23
frontend/tailwind.config.js
Normal file
@@ -0,0 +1,23 @@
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
// Government of Canada Palette
|
||||
'goc-blue': '#26374a',
|
||||
'goc-red': '#d3080c',
|
||||
'goc-link': '#2b4380',
|
||||
'goc-link-hover': '#0535d2',
|
||||
'goc-grey': '#eaebed',
|
||||
'goc-footer': '#f8f8f8',
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['"Noto Sans"', '"Lato"', 'sans-serif'],
|
||||
}
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
23
frontend/test-rules.js
Normal file
23
frontend/test-rules.js
Normal file
@@ -0,0 +1,23 @@
|
||||
import { calculateBankedHours } from './src/lib/time-rules.js';
|
||||
|
||||
const tests = [
|
||||
{ desc: "Standard OT (1h)", duration: 1, type: "overtime", day: "workday", expect: 1.5 },
|
||||
{ desc: "Long OT (8.5h)", duration: 8.5, type: "overtime", day: "workday", expect: (7.5 * 1.5) + (1.0 * 2.0) }, // 13.25
|
||||
{ desc: "Sunday OT (2h)", duration: 2, type: "overtime", day: "rest_day_2", expect: 4.0 },
|
||||
{ desc: "Standby (8h)", duration: 8, type: "standby", day: "workday", expect: 1.0 },
|
||||
{ desc: "Call-back short (1h)", duration: 1, type: "callback", day: "workday", expect: 4.5 },
|
||||
{ desc: "Call-back Sunday (1h)", duration: 1, type: "callback", day: "rest_day_2", expect: 6.0 },
|
||||
|
||||
// Non-Contiguous Hours Tests
|
||||
{ desc: "Non-Contiguous (1h @ 1.5x)", duration: 1, type: "non_contiguous", day: "workday", expect: 2.0 }, // Worked=1.5, Min=2.0 -> 2.0
|
||||
{ desc: "Non-Contiguous (2h @ 1.5x)", duration: 2, type: "non_contiguous", day: "workday", expect: 3.0 }, // Worked=3.0, Min=2.0 -> 3.0
|
||||
];
|
||||
|
||||
tests.forEach(t => {
|
||||
const res = calculateBankedHours(t.duration, t.type, t.day);
|
||||
if (Math.abs(res - t.expect) < 0.01) {
|
||||
console.log(`PASS: ${t.desc} -> ${res}`);
|
||||
} else {
|
||||
console.error(`FAIL: ${t.desc} -> Expected ${t.expect}, got ${res}`);
|
||||
}
|
||||
});
|
||||
7
frontend/vite.config.js
Normal file
7
frontend/vite.config.js
Normal file
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
})
|
||||
Reference in New Issue
Block a user