Commit 83d8f490 authored by Administrator's avatar Administrator

Update 12 files via Son of Anton

parent d6979653
'use client';
import { useEffect, useState } from 'react';
import { apiGet } from '@/lib/api';
import { PageHeader } from '@/components/shared/page-header';
import { PageLoadingSkeleton } from '@/components/shared/loading-skeleton';
import { EmptyState } from '@/components/shared/empty-state';
import { formatDateTime } from '@/lib/date';
import { Shield, Search, Download } from 'lucide-react';
export default function AuditTrailPage() {
const [entries, setEntries] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const [actionFilter, setActionFilter] = useState('');
const [entityFilter, setEntityFilter] = useState('');
const [searchUser, setSearchUser] = useState('');
useEffect(() => {
loadEntries();
}, [page, actionFilter, entityFilter]);
const loadEntries = async () => {
try {
const params: any = { page, limit: 30, sortOrder: 'desc' };
if (actionFilter) params.action = actionFilter;
if (entityFilter) params.entityType = entityFilter;
if (searchUser) params.userId = searchUser;
const res = await apiGet('/audit-trail', params);
setEntries(res.data || []);
setTotal(res.meta?.total || 0);
} catch (err) {
console.error('Failed to load audit trail:', err);
} finally {
setIsLoading(false);
}
};
if (isLoading) return <PageLoadingSkeleton />;
return (
<div className="space-y-6">
<PageHeader
title="Audit Trail"
description={`${total} total entries`}
actions={
<a
href="/api/audit-trail/export"
target="_blank"
className="flex items-center gap-2 px-3 py-2 text-sm rounded-lg border hover:bg-accent transition-colors"
>
<Download size={14} />
Export
</a>
}
/>
{/* Filters */}
<div className="flex items-center gap-3 flex-wrap">
<select
value={actionFilter}
onChange={(e) => { setActionFilter(e.target.value); setPage(1); }}
className="px-3 py-2 rounded-lg border bg-background text-sm"
>
<option value="">All Actions</option>
<option value="CREATE">Create</option>
<option value="UPDATE">Update</option>
<option value="DELETE">Delete</option>
<option value="LOGIN">Login</option>
<option value="LOGOUT">Logout</option>
<option value="MOVE">Move</option>
<option value="ASSIGN">Assign</option>
<option value="APPROVE">Approve</option>
<option value="ACKNOWLEDGE">Acknowledge</option>
</select>
<select
value={entityFilter}
onChange={(e) => { setEntityFilter(e.target.value); setPage(1); }}
className="px-3 py-2 rounded-lg border bg-background text-sm"
>
<option value="">All Entities</option>
<option value="auth">Auth</option>
<option value="users">Users</option>
<option value="boards">Boards</option>
<option value="cards">Cards</option>
<option value="deductions">Deductions</option>
<option value="reports">Reports</option>
<option value="payroll">Payroll</option>
<option value="evaluations">Evaluations</option>
<option value="notifications">Notifications</option>
<option value="settings">Settings</option>
</select>
</div>
{/* Table */}
{entries.length === 0 ? (
<EmptyState icon={Shield} title="No audit entries" description="The audit trail will populate as actions are performed." />
) : (
<div className="bg-card rounded-xl border overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="text-left px-4 py-3 font-medium text-muted-foreground">Time</th>
<th className="text-left px-4 py-3 font-medium text-muted-foreground">User</th>
<th className="text-left px-4 py-3 font-medium text-muted-foreground">Action</th>
<th className="text-left px-4 py-3 font-medium text-muted-foreground">Entity</th>
<th className="text-left px-4 py-3 font-medium text-muted-foreground">Method</th>
<th className="text-left px-4 py-3 font-medium text-muted-foreground">IP</th>
</tr>
</thead>
<tbody className="divide-y">
{entries.map((entry) => (
<tr key={entry.id} className="hover:bg-accent/50">
<td className="px-4 py-3 text-xs text-muted-foreground whitespace-nowrap">
{entry.createdAt ? formatDateTime(entry.createdAt) : '—'}
</td>
<td className="px-4 py-3 text-xs">
{entry.user ? (
<span>{entry.user.firstName} {entry.user.lastName}</span>
) : (
<span className="text-muted-foreground">System</span>
)}
</td>
<td className="px-4 py-3">
<span className="text-xs font-mono bg-muted px-1.5 py-0.5 rounded">
{entry.action}
</span>
</td>
<td className="px-4 py-3 text-xs text-muted-foreground">{entry.entityType}</td>
<td className="px-4 py-3 text-xs font-mono text-muted-foreground">{entry.method}</td>
<td className="px-4 py-3 text-xs text-muted-foreground">{entry.ipAddress}</td>
</tr>
))}
</tbody>
</table>
</div>
{total > 30 && (
<div className="flex items-center justify-between px-4 py-3 border-t">
<span className="text-xs text-muted-foreground">
Page {page} of {Math.ceil(total / 30)}
</span>
<div className="flex gap-2">
<button
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page <= 1}
className="px-3 py-1.5 text-xs rounded border hover:bg-accent disabled:opacity-50"
>
Previous
</button>
<button
onClick={() => setPage((p) => p + 1)}
disabled={page >= Math.ceil(total / 30)}
className="px-3 py-1.5 text-xs rounded border hover:bg-accent disabled:opacity-50"
>
Next
</button>
</div>
</div>
)}
</div>
)}
</div>
);
}
\ No newline at end of file
'use client';
import { useEffect, useState } from 'react';
import { apiGet, apiPut } from '@/lib/api';
import { PageHeader } from '@/components/shared/page-header';
import { PageLoadingSkeleton } from '@/components/shared/loading-skeleton';
import { Settings, Save, Loader2 } from 'lucide-react';
import { toast } from 'sonner';
export default function SettingsPage() {
const [settings, setSettings] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [editedValues, setEditedValues] = useState<Record<string, any>>({});
useEffect(() => {
apiGet('/settings')
.then((res) => {
const data = Array.isArray(res.data) ? res.data : Object.entries(res.data || {}).map(([key, val]) => ({ key, value: val }));
setSettings(data);
})
.catch(console.error)
.finally(() => setIsLoading(false));
}, []);
const handleChange = (key: string, value: any) => {
setEditedValues((prev) => ({ ...prev, [key]: value }));
};
const handleSave = async () => {
if (Object.keys(editedValues).length === 0) return;
setIsSaving(true);
try {
await apiPut('/settings', { settings: editedValues });
toast.success('Settings saved');
setEditedValues({});
} catch (err: any) {
toast.error(err.message || 'Failed to save settings');
} finally {
setIsSaving(false);
}
};
if (isLoading) return <PageLoadingSkeleton />;
const groups: Record<string, any[]> = {};
for (const s of settings) {
const group = s.key?.split(/(?=[A-Z])/)[0] || 'General';
if (!groups[group]) groups[group] = [];
groups[group].push(s);
}
return (
<div className="space-y-6">
<PageHeader
title="System Settings"
description="Configure platform behavior (Super Admin only)"
actions={
Object.keys(editedValues).length > 0 && (
<button
onClick={handleSave}
disabled={isSaving}
className="flex items-center gap-2 bg-primary text-primary-foreground rounded-lg px-4 py-2 text-sm font-medium hover:bg-primary/90 disabled:opacity-50"
>
{isSaving ? <Loader2 size={16} className="animate-spin" /> : <Save size={16} />}
Save Changes ({Object.keys(editedValues).length})
</button>
)
}
/>
{settings.length === 0 ? (
<div className="bg-card rounded-xl border p-6 text-center text-muted-foreground">
<Settings size={32} className="mx-auto mb-2 opacity-30" />
<p>No configurable settings found.</p>
</div>
) : (
<div className="space-y-6">
{Object.entries(groups).map(([group, items]) => (
<div key={group} className="bg-card rounded-xl border">
<div className="p-4 border-b">
<h3 className="font-semibold capitalize">{group}</h3>
</div>
<div className="divide-y">
{items.map((setting) => {
const currentValue = editedValues[setting.key] ?? setting.value;
const isNumber = typeof setting.value === 'number';
const isBool = typeof setting.value === 'boolean';
return (
<div key={setting.key} className="p-4 flex items-center justify-between gap-4">
<div className="flex-1 min-w-0">
<p className="text-sm font-medium">{setting.key}</p>
{setting.description && (
<p className="text-xs text-muted-foreground mt-0.5">{setting.description}</p>
)}
</div>
<div className="w-48 shrink-0">
{isBool ? (
<button
onClick={() => handleChange(setting.key, !currentValue)}
className={`px-3 py-1.5 rounded-lg text-xs font-medium ${
currentValue
? 'bg-emerald-500/10 text-emerald-500'
: 'bg-muted text-muted-foreground'
}`}
>
{currentValue ? 'Enabled' : 'Disabled'}
</button>
) : isNumber ? (
<input
type="number"
value={currentValue}
onChange={(e) => handleChange(setting.key, Number(e.target.value))}
className="w-full px-3 py-1.5 rounded-lg border bg-background text-sm text-right focus:outline-none focus:ring-2 focus:ring-ring"
/>
) : (
<input
type="text"
value={String(currentValue || '')}
onChange={(e) => handleChange(setting.key, e.target.value)}
className="w-full px-3 py-1.5 rounded-lg border bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
)}
</div>
</div>
);
})}
</div>
</div>
))}
</div>
)}
</div>
);
}
\ No newline at end of file
'use client';
import { useEffect, useState } from 'react';
import { apiGet, apiPost, apiDelete } from '@/lib/api';
import { PageHeader } from '@/components/shared/page-header';
import { PageLoadingSkeleton } from '@/components/shared/loading-skeleton';
import { EmptyState } from '@/components/shared/empty-state';
import { formatDate } from '@/lib/date';
import { Calendar, Plus, Trash2, Loader2 } from 'lucide-react';
import { toast } from 'sonner';
export default function AvailabilityPage() {
const [unavailability, setUnavailability] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [showForm, setShowForm] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [form, setForm] = useState({
startDate: '',
endDate: '',
reason: 'PERSONAL',
notes: '',
});
useEffect(() => {
loadData();
}, []);
const loadData = async () => {
try {
const res = await apiGet('/unavailability', { limit: 50, sortOrder: 'desc' });
setUnavailability(res.data || []);
} catch (err) {
console.error('Failed to load unavailability:', err);
} finally {
setIsLoading(false);
}
};
const handleSubmit = async () => {
if (!form.startDate || !form.endDate) {
toast.error('Start and end dates are required.');
return;
}
setIsSubmitting(true);
try {
await apiPost('/unavailability', form);
toast.success('Unavailability logged');
setShowForm(false);
setForm({ startDate: '', endDate: '', reason: 'PERSONAL', notes: '' });
loadData();
} catch (err: any) {
toast.error(err.message || 'Failed to log unavailability');
} finally {
setIsSubmitting(false);
}
};
const handleDelete = async (id: string) => {
try {
await apiDelete(`/unavailability/${id}`);
toast.success('Unavailability removed');
loadData();
} catch (err: any) {
toast.error(err.message || 'Failed to remove');
}
};
if (isLoading) return <PageLoadingSkeleton />;
return (
<div className="max-w-2xl mx-auto space-y-6">
<PageHeader
title="Availability"
description="Log your unavailable days"
actions={
<button
onClick={() => setShowForm(!showForm)}
className="flex items-center gap-2 bg-primary text-primary-foreground rounded-lg px-4 py-2 text-sm font-medium hover:bg-primary/90"
>
<Plus size={16} />
Log Unavailability
</button>
}
/>
{/* Form */}
{showForm && (
<div className="bg-card rounded-xl border p-4 space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1">
<label className="text-sm font-medium">Start Date</label>
<input
type="date"
value={form.startDate}
onChange={(e) => setForm({ ...form, startDate: e.target.value })}
className="w-full px-3 py-2 rounded-lg border bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
<div className="space-y-1">
<label className="text-sm font-medium">End Date</label>
<input
type="date"
value={form.endDate}
onChange={(e) => setForm({ ...form, endDate: e.target.value })}
min={form.startDate}
className="w-full px-3 py-2 rounded-lg border bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
</div>
<div className="space-y-1">
<label className="text-sm font-medium">Reason</label>
<select
value={form.reason}
onChange={(e) => setForm({ ...form, reason: e.target.value })}
className="w-full px-3 py-2 rounded-lg border bg-background text-sm"
>
<option value="PERSONAL">Personal</option>
<option value="MEDICAL">Medical</option>
<option value="RELIGIOUS">Religious</option>
<option value="EMERGENCY">Emergency</option>
<option value="OTHER">Other</option>
</select>
</div>
<div className="space-y-1">
<label className="text-sm font-medium">Notes (optional)</label>
<textarea
value={form.notes}
onChange={(e) => setForm({ ...form, notes: e.target.value })}
rows={2}
className="w-full px-3 py-2 rounded-lg border bg-background text-sm resize-none focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
<div className="flex justify-end gap-2">
<button
onClick={() => setShowForm(false)}
className="px-4 py-2 text-sm rounded-lg border hover:bg-accent"
>
Cancel
</button>
<button
onClick={handleSubmit}
disabled={isSubmitting}
className="flex items-center gap-2 px-4 py-2 text-sm rounded-lg bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
{isSubmitting ? <Loader2 size={14} className="animate-spin" /> : <Calendar size={14} />}
Save
</button>
</div>
</div>
)}
{/* List */}
{unavailability.length === 0 ? (
<EmptyState
icon={Calendar}
title="No unavailability logged"
description="Log your unavailable days to keep your team informed."
/>
) : (
<div className="bg-card rounded-xl border divide-y">
{unavailability.map((entry) => {
const isFuture = new Date(entry.startDate) > new Date();
return (
<div key={entry.id} className="p-4 flex items-center justify-between">
<div>
<p className="text-sm font-medium">
{formatDate(entry.startDate)}
{entry.endDate !== entry.startDate && ` — ${formatDate(entry.endDate)}`}
</p>
<div className="flex items-center gap-2 mt-0.5">
<span className="text-xs text-muted-foreground capitalize">
{entry.reason?.toLowerCase() || 'personal'}
</span>
{entry.notes && (
<span className="text-xs text-muted-foreground">· {entry.notes}</span>
)}
</div>
</div>
{isFuture && (
<button
onClick={() => handleDelete(entry.id)}
className="p-2 text-muted-foreground hover:text-destructive transition-colors"
>
<Trash2 size={14} />
</button>
)}
</div>
);
})}
</div>
)}
</div>
);
}
\ No newline at end of file
'use client';
import { useEffect, useState } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { apiGet } from '@/lib/api';
import { PageLoadingSkeleton } from '@/components/shared/loading-skeleton';
import { StatusBadge } from '@/components/shared/status-badge';
import { UserAvatar } from '@/components/shared/user-avatar';
import { formatMonthYear, formatDateTime } from '@/lib/date';
import { ArrowLeft } from 'lucide-react';
export default function EvaluationDetailPage() {
const { evaluationId } = useParams<{ evaluationId: string }>();
const router = useRouter();
const [evaluation, setEvaluation] = useState<any>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
apiGet(`/evaluations/${evaluationId}`)
.then((res) => setEvaluation(res.data))
.catch(console.error)
.finally(() => setIsLoading(false));
}, [evaluationId]);
if (isLoading) return <PageLoadingSkeleton />;
if (!evaluation) return <p className="text-muted-foreground p-6">Evaluation not found.</p>;
const techScores = evaluation.technicalScores as any;
const profScores = evaluation.professionalScores as any;
const autoMetrics = evaluation.autoMetrics as any;
return (
<div className="max-w-3xl mx-auto space-y-6">
<div className="flex items-center gap-3">
<button onClick={() => router.push('/evaluations')} className="p-2 rounded-md hover:bg-accent">
<ArrowLeft size={16} />
</button>
<div>
<h1 className="text-xl font-bold">
Evaluation — {formatMonthYear(evaluation.month, evaluation.year)}
</h1>
{evaluation.user && (
<p className="text-sm text-muted-foreground">
{evaluation.user.firstName} {evaluation.user.lastName}
</p>
)}
</div>
</div>
{/* Overall Score */}
<div className="bg-card rounded-xl border p-6 text-center">
<p className="text-xs text-muted-foreground uppercase tracking-wider mb-1">Overall Score</p>
<p className="text-5xl font-bold">
{evaluation.overallScore != null ? evaluation.overallScore.toFixed(1) : '—'}
</p>
<p className="text-sm mt-1">{evaluation.rating || '—'}</p>
<StatusBadge status={evaluation.status} className="mt-2" />
</div>
{/* Technical Scores */}
{techScores && (
<div className="bg-card rounded-xl border p-4">
<h3 className="font-semibold mb-3">Technical Evaluation</h3>
<p className="text-xs text-muted-foreground mb-3">
Score: {evaluation.technicalScore?.toFixed(1) || '—'}/5
</p>
<div className="space-y-3">
{Object.entries(techScores).map(([key, val]: [string, any]) => (
<div key={key} className="flex items-center justify-between">
<span className="text-sm capitalize">{key.replace(/([A-Z])/g, ' $1').trim()}</span>
<div className="flex items-center gap-2">
<div className="w-24 h-2 bg-muted rounded-full overflow-hidden">
<div
className="h-full bg-primary rounded-full"
style={{ width: `${((typeof val === 'number' ? val : val?.score || 0) / 5) * 100}%` }}
/>
</div>
<span className="text-sm font-mono w-8 text-right">
{typeof val === 'number' ? val.toFixed(1) : (val?.score || 0).toFixed(1)}
</span>
</div>
</div>
))}
</div>
{evaluation.technicalNotes && (
<p className="text-sm text-muted-foreground mt-3 border-t pt-3">{evaluation.technicalNotes}</p>
)}
</div>
)}
{/* Professional Scores */}
{profScores && (
<div className="bg-card rounded-xl border p-4">
<h3 className="font-semibold mb-3">Professional Evaluation</h3>
<p className="text-xs text-muted-foreground mb-3">
Score: {evaluation.professionalScore?.toFixed(1) || '—'}/5
</p>
<div className="space-y-3">
{Object.entries(profScores).map(([key, val]: [string, any]) => (
<div key={key} className="flex items-center justify-between">
<span className="text-sm capitalize">{key.replace(/([A-Z])/g, ' $1').trim()}</span>
<div className="flex items-center gap-2">
<div className="w-24 h-2 bg-muted rounded-full overflow-hidden">
<div
className="h-full bg-blue-500 rounded-full"
style={{ width: `${((typeof val === 'number' ? val : val?.score || 0) / 5) * 100}%` }}
/>
</div>
<span className="text-sm font-mono w-8 text-right">
{typeof val === 'number' ? val.toFixed(1) : (val?.score || 0).toFixed(1)}
</span>
</div>
</div>
))}
</div>
{evaluation.professionalNotes && (
<p className="text-sm text-muted-foreground mt-3 border-t pt-3">{evaluation.professionalNotes}</p>
)}
</div>
)}
{/* Auto Metrics */}
{autoMetrics && (
<div className="bg-card rounded-xl border p-4">
<h3 className="font-semibold mb-3">System Metrics</h3>
<div className="grid gap-3 sm:grid-cols-2">
{autoMetrics.reportsSubmitted != null && (
<MetricItem label="Reports Submitted" value={`${autoMetrics.reportsSubmitted}/${autoMetrics.reportsExpected || '?'}`} />
)}
{autoMetrics.onTimeRate != null && (
<MetricItem label="On-Time Rate" value={`${Math.round(autoMetrics.onTimeRate * 100)}%`} />
)}
{autoMetrics.cardsCompleted != null && (
<MetricItem label="Cards Completed" value={autoMetrics.cardsCompleted} />
)}
{autoMetrics.deadlineHitRate != null && (
<MetricItem label="Deadline Hit Rate" value={`${Math.round(autoMetrics.deadlineHitRate * 100)}%`} />
)}
{autoMetrics.deductionCount != null && (
<MetricItem label="Deductions" value={autoMetrics.deductionCount} />
)}
{autoMetrics.bountyCount != null && (
<MetricItem label="Bounties Earned" value={autoMetrics.bountyCount} />
)}
</div>
</div>
)}
{/* Contractor Response */}
{evaluation.contractorResponse && (
<div className="bg-card rounded-xl border p-4">
<h3 className="font-semibold mb-2">Contractor Response</h3>
<p className="text-sm text-muted-foreground whitespace-pre-wrap">{evaluation.contractorResponse}</p>
{evaluation.respondedAt && (
<p className="text-xs text-muted-foreground mt-2">Responded {formatDateTime(evaluation.respondedAt)}</p>
)}
</div>
)}
</div>
);
}
function MetricItem({ label, value }: { label: string; value: string | number }) {
return (
<div className="flex items-center justify-between p-2 bg-muted/30 rounded-lg">
<span className="text-xs text-muted-foreground">{label}</span>
<span className="text-sm font-bold">{value}</span>
</div>
);
}
\ No newline at end of file
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { apiGet } from '@/lib/api';
import { useAuthStore } from '@/stores/auth.store';
import { PageHeader } from '@/components/shared/page-header';
import { PageLoadingSkeleton } from '@/components/shared/loading-skeleton';
import { EmptyState } from '@/components/shared/empty-state';
import { StatusBadge } from '@/components/shared/status-badge';
import { UserAvatar } from '@/components/shared/user-avatar';
import { formatMonthYear } from '@/lib/date';
import { Star, TrendingUp } from 'lucide-react';
export default function EvaluationsPage() {
const router = useRouter();
const user = useAuthStore((s) => s.user);
const [evaluations, setEvaluations] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
apiGet('/evaluations', { limit: 50, sortOrder: 'desc' })
.then((res) => setEvaluations(res.data || []))
.catch(console.error)
.finally(() => setIsLoading(false));
}, []);
if (isLoading) return <PageLoadingSkeleton />;
const getRatingEmoji = (score: number | null) => {
if (!score) return '—';
if (score >= 4.5) return '⭐';
if (score >= 3.5) return '🟢';
if (score >= 2.5) return '🟡';
if (score >= 1.5) return '🟠';
return '🔴';
};
return (
<div className="space-y-6">
<PageHeader title="Evaluations" description="Monthly performance evaluations" />
{evaluations.length === 0 ? (
<EmptyState
icon={Star}
title="No evaluations yet"
description="Your evaluations will appear here after the first evaluation cycle."
/>
) : (
<div className="bg-card rounded-xl border divide-y">
{evaluations.map((ev) => (
<div
key={ev.id}
onClick={() => router.push(`/evaluations/${ev.id}`)}
className="p-4 flex items-center justify-between hover:bg-accent/50 cursor-pointer transition-colors"
>
<div className="flex items-center gap-4">
{ev.user && user?.role !== 'CONTRACTOR' && (
<UserAvatar
firstName={ev.user.firstName}
lastName={ev.user.lastName}
avatar={ev.user.avatar}
size="sm"
/>
)}
<div>
<p className="text-sm font-medium">
{user?.role === 'CONTRACTOR'
? formatMonthYear(ev.month, ev.year)
: `${ev.user?.firstName} ${ev.user?.lastName} — ${formatMonthYear(ev.month, ev.year)}`}
</p>
<div className="flex items-center gap-2 mt-0.5">
<StatusBadge status={ev.status} />
{ev.rating && <span className="text-xs text-muted-foreground">{ev.rating}</span>}
</div>
</div>
</div>
<div className="flex items-center gap-3">
{ev.overallScore != null && (
<div className="text-right">
<span className="text-lg mr-1">{getRatingEmoji(ev.overallScore)}</span>
<span className="text-lg font-bold">{ev.overallScore.toFixed(1)}</span>
<span className="text-xs text-muted-foreground">/5</span>
</div>
)}
</div>
</div>
))}
</div>
)}
</div>
);
}
\ No newline at end of file
'use client';
import { useEffect, useState } from 'react';
import { apiGet } from '@/lib/api';
import { useAuthStore } from '@/stores/auth.store';
import { PageHeader } from '@/components/shared/page-header';
import { PageLoadingSkeleton } from '@/components/shared/loading-skeleton';
import { EmptyState } from '@/components/shared/empty-state';
import { StatusBadge } from '@/components/shared/status-badge';
import { formatDate, daysUntil, isOverdue } from '@/lib/date';
import { cn } from '@/lib/utils';
import { GraduationCap, Clock, Target, AlertTriangle } from 'lucide-react';
export default function LearningPage() {
const user = useAuthStore((s) => s.user);
const [goals, setGoals] = useState<any[]>([]);
const [competencyAreas, setCompetencyAreas] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
Promise.all([
apiGet('/learning-goals', { limit: 50 }).then((res) => setGoals(res.data || [])),
apiGet('/learning/competency-areas').then((res) => setCompetencyAreas(res.data || [])).catch(() => {}),
]).finally(() => setIsLoading(false));
}, []);
if (isLoading) return <PageLoadingSkeleton />;
const activeGoals = goals.filter((g) => ['ACTIVE', 'OVERDUE', 'EXTENDED'].includes(g.status));
const completedGoals = goals.filter((g) => ['PASSED', 'FAILED'].includes(g.status));
return (
<div className="space-y-6">
<PageHeader title="Learning & Competency" description="Your growth goals and skill development" />
{/* Stats */}
<div className="grid gap-4 md:grid-cols-3">
<div className="bg-card rounded-xl border p-4">
<div className="flex items-center gap-2 text-muted-foreground mb-1">
<Target size={14} />
<span className="text-xs font-medium uppercase">Active Goals</span>
</div>
<p className="text-2xl font-bold">{activeGoals.length}</p>
</div>
<div className="bg-card rounded-xl border p-4">
<div className="flex items-center gap-2 text-emerald-500 mb-1">
<GraduationCap size={14} />
<span className="text-xs font-medium uppercase">Completed</span>
</div>
<p className="text-2xl font-bold text-emerald-500">{completedGoals.filter((g) => g.status === 'PASSED').length}</p>
</div>
<div className="bg-card rounded-xl border p-4">
<div className="flex items-center gap-2 text-red-500 mb-1">
<AlertTriangle size={14} />
<span className="text-xs font-medium uppercase">Overdue</span>
</div>
<p className="text-2xl font-bold text-red-500">{activeGoals.filter((g) => g.status === 'OVERDUE' || (g.deadline && isOverdue(g.deadline))).length}</p>
</div>
</div>
{/* Active Goals */}
{activeGoals.length > 0 ? (
<div className="space-y-3">
<h3 className="font-semibold">Active Goals</h3>
{activeGoals.map((goal) => {
const days = goal.deadline ? daysUntil(goal.deadline) : null;
const overdue = goal.deadline && isOverdue(goal.deadline);
return (
<div key={goal.id} className="bg-card rounded-xl border p-4">
<div className="flex items-start justify-between">
<div>
<h4 className="text-sm font-semibold">{goal.title}</h4>
{goal.competencyArea && (
<p className="text-xs text-muted-foreground mt-0.5">{goal.competencyArea.name}</p>
)}
</div>
<StatusBadge status={goal.status} />
</div>
{goal.description && (
<p className="text-sm text-muted-foreground mt-2">{goal.description}</p>
)}
<div className="flex items-center gap-4 mt-3 text-xs text-muted-foreground">
{goal.deadline && (
<span className={cn('flex items-center gap-1', overdue && 'text-red-500 font-medium')}>
<Clock size={12} />
{overdue
? `${Math.abs(days!)} days overdue`
: `${days} days remaining`}
</span>
)}
{goal.assessmentMethod && (
<span>Assessment: {goal.assessmentMethod.replace(/_/g, ' ')}</span>
)}
</div>
</div>
);
})}
</div>
) : (
<EmptyState
icon={GraduationCap}
title="No active learning goals"
description="You don't have any active learning goals at the moment."
/>
)}
{/* Completed Goals */}
{completedGoals.length > 0 && (
<div className="space-y-3">
<h3 className="font-semibold">Completed</h3>
{completedGoals.map((goal) => (
<div key={goal.id} className="bg-card rounded-xl border p-4 opacity-75">
<div className="flex items-center justify-between">
<div>
<h4 className="text-sm font-medium">{goal.title}</h4>
{goal.competencyArea && (
<p className="text-xs text-muted-foreground">{goal.competencyArea.name}</p>
)}
</div>
<StatusBadge status={goal.status} />
</div>
{goal.assessedAt && (
<p className="text-xs text-muted-foreground mt-2">Assessed {formatDate(goal.assessedAt)}</p>
)}
</div>
))}
</div>
)}
</div>
);
}
\ No newline at end of file
'use client';
import { useEffect, useState } from 'react';
import { apiGet } from '@/lib/api';
import { useAuthStore } from '@/stores/auth.store';
import { PageHeader } from '@/components/shared/page-header';
import { PageLoadingSkeleton } from '@/components/shared/loading-skeleton';
import { EmptyState } from '@/components/shared/empty-state';
import { StatusBadge } from '@/components/shared/status-badge';
import { formatDate, formatTime, relativeTime, isOverdue } from '@/lib/date';
import { cn } from '@/lib/utils';
import { Calendar, Clock, MapPin, Users } from 'lucide-react';
export default function MeetingsPage() {
const user = useAuthStore((s) => s.user);
const [meetings, setMeetings] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [tab, setTab] = useState<'upcoming' | 'past'>('upcoming');
useEffect(() => {
apiGet('/meetings', { limit: 50, sortOrder: tab === 'upcoming' ? 'asc' : 'desc' })
.then((res) => setMeetings(res.data || []))
.catch(console.error)
.finally(() => setIsLoading(false));
}, [tab]);
if (isLoading) return <PageLoadingSkeleton />;
const now = new Date();
const upcoming = meetings.filter((m) => new Date(m.startTime) >= now && m.status === 'SCHEDULED');
const past = meetings.filter((m) => new Date(m.startTime) < now || m.status !== 'SCHEDULED');
const displayed = tab === 'upcoming' ? upcoming : past;
return (
<div className="space-y-6">
<PageHeader title="Meetings" description="Your scheduled meetings" />
{/* Tabs */}
<div className="flex gap-2">
<button
onClick={() => setTab('upcoming')}
className={cn('px-3 py-1.5 text-sm rounded-lg transition-colors', tab === 'upcoming' ? 'bg-accent font-medium' : 'hover:bg-accent/50')}
>
Upcoming ({upcoming.length})
</button>
<button
onClick={() => setTab('past')}
className={cn('px-3 py-1.5 text-sm rounded-lg transition-colors', tab === 'past' ? 'bg-accent font-medium' : 'hover:bg-accent/50')}
>
Past ({past.length})
</button>
</div>
{displayed.length === 0 ? (
<EmptyState
icon={Calendar}
title={tab === 'upcoming' ? 'No upcoming meetings' : 'No past meetings'}
description={tab === 'upcoming' ? "You don't have any scheduled meetings." : 'No past meetings found.'}
/>
) : (
<div className="space-y-3">
{displayed.map((meeting) => (
<div key={meeting.id} className="bg-card rounded-xl border p-4">
<div className="flex items-start justify-between">
<div>
<h3 className="text-sm font-semibold">{meeting.title}</h3>
{meeting.description && (
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">{meeting.description}</p>
)}
</div>
<StatusBadge status={meeting.status} />
</div>
<div className="flex items-center gap-4 mt-3 text-xs text-muted-foreground flex-wrap">
<span className="flex items-center gap-1">
<Calendar size={12} />
{formatDate(meeting.startTime)}
</span>
<span className="flex items-center gap-1">
<Clock size={12} />
{formatTime(meeting.startTime)}{formatTime(meeting.endTime)}
</span>
{meeting.location && (
<span className="flex items-center gap-1">
<MapPin size={12} />
{meeting.location}
</span>
)}
{meeting.invitees && (
<span className="flex items-center gap-1">
<Users size={12} />
{meeting.invitees.length} invitee{meeting.invitees.length !== 1 ? 's' : ''}
</span>
)}
</div>
</div>
))}
</div>
)}
</div>
);
}
\ No newline at end of file
'use client';
import { useEffect, useState } from 'react';
import { apiGet, apiPut } from '@/lib/api';
import { useAuthStore } from '@/stores/auth.store';
import { PageHeader } from '@/components/shared/page-header';
import { PageLoadingSkeleton } from '@/components/shared/loading-skeleton';
import { StatusBadge } from '@/components/shared/status-badge';
import { UserAvatar } from '@/components/shared/user-avatar';
import { formatDate } from '@/lib/date';
import { formatEgp } from '@/lib/utils';
import { User, Phone, MapPin, Building, Shield, Calendar, Edit2, Save, X, Loader2 } from 'lucide-react';
import { toast } from 'sonner';
export default function ProfilePage() {
const authUser = useAuthStore((s) => s.user);
const [profile, setProfile] = useState<any>(null);
const [isLoading, setIsLoading] = useState(true);
const [isEditing, setIsEditing] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [editData, setEditData] = useState<any>({});
useEffect(() => {
loadProfile();
}, []);
const loadProfile = async () => {
try {
const res = await apiGet(`/users/${authUser?.id}`);
setProfile(res.data);
} catch (err) {
console.error('Failed to load profile:', err);
} finally {
setIsLoading(false);
}
};
const startEditing = () => {
setEditData({
phone: profile?.phone || '',
phoneSecondary: profile?.phoneSecondary || '',
address: profile?.address || '',
emergencyContactName: profile?.emergencyContactName || '',
emergencyContactPhone: profile?.emergencyContactPhone || '',
emergencyContactRelationship: profile?.emergencyContactRelationship || '',
bankName: profile?.bankName || '',
bankAccountNumber: profile?.bankAccountNumber || '',
bankAccountHolderName: profile?.bankAccountHolderName || '',
});
setIsEditing(true);
};
const handleSave = async () => {
setIsSaving(true);
try {
await apiPut(`/users/${authUser?.id}`, editData);
toast.success('Profile updated');
setIsEditing(false);
loadProfile();
} catch (err: any) {
toast.error(err.message || 'Failed to update profile');
} finally {
setIsSaving(false);
}
};
if (isLoading) return <PageLoadingSkeleton />;
if (!profile) return <p className="text-muted-foreground p-6">Failed to load profile.</p>;
return (
<div className="max-w-3xl mx-auto space-y-6">
<PageHeader
title="My Profile"
actions={
!isEditing ? (
<button
onClick={startEditing}
className="flex items-center gap-2 px-4 py-2 text-sm rounded-lg border hover:bg-accent transition-colors"
>
<Edit2 size={14} />
Edit
</button>
) : (
<div className="flex gap-2">
<button
onClick={() => setIsEditing(false)}
className="flex items-center gap-2 px-4 py-2 text-sm rounded-lg border hover:bg-accent"
>
<X size={14} />
Cancel
</button>
<button
onClick={handleSave}
disabled={isSaving}
className="flex items-center gap-2 px-4 py-2 text-sm rounded-lg bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
{isSaving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
Save
</button>
</div>
)
}
/>
{/* Header Card */}
<div className="bg-card rounded-xl border p-6 flex items-center gap-6">
<UserAvatar
firstName={profile.firstName}
lastName={profile.lastName}
avatar={profile.avatar}
size="lg"
/>
<div>
<h2 className="text-xl font-bold">{profile.firstName} {profile.lastName}</h2>
<p className="text-sm text-muted-foreground">@{profile.username}</p>
<div className="flex items-center gap-2 mt-2">
<StatusBadge status={profile.status} />
<StatusBadge status={profile.contractorType || profile.role} />
</div>
</div>
</div>
{/* Personal Info */}
<div className="bg-card rounded-xl border p-4 space-y-4">
<h3 className="font-semibold flex items-center gap-2"><User size={16} /> Personal Information</h3>
<div className="grid gap-4 sm:grid-cols-2">
<InfoField label="Full Name (English)" value={`${profile.firstName} ${profile.lastName}`} />
<InfoField label="Full Name (Arabic)" value={profile.nameArabic || '—'} />
<InfoField label="Date of Birth" value={profile.dateOfBirth ? formatDate(profile.dateOfBirth) : '—'} />
<InfoField label="National ID" value={profile.nationalId || '••••••••••••••'} />
</div>
</div>
{/* Contact Info */}
<div className="bg-card rounded-xl border p-4 space-y-4">
<h3 className="font-semibold flex items-center gap-2"><Phone size={16} /> Contact Information</h3>
<div className="grid gap-4 sm:grid-cols-2">
{isEditing ? (
<>
<EditField label="Phone" value={editData.phone} onChange={(v) => setEditData({ ...editData, phone: v })} />
<EditField label="Secondary Phone" value={editData.phoneSecondary} onChange={(v) => setEditData({ ...editData, phoneSecondary: v })} />
<EditField label="Address" value={editData.address} onChange={(v) => setEditData({ ...editData, address: v })} />
</>
) : (
<>
<InfoField label="Phone" value={profile.phone || '—'} />
<InfoField label="Secondary Phone" value={profile.phoneSecondary || '—'} />
<InfoField label="Address" value={profile.address || '—'} className="sm:col-span-2" />
</>
)}
</div>
</div>
{/* Emergency Contact */}
<div className="bg-card rounded-xl border p-4 space-y-4">
<h3 className="font-semibold flex items-center gap-2"><Shield size={16} /> Emergency Contact</h3>
<div className="grid gap-4 sm:grid-cols-2">
{isEditing ? (
<>
<EditField label="Name" value={editData.emergencyContactName} onChange={(v) => setEditData({ ...editData, emergencyContactName: v })} />
<EditField label="Phone" value={editData.emergencyContactPhone} onChange={(v) => setEditData({ ...editData, emergencyContactPhone: v })} />
<EditField label="Relationship" value={editData.emergencyContactRelationship} onChange={(v) => setEditData({ ...editData, emergencyContactRelationship: v })} />
</>
) : (
<>
<InfoField label="Name" value={profile.emergencyContactName || '—'} />
<InfoField label="Phone" value={profile.emergencyContactPhone || '—'} />
<InfoField label="Relationship" value={profile.emergencyContactRelationship || '—'} />
</>
)}
</div>
</div>
{/* Bank Details */}
<div className="bg-card rounded-xl border p-4 space-y-4">
<h3 className="font-semibold flex items-center gap-2"><Building size={16} /> Bank Details</h3>
<div className="grid gap-4 sm:grid-cols-2">
{isEditing ? (
<>
<EditField label="Bank Name" value={editData.bankName} onChange={(v) => setEditData({ ...editData, bankName: v })} />
<EditField label="Account Number" value={editData.bankAccountNumber} onChange={(v) => setEditData({ ...editData, bankAccountNumber: v })} />
<EditField label="Account Holder" value={editData.bankAccountHolderName} onChange={(v) => setEditData({ ...editData, bankAccountHolderName: v })} />
</>
) : (
<>
<InfoField label="Bank Name" value={profile.bankName || '—'} />
<InfoField label="Account Number" value={profile.bankAccountNumber ? '••••' + profile.bankAccountNumber.slice(-4) : '—'} />
<InfoField label="Account Holder" value={profile.bankAccountHolderName || '—'} />
</>
)}
</div>
</div>
{/* Employment Info */}
<div className="bg-card rounded-xl border p-4 space-y-4">
<h3 className="font-semibold flex items-center gap-2"><Calendar size={16} /> Employment</h3>
<div className="grid gap-4 sm:grid-cols-2">
<InfoField label="Role" value={profile.role?.replace('_', ' ')} />
<InfoField label="Contractor Type" value={profile.contractorType?.replace('_', ' ') || '—'} />
<InfoField label="Joined" value={profile.createdAt ? formatDate(profile.createdAt) : '—'} />
<InfoField label="Activated" value={profile.activatedAt ? formatDate(profile.activatedAt) : '—'} />
{profile.actualSalaryPiasters && (
<InfoField label="Current Salary" value={formatEgp(profile.actualSalaryPiasters)} />
)}
<InfoField label="Current Streak" value={`${profile.currentStreak || 0} days (Best: ${profile.bestStreak || 0})`} />
</div>
</div>
</div>
);
}
function InfoField({ label, value, className }: { label: string; value: string; className?: string }) {
return (
<div className={className}>
<p className="text-xs text-muted-foreground">{label}</p>
<p className="text-sm font-medium mt-0.5">{value}</p>
</div>
);
}
function EditField({ label, value, onChange }: { label: string; value: string; onChange: (v: string) => void }) {
return (
<div className="space-y-1">
<label className="text-xs text-muted-foreground">{label}</label>
<input
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
className="w-full px-3 py-2 rounded-lg border bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
);
}
\ No newline at end of file
'use client';
import { useEffect, useState } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { apiGet, apiPut } from '@/lib/api';
import { useAuthStore } from '@/stores/auth.store';
import { PageHeader } from '@/components/shared/page-header';
import { PageLoadingSkeleton } from '@/components/shared/loading-skeleton';
import { StatusBadge } from '@/components/shared/status-badge';
import { UserAvatar } from '@/components/shared/user-avatar';
import { formatDate, formatDateTime } from '@/lib/date';
import { ArrowLeft, CheckCircle2, Flag, RotateCcw } from 'lucide-react';
import { toast } from 'sonner';
export default function ReportDetailPage() {
const { reportId } = useParams<{ reportId: string }>();
const router = useRouter();
const user = useAuthStore((s) => s.user);
const [report, setReport] = useState<any>(null);
const [isLoading, setIsLoading] = useState(true);
const [reviewAction, setReviewAction] = useState('');
const [reviewNotes, setReviewNotes] = useState('');
const [isReviewing, setIsReviewing] = useState(false);
const isReviewer = user?.role === 'SUPER_ADMIN' || user?.role === 'ADMIN' || user?.role === 'TEAM_LEAD';
const canReview = isReviewer && report && ['SUBMITTED', 'LATE'].includes(report.status);
useEffect(() => {
loadReport();
}, [reportId]);
const loadReport = async () => {
try {
const res = await apiGet(`/reports/${reportId}`);
setReport(res.data);
} catch (err) {
console.error('Failed to load report:', err);
} finally {
setIsLoading(false);
}
};
const handleReview = async (action: string) => {
setIsReviewing(true);
try {
await apiPut(`/reports/${reportId}/review`, {
decision: action,
reviewNotes: reviewNotes || `${action} by ${user?.firstName}`,
});
toast.success(`Report ${action.toLowerCase()}`);
loadReport();
setReviewAction('');
setReviewNotes('');
} catch (err: any) {
toast.error(err.message || 'Failed to review report');
} finally {
setIsReviewing(false);
}
};
if (isLoading) return <PageLoadingSkeleton />;
if (!report) return <p className="text-muted-foreground p-6">Report not found.</p>;
return (
<div className="max-w-3xl mx-auto space-y-6">
<div className="flex items-center gap-3">
<button onClick={() => router.push('/reports')} className="p-2 rounded-md hover:bg-accent">
<ArrowLeft size={16} />
</button>
<PageHeader
title={`Daily Report ${report.reportDate ? formatDate(report.reportDate) : 'Unknown'}`}
description={report.user ? `${report.user.firstName} ${report.user.lastName}` : undefined}
/>
</div>
{/* Status & Meta */}
<div className="bg-card rounded-xl border p-4 flex items-center justify-between">
<div className="flex items-center gap-3">
{report.user && (
<UserAvatar
firstName={report.user.firstName}
lastName={report.user.lastName}
avatar={report.user.avatar}
size="md"
/>
)}
<div>
<p className="text-sm font-semibold">{report.user?.firstName} {report.user?.lastName}</p>
<p className="text-xs text-muted-foreground">
Submitted {report.submittedAt ? formatDateTime(report.submittedAt) : 'N/A'}
</p>
</div>
</div>
<StatusBadge status={report.status} />
</div>
{/* Task Entries */}
<div className="bg-card rounded-xl border">
<div className="p-4 border-b">
<h3 className="font-semibold">Tasks ({report.taskEntries?.length || 0})</h3>
<p className="text-xs text-muted-foreground">Total: {report.totalHours || 0}h logged</p>
</div>
<div className="divide-y">
{(report.taskEntries || []).map((entry: any, i: number) => (
<div key={i} className="p-4">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
{entry.cardNumber && (
<span className="text-xs font-mono bg-muted px-1.5 py-0.5 rounded">
{entry.cardNumber}
</span>
)}
<StatusBadge status={entry.status} />
</div>
<span className="text-xs text-muted-foreground">
{Math.floor((entry.timeSpentMinutes || 0) / 60)}h{' '}
{(entry.timeSpentMinutes || 0) % 60 > 0 ? `${(entry.timeSpentMinutes || 0) % 60}m` : ''}
</span>
</div>
<p className="text-sm whitespace-pre-wrap">{entry.description}</p>
</div>
))}
</div>
</div>
{/* Blockers */}
{report.blockers && (
<div className="bg-card rounded-xl border p-4">
<h3 className="font-semibold mb-2">Blockers</h3>
<p className="text-sm text-muted-foreground whitespace-pre-wrap">{report.blockers}</p>
</div>
)}
{/* Additional Notes */}
{report.additionalNotes && (
<div className="bg-card rounded-xl border p-4">
<h3 className="font-semibold mb-2">Additional Notes</h3>
<p className="text-sm text-muted-foreground whitespace-pre-wrap">{report.additionalNotes}</p>
</div>
)}
{/* Review Section */}
{canReview && (
<div className="bg-card rounded-xl border p-4 space-y-4">
<h3 className="font-semibold">Review Actions</h3>
<textarea
value={reviewNotes}
onChange={(e) => setReviewNotes(e.target.value)}
placeholder="Review notes (optional)"
rows={2}
className="w-full px-3 py-2 rounded-lg border bg-background text-sm resize-none focus:outline-none focus:ring-2 focus:ring-ring"
/>
<div className="flex gap-2">
<button
onClick={() => handleReview('APPROVED')}
disabled={isReviewing}
className="flex items-center gap-2 px-4 py-2 text-sm bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 disabled:opacity-50"
>
<CheckCircle2 size={14} />
Approve
</button>
<button
onClick={() => handleReview('FLAGGED_VAGUE')}
disabled={isReviewing}
className="flex items-center gap-2 px-4 py-2 text-sm bg-yellow-600 text-white rounded-lg hover:bg-yellow-700 disabled:opacity-50"
>
<Flag size={14} />
Flag: Vague
</button>
<button
onClick={() => handleReview('REVISION_REQUESTED')}
disabled={isReviewing}
className="flex items-center gap-2 px-4 py-2 text-sm bg-orange-600 text-white rounded-lg hover:bg-orange-700 disabled:opacity-50"
>
<RotateCcw size={14} />
Request Revision
</button>
</div>
</div>
)}
{/* Review History */}
{report.reviewedBy && (
<div className="bg-card rounded-xl border p-4">
<h3 className="font-semibold mb-2">Review</h3>
<p className="text-sm">
Reviewed by <span className="font-medium">{report.reviewedBy.firstName} {report.reviewedBy.lastName}</span>
{report.reviewedAt && <span className="text-muted-foreground"> — {formatDateTime(report.reviewedAt)}</span>}
</p>
{report.reviewNotes && (
<p className="text-sm text-muted-foreground mt-1">{report.reviewNotes}</p>
)}
</div>
)}
</div>
);
}
\ No newline at end of file
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { apiGet } from '@/lib/api';
import { useAuthStore } from '@/stores/auth.store';
import { PageHeader } from '@/components/shared/page-header';
import { PageLoadingSkeleton } from '@/components/shared/loading-skeleton';
import { EmptyState } from '@/components/shared/empty-state';
import { StatusBadge } from '@/components/shared/status-badge';
import { UserAvatar } from '@/components/shared/user-avatar';
import { formatDate, formatShortDate } from '@/lib/date';
import { cn } from '@/lib/utils';
import { FileText, Plus, Search, Filter, CheckCircle2, Clock, AlertTriangle } from 'lucide-react';
export default function ReportsPage() {
const router = useRouter();
const user = useAuthStore((s) => s.user);
const [reports, setReports] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [statusFilter, setStatusFilter] = useState('');
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const isReviewer = user?.role === 'SUPER_ADMIN' || user?.role === 'ADMIN' || user?.role === 'TEAM_LEAD';
useEffect(() => {
loadReports();
}, [page, statusFilter]);
const loadReports = async () => {
try {
const params: any = { page, limit: 20, sortOrder: 'desc' };
if (statusFilter) params.status = statusFilter;
const res = await apiGet('/reports', params);
setReports(res.data || []);
setTotal(res.meta?.total || 0);
} catch (err) {
console.error('Failed to load reports:', err);
} finally {
setIsLoading(false);
}
};
if (isLoading) return <PageLoadingSkeleton />;
return (
<div className="space-y-6">
<PageHeader
title="Daily Reports"
description={isReviewer ? 'Review and manage daily reports' : 'Your daily check-in reports'}
actions={
<button
onClick={() => router.push('/reports/submit')}
className="flex items-center gap-2 bg-primary text-primary-foreground rounded-lg px-4 py-2 text-sm font-medium hover:bg-primary/90"
>
<Plus size={16} />
Submit Report
</button>
}
/>
{/* Filters */}
<div className="flex items-center gap-3 flex-wrap">
<select
value={statusFilter}
onChange={(e) => { setStatusFilter(e.target.value); setPage(1); }}
className="px-3 py-2 rounded-lg border bg-background text-sm"
>
<option value="">All Statuses</option>
<option value="DRAFT">Draft</option>
<option value="SUBMITTED">Submitted</option>
<option value="LATE">Late</option>
<option value="APPROVED">Approved</option>
<option value="AUTO_APPROVED">Auto-Approved</option>
<option value="FLAGGED_VAGUE">Flagged: Vague</option>
<option value="FLAGGED_INCONSISTENT">Flagged: Inconsistent</option>
<option value="REVISION_REQUESTED">Revision Requested</option>
<option value="AMENDED">Amended</option>
</select>
</div>
{/* Reports List */}
{reports.length === 0 ? (
<EmptyState
icon={FileText}
title="No reports found"
description={statusFilter ? 'Try changing the filter.' : 'Submit your first daily report.'}
action={
<button
onClick={() => router.push('/reports/submit')}
className="bg-primary text-primary-foreground rounded-lg px-4 py-2 text-sm font-medium hover:bg-primary/90"
>
Submit Report
</button>
}
/>
) : (
<div className="bg-card rounded-xl border divide-y">
{reports.map((report) => (
<div
key={report.id}
onClick={() => router.push(`/reports/${report.id}`)}
className="p-4 flex items-center justify-between hover:bg-accent/50 cursor-pointer transition-colors"
>
<div className="flex items-center gap-4">
{isReviewer && report.user && (
<UserAvatar
firstName={report.user.firstName || '?'}
lastName={report.user.lastName || '?'}
avatar={report.user.avatar}
size="sm"
/>
)}
<div>
<div className="flex items-center gap-2">
<p className="text-sm font-medium">
{isReviewer && report.user
? `${report.user.firstName} ${report.user.lastName}`
: 'Daily Report'}
</p>
<StatusBadge status={report.status} />
</div>
<p className="text-xs text-muted-foreground mt-0.5">
{report.reportDate ? formatDate(report.reportDate) : 'No date'}
{report.totalHours != null && ` · ${report.totalHours}h logged`}
{report.taskEntries?.length > 0 && ` · ${report.taskEntries.length} tasks`}
</p>
</div>
</div>
<div className="flex items-center gap-2">
{report.status === 'LATE' && <Clock size={14} className="text-yellow-500" />}
{report.status === 'FLAGGED_VAGUE' && <AlertTriangle size={14} className="text-red-500" />}
{(report.status === 'APPROVED' || report.status === 'AUTO_APPROVED') && (
<CheckCircle2 size={14} className="text-emerald-500" />
)}
</div>
</div>
))}
</div>
)}
{/* Pagination */}
{total > 20 && (
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">
Page {page} of {Math.ceil(total / 20)}
</span>
<div className="flex gap-2">
<button
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page <= 1}
className="px-3 py-1.5 text-xs rounded border hover:bg-accent disabled:opacity-50"
>
Previous
</button>
<button
onClick={() => setPage((p) => p + 1)}
disabled={page >= Math.ceil(total / 20)}
className="px-3 py-1.5 text-xs rounded border hover:bg-accent disabled:opacity-50"
>
Next
</button>
</div>
</div>
)}
</div>
);
}
\ No newline at end of file
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { apiGet, apiPost } from '@/lib/api';
import { PageHeader } from '@/components/shared/page-header';
import { PageLoadingSkeleton } from '@/components/shared/loading-skeleton';
import { toast } from 'sonner';
import { Send, Plus, Trash2, Loader2, Save } from 'lucide-react';
interface TaskEntry {
cardId: string;
cardTitle: string;
description: string;
timeSpentMinutes: number;
status: string;
}
export default function SubmitReportPage() {
const router = useRouter();
const [isLoading, setIsLoading] = useState(true);
const [isSubmitting, setIsSubmitting] = useState(false);
const [myCards, setMyCards] = useState<any[]>([]);
const [reportDate, setReportDate] = useState(() => {
const today = new Date();
return today.toISOString().split('T')[0];
});
const [taskEntries, setTaskEntries] = useState<TaskEntry[]>([
{ cardId: '', cardTitle: '', description: '', timeSpentMinutes: 60, status: 'IN_PROGRESS' },
]);
const [blockers, setBlockers] = useState('');
const [additionalNotes, setAdditionalNotes] = useState('');
const [mood, setMood] = useState('');
useEffect(() => {
apiGet('/cards/my-tasks')
.then((res) => {
const cards: any[] = [];
if (res.data?.boards) {
for (const board of res.data.boards) {
for (const card of board.cards || []) {
cards.push({
id: card.id,
title: card.title,
cardNumber: card.cardNumber,
boardName: board.board.name,
});
}
}
}
setMyCards(cards);
})
.catch(console.error)
.finally(() => setIsLoading(false));
}, []);
const totalHours = taskEntries.reduce((sum, t) => sum + t.timeSpentMinutes, 0) / 60;
const hasBlockedTask = taskEntries.some((t) => t.status === 'BLOCKED');
const addTaskEntry = () => {
setTaskEntries((prev) => [
...prev,
{ cardId: '', cardTitle: '', description: '', timeSpentMinutes: 60, status: 'IN_PROGRESS' },
]);
};
const removeTaskEntry = (index: number) => {
if (taskEntries.length <= 1) return;
setTaskEntries((prev) => prev.filter((_, i) => i !== index));
};
const updateTaskEntry = (index: number, field: keyof TaskEntry, value: any) => {
setTaskEntries((prev) =>
prev.map((entry, i) => {
if (i !== index) return entry;
const updated = { ...entry, [field]: value };
if (field === 'cardId') {
const card = myCards.find((c) => c.id === value);
updated.cardTitle = card ? `${card.cardNumber}: ${card.title}` : '';
}
return updated;
}),
);
};
const handleSubmit = async (isDraft: boolean) => {
if (!isDraft) {
if (taskEntries.some((t) => !t.description || t.description.length < 50)) {
toast.error('Each task description must be at least 50 characters.');
return;
}
if (hasBlockedTask && blockers.length < 30) {
toast.error('Blockers description must be at least 30 characters when a task is blocked.');
return;
}
}
setIsSubmitting(true);
try {
await apiPost('/reports', {
reportDate,
status: isDraft ? 'DRAFT' : 'SUBMITTED',
taskEntries: taskEntries.map((t) => ({
cardId: t.cardId || undefined,
description: t.description,
timeSpentMinutes: t.timeSpentMinutes,
status: t.status,
})),
blockers: blockers || undefined,
additionalNotes: additionalNotes || undefined,
mood: mood || undefined,
totalHours,
});
toast.success(isDraft ? 'Report saved as draft' : 'Report submitted successfully');
router.push('/reports');
} catch (err: any) {
toast.error(err.message || 'Failed to submit report');
} finally {
setIsSubmitting(false);
}
};
if (isLoading) return <PageLoadingSkeleton />;
return (
<div className="max-w-3xl mx-auto space-y-6">
<PageHeader title="Submit Daily Report" description="Log your work for the day" />
<div className="bg-card rounded-xl border p-6 space-y-6">
{/* Report Date */}
<div className="space-y-2">
<label className="text-sm font-medium">Report Date</label>
<input
type="date"
value={reportDate}
onChange={(e) => setReportDate(e.target.value)}
max={new Date().toISOString().split('T')[0]}
className="w-full px-3 py-2 rounded-lg border bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
{/* Task Entries */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<label className="text-sm font-medium">Tasks Worked On</label>
<span className="text-xs text-muted-foreground">
Total: {totalHours.toFixed(1)}h
{totalHours > 12 && <span className="text-yellow-500 ml-1">(⚠️ Over 12h)</span>}
</span>
</div>
{taskEntries.map((entry, index) => (
<div key={index} className="bg-muted/30 rounded-lg p-4 space-y-3">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-muted-foreground">Task #{index + 1}</span>
{taskEntries.length > 1 && (
<button
onClick={() => removeTaskEntry(index)}
className="p-1 text-muted-foreground hover:text-destructive"
>
<Trash2 size={14} />
</button>
)}
</div>
{/* Card selector */}
<select
value={entry.cardId}
onChange={(e) => updateTaskEntry(index, 'cardId', e.target.value)}
className="w-full px-3 py-2 rounded-lg border bg-background text-sm"
>
<option value="">Select a card (optional)</option>
{myCards.map((card) => (
<option key={card.id} value={card.id}>
{card.cardNumber}: {card.title} ({card.boardName})
</option>
))}
</select>
{/* Description */}
<textarea
value={entry.description}
onChange={(e) => updateTaskEntry(index, 'description', e.target.value)}
placeholder="What did you do on this task? (min 50 characters)"
rows={3}
className="w-full px-3 py-2 rounded-lg border bg-background text-sm resize-none focus:outline-none focus:ring-2 focus:ring-ring"
/>
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>{entry.description.length}/50 min characters</span>
</div>
<div className="grid grid-cols-2 gap-3">
{/* Time Spent */}
<div className="space-y-1">
<label className="text-xs text-muted-foreground">Time Spent</label>
<select
value={entry.timeSpentMinutes}
onChange={(e) => updateTaskEntry(index, 'timeSpentMinutes', Number(e.target.value))}
className="w-full px-3 py-2 rounded-lg border bg-background text-sm"
>
{Array.from({ length: 48 }, (_, i) => (i + 1) * 15).map((mins) => (
<option key={mins} value={mins}>
{Math.floor(mins / 60)}h {mins % 60 > 0 ? `${mins % 60}m` : ''}
</option>
))}
</select>
</div>
{/* Task Status */}
<div className="space-y-1">
<label className="text-xs text-muted-foreground">Status</label>
<select
value={entry.status}
onChange={(e) => updateTaskEntry(index, 'status', e.target.value)}
className="w-full px-3 py-2 rounded-lg border bg-background text-sm"
>
<option value="IN_PROGRESS">In Progress</option>
<option value="COMPLETED">Completed</option>
<option value="BLOCKED">Blocked</option>
</select>
</div>
</div>
</div>
))}
<button
onClick={addTaskEntry}
className="w-full flex items-center justify-center gap-2 px-3 py-2 text-sm text-muted-foreground hover:text-foreground border border-dashed rounded-lg hover:bg-accent/50 transition-colors"
>
<Plus size={14} />
Add Another Task
</button>
</div>
{/* Blockers */}
<div className="space-y-2">
<label className="text-sm font-medium">
Blockers {hasBlockedTask && <span className="text-red-500">*</span>}
</label>
<textarea
value={blockers}
onChange={(e) => setBlockers(e.target.value)}
placeholder={hasBlockedTask ? 'Describe what is blocking you (min 30 characters)' : 'Any blockers? (optional)'}
rows={2}
className="w-full px-3 py-2 rounded-lg border bg-background text-sm resize-none focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
{/* Additional Notes */}
<div className="space-y-2">
<label className="text-sm font-medium">Additional Notes</label>
<textarea
value={additionalNotes}
onChange={(e) => setAdditionalNotes(e.target.value)}
placeholder="Anything else to mention? (optional)"
rows={2}
className="w-full px-3 py-2 rounded-lg border bg-background text-sm resize-none focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
{/* Mood */}
<div className="space-y-2">
<label className="text-sm font-medium">How are you feeling? (optional)</label>
<div className="flex gap-2">
{[
{ value: 'FRUSTRATED', emoji: '😤', label: 'Frustrated' },
{ value: 'NEUTRAL', emoji: '😐', label: 'Neutral' },
{ value: 'GOOD', emoji: '😊', label: 'Good' },
{ value: 'ON_FIRE', emoji: '🔥', label: 'On Fire' },
].map((m) => (
<button
key={m.value}
onClick={() => setMood(mood === m.value ? '' : m.value)}
className={`flex items-center gap-1.5 px-3 py-2 rounded-lg border text-sm transition-colors ${
mood === m.value ? 'bg-accent border-primary' : 'hover:bg-accent/50'
}`}
>
<span className="text-lg">{m.emoji}</span>
<span className="text-xs">{m.label}</span>
</button>
))}
</div>
</div>
{/* Actions */}
<div className="flex justify-end gap-3 pt-4 border-t">
<button
onClick={() => router.back()}
className="px-4 py-2 text-sm rounded-lg border hover:bg-accent transition-colors"
>
Cancel
</button>
<button
onClick={() => handleSubmit(true)}
disabled={isSubmitting}
className="flex items-center gap-2 px-4 py-2 text-sm rounded-lg border hover:bg-accent transition-colors disabled:opacity-50"
>
<Save size={14} />
Save Draft
</button>
<button
onClick={() => handleSubmit(false)}
disabled={isSubmitting || taskEntries.length === 0}
className="flex items-center gap-2 px-4 py-2 text-sm rounded-lg bg-primary text-primary-foreground font-medium hover:bg-primary/90 disabled:opacity-50 transition-colors"
>
{isSubmitting ? <Loader2 size={14} className="animate-spin" /> : <Send size={14} />}
Submit Report
</button>
</div>
</div>
</div>
);
}
\ No newline at end of file
'use client';
import { useEffect, useState } from 'react';
import { apiGet, apiPost } from '@/lib/api';
import { useAuthStore } from '@/stores/auth.store';
import { PageHeader } from '@/components/shared/page-header';
import { PageLoadingSkeleton } from '@/components/shared/loading-skeleton';
import { formatEgp } from '@/lib/utils';
import { Clock, Building, Home, X, Send, Loader2 } from 'lucide-react';
import { toast } from 'sonner';
const DAY_NAMES = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday'];
const DAY_LABELS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday'];
const DAY_TYPES = [
{ value: 'IN_OFFICE', label: 'In Office', icon: Building, color: 'bg-blue-500/10 text-blue-500 border-blue-500/20' },
{ value: 'REMOTE', label: 'Remote', icon: Home, color: 'bg-emerald-500/10 text-emerald-500 border-emerald-500/20' },
{ value: 'OFF', label: 'Off', icon: X, color: 'bg-muted text-muted-foreground border-border' },
];
export default function SchedulePage() {
const user = useAuthStore((s) => s.user);
const [profile, setProfile] = useState<any>(null);
const [isLoading, setIsLoading] = useState(true);
const [showChangeRequest, setShowChangeRequest] = useState(false);
const [proposedSchedule, setProposedSchedule] = useState<Record<string, string>>({});
const [reason, setReason] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
useEffect(() => {
apiGet(`/users/${user?.id}`)
.then((res) => {
setProfile(res.data);
const schedule = (res.data?.weeklySchedule as Record<string, string>) || {};
setProposedSchedule({ ...schedule });
})
.catch(console.error)
.finally(() => setIsLoading(false));
}, []);
if (isLoading) return <PageLoadingSkeleton />;
if (!profile) return <p className="text-muted-foreground p-6">Failed to load schedule.</p>;
const currentSchedule = (profile.weeklySchedule as Record<string, string>) || {};
const handleRequestChange = async () => {
if (reason.length < 50) {
toast.error('Reason must be at least 50 characters.');
return;
}
setIsSubmitting(true);
try {
const effectiveDate = new Date();
effectiveDate.setDate(effectiveDate.getDate() + 7);
await apiPost('/schedules/change-request', {
proposedSchedule,
effectiveDate: effectiveDate.toISOString().split('T')[0],
reason,
});
toast.success('Schedule change request submitted');
setShowChangeRequest(false);
setReason('');
} catch (err: any) {
toast.error(err.message || 'Failed to submit request');
} finally {
setIsSubmitting(false);
}
};
return (
<div className="max-w-2xl mx-auto space-y-6">
<PageHeader
title="My Schedule"
description="Your weekly work schedule"
actions={
user?.role === 'CONTRACTOR' && !showChangeRequest && (
<button
onClick={() => setShowChangeRequest(true)}
className="flex items-center gap-2 px-4 py-2 text-sm rounded-lg border hover:bg-accent transition-colors"
>
<Clock size={14} />
Request Change
</button>
)
}
/>
{/* Current Schedule */}
<div className="bg-card rounded-xl border p-4">
<h3 className="font-semibold mb-4">Current Schedule</h3>
<div className="space-y-2">
{DAY_NAMES.map((day, i) => {
const type = currentSchedule[day] || 'OFF';
const dayType = DAY_TYPES.find((d) => d.value === type) || DAY_TYPES[2];
const Icon = dayType.icon;
return (
<div key={day} className="flex items-center justify-between p-3 rounded-lg border">
<span className="text-sm font-medium w-24">{DAY_LABELS[i]}</span>
<span className={`flex items-center gap-2 px-3 py-1 rounded-md text-xs font-medium border ${dayType.color}`}>
<Icon size={12} />
{dayType.label}
</span>
</div>
);
})}
</div>
<div className="mt-4 pt-4 border-t">
<p className="text-sm text-muted-foreground">
Base Salary: <span className="font-bold text-foreground">{formatEgp(profile.baseSalaryPiasters || 0)}</span>
</p>
<p className="text-sm text-muted-foreground">
Actual Salary: <span className="font-bold text-foreground">{formatEgp(profile.actualSalaryPiasters || 0)}</span>
</p>
</div>
</div>
{/* Change Request Form */}
{showChangeRequest && (
<div className="bg-card rounded-xl border p-4 space-y-4">
<h3 className="font-semibold">Request Schedule Change</h3>
<p className="text-xs text-muted-foreground">
Select your proposed schedule. Changes take effect 7 days after approval.
</p>
<div className="space-y-2">
{DAY_NAMES.map((day, i) => (
<div key={day} className="flex items-center justify-between">
<span className="text-sm font-medium w-24">{DAY_LABELS[i]}</span>
<div className="flex gap-1">
{DAY_TYPES.map((dt) => {
const Icon = dt.icon;
const isSelected = proposedSchedule[day] === dt.value;
return (
<button
key={dt.value}
onClick={() => setProposedSchedule((prev) => ({ ...prev, [day]: dt.value }))}
className={`flex items-center gap-1 px-2.5 py-1.5 rounded-md text-xs border transition-colors ${
isSelected ? dt.color + ' font-medium' : 'hover:bg-accent/50'
}`}
>
<Icon size={12} />
{dt.label}
</button>
);
})}
</div>
</div>
))}
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Reason for change (min 50 characters)</label>
<textarea
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder="Explain why you need this schedule change..."
rows={3}
className="w-full px-3 py-2 rounded-lg border bg-background text-sm resize-none focus:outline-none focus:ring-2 focus:ring-ring"
/>
<p className="text-xs text-muted-foreground">{reason.length}/50 min characters</p>
</div>
<div className="flex justify-end gap-2">
<button
onClick={() => { setShowChangeRequest(false); setReason(''); }}
className="px-4 py-2 text-sm rounded-lg border hover:bg-accent"
>
Cancel
</button>
<button
onClick={handleRequestChange}
disabled={isSubmitting || reason.length < 50}
className="flex items-center gap-2 px-4 py-2 text-sm rounded-lg bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
{isSubmitting ? <Loader2 size={14} className="animate-spin" /> : <Send size={14} />}
Submit Request
</button>
</div>
</div>
)}
</div>
);
}
\ No newline at end of file
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment