Remove Docker-related configuration files and integrate dark mode functionality.

This commit is contained in:
Mathieu 2025-11-20 11:45:41 +01:00
parent e5733a5704
commit a06288c70a
7 changed files with 174 additions and 268 deletions

View file

@ -1,45 +0,0 @@
FROM node:20-alpine AS base
# Install dependencies only when needed
FROM base AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
# Install dependencies
COPY package.json package-lock.json* ./
RUN npm ci
# Rebuild the source code only when needed
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Next.js collects anonymous telemetry data about general usage
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build
# Production image, copy all the files and run next
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
# Copy necessary files
# Note: Automatically copy public folder. Next.js standalone build includes public in .next/standalone
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]

View file

@ -1,19 +0,0 @@
FROM node:20-alpine
WORKDIR /app
# Install json-server globally
RUN npm install -g json-server@0.17.4
# Create data directory
RUN mkdir -p /data
# Copy initial db.json
COPY db.json /data/db.json
EXPOSE 3001
# Use volume mount for persistence
VOLUME ["/data"]
CMD ["json-server", "--watch", "/data/db.json", "--host", "0.0.0.0", "--port", "3001"]

View file

@ -29,39 +29,6 @@ npm run dev
4. Open [http://localhost:3000](http://localhost:3000) in your browser 4. Open [http://localhost:3000](http://localhost:3000) in your browser
### Docker Deployment
#### Local Testing with Docker Compose
```bash
docker-compose up -d
```
#### Docker Swarm Deployment
1. Build the images:
```bash
./deploy.sh
```
2. Deploy to swarm:
```bash
docker stack deploy -c docker-compose.swarm.yml presence-tracker
```
3. Check the deployment:
```bash
docker stack services presence-tracker
```
4. Remove the stack:
```bash
docker stack rm presence-tracker
```
The application will be available at:
- Web UI: http://localhost:3000
- JSON API: http://localhost:3001
## Features ## Features
- Add workers with their presence days - Add workers with their presence days

View file

@ -17,16 +17,38 @@ export default function Home() {
const [showResetConfirm, setShowResetConfirm] = useState(false); const [showResetConfirm, setShowResetConfirm] = useState(false);
const [editTotalPoints, setEditTotalPoints] = useState(''); const [editTotalPoints, setEditTotalPoints] = useState('');
const [editTotalDays, setEditTotalDays] = useState(''); const [editTotalDays, setEditTotalDays] = useState('');
const [darkMode, setDarkMode] = useState(false);
const [mounted, setMounted] = useState(false);
const TOTAL_POINTS = settings.totalPoints; const TOTAL_POINTS = settings.totalPoints;
const TOTAL_DAYS = settings.totalDays; const TOTAL_DAYS = settings.totalDays;
const POINTS_PER_DAY = TOTAL_POINTS / TOTAL_DAYS; const POINTS_PER_DAY = TOTAL_POINTS / TOTAL_DAYS;
useEffect(() => { useEffect(() => {
setMounted(true);
loadWorkers(); loadWorkers();
loadSettings(); loadSettings();
// Load dark mode preference from localStorage
const savedDarkMode = localStorage.getItem('darkMode');
if (savedDarkMode !== null) {
setDarkMode(savedDarkMode === 'true');
}
}, []); }, []);
useEffect(() => {
if (!mounted) return;
// Apply dark mode class to document root
if (darkMode) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
// Save preference to localStorage
localStorage.setItem('darkMode', darkMode.toString());
}, [darkMode, mounted]);
const loadWorkers = async () => { const loadWorkers = async () => {
try { try {
const data = await workersAPI.getAll(); const data = await workersAPI.getAll();
@ -188,27 +210,31 @@ export default function Home() {
}; };
return ( return (
<main className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 p-8"> <main className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-slate-100 dark:from-slate-900 dark:via-slate-800 dark:to-slate-900 p-8 transition-colors">
<div className="max-w-4xl mx-auto"> <div className="max-w-6xl mx-auto">
<div className="flex justify-between items-start mb-8"> <div className="flex justify-between items-start mb-8">
<div> <div>
<h1 className="text-4xl font-bold text-gray-800 mb-2">Worker Presence Manager</h1> <h1 className="text-4xl font-bold text-slate-900 dark:text-slate-100 mb-2">Worker Presence Manager</h1>
<p className="text-gray-600"> <p className="text-slate-600 dark:text-slate-400 text-lg">
Sprint duration: {TOTAL_DAYS} working days | Total points per worker: {TOTAL_POINTS} Sprint duration: <span className="font-semibold text-slate-700 dark:text-slate-300">{TOTAL_DAYS}</span> working days | Total points per worker: <span className="font-semibold text-slate-700 dark:text-slate-300">{TOTAL_POINTS}</span>
</p> </p>
</div> </div>
<button <button
onClick={openSettings} onClick={openSettings}
disabled={loading} 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" className="px-5 py-2.5 bg-slate-700 dark:bg-slate-600 text-white rounded-lg hover:bg-slate-800 dark:hover:bg-slate-700 transition-all shadow-sm hover:shadow-md font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
> >
<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="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
</svg>
Settings Settings
</button> </button>
</div> </div>
{/* Add Worker Form */} {/* Add Worker Form */}
<div className="bg-white rounded-lg shadow-md p-6 mb-8"> <div className="bg-white dark:bg-slate-800 rounded-xl shadow-sm border border-slate-200 dark:border-slate-700 p-6 mb-8">
<h2 className="text-xl font-semibold text-gray-800 mb-4">Add Worker</h2> <h2 className="text-xl font-semibold text-slate-800 dark:text-slate-200 mb-4">Add Worker</h2>
<div className="flex flex-col sm:flex-row gap-3"> <div className="flex flex-col sm:flex-row gap-3">
<input <input
type="text" type="text"
@ -218,10 +244,10 @@ export default function Home() {
setNewWorkerName(e.target.value); setNewWorkerName(e.target.value);
if (nameError) setNameError(false); if (nameError) setNameError(false);
}} }}
className={`flex-1 px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 ${ className={`flex-1 px-4 py-2.5 border rounded-lg focus:outline-none focus:ring-2 transition-all bg-white dark:bg-slate-900 text-slate-900 dark:text-slate-100 placeholder:text-slate-400 dark:placeholder:text-slate-500 ${
nameError nameError
? 'border-red-500 focus:ring-red-500' ? 'border-red-400 focus:ring-red-400 focus:border-red-400'
: 'border-gray-300 focus:ring-blue-500' : 'border-slate-300 dark:border-slate-600 focus:ring-indigo-500 focus:border-indigo-500'
}`} }`}
onKeyPress={(e) => e.key === 'Enter' && addWorker()} onKeyPress={(e) => e.key === 'Enter' && addWorker()}
/> />
@ -235,17 +261,17 @@ export default function Home() {
}} }}
min="0" min="0"
max={TOTAL_DAYS} max={TOTAL_DAYS}
className={`w-full sm:w-48 px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 ${ className={`w-full sm:w-48 px-4 py-2.5 border rounded-lg focus:outline-none focus:ring-2 transition-all bg-white dark:bg-slate-900 text-slate-900 dark:text-slate-100 placeholder:text-slate-400 dark:placeholder:text-slate-500 ${
daysError daysError
? 'border-red-500 focus:ring-red-500' ? 'border-red-400 focus:ring-red-400 focus:border-red-400'
: 'border-gray-300 focus:ring-blue-500' : 'border-slate-300 dark:border-slate-600 focus:ring-indigo-500 focus:border-indigo-500'
}`} }`}
onKeyPress={(e) => e.key === 'Enter' && addWorker()} onKeyPress={(e) => e.key === 'Enter' && addWorker()}
/> />
<button <button
onClick={addWorker} onClick={addWorker}
disabled={loading} 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" className="px-6 py-2.5 bg-indigo-600 dark:bg-indigo-500 text-white rounded-lg hover:bg-indigo-700 dark:hover:bg-indigo-600 transition-all shadow-sm hover:shadow-md font-medium disabled:opacity-50 disabled:cursor-not-allowed"
> >
Add Add
</button> </button>
@ -254,32 +280,32 @@ export default function Home() {
{/* Workers List */} {/* Workers List */}
{workers.length > 0 ? ( {workers.length > 0 ? (
<div className="bg-white rounded-lg shadow-md overflow-hidden"> <div className="bg-white dark:bg-slate-800 rounded-xl shadow-sm border border-slate-200 dark:border-slate-700 overflow-hidden">
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full"> <table className="w-full">
<thead className="bg-gray-50 border-b border-gray-200"> <thead className="bg-slate-50 dark:bg-slate-900 border-b border-slate-200 dark:border-slate-700">
<tr> <tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <th className="px-6 py-4 text-left text-xs font-semibold text-slate-600 dark:text-slate-400 uppercase tracking-wider">
Worker Name Worker Name
</th> </th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <th className="px-6 py-4 text-left text-xs font-semibold text-slate-600 dark:text-slate-400 uppercase tracking-wider">
Presence Days Presence Days
</th> </th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <th className="px-6 py-4 text-left text-xs font-semibold text-slate-600 dark:text-slate-400 uppercase tracking-wider">
Points Earned Points Earned
</th> </th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <th className="px-6 py-4 text-left text-xs font-semibold text-slate-600 dark:text-slate-400 uppercase tracking-wider">
Points Used Points Used
</th> </th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <th className="px-6 py-4 text-left text-xs font-semibold text-slate-600 dark:text-slate-400 uppercase tracking-wider">
Remaining Points Remaining Points
</th> </th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider"> <th className="px-6 py-4 text-right text-xs font-semibold text-slate-600 dark:text-slate-400 uppercase tracking-wider">
Actions Actions
</th> </th>
</tr> </tr>
</thead> </thead>
<tbody className="bg-white divide-y divide-gray-200"> <tbody className="bg-white dark:bg-slate-800 divide-y divide-slate-100 dark:divide-slate-700">
{workers.map((worker) => { {workers.map((worker) => {
const pointsEarned = calculatePoints(worker.presenceDays); const pointsEarned = calculatePoints(worker.presenceDays);
const pointsUsed = calculateUsedPoints(worker.presenceDays); const pointsUsed = calculateUsedPoints(worker.presenceDays);
@ -287,11 +313,11 @@ export default function Home() {
const isEditing = editingId === worker.id; const isEditing = editingId === worker.id;
return ( return (
<tr key={worker.id} className="hover:bg-gray-50"> <tr key={worker.id} className="hover:bg-slate-50 dark:hover:bg-slate-700/50 transition-colors">
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900"> <td className="px-6 py-4 whitespace-nowrap text-sm font-semibold text-slate-900 dark:text-slate-100">
{worker.name} {worker.name}
</td> </td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-700"> <td className="px-6 py-4 whitespace-nowrap text-sm text-slate-700 dark:text-slate-300">
{isEditing ? ( {isEditing ? (
<input <input
type="number" type="number"
@ -299,7 +325,7 @@ export default function Home() {
onChange={(e) => setEditingDays(e.target.value)} onChange={(e) => setEditingDays(e.target.value)}
min="0" min="0"
max={TOTAL_DAYS} 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" className="w-20 px-2 py-1.5 border border-indigo-300 dark:border-indigo-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-all bg-white dark:bg-slate-900 text-slate-900 dark:text-slate-100"
onKeyPress={(e) => { onKeyPress={(e) => {
if (e.key === 'Enter') saveEditing(worker.id); if (e.key === 'Enter') saveEditing(worker.id);
if (e.key === 'Escape') cancelEditing(); if (e.key === 'Escape') cancelEditing();
@ -310,13 +336,13 @@ export default function Home() {
`${worker.presenceDays} / ${TOTAL_DAYS}` `${worker.presenceDays} / ${TOTAL_DAYS}`
)} )}
</td> </td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-green-600 font-medium"> <td className="px-6 py-4 whitespace-nowrap text-sm text-emerald-600 dark:text-emerald-400 font-semibold">
{pointsEarned.toFixed(2)} {pointsEarned.toFixed(2)}
</td> </td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-red-600 font-medium"> <td className="px-6 py-4 whitespace-nowrap text-sm text-rose-600 dark:text-rose-400 font-semibold">
{pointsUsed.toFixed(2)} {pointsUsed.toFixed(2)}
</td> </td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-700 font-medium"> <td className="px-6 py-4 whitespace-nowrap text-sm text-slate-700 dark:text-slate-300 font-semibold">
{remainingPoints.toFixed(2)} / {TOTAL_POINTS} {remainingPoints.toFixed(2)} / {TOTAL_POINTS}
</td> </td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium"> <td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
@ -324,19 +350,19 @@ export default function Home() {
<div className="flex gap-2 justify-end"> <div className="flex gap-2 justify-end">
<button <button
onClick={() => saveEditing(worker.id)} onClick={() => saveEditing(worker.id)}
className="p-2 text-green-600 hover:text-green-900 hover:bg-green-50 rounded transition-colors" className="p-2 text-emerald-600 dark:text-emerald-400 hover:text-emerald-700 dark:hover:text-emerald-300 hover:bg-emerald-50 dark:hover:bg-emerald-900/30 rounded-lg transition-all"
title="Save" 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"> <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className="w-5 h-5">
<path strokeLinecap="round" strokeLinejoin="round" d="m4.5 12.75 6 6 9-13.5" /> <path strokeLinecap="round" strokeLinejoin="round" d="m4.5 12.75 6 6 9-13.5" />
</svg> </svg>
</button> </button>
<button <button
onClick={cancelEditing} onClick={cancelEditing}
className="p-2 text-gray-600 hover:text-gray-900 hover:bg-gray-50 rounded transition-colors" className="p-2 text-slate-600 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-700 rounded-lg transition-all"
title="Cancel" 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"> <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className="w-5 h-5">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18 18 6M6 6l12 12" /> <path strokeLinecap="round" strokeLinejoin="round" d="M6 18 18 6M6 6l12 12" />
</svg> </svg>
</button> </button>
@ -345,7 +371,7 @@ export default function Home() {
<div className="flex gap-2 justify-end"> <div className="flex gap-2 justify-end">
<button <button
onClick={() => startEditing(worker)} onClick={() => startEditing(worker)}
className="p-2 text-blue-600 hover:text-blue-900 hover:bg-blue-50 rounded transition-colors" className="p-2 text-indigo-600 dark:text-indigo-400 hover:text-indigo-700 dark:hover:text-indigo-300 hover:bg-indigo-50 dark:hover:bg-indigo-900/30 rounded-lg transition-all"
title="Edit" 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"> <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">
@ -354,7 +380,7 @@ export default function Home() {
</button> </button>
<button <button
onClick={() => removeWorker(worker.id)} onClick={() => removeWorker(worker.id)}
className="p-2 text-red-600 hover:text-red-900 hover:bg-red-50 rounded transition-colors" className="p-2 text-rose-600 dark:text-rose-400 hover:text-rose-700 dark:hover:text-rose-300 hover:bg-rose-50 dark:hover:bg-rose-900/30 rounded-lg transition-all"
title="Remove" 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"> <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">
@ -372,38 +398,38 @@ export default function Home() {
</div> </div>
{/* Summary */} {/* Summary */}
<div className="bg-gray-50 px-6 py-4 border-t border-gray-200"> <div className="bg-slate-50 dark:bg-slate-900 px-6 py-5 border-t border-slate-200 dark:border-slate-700">
<div className="flex flex-wrap items-center justify-between gap-4"> <div className="flex flex-wrap items-center justify-between gap-4">
<div className="flex flex-wrap gap-6 text-sm"> <div className="flex flex-wrap gap-6 text-sm">
<div> <div>
<span className="text-gray-600">Total Workers: </span> <span className="text-slate-600 dark:text-slate-400">Total Workers: </span>
<span className="font-semibold text-gray-900">{workers.length}</span> <span className="font-semibold text-slate-900 dark:text-slate-100">{workers.length}</span>
</div> </div>
<div> <div>
<span className="text-gray-600">Avg Presence Days: </span> <span className="text-slate-600 dark:text-slate-400">Avg Presence Days: </span>
<span className="font-semibold text-gray-900"> <span className="font-semibold text-slate-900 dark:text-slate-100">
{workers.length > 0 {workers.length > 0
? (workers.reduce((sum, w) => sum + w.presenceDays, 0) / workers.length).toFixed(1) ? (workers.reduce((sum, w) => sum + w.presenceDays, 0) / workers.length).toFixed(1)
: '0'} : '0'}
</span> </span>
</div> </div>
<div> <div>
<span className="text-gray-600">Total Presence Days: </span> <span className="text-slate-600 dark:text-slate-400">Total Presence Days: </span>
<span className="font-semibold text-gray-900"> <span className="font-semibold text-slate-900 dark:text-slate-100">
{workers.reduce((sum, w) => sum + w.presenceDays, 0)} {workers.reduce((sum, w) => sum + w.presenceDays, 0)}
</span> </span>
</div> </div>
</div> </div>
<div> <div>
<span className="text-gray-600">Total Points in the sprint: </span> <span className="text-slate-600 dark:text-slate-400">Total Points in the sprint: </span>
<span className="font-semibold text-blue-600"> <span className="font-semibold text-indigo-600 dark:text-indigo-400">
{workers.reduce((sum, w) => sum + calculatePoints(w.presenceDays), 0).toFixed(2)} {workers.reduce((sum, w) => sum + calculatePoints(w.presenceDays), 0).toFixed(2)}
</span> </span>
</div> </div>
<button <button
onClick={() => setShowResetConfirm(true)} onClick={() => setShowResetConfirm(true)}
disabled={loading} 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" className="p-2.5 bg-amber-600 dark:bg-amber-500 text-white rounded-lg hover:bg-amber-700 dark:hover:bg-amber-600 transition-all shadow-sm hover:shadow-md disabled:opacity-50 disabled:cursor-not-allowed"
title="Reset All Presences" 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"> <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">
@ -414,32 +440,95 @@ export default function Home() {
</div> </div>
</div> </div>
) : ( ) : (
<div className="bg-white rounded-lg shadow-md p-12 text-center"> <div className="bg-white dark:bg-slate-800 rounded-xl shadow-sm border border-slate-200 dark:border-slate-700 p-12 text-center">
<p className="text-gray-500 text-lg">No workers added yet. Add your first worker above.</p> <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-16 h-16 mx-auto mb-4 text-slate-300 dark:text-slate-600">
<path strokeLinecap="round" strokeLinejoin="round" d="M18 18.72a9.094 9.094 0 0 0 3.741-.479 3 3 0 0 0-4.682-2.72m.94 3.198.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0 1 12 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 0 1 6 18.719m12 0a5.971 5.971 0 0 0-.941-3.197m0 0A5.995 5.995 0 0 0 12 12.75a5.995 5.995 0 0 0-5.058 2.772m0 0a3 3 0 0 0-4.681 2.72 8.986 8.986 0 0 0 3.74.477m.94-3.197a5.971 5.971 0 0 0-.94 3.197M15 6.75a3 3 0 1 1-6 0 3 3 0 0 1 6 0Zm6 3a2.25 2.25 0 1 1-4.5 0 2.25 2.25 0 0 1 4.5 0Zm-13.5 0a2.25 2.25 0 1 1-4.5 0 2.25 2.25 0 0 1 4.5 0Z" />
</svg>
<p className="text-slate-500 dark:text-slate-400 text-lg">No workers added yet. Add your first worker above.</p>
</div> </div>
)} )}
{/* Info Box */} {/* Info Box */}
<div className="mt-8 bg-blue-50 border border-blue-200 rounded-lg p-4"> <div className="mt-8 bg-gradient-to-br from-indigo-50 to-purple-50 dark:from-indigo-950/50 dark:to-purple-950/50 border border-indigo-100 dark:border-indigo-900 rounded-xl p-6 shadow-sm">
<h3 className="font-semibold text-blue-900 mb-2">How it works:</h3> <h3 className="font-semibold text-indigo-900 dark:text-indigo-300 mb-3 flex items-center gap-2">
<ul className="text-sm text-blue-800 space-y-1"> <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">
<li> Each worker has {TOTAL_POINTS} points for a sprint ({TOTAL_DAYS} working days)</li> <path strokeLinecap="round" strokeLinejoin="round" d="m11.25 11.25.041-.02a.75.75 0 0 1 1.063.852l-.708 2.836a.75.75 0 0 0 1.063.853l.041-.021M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9-3.75h.008v.008H12V8.25Z" />
<li> Each presence day equals {POINTS_PER_DAY.toFixed(2)} points</li> </svg>
<li> Points Earned = Presence Days × {POINTS_PER_DAY.toFixed(2)}</li> How it works:
<li> Points Used = {TOTAL_POINTS} - Points Earned (days not present)</li> </h3>
<li> Remaining Points = Points Earned</li> <ul className="text-sm text-indigo-800 dark:text-indigo-300 space-y-2">
<li className="flex items-start gap-2">
<span className="text-indigo-400 dark:text-indigo-500 mt-0.5"></span>
<span>Each worker has {TOTAL_POINTS} points for a sprint ({TOTAL_DAYS} working days)</span>
</li>
<li className="flex items-start gap-2">
<span className="text-indigo-400 dark:text-indigo-500 mt-0.5"></span>
<span>Each presence day equals {POINTS_PER_DAY.toFixed(2)} points</span>
</li>
<li className="flex items-start gap-2">
<span className="text-indigo-400 dark:text-indigo-500 mt-0.5"></span>
<span>Points Earned = Presence Days × {POINTS_PER_DAY.toFixed(2)}</span>
</li>
<li className="flex items-start gap-2">
<span className="text-indigo-400 dark:text-indigo-500 mt-0.5"></span>
<span>Points Used = {TOTAL_POINTS} - Points Earned (days not present)</span>
</li>
<li className="flex items-start gap-2">
<span className="text-indigo-400 dark:text-indigo-500 mt-0.5"></span>
<span>Remaining Points = Points Earned</span>
</li>
</ul> </ul>
</div> </div>
{/* Settings Modal */} {/* Settings Modal */}
{showSettings && ( {showSettings && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50"> <div className="fixed inset-0 bg-black bg-opacity-50 backdrop-blur-sm 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="bg-white dark:bg-slate-800 rounded-2xl shadow-2xl max-w-md w-full p-6">
<h2 className="text-2xl font-bold text-gray-800 mb-4">Settings</h2> <h2 className="text-2xl font-bold text-slate-800 dark:text-slate-100 mb-6">Settings</h2>
<div className="space-y-5">
{/* Dark Mode Toggle */}
<div className="pb-5 border-b border-slate-200 dark:border-slate-700">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="p-2 bg-slate-100 dark:bg-slate-700 rounded-lg">
{darkMode ? (
<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 text-slate-700 dark:text-slate-300">
<path strokeLinecap="round" strokeLinejoin="round" d="M21.752 15.002A9.72 9.72 0 0 1 18 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 0 0 3 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 0 0 9.002-5.998Z" />
</svg>
) : (
<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 text-slate-700 dark:text-slate-300">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3v2.25m6.364.386-1.591 1.591M21 12h-2.25m-.386 6.364-1.591-1.591M12 18.75V21m-4.773-4.227-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0Z" />
</svg>
)}
</div>
<div>
<span className="block text-sm font-semibold text-slate-700 dark:text-slate-200">Dark Mode</span>
<span className="text-xs text-slate-500 dark:text-slate-400">Toggle theme appearance</span>
</div>
</div>
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setDarkMode(!darkMode);
}}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 dark:focus:ring-offset-slate-800 ${
darkMode ? 'bg-indigo-600' : 'bg-slate-300'
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
darkMode ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
</div>
</div>
<div className="space-y-4">
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-2"> <label className="block text-sm font-semibold text-slate-700 dark:text-slate-300 mb-2">
Points per Worker per Sprint Points per Worker per Sprint
</label> </label>
<input <input
@ -447,16 +536,16 @@ export default function Home() {
value={editTotalPoints} value={editTotalPoints}
onChange={(e) => setEditTotalPoints(e.target.value)} onChange={(e) => setEditTotalPoints(e.target.value)}
min="1" 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" className="w-full px-4 py-2.5 border border-slate-300 dark:border-slate-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-all bg-white dark:bg-slate-900 text-slate-900 dark:text-slate-100 placeholder:text-slate-400 dark:placeholder:text-slate-500"
placeholder="e.g., 13" placeholder="e.g., 13"
/> />
<p className="mt-1 text-sm text-gray-500"> <p className="mt-2 text-sm text-slate-500 dark:text-slate-400">
Total points allocated to each worker for the sprint Total points allocated to each worker for the sprint
</p> </p>
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-2"> <label className="block text-sm font-semibold text-slate-700 dark:text-slate-300 mb-2">
Number of Days in Sprint Number of Days in Sprint
</label> </label>
<input <input
@ -464,20 +553,22 @@ export default function Home() {
value={editTotalDays} value={editTotalDays}
onChange={(e) => setEditTotalDays(e.target.value)} onChange={(e) => setEditTotalDays(e.target.value)}
min="1" 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" className="w-full px-4 py-2.5 border border-slate-300 dark:border-slate-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-all bg-white dark:bg-slate-900 text-slate-900 dark:text-slate-100 placeholder:text-slate-400 dark:placeholder:text-slate-500"
placeholder="e.g., 10" placeholder="e.g., 10"
/> />
<p className="mt-1 text-sm text-gray-500"> <p className="mt-2 text-sm text-slate-500 dark:text-slate-400">
Total working days in the sprint Total working days in the sprint
</p> </p>
</div> </div>
<div className="bg-gray-50 rounded p-3 text-sm text-gray-600"> <div className="bg-indigo-50 dark:bg-indigo-950/50 border border-indigo-100 dark:border-indigo-900 rounded-lg p-4 text-sm text-slate-700 dark:text-slate-300">
<p> <p>
<strong>Points per day:</strong>{' '} <strong className="text-indigo-900 dark:text-indigo-300">Points per day:</strong>{' '}
{editTotalPoints && editTotalDays && parseInt(editTotalPoints) > 0 && parseInt(editTotalDays) > 0 <span className="font-semibold text-indigo-700 dark:text-indigo-400">
? (parseInt(editTotalPoints) / parseInt(editTotalDays)).toFixed(2) {editTotalPoints && editTotalDays && parseInt(editTotalPoints) > 0 && parseInt(editTotalDays) > 0
: 'N/A'} ? (parseInt(editTotalPoints) / parseInt(editTotalDays)).toFixed(2)
: 'N/A'}
</span>
</p> </p>
</div> </div>
</div> </div>
@ -486,14 +577,14 @@ export default function Home() {
<button <button
onClick={saveSettings} onClick={saveSettings}
disabled={loading} 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" className="flex-1 px-4 py-2.5 bg-indigo-600 dark:bg-indigo-500 text-white rounded-lg hover:bg-indigo-700 dark:hover:bg-indigo-600 transition-all shadow-sm hover:shadow-md font-medium disabled:opacity-50 disabled:cursor-not-allowed"
> >
Save Save
</button> </button>
<button <button
onClick={closeSettings} onClick={closeSettings}
disabled={loading} 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" className="flex-1 px-4 py-2.5 bg-slate-200 dark:bg-slate-700 text-slate-800 dark:text-slate-200 rounded-lg hover:bg-slate-300 dark:hover:bg-slate-600 transition-all font-medium disabled:opacity-50 disabled:cursor-not-allowed"
> >
Cancel Cancel
</button> </button>
@ -504,18 +595,18 @@ export default function Home() {
{/* Reset Confirmation Modal */} {/* Reset Confirmation Modal */}
{showResetConfirm && ( {showResetConfirm && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50"> <div className="fixed inset-0 bg-black bg-opacity-50 backdrop-blur-sm 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="bg-white dark:bg-slate-800 rounded-2xl shadow-2xl max-w-md w-full p-6">
<div className="flex items-center gap-3 mb-4"> <div className="flex items-center gap-3 mb-4">
<div className="p-3 bg-orange-100 rounded-full"> <div className="p-3 bg-amber-100 dark:bg-amber-900/50 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"> <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-amber-600 dark:text-amber-400">
<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" /> <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> </svg>
</div> </div>
<h2 className="text-xl font-bold text-gray-800">Reset All Presences</h2> <h2 className="text-xl font-bold text-slate-800 dark:text-slate-100">Reset All Presences</h2>
</div> </div>
<p className="text-gray-600 mb-6"> <p className="text-slate-600 dark:text-slate-400 mb-6">
Are you sure you want to reset all worker presences to 0? This action cannot be undone. Are you sure you want to reset all worker presences to 0? This action cannot be undone.
</p> </p>
@ -523,14 +614,14 @@ export default function Home() {
<button <button
onClick={() => setShowResetConfirm(false)} onClick={() => setShowResetConfirm(false)}
disabled={loading} 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" className="flex-1 px-4 py-2.5 bg-slate-200 dark:bg-slate-700 text-slate-800 dark:text-slate-200 rounded-lg hover:bg-slate-300 dark:hover:bg-slate-600 transition-all font-medium disabled:opacity-50 disabled:cursor-not-allowed"
> >
Cancel Cancel
</button> </button>
<button <button
onClick={confirmResetAllPresences} onClick={confirmResetAllPresences}
disabled={loading} 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" className="flex-1 px-4 py-2.5 bg-amber-600 dark:bg-amber-500 text-white rounded-lg hover:bg-amber-700 dark:hover:bg-amber-600 transition-all shadow-sm hover:shadow-md font-medium disabled:opacity-50 disabled:cursor-not-allowed"
> >
Reset All Reset All
</button> </button>

View file

@ -1,51 +0,0 @@
version: '3.8'
services:
json-server:
image: presence-tracker-api:latest
ports:
- "3001:3001"
volumes:
- json-data:/data
networks:
- presence-network
deploy:
replicas: 1
restart_policy:
condition: on-failure
delay: 5s
max_attempts: 3
placement:
constraints:
- node.role == worker
nextjs:
image: presence-tracker-web:latest
ports:
- "3000:3000"
environment:
- API_URL=http://json-server:3001
networks:
- presence-network
deploy:
replicas: 2
restart_policy:
condition: on-failure
delay: 5s
max_attempts: 3
update_config:
parallelism: 1
delay: 10s
placement:
constraints:
- node.role == worker
depends_on:
- json-server
volumes:
json-data:
driver: local
networks:
presence-network:
driver: overlay

View file

@ -1,38 +0,0 @@
version: '3.8'
services:
json-server:
build:
context: .
dockerfile: Dockerfile.jsonserver
container_name: presence-tracker-api
ports:
- "3001:3001"
volumes:
- json-data:/data
networks:
- presence-network
restart: unless-stopped
nextjs:
build:
context: .
dockerfile: Dockerfile
container_name: presence-tracker-web
ports:
- "3000:3000"
environment:
- API_URL=http://json-server:3001
depends_on:
- json-server
networks:
- presence-network
restart: unless-stopped
volumes:
json-data:
driver: local
networks:
presence-network:
driver: bridge

View file

@ -6,6 +6,7 @@ const config: Config = {
"./components/**/*.{js,ts,jsx,tsx,mdx}", "./components/**/*.{js,ts,jsx,tsx,mdx}",
"./app/**/*.{js,ts,jsx,tsx,mdx}", "./app/**/*.{js,ts,jsx,tsx,mdx}",
], ],
darkMode: 'class',
theme: { theme: {
extend: {}, extend: {},
}, },