diff --git a/src/components/Timelog/TimeEntryForm/SplitTimeEntryForm.jsx b/src/components/Timelog/TimeEntryForm/SplitTimeEntryForm.jsx new file mode 100644 index 00000000000..f935e5433f6 --- /dev/null +++ b/src/components/Timelog/TimeEntryForm/SplitTimeEntryForm.jsx @@ -0,0 +1,321 @@ +import { useRef, useState } from 'react'; +import PropTypes from 'prop-types'; +import axios from 'axios'; +import { ENDPOINTS } from '~/utils/URL'; +import { + allocationMinutes, + formatSplitMinutes, + readSplitDraft, + splitDraftKey, + splitValidation, +} from './splitTimeEntryUtils'; +import styles from './SplitTimeEntryForm.module.css'; + +const emptyRow = () => ({ + id: crypto.randomUUID(), + taskId: '', + hours: '0', + minutes: '0', + notes: '', +}); + +async function requestReceipt(payload) { + try { + return await axios.post(ENDPOINTS.TIME_ENTRY_SPLIT(), payload); + } catch (error_) { + if (error_.response?.status !== 409) throw error_; + // Another tab may already have saved this server-issued timer session. + return axios.get(ENDPOINTS.TIME_ENTRY_SPLIT_RECEIPT(payload.batchId)); + } +} + +function submitLabel(busy, saved, locked, count) { + if (busy) return 'Saving…'; + if (saved) return 'Finish timer'; + if (locked) return 'Retry split save'; + return `Submit ${count} time entries`; +} + +export default function SplitTimeEntryForm({ + personId, + batchId, + dateOfWork, + totalMinutes, + tasks, + loading, + connected, + darkMode, + onComplete, + onLockChange, +}) { + const [draft, setDraft] = useState(() => { + const saved = readSplitDraft(personId); + if (saved && (saved.attempted || saved.payload.batchId === batchId)) return saved; + return { + payload: { personId, batchId, dateOfWork, totalMinutes }, + rows: [emptyRow(), emptyRow()], + attempted: false, + saved: false, + }; + }); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(''); + const inFlight = useRef(false); + const locked = draft.attempted; + const activeTasks = tasks.filter(task => task.isActive !== false && task.deleted !== true); + const allocated = draft.rows.reduce((sum, row) => sum + (allocationMinutes(row) || 0), 0); + const remaining = draft.payload.totalMinutes - allocated; + const validation = splitValidation( + draft.rows, + draft.payload.totalMinutes, + activeTasks.map(task => task._id), + ); + + const persist = next => { + localStorage.setItem(splitDraftKey(personId), JSON.stringify(next)); + setDraft(next); + }; + const updateDraft = next => { + try { + persist(next); + setError(''); + } catch { + setDraft(next); + setError( + 'Your browser could not keep a recovery copy. Allow site storage before saving a split.', + ); + } + }; + const change = (id, field, value) => { + const next = { + ...draft, + rows: draft.rows.map(row => (row.id === id ? { ...row, [field]: value } : row)), + }; + updateDraft(next); + }; + const applyReceipt = (receipt, current) => ({ + ...current, + saved: true, + receipt, + payload: { + ...current.payload, + dateOfWork: receipt.dateOfWork, + totalMinutes: receipt.totalMinutes, + }, + rows: receipt.allocations.map((row, index) => ({ + id: String(index), + taskId: String(row.taskId), + hours: String(Math.floor(row.minutes / 60)), + minutes: String(row.minutes % 60), + notes: row.notes, + })), + }); + + const submit = async () => { + if (inFlight.current || !connected || (!locked && (loading || validation))) return; + inFlight.current = true; + setBusy(true); + setError(''); + let current = draft; + try { + if (!current.attempted) { + current = { + ...current, + attempted: true, + payload: { + ...current.payload, + allocations: current.rows.map(row => ({ + taskId: row.taskId, + minutes: allocationMinutes(row), + notes: row.notes, + })), + }, + }; + // Keep the exact submitted body before sending; retries cannot change it. + persist(current); + onLockChange(true); + } + if (!current.saved) { + const response = await requestReceipt(current.payload); + current = applyReceipt(response.data, current); + persist(current); + } + const complete = await onComplete(current.receipt, current.payload); + if (!complete) + throw new Error( + 'Time is saved, but the timer stop is not confirmed. Choose Finish timer to retry without adding time.', + ); + try { + localStorage.removeItem(splitDraftKey(personId)); + } catch { + /* The server receipt still prevents duplicates. */ + } + onLockChange(false); + } catch (error_) { + if (!current.saved && [400, 403, 404].includes(error_.response?.status)) { + current = { ...current, attempted: false }; + try { + persist(current); + } catch { + setDraft(current); + } + onLockChange(false); + } + setError( + error_.response?.data?.error || + error_.message || + 'Could not confirm the save. Your allocations are kept; retry this split.', + ); + } finally { + inFlight.current = false; + setBusy(false); + } + }; + + return ( +
+

+ Divide this session between your assigned tasks. Each task needs its own work description + and reference link. +

+

+ Session date: {draft.payload.dateOfWork} +

+
+
+
Session total
+
{formatSplitMinutes(draft.payload.totalMinutes)}
+
+
+
Allocated
+
{formatSplitMinutes(allocated)}
+
+
+
{remaining < 0 ? 'Over allocated' : 'Remaining'}
+
{formatSplitMinutes(remaining)}
+
+
+ {locked && ( + + {draft.saved + ? 'These entries are already saved. Finish the timer to complete.' + : 'Confirming this split. Your original allocations are kept for a safe retry.'} + + )} +
+ Task allocations + {draft.rows.map((row, index) => ( +
+ + +
+ + +
+ +