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:
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;
|
||||
Reference in New Issue
Block a user