Initial commit
All checks were successful
Docker Build and Publish / build-and-push (push) Successful in 2m17s

This commit is contained in:
2026-01-11 18:55:03 -05:00
commit 493ea4688c
45 changed files with 7107 additions and 0 deletions

9
.dockerignore Normal file
View File

@@ -0,0 +1,9 @@
node_modules
.git
.vscode
dist
backend/pb_data
backend/pocketbase.exe
backend/*.zip
coverage
**/.DS_Store

30
.github/workflows/docker-publish.yml vendored Normal file
View File

@@ -0,0 +1,30 @@
name: Docker Build and Publish
on:
push:
branches:
- main
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Login to Gitea Registry
uses: docker/login-action@v2
with:
registry: gitea.krisforbes.ca
username: ${{ secrets.REGISTRY_USERNAME }}
password: ${{ secrets.REGISTRY_PASSWORD }}
- name: Build and push
uses: docker/build-push-action@v4
with:
context: .
push: true
tags: gitea.krisforbes.ca/krisf/time_tracker:latest

9
.gitignore vendored Normal file
View File

@@ -0,0 +1,9 @@
node_modules/
dist/
.DS_Store
.env
pb_data/
coverage/
.idea/
.vscode/
*.log

49
Dockerfile Normal file
View File

@@ -0,0 +1,49 @@
# Stage 1: Build Frontend
FROM node:22-alpine AS builder
WORKDIR /app
# Install dependencies
COPY frontend/package*.json ./
RUN npm install
# Copy source and build
COPY frontend/ ./
RUN npm run build
# Stage 2: Runtime
FROM alpine:latest
WORKDIR /pb
# Install dependencies
RUN apk add --no-cache \
ca-certificates \
unzip \
wget
# Download PocketBase
# Using version 0.22.21 as referenced in backend scripts
ARG PB_VERSION=0.22.21
RUN wget https://github.com/pocketbase/pocketbase/releases/download/v${PB_VERSION}/pocketbase_${PB_VERSION}_linux_amd64.zip \
&& unzip pocketbase_${PB_VERSION}_linux_amd64.zip \
&& rm pocketbase_${PB_VERSION}_linux_amd64.zip
# Make executable
RUN chmod +x /pb/pocketbase
# Copy built frontend assets to PocketBase public directory
# PocketBase checks pb_public by default for static files
COPY --from=builder /app/dist /pb/pb_public
# Copy local migrations to container
COPY backend/pb_migrations /pb/pb_migrations
# Expose PocketBase port
EXPOSE 8090
# Define volume for data persistence (handled in compose, but good practice to document)
VOLUME /pb/pb_data
# Start PocketBase
CMD ["/pb/pocketbase", "serve", "--http=0.0.0.0:8090"]

1110
backend/CHANGELOG.md Normal file

File diff suppressed because it is too large Load Diff

17
backend/LICENSE.md Normal file
View File

@@ -0,0 +1,17 @@
The MIT License (MIT)
Copyright (c) 2022 - present, Gani Georgiev
Permission is hereby granted, free of charge, to any person obtaining a copy of this software
and associated documentation files (the "Software"), to deal in the Software without restriction,
including without limitation the rights to use, copy, modify, merge, publish, distribute,
sublicense, and/or sell copies of the Software, and to permit persons to whom the Software
is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or
substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING
BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

14
backend/download_pb.ps1 Normal file
View File

@@ -0,0 +1,14 @@
$pbVersion = "0.22.21"
$zipUrl = "https://github.com/pocketbase/pocketbase/releases/download/v${pbVersion}/pocketbase_${pbVersion}_windows_amd64.zip"
$outputZip = "pocketbase.zip"
Write-Host "Downloading PocketBase v${pbVersion}..."
Invoke-WebRequest -Uri $zipUrl -OutFile $outputZip
Write-Host "Extracting..."
Expand-Archive -Path $outputZip -DestinationPath . -Force
Write-Host "Cleaning up..."
Remove-Item $outputZip
Write-Host "PocketBase setup complete."

View File

