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

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;