planning-fight/app/page.tsx

544 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client';
import { useState, useEffect } from 'react';
import { workersAPI, Worker, settingsAPI, Settings } from '@/lib/api';
export default function Home() {
const [workers, setWorkers] = useState<Worker[]>([]);
const [newWorkerName, setNewWorkerName] = useState('');
const [newPresenceDays, setNewPresenceDays] = useState('');
const [nameError, setNameError] = useState(false);
const [daysError, setDaysError] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [editingDays, setEditingDays] = useState('');
const [loading, setLoading] = useState(false);
const [settings, setSettings] = useState<Settings>({ id: 1, totalPoints: 13, totalDays: 10 });
const [showSettings, setShowSettings] = useState(false);
const [showResetConfirm, setShowResetConfirm] = useState(false);
const [editTotalPoints, setEditTotalPoints] = useState('');
const [editTotalDays, setEditTotalDays] = useState('');
const TOTAL_POINTS = settings.totalPoints;
const TOTAL_DAYS = settings.totalDays;
const POINTS_PER_DAY = TOTAL_POINTS / TOTAL_DAYS;
useEffect(() => {
loadWorkers();
loadSettings();
}, []);
const loadWorkers = async () => {
try {
const data = await workersAPI.getAll();
setWorkers(data);
} catch (error) {
console.error('Failed to load workers:', error);
alert('Failed to load workers. Make sure json-server is running.');
}
};
const loadSettings = async () => {
try {
const data = await settingsAPI.get();
setSettings(data);
} catch (error) {
console.error('Failed to load settings:', error);
}
};
const openSettings = () => {
setEditTotalPoints(settings.totalPoints.toString());
setEditTotalDays(settings.totalDays.toString());
setShowSettings(true);
};
const closeSettings = () => {
setShowSettings(false);
setEditTotalPoints('');
setEditTotalDays('');
};
const saveSettings = async () => {
const points = parseInt(editTotalPoints);
const days = parseInt(editTotalDays);
if (isNaN(points) || points <= 0) {
alert('Please enter a valid number of points greater than 0');
return;
}
if (isNaN(days) || days <= 0) {
alert('Please enter a valid number of days greater than 0');
return;
}
try {
setLoading(true);
const updatedSettings = await settingsAPI.update({ totalPoints: points, totalDays: days });
setSettings(updatedSettings);
closeSettings();
} catch (error) {
console.error('Failed to save settings:', error);
alert('Failed to save settings');
} finally {
setLoading(false);
}
};
const addWorker = async () => {
// Validate required fields
const isNameEmpty = !newWorkerName.trim();
const isDaysEmpty = !newPresenceDays.trim();
setNameError(isNameEmpty);
setDaysError(isDaysEmpty);
if (isNameEmpty || isDaysEmpty) return;
const days = parseInt(newPresenceDays);
if (isNaN(days) || days < 0 || days > TOTAL_DAYS) {
alert(`Please enter a valid number of days between 0 and ${TOTAL_DAYS}`);
setDaysError(true);
return;
}
try {
setLoading(true);
await workersAPI.create({
name: newWorkerName.trim(),
presenceDays: days,
});
await loadWorkers();
setNewWorkerName('');
setNewPresenceDays('');
setNameError(false);
setDaysError(false);
} catch (error) {
console.error('Failed to add worker:', error);
alert('Failed to add worker');
} finally {
setLoading(false);
}
};
const removeWorker = async (id: string) => {
try {
setLoading(true);
await workersAPI.delete(id);
await loadWorkers();
} catch (error) {
console.error('Failed to remove worker:', error);
alert('Failed to remove worker');
} finally {
setLoading(false);
}
};
const startEditing = (worker: Worker) => {
setEditingId(worker.id);
setEditingDays(worker.presenceDays.toString());
};
const cancelEditing = () => {
setEditingId(null);
setEditingDays('');
};
const saveEditing = async (id: string) => {
const days = parseInt(editingDays);
if (isNaN(days) || days < 0 || days > TOTAL_DAYS) {
alert(`Please enter a valid number of days between 0 and ${TOTAL_DAYS}`);
return;
}
try {
setLoading(true);
await workersAPI.update(id, { presenceDays: days });
await loadWorkers();
setEditingId(null);
setEditingDays('');
} catch (error) {
console.error('Failed to update worker:', error);
alert('Failed to update worker');
} finally {
setLoading(false);
}
};
const confirmResetAllPresences = async () => {
try {
setLoading(true);
setShowResetConfirm(false);
await workersAPI.resetAllPresences();
await loadWorkers();
} catch (error) {
console.error('Failed to reset presences:', error);
alert('Failed to reset presences');
} finally {
setLoading(false);
}
};
const calculatePoints = (days: number): number => {
return days * POINTS_PER_DAY;
};
const calculateUsedPoints = (days: number): number => {
return TOTAL_POINTS - calculatePoints(days);
};
return (
<main className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 p-8">
<div className="max-w-4xl mx-auto">
<div className="flex justify-between items-start mb-8">
<div>
<h1 className="text-4xl font-bold text-gray-800 mb-2">Worker Presence Manager</h1>
<p className="text-gray-600">
Sprint duration: {TOTAL_DAYS} working days | Total points per worker: {TOTAL_POINTS}
</p>
</div>
<button
onClick={openSettings}
disabled={loading}
className="px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition-colors font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>
Settings
</button>
</div>
{/* Add Worker Form */}
<div className="bg-white rounded-lg shadow-md p-6 mb-8">
<h2 className="text-xl font-semibold text-gray-800 mb-4">Add Worker</h2>
<div className="flex flex-col sm:flex-row gap-3">
<input
type="text"
placeholder="Worker name"
value={newWorkerName}
onChange={(e) => {
setNewWorkerName(e.target.value);
if (nameError) setNameError(false);
}}
className={`flex-1 px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 ${
nameError
? 'border-red-500 focus:ring-red-500'
: 'border-gray-300 focus:ring-blue-500'
}`}
onKeyPress={(e) => e.key === 'Enter' && addWorker()}
/>
<input
type="number"
placeholder={`Presence days (0-${TOTAL_DAYS})`}
value={newPresenceDays}
onChange={(e) => {
setNewPresenceDays(e.target.value);
if (daysError) setDaysError(false);
}}
min="0"
max={TOTAL_DAYS}
className={`w-full sm:w-48 px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 ${
daysError
? 'border-red-500 focus:ring-red-500'
: 'border-gray-300 focus:ring-blue-500'
}`}
onKeyPress={(e) => e.key === 'Enter' && addWorker()}
/>
<button
onClick={addWorker}
disabled={loading}
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>
Add
</button>
</div>
</div>
{/* Workers List */}
{workers.length > 0 ? (
<div className="bg-white rounded-lg shadow-md overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Worker Name
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Presence Days
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Points Earned
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Points Used
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Remaining Points
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{workers.map((worker) => {
const pointsEarned = calculatePoints(worker.presenceDays);
const pointsUsed = calculateUsedPoints(worker.presenceDays);
const remainingPoints = TOTAL_POINTS - pointsUsed;
const isEditing = editingId === worker.id;
return (
<tr key={worker.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
{worker.name}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-700">
{isEditing ? (
<input
type="number"
value={editingDays}
onChange={(e) => setEditingDays(e.target.value)}
min="0"
max={TOTAL_DAYS}
className="w-20 px-2 py-1 border border-blue-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
onKeyPress={(e) => {
if (e.key === 'Enter') saveEditing(worker.id);
if (e.key === 'Escape') cancelEditing();
}}
autoFocus
/>
) : (
`${worker.presenceDays} / ${TOTAL_DAYS}`
)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-green-600 font-medium">
{pointsEarned.toFixed(2)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-red-600 font-medium">
{pointsUsed.toFixed(2)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-700 font-medium">
{remainingPoints.toFixed(2)} / {TOTAL_POINTS}
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
{isEditing ? (
<div className="flex gap-2 justify-end">
<button
onClick={() => saveEditing(worker.id)}
className="p-2 text-green-600 hover:text-green-900 hover:bg-green-50 rounded transition-colors"
title="Save"
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-5 h-5">
<path strokeLinecap="round" strokeLinejoin="round" d="m4.5 12.75 6 6 9-13.5" />
</svg>
</button>
<button
onClick={cancelEditing}
className="p-2 text-gray-600 hover:text-gray-900 hover:bg-gray-50 rounded transition-colors"
title="Cancel"
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-5 h-5">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18 18 6M6 6l12 12" />
</svg>
</button>
</div>
) : (
<div className="flex gap-2 justify-end">
<button
onClick={() => startEditing(worker)}
className="p-2 text-blue-600 hover:text-blue-900 hover:bg-blue-50 rounded transition-colors"
title="Edit"
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-5 h-5">
<path strokeLinecap="round" strokeLinejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0 1 15.75 21H5.25A2.25 2.25 0 0 1 3 18.75V8.25A2.25 2.25 0 0 1 5.25 6H10" />
</svg>
</button>
<button
onClick={() => removeWorker(worker.id)}
className="p-2 text-red-600 hover:text-red-900 hover:bg-red-50 rounded transition-colors"
title="Remove"
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-5 h-5">
<path strokeLinecap="round" strokeLinejoin="round" d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0" />
</svg>
</button>
</div>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{/* Summary */}
<div className="bg-gray-50 px-6 py-4 border-t border-gray-200">
<div className="flex flex-wrap items-center justify-between gap-4">
<div className="flex flex-wrap gap-6 text-sm">
<div>
<span className="text-gray-600">Total Workers: </span>
<span className="font-semibold text-gray-900">{workers.length}</span>
</div>
<div>
<span className="text-gray-600">Avg Presence Days: </span>
<span className="font-semibold text-gray-900">
{workers.length > 0
? (workers.reduce((sum, w) => sum + w.presenceDays, 0) / workers.length).toFixed(1)
: '0'}
</span>
</div>
<div>
<span className="text-gray-600">Total Presence Days: </span>
<span className="font-semibold text-gray-900">
{workers.reduce((sum, w) => sum + w.presenceDays, 0)}
</span>
</div>
</div>
<div>
<span className="text-gray-600">Total Points in the sprint: </span>
<span className="font-semibold text-blue-600">
{workers.reduce((sum, w) => sum + calculatePoints(w.presenceDays), 0).toFixed(2)}
</span>
</div>
<button
onClick={() => setShowResetConfirm(true)}
disabled={loading}
className="p-2 bg-orange-600 text-white rounded-lg hover:bg-orange-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
title="Reset All Presences"
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-5 h-5">
<path strokeLinecap="round" strokeLinejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99" />
</svg>
</button>
</div>
</div>
</div>
) : (
<div className="bg-white rounded-lg shadow-md p-12 text-center">
<p className="text-gray-500 text-lg">No workers added yet. Add your first worker above.</p>
</div>
)}
{/* Info Box */}
<div className="mt-8 bg-blue-50 border border-blue-200 rounded-lg p-4">
<h3 className="font-semibold text-blue-900 mb-2">How it works:</h3>
<ul className="text-sm text-blue-800 space-y-1">
<li> Each worker has {TOTAL_POINTS} points for a sprint ({TOTAL_DAYS} working days)</li>
<li> Each presence day equals {POINTS_PER_DAY.toFixed(2)} points</li>
<li> Points Earned = Presence Days × {POINTS_PER_DAY.toFixed(2)}</li>
<li> Points Used = {TOTAL_POINTS} - Points Earned (days not present)</li>
<li> Remaining Points = Points Earned</li>
</ul>
</div>
{/* Settings Modal */}
{showSettings && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
<div className="bg-white rounded-lg shadow-xl max-w-md w-full p-6">
<h2 className="text-2xl font-bold text-gray-800 mb-4">Settings</h2>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Points per Worker per Sprint
</label>
<input
type="number"
value={editTotalPoints}
onChange={(e) => setEditTotalPoints(e.target.value)}
min="1"
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="e.g., 13"
/>
<p className="mt-1 text-sm text-gray-500">
Total points allocated to each worker for the sprint
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Number of Days in Sprint
</label>
<input
type="number"
value={editTotalDays}
onChange={(e) => setEditTotalDays(e.target.value)}
min="1"
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="e.g., 10"
/>
<p className="mt-1 text-sm text-gray-500">
Total working days in the sprint
</p>
</div>
<div className="bg-gray-50 rounded p-3 text-sm text-gray-600">
<p>
<strong>Points per day:</strong>{' '}
{editTotalPoints && editTotalDays && parseInt(editTotalPoints) > 0 && parseInt(editTotalDays) > 0
? (parseInt(editTotalPoints) / parseInt(editTotalDays)).toFixed(2)
: 'N/A'}
</p>
</div>
</div>
<div className="flex gap-3 mt-6">
<button
onClick={saveSettings}
disabled={loading}
className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>
Save
</button>
<button
onClick={closeSettings}
disabled={loading}
className="flex-1 px-4 py-2 bg-gray-200 text-gray-800 rounded-lg hover:bg-gray-300 transition-colors font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>
Cancel
</button>
</div>
</div>
</div>
)}
{/* Reset Confirmation Modal */}
{showResetConfirm && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
<div className="bg-white rounded-lg shadow-xl max-w-md w-full p-6">
<div className="flex items-center gap-3 mb-4">
<div className="p-3 bg-orange-100 rounded-full">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-6 h-6 text-orange-600">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z" />
</svg>
</div>
<h2 className="text-xl font-bold text-gray-800">Reset All Presences</h2>
</div>
<p className="text-gray-600 mb-6">
Are you sure you want to reset all worker presences to 0? This action cannot be undone.
</p>
<div className="flex gap-3">
<button
onClick={() => setShowResetConfirm(false)}
disabled={loading}
className="flex-1 px-4 py-2 bg-gray-200 text-gray-800 rounded-lg hover:bg-gray-300 transition-colors font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>
Cancel
</button>
<button
onClick={confirmResetAllPresences}
disabled={loading}
className="flex-1 px-4 py-2 bg-orange-600 text-white rounded-lg hover:bg-orange-700 transition-colors font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>
Reset All
</button>
</div>
</div>
</div>
)}
</div>
</main>
);
}