@@ -0,0 +1,111 @@
migrate((db) => {
const dao = new Dao(db);
const collection = new Collection({
name: "time_entries",
type: "base",
schema: [
{
name: "user",
type: "relation",
required: true,
options: {
collectionId: "_pb_users_auth_",
cascadeDelete: true,
maxSelect: 1,
displayFields: []
}
},
{
name: "date",
type: "date",
required: true,
options: {
min: "",
max: ""
}
},
{
name: "start_time",
type: "date",
required: false,
options: {
min: "",
max: ""
}
},
{
name: "end_time",
type: "date",
required: false,
options: {
min: "",
max: ""
}
},
{
name: "duration",
type: "number",
required: true,
options: {
min: 0,
max: null,
noDecimal: false
}
},
{
name: "type",
type: "select",
required: true,
options: {
maxSelect: 1,
values: ["regular", "overtime", "standby", "callback"]
}
},
{
name: "day_type",
type: "select",
required: true,
options: {
maxSelect: 1,
values: ["workday", "rest_day_1", "rest_day_2", "holiday"]
}
},
{
name: "multiplier",
type: "number",
required: true,
options: {
min: 0,
max: null,
noDecimal: false
}
},
{
name: "notes",
type: "text",
required: false,
options: {
min: null,
max: null,
pattern: ""
}
}
],
listRule: "@request.auth.id = user.id",
viewRule: "@request.auth.id = user.id",
createRule: "@request.auth.id = user.id",
updateRule: "@request.auth.id = user.id",
deleteRule: "@request.auth.id = user.id",
});
return dao.saveCollection(collection);
}, (db) => {
const dao = new Dao(db);
try {
const collection = dao.findCollectionByNameOrId("time_entries");
return dao.deleteCollection(collection);
} catch (_) {
return null;
}
})

View File

@@ -0,0 +1,45 @@
migrate((db) => {
const dao = new Dao(db);
const collection = dao.findCollectionByNameOrId("time_entries");
// Add is_banked field
collection.schema.addField(new SchemaField({
name: "is_banked",
type: "bool",
required: false, // Default true handled in app logic or ignored for now
options: {}
}));
// Add manual_override field
collection.schema.addField(new SchemaField({
name: "manual_override",
type: "bool",
required: false,
options: {}
}));
// Add calculated_hours field
collection.schema.addField(new SchemaField({
name: "calculated_hours",
type: "number",
required: false,
options: {
min: null,
max: null,
noDecimal: false
}
}));
return dao.saveCollection(collection);
}, (db) => {
const dao = new Dao(db);
const collection = dao.findCollectionByNameOrId("time_entries");
// logic to remove fields if needed, usually tricky in PB migrations securely without losing data
// For now we just return null or attempt simple removal
collection.schema.removeField("is_banked");
collection.schema.removeField("manual_override");
collection.schema.removeField("calculated_hours");
return dao.saveCollection(collection);
})

View File

@@ -0,0 +1,53 @@
migrate((db) => {
const dao = new Dao(db);
// 1. Update 'users' collection
const usersCollection = dao.findCollectionByNameOrId("users");
usersCollection.schema.addField(new SchemaField({
name: "is_supervisor",
type: "bool",
required: false,
options: {}
}));
usersCollection.schema.addField(new SchemaField({
name: "supervisor",
type: "relation",
required: false,
options: {
collectionId: usersCollection.id,
cascadeDelete: false,
maxSelect: 1,
displayFields: ["name", "email"]
}
}));
dao.saveCollection(usersCollection);
// 2. Update 'time_entries' collection rules
const timeEntries = dao.findCollectionByNameOrId("time_entries");
// Allow if owner OR if user's supervisor is requestor
const rule = "@request.auth.id = user.id || user.supervisor.id = @request.auth.id";
timeEntries.listRule = rule;
timeEntries.viewRule = rule;
// create/update/delete usually restricted to owner, supervisors maybe read-only for now?
// Plan said "view", so list/view is enough.
dao.saveCollection(timeEntries);
}, (db) => {
// Revert logic (simplified)
const dao = new Dao(db);
const usersCollection = dao.findCollectionByNameOrId("users");
usersCollection.schema.removeField("is_supervisor");
usersCollection.schema.removeField("supervisor");
dao.saveCollection(usersCollection);
const timeEntries = dao.findCollectionByNameOrId("time_entries");
timeEntries.listRule = "@request.auth.id = user.id";
timeEntries.viewRule = "@request.auth.id = user.id";
dao.saveCollection(timeEntries);
})

BIN
backend/pocketbase.exe Normal file

Binary file not shown.

14
docker-compose.yml Normal file
View File

@@ -0,0 +1,14 @@
services:
app:
build:
context: .
dockerfile: Dockerfile
container_name: time_tracker
ports:
- "8090:8090"
volumes:
- pb_data:/pb/pb_data
restart: unless-stopped
volumes:
pb_data:

24
frontend/.gitignore vendored Normal file
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

33
frontend/package.json Normal file
View 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"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
'@tailwindcss/postcss': {},
autoprefixer: {},
},
}

1
frontend/public/vite.svg Normal file
View 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
View 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
View 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
View 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;

Binary file not shown.

After

Width:  |  Height:  |  Size: 325 KiB

View 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

View 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;

View 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;

View 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 &gt; Time Tracker &gt; <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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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>
);
};

View 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
View 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;
}

View File

@@ -0,0 +1,3 @@
import PocketBase from 'pocketbase';
export const pb = new PocketBase('http://127.0.0.1:8090');

View 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;
};

View 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
View 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>,
)

View 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;

View 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
View 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
View File

@@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
})