Commit d6979653 authored by Administrator's avatar Administrator

Update 19 files via Son of Anton

parent 19c951e4
'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 { StatusBadge } from '@/components/shared/status-badge';
import { formatEgp } from '@/lib/utils';
import {
Users, DollarSign, TrendingDown, TrendingUp, AlertTriangle,
BarChart3, Cpu, HardDrive, Clock,
} from 'lucide-react';
export default function AnalyticsPage() {
const user = useAuthStore((s) => s.user);
const [dashboard, setDashboard] = useState<any>(null);
const [systemHealth, setSystemHealth] = useState<any>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
loadData();
}, []);
const loadData = async () => {
try {
const [dashRes, healthRes] = await Promise.all([
apiGet('/analytics/dashboard/super-admin').catch(() => apiGet('/analytics/dashboard/admin')),
user?.role === 'SUPER_ADMIN' ? apiGet('/analytics/system-health').catch(() => null) : Promise.resolve(null),
]);
setDashboard(dashRes.data);
if (healthRes) setSystemHealth(healthRes.data);
} catch (err) {
console.error('Failed to load analytics:', err);
} finally {
setIsLoading(false);
}
};
if (isLoading) return <PageLoadingSkeleton />;
if (!dashboard) return <p className="text-muted-foreground p-6">Failed to load analytics.</p>;
return (
<div className="space-y-6">
<PageHeader title="Analytics & Reports" description="Organization overview and metrics" />
{/* Top KPIs */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<KpiCard
icon={Users}
label="Active Contractors"
value={dashboard.contractors?.total || 0}
sub={`${dashboard.contractors?.fullTimers || 0} FT · ${dashboard.contractors?.interns || 0} INT`}
/>
<KpiCard
icon={DollarSign}
label="Net Expense"
value={formatEgp(dashboard.financials?.netExpensePiasters || 0)}
sub={dashboard.financials?.monthOverMonthChange
? `${dashboard.financials.monthOverMonthChange > 0 ? '+' : ''}${dashboard.financials.monthOverMonthChange}% vs last month`
: 'This month'}
highlight={dashboard.financials?.monthOverMonthChange > 10}
/>
<KpiCard
icon={TrendingDown}
label="Deductions"
value={formatEgp(dashboard.deductionsThisMonth?.totalPiasters || 0)}
sub={`${dashboard.deductionsThisMonth?.count || 0} applied`}
/>
<KpiCard
icon={TrendingUp}
label="Bounties"
value={formatEgp(dashboard.bountiesThisMonth?.totalPiasters || 0)}
sub={`${dashboard.bountiesThisMonth?.count || 0} paid`}
/>
</div>
{/* Payroll & Actions */}
<div className="grid gap-4 lg:grid-cols-2">
{/* Payroll Status */}
<div className="bg-card rounded-xl border p-4">
<h3 className="font-semibold mb-3">Payroll Status</h3>
<div className="flex items-center gap-3">
<StatusBadge status={dashboard.payroll?.status || 'PENDING_CALCULATION'} />
<span className="text-sm text-muted-foreground">
{dashboard.payroll?.contractorCount || 0} contractors
{dashboard.payroll?.totalNetPiasters ? ` · Net: ${formatEgp(dashboard.payroll.totalNetPiasters)}` : ''}
</span>
</div>
</div>
{/* Pending Actions */}
<div className="bg-card rounded-xl border p-4">
<h3 className="font-semibold mb-3">Pending Actions</h3>
<div className="grid grid-cols-3 gap-2">
<div className="bg-accent rounded-lg p-3 text-center">
<p className="text-2xl font-bold">{dashboard.pendingActions?.deductionReviews || 0}</p>
<p className="text-[10px] text-muted-foreground">Deduction Reviews</p>
</div>
<div className="bg-accent rounded-lg p-3 text-center">
<p className="text-2xl font-bold">{dashboard.pendingActions?.adjustments || 0}</p>
<p className="text-[10px] text-muted-foreground">Adjustments</p>
</div>
<div className="bg-accent rounded-lg p-3 text-center">
<p className="text-2xl font-bold">{dashboard.pendingActions?.scheduleChanges || 0}</p>
<p className="text-[10px] text-muted-foreground">Schedule Requests</p>
</div>
</div>
</div>
</div>
{/* At-Risk & Top Performers */}
{dashboard.performance && (
<div className="grid gap-4 lg:grid-cols-2">
{/* Top Performers */}
{dashboard.performance.topPerformersByEval?.length > 0 && (
<div className="bg-card rounded-xl border p-4">
<h3 className="font-semibold mb-3">Top Performers (Eval Score)</h3>
<div className="space-y-2">
{dashboard.performance.topPerformersByEval.map((p: any, i: number) => (
<div key={i} className="flex items-center justify-between text-sm">
<span>{p.user?.firstName} {p.user?.lastName}</span>
<span className="font-mono font-bold">{p.score?.toFixed(1)}</span>
</div>
))}
</div>
</div>
)}
{/* At-Risk */}
{dashboard.performance.atRiskContractors?.length > 0 && (
<div className="bg-card rounded-xl border p-4">
<h3 className="font-semibold mb-3 flex items-center gap-2">
<AlertTriangle size={16} className="text-red-500" />
At-Risk Contractors
</h3>
<div className="space-y-2">
{dashboard.performance.atRiskContractors.map((c: any, i: number) => (
<div key={i} className="flex items-center justify-between text-sm">
<span>{c.user?.firstName} {c.user?.lastName}</span>
<span className="text-red-500 font-mono">
{c.deductionPercentage}% deducted
</span>
</div>
))}
</div>
</div>
)}
</div>
)}
{/* System Health */}
{systemHealth && (
<div className="bg-card rounded-xl border p-4">
<h3 className="font-semibold mb-3 flex items-center gap-2">
<Cpu size={16} />
System Health
</h3>
<div className="grid gap-4 sm:grid-cols-4">
<div>
<p className="text-xs text-muted-foreground">Active Sessions</p>
<p className="text-lg font-bold">{systemHealth.activeSessions}</p>
</div>
<div>
<p className="text-xs text-muted-foreground">Errors (24h)</p>
<p className="text-lg font-bold">{systemHealth.recentErrors24h}</p>
</div>
<div>
<p className="text-xs text-muted-foreground">Storage</p>
<p className="text-lg font-bold">{systemHealth.storage?.totalSizeMB || 0} MB</p>
</div>
<div>
<p className="text-xs text-muted-foreground">Uptime</p>
<p className="text-lg font-bold">{Math.floor((systemHealth.uptime || 0) / 3600)}h</p>
</div>
</div>
</div>
)}
</div>
);
}
function KpiCard({
icon: Icon,
label,
value,
sub,
highlight,
}: {
icon: React.ElementType;
label: string;
value: string | number;
sub?: string;
highlight?: boolean;
}) {
return (
<div className="bg-card rounded-xl border p-4">
<div className="flex items-center gap-2 text-muted-foreground mb-2">
<Icon size={16} />
<span className="text-xs font-medium uppercase tracking-wider">{label}</span>
</div>
<p className={`text-2xl font-bold ${highlight ? 'text-red-500' : ''}`}>{value}</p>
{sub && <p className="text-xs text-muted-foreground mt-0.5">{sub}</p>}
</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 { 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 { formatEgp } from '@/lib/utils';
import { formatDate } from '@/lib/date';
import { Search, UserPlus, Download } from 'lucide-react';
export default function ContractorsPage() {
const router = useRouter();
const [contractors, setContractors] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState('');
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
useEffect(() => {
loadContractors();
}, [page, search, statusFilter]);
const loadContractors = async () => {
try {
const params: any = { page, limit: 20, role: 'CONTRACTOR' };
if (search) params.search = search;
if (statusFilter) params.status = statusFilter;
const res = await apiGet('/users', params);
setContractors(res.data || []);
setTotal(res.meta?.total || 0);
} catch (err) {
console.error('Failed to load contractors:', err);
} finally {
setIsLoading(false);
}
};
if (isLoading) return <PageLoadingSkeleton />;
return (
<div className="space-y-6">
<PageHeader
title="Contractor Management"
description={`${total} contractors`}
actions={
<button
onClick={() => router.push('/admin/invites')}
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"
>
<UserPlus size={16} />
Invite Contractor
</button>
}
/>
{/* Filters */}
<div className="flex items-center gap-3 flex-wrap">
<div className="relative flex-1 max-w-sm">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" />
<input
type="text"
placeholder="Search by name or username..."
value={search}
onChange={(e) => { setSearch(e.target.value); setPage(1); }}
className="w-full pl-9 pr-3 py-2 rounded-lg border bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
<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="ACTIVE">Active</option>
<option value="ONBOARDING">Onboarding</option>
<option value="ON_PIP">On PIP</option>
<option value="SUSPENDED">Suspended</option>
<option value="OFFBOARDED">Offboarded</option>
</select>
</div>
{/* Table */}
<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">Contractor</th>
<th className="text-left px-4 py-3 font-medium text-muted-foreground">Type</th>
<th className="text-left px-4 py-3 font-medium text-muted-foreground">Status</th>
<th className="text-left px-4 py-3 font-medium text-muted-foreground">Salary</th>
<th className="text-left px-4 py-3 font-medium text-muted-foreground">Joined</th>
</tr>
</thead>
<tbody className="divide-y">
{contractors.map((c) => (
<tr
key={c.id}
onClick={() => router.push(`/admin/contractors/${c.id}`)}
className="hover:bg-accent/50 cursor-pointer transition-colors"
>
<td className="px-4 py-3">
<div className="flex items-center gap-3">
<UserAvatar
firstName={c.firstName}
lastName={c.lastName}
avatar={c.avatar}
size="sm"
/>
<div>
<p className="font-medium">{c.firstName} {c.lastName}</p>
<p className="text-xs text-muted-foreground">@{c.username}</p>
</div>
</div>
</td>
<td className="px-4 py-3">
<StatusBadge status={c.contractorType || 'UNKNOWN'} />
</td>
<td className="px-4 py-3">
<StatusBadge status={c.status} />
</td>
<td className="px-4 py-3 font-mono">
{c.actualSalaryPiasters ? formatEgp(c.actualSalaryPiasters) : '—'}
</td>
<td className="px-4 py-3 text-muted-foreground">
{c.createdAt ? formatDate(c.createdAt) : '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Pagination */}
{total > 20 && (
<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 / 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>
</div>
);
}
\ No newline at end of file
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { apiGet, apiPut } from '@/lib/api';
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 { formatEgp } from '@/lib/utils';
import { formatDate } from '@/lib/date';
import { AlertTriangle, Plus, Search, Filter } from 'lucide-react';
import { toast } from 'sonner';
export default function DeductionsPage() {
const router = useRouter();
const [deductions, setDeductions] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState('');
const [categoryFilter, setCategoryFilter] = useState('');
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
useEffect(() => {
loadDeductions();
}, [page, statusFilter, categoryFilter]);
const loadDeductions = async () => {
try {
const params: any = { page, limit: 20 };
if (statusFilter) params.status = statusFilter;
if (categoryFilter) params.category = categoryFilter;
const res = await apiGet('/deductions', params);
setDeductions(res.data || []);
setTotal(res.meta?.total || 0);
} catch (err) {
console.error('Failed to load deductions:', err);
} finally {
setIsLoading(false);
}
};
const handleReview = async (id: string, decision: string, reason?: string) => {
try {
await apiPut(`/deductions/${id}/review`, { decision, reviewNotes: reason || `${decision} by admin` });
toast.success(`Deduction ${decision.toLowerCase()}`);
loadDeductions();
} catch (err: any) {
toast.error(err.message || 'Failed to review deduction');
}
};
if (isLoading) return <PageLoadingSkeleton />;
return (
<div className="space-y-6">
<PageHeader
title="Deduction Management"
description={`${total} deductions`}
actions={
<button
onClick={() => router.push('/admin/deductions/create')}
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} />
New Deduction
</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="PENDING_ACKNOWLEDGMENT">Pending Acknowledgment</option>
<option value="PENDING_RESPONSE">Pending Response</option>
<option value="PENDING_ADMIN_REVIEW">Pending Review</option>
<option value="UPHELD">Upheld</option>
<option value="REDUCED">Reduced</option>
<option value="DISMISSED">Dismissed</option>
<option value="AUTO_APPLIED">Auto-Applied</option>
</select>
<select
value={categoryFilter}
onChange={(e) => { setCategoryFilter(e.target.value); setPage(1); }}
className="px-3 py-2 rounded-lg border bg-background text-sm"
>
<option value="">All Categories</option>
<option value="A">A — Deadline</option>
<option value="B">B — Reporting</option>
<option value="C">C — Quality</option>
<option value="D">D — Communication</option>
</select>
</div>
{/* Table */}
<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">Contractor</th>
<th className="text-left px-4 py-3 font-medium text-muted-foreground">Category</th>
<th className="text-left px-4 py-3 font-medium text-muted-foreground">Amount</th>
<th className="text-left px-4 py-3 font-medium text-muted-foreground">Status</th>
<th className="text-left px-4 py-3 font-medium text-muted-foreground">Date</th>
<th className="text-left px-4 py-3 font-medium text-muted-foreground">Actions</th>
</tr>
</thead>
<tbody className="divide-y">
{deductions.map((d) => (
<tr key={d.id} className="hover:bg-accent/50 transition-colors">
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<UserAvatar
firstName={d.user?.firstName || '?'}
lastName={d.user?.lastName || '?'}
avatar={d.user?.avatar}
size="xs"
/>
<span className="text-sm">
{d.user?.firstName} {d.user?.lastName}
</span>
</div>
</td>
<td className="px-4 py-3">
<span className="font-mono text-xs bg-muted px-1.5 py-0.5 rounded">
{d.category}{d.subCategory}
</span>
</td>
<td className="px-4 py-3 font-mono text-red-500">
-{formatEgp(d.appliedAmountPiasters || d.amountPiasters || 0)}
</td>
<td className="px-4 py-3">
<StatusBadge status={d.status} />
</td>
<td className="px-4 py-3 text-muted-foreground text-xs">
{d.violationDate ? formatDate(d.violationDate) : '—'}
</td>
<td className="px-4 py-3">
{d.status === 'PENDING_ADMIN_REVIEW' && (
<div className="flex gap-1">
<button
onClick={() => handleReview(d.id, 'UPHELD')}
className="px-2 py-1 text-xs bg-red-500/10 text-red-500 rounded hover:bg-red-500/20"
>
Uphold
</button>
<button
onClick={() => handleReview(d.id, 'DISMISSED')}
className="px-2 py-1 text-xs bg-emerald-500/10 text-emerald-500 rounded hover:bg-emerald-500/20"
>
Dismiss
</button>
</div>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
{total > 20 && (
<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 / 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>
</div>
);
}
\ No newline at end of file
'use client';
import { useEffect, useState } from 'react';
import { apiGet, apiPost, apiPut } from '@/lib/api';
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 { ConfirmDialog } from '@/components/shared/confirm-dialog';
import { formatEgp } from '@/lib/utils';
import { formatMonthYear } from '@/lib/date';
import { useAuthStore } from '@/stores/auth.store';
import {
Calculator, CheckCircle2, XCircle, Send, DollarSign, ChevronLeft, ChevronRight, Loader2,
} from 'lucide-react';
import { toast } from 'sonner';
export default function PayrollPage() {
const user = useAuthStore((s) => s.user);
const [payroll, setPayroll] = useState<any>(null);
const [payrollLines, setPayrollLines] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isCalculating, setIsCalculating] = useState(false);
const [month, setMonth] = useState(new Date().getMonth() + 1);
const [year, setYear] = useState(new Date().getFullYear());
const [confirmAction, setConfirmAction] = useState<{ type: string; id?: string } | null>(null);
useEffect(() => {
loadPayroll();
}, [month, year]);
const loadPayroll = async () => {
setIsLoading(true);
try {
const res = await apiGet('/payroll', { month, year });
const payrolls = res.data || [];
if (payrolls.length > 0) {
setPayroll(payrolls[0]);
// Load lines
try {
const lineRes = await apiGet(`/payroll/${payrolls[0].id}`);
setPayrollLines(lineRes.data?.lines || []);
} catch { /* ok */ }
} else {
setPayroll(null);
setPayrollLines([]);
}
} catch (err) {
console.error('Failed to load payroll:', err);
} finally {
setIsLoading(false);
}
};
const handleCalculate = async () => {
setIsCalculating(true);
try {
await apiPost('/payroll/calculate', { month, year });
toast.success('Payroll calculated successfully');
loadPayroll();
} catch (err: any) {
toast.error(err.message || 'Failed to calculate payroll');
} finally {
setIsCalculating(false);
}
};
const handleSubmit = async () => {
if (!payroll) return;
try {
await apiPut(`/payroll/${payroll.id}/submit`);
toast.success('Payroll submitted for approval');
loadPayroll();
} catch (err: any) {
toast.error(err.message || 'Failed to submit payroll');
}
};
const handleApprove = async () => {
if (!payroll) return;
try {
await apiPut(`/payroll/${payroll.id}/approve`);
toast.success('Payroll approved');
loadPayroll();
} catch (err: any) {
toast.error(err.message || 'Failed to approve payroll');
}
};
const prevMonth = () => {
if (month === 1) { setMonth(12); setYear((y) => y - 1); }
else setMonth((m) => m - 1);
};
const nextMonth = () => {
if (month === 12) { setMonth(1); setYear((y) => y + 1); }
else setMonth((m) => m + 1);
};
if (isLoading) return <PageLoadingSkeleton />;
return (
<div className="space-y-6">
<PageHeader title="Payroll Management" description="Monthly payroll processing" />
{/* Month Selector */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<button onClick={prevMonth} className="p-2 rounded-md hover:bg-accent">
<ChevronLeft size={16} />
</button>
<span className="text-sm font-semibold w-40 text-center">
{formatMonthYear(month, year)}
</span>
<button onClick={nextMonth} className="p-2 rounded-md hover:bg-accent">
<ChevronRight size={16} />
</button>
</div>
<div className="flex gap-2">
{(!payroll || payroll.status === 'PENDING_CALCULATION' || payroll.status === 'REJECTED') && (
<button
onClick={handleCalculate}
disabled={isCalculating}
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"
>
{isCalculating ? <Loader2 size={16} className="animate-spin" /> : <Calculator size={16} />}
{isCalculating ? 'Calculating...' : 'Calculate Payroll'}
</button>
)}
{payroll?.status === 'CALCULATED' && (
<button
onClick={handleSubmit}
className="flex items-center gap-2 bg-blue-600 text-white rounded-lg px-4 py-2 text-sm font-medium hover:bg-blue-700"
>
<Send size={16} />
Submit for Approval
</button>
)}
{payroll?.status === 'SUBMITTED' && user?.role === 'SUPER_ADMIN' && (
<button
onClick={handleApprove}
className="flex items-center gap-2 bg-emerald-600 text-white rounded-lg px-4 py-2 text-sm font-medium hover:bg-emerald-700"
>
<CheckCircle2 size={16} />
Approve Payroll
</button>
)}
</div>
</div>
{/* Status */}
{payroll && (
<div className="bg-card rounded-xl border p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<StatusBadge status={payroll.status} />
<span className="text-sm text-muted-foreground">
{payroll.contractorCount || 0} contractors
</span>
</div>
<div className="flex items-center gap-6 text-sm">
<div>
<span className="text-muted-foreground">Total Gross: </span>
<span className="font-bold">{formatEgp(payroll.totalGrossPiasters || 0)}</span>
</div>
<div>
<span className="text-muted-foreground">Total Net: </span>
<span className="font-bold text-emerald-600">{formatEgp(payroll.totalNetPiasters || 0)}</span>
</div>
</div>
</div>
</div>
)}
{/* Lines */}
{payrollLines.length > 0 && (
<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">Contractor</th>
<th className="text-right px-4 py-3 font-medium text-muted-foreground">Salary</th>
<th className="text-right px-4 py-3 font-medium text-muted-foreground">Bounties</th>
<th className="text-right px-4 py-3 font-medium text-muted-foreground">Deductions</th>
<th className="text-right px-4 py-3 font-medium text-muted-foreground">Net</th>
</tr>
</thead>
<tbody className="divide-y">
{payrollLines.map((line: any) => (
<tr key={line.id} className="hover:bg-accent/50">
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<UserAvatar
firstName={line.user?.firstName || '?'}
lastName={line.user?.lastName || '?'}
size="xs"
/>
<span>{line.user?.firstName} {line.user?.lastName}</span>
</div>
</td>
<td className="px-4 py-3 text-right font-mono">
{formatEgp(line.actualSalaryPiasters || 0)}
</td>
<td className="px-4 py-3 text-right font-mono text-emerald-500">
+{formatEgp(line.totalBountiesPiasters || 0)}
</td>
<td className="px-4 py-3 text-right font-mono text-red-500">
-{formatEgp(line.totalDeductionsPiasters || 0)}
</td>
<td className="px-4 py-3 text-right font-mono font-bold">
{formatEgp(line.netPayablePiasters || 0)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{!payroll && (
<div className="text-center py-16">
<DollarSign size={48} className="mx-auto text-muted-foreground/30 mb-4" />
<p className="text-muted-foreground">
No payroll calculated for {formatMonthYear(month, year)}.
</p>
<button
onClick={handleCalculate}
disabled={isCalculating}
className="mt-4 bg-primary text-primary-foreground rounded-lg px-4 py-2 text-sm font-medium hover:bg-primary/90 disabled:opacity-50"
>
Calculate Now
</button>
</div>
)}
</div>
);
}
\ No newline at end of file
'use client';
import { useEffect, useState, useCallback } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { apiGet, apiPut } from '@/lib/api';
import { PageLoadingSkeleton } from '@/components/shared/loading-skeleton';
import { KanbanBoard } from '@/components/kanban/board';
import { BoardHeader } from '@/components/kanban/board-header';
import { toast } from 'sonner';
export default function BoardPage() {
const { boardId } = useParams<{ boardId: string }>();
const [board, setBoard] = useState<any>(null);
const [cards, setCards] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(true);
const loadBoard = useCallback(async () => {
try {
const res = await apiGet(`/boards/${boardId}`);
setBoard(res.data);
} catch (err: any) {
toast.error(err.message || 'Failed to load board');
}
}, [boardId]);
const loadCards = useCallback(async () => {
try {
const res = await apiGet('/cards', { boardId, limit: 500, isArchived: false });
setCards(res.data || []);
} catch (err) {
console.error('Failed to load cards:', err);
}
}, [boardId]);
useEffect(() => {
Promise.all([loadBoard(), loadCards()]).finally(() => setIsLoading(false));
}, [loadBoard, loadCards]);
const handleCardMoved = async (cardId: string, columnId: string, position: number, frozenReason?: string) => {
try {
await apiPut(`/cards/${cardId}/move`, { columnId, position, frozenReason });
await loadCards();
} catch (err: any) {
toast.error(err.message || 'Failed to move card');
await loadCards();
}
};
const handleCardCreated = () => {
loadCards();
};
if (isLoading) return <PageLoadingSkeleton />;
if (!board) return <div className="p-6 text-muted-foreground">Board not found.</div>;
return (
<div className="space-y-4 -m-6">
<div className="px-6 pt-6">
<BoardHeader board={board} onRefresh={loadBoard} />
</div>
<KanbanBoard
board={board}
cards={cards}
onCardMoved={handleCardMoved}
onCardCreated={handleCardCreated}
onRefresh={loadCards}
/>
</div>
);
}
\ No newline at end of file
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { apiPost } from '@/lib/api';
import { PageHeader } from '@/components/shared/page-header';
import { toast } from 'sonner';
export default function NewBoardPage() {
const router = useRouter();
const [isSubmitting, setIsSubmitting] = useState(false);
const [form, setForm] = useState({
name: '',
description: '',
key: '',
visibility: 'PRIVATE',
allowContractorCreation: true,
autoArchiveDoneCardsDays: 30,
});
const generateKey = (name: string) => {
return name
.toUpperCase()
.replace(/[^A-Z0-9\s]/g, '')
.trim()
.split(/\s+/)
.slice(0, 3)
.map((w) => w.slice(0, 4))
.join('')
.slice(0, 8) || 'BOARD';
};
const handleNameChange = (name: string) => {
setForm((prev) => ({
...prev,
name,
key: prev.key || generateKey(name),
}));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!form.name.trim()) return;
setIsSubmitting(true);
try {
const res = await apiPost('/boards', {
...form,
key: form.key || generateKey(form.name),
});
toast.success('Board created successfully');
router.push(`/boards/${res.data.id}`);
} catch (err: any) {
toast.error(err.message || 'Failed to create board');
} finally {
setIsSubmitting(false);
}
};
return (
<div className="max-w-2xl mx-auto">
<PageHeader title="Create New Board" description="Set up a new project board" />
<form onSubmit={handleSubmit} className="bg-card rounded-xl border p-6 space-y-5">
<div className="space-y-2">
<label className="text-sm font-medium">Board Name *</label>
<input
type="text"
value={form.name}
onChange={(e) => handleNameChange(e.target.value)}
placeholder="e.g., Game Development"
required
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-2">
<label className="text-sm font-medium">Board Key *</label>
<input
type="text"
value={form.key}
onChange={(e) => setForm((prev) => ({ ...prev, key: e.target.value.toUpperCase().replace(/[^A-Z0-9_]/g, '') }))}
placeholder="PROJ"
maxLength={10}
className="w-full px-3 py-2 rounded-lg border bg-background text-sm font-mono focus:outline-none focus:ring-2 focus:ring-ring"
/>
<p className="text-xs text-muted-foreground">Used for card numbering (e.g., {form.key || 'PROJ'}-1)</p>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Description</label>
<textarea
value={form.description}
onChange={(e) => setForm((prev) => ({ ...prev, description: e.target.value }))}
placeholder="What is this board for?"
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>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<label className="text-sm font-medium">Visibility</label>
<select
value={form.visibility}
onChange={(e) => setForm((prev) => ({ ...prev, visibility: 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"
>
<option value="PRIVATE">Private — Members only</option>
<option value="PUBLIC">Public — All users</option>
</select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Auto-Archive Done Cards</label>
<select
value={form.autoArchiveDoneCardsDays}
onChange={(e) => setForm((prev) => ({ ...prev, autoArchiveDoneCardsDays: Number(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"
>
<option value={7}>After 7 days</option>
<option value={14}>After 14 days</option>
<option value={30}>After 30 days</option>
<option value={60}>After 60 days</option>
<option value={90}>After 90 days</option>
</select>
</div>
</div>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={form.allowContractorCreation}
onChange={(e) => setForm((prev) => ({ ...prev, allowContractorCreation: e.target.checked }))}
className="rounded border-border"
/>
<span className="text-sm">Allow contractors to create cards in Backlog</span>
</label>
<div className="flex justify-end gap-3 pt-4 border-t">
<button
type="button"
onClick={() => router.back()}
className="px-4 py-2 text-sm rounded-lg border hover:bg-accent transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={isSubmitting || !form.name.trim()}
className="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 ? 'Creating...' : 'Create Board'}
</button>
</div>
</form>
</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 { EmptyState } from '@/components/shared/empty-state';
import { PageLoadingSkeleton } from '@/components/shared/loading-skeleton';
import { PermissionGate } from '@/components/shared/permission-gate';
import { useAuthStore } from '@/stores/auth.store';
import { Kanban, Plus, Archive, Users, Search } from 'lucide-react';
import { cn } from '@/lib/utils';
export default function BoardsPage() {
const router = useRouter();
const user = useAuthStore((s) => s.user);
const [boards, setBoards] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [search, setSearch] = useState('');
const [showArchived, setShowArchived] = useState(false);
useEffect(() => {
loadBoards();
}, [showArchived, search]);
const loadBoards = async () => {
try {
const params: Record<string, any> = {
limit: 50,
isArchived: showArchived,
};
if (search) params.search = search;
const res = await apiGet('/boards', params);
setBoards(res.data || []);
} catch (err) {
console.error('Failed to load boards:', err);
} finally {
setIsLoading(false);
}
};
const handleCreateBoard = () => {
router.push('/boards/new');
};
if (isLoading) return <PageLoadingSkeleton />;
return (
<div className="space-y-6">
<PageHeader
title="Boards"
description="Your project boards and workstreams"
actions={
<PermissionGate roles={['SUPER_ADMIN', 'ADMIN']}>
<button
onClick={handleCreateBoard}
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 transition-colors"
>
<Plus size={16} />
New Board
</button>
</PermissionGate>
}
/>
{/* Filters */}
<div className="flex items-center gap-3">
<div className="relative flex-1 max-w-sm">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" />
<input
type="text"
placeholder="Search boards..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full pl-9 pr-3 py-2 rounded-lg border bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
<button
onClick={() => setShowArchived(!showArchived)}
className={cn(
'flex items-center gap-2 px-3 py-2 rounded-lg border text-sm transition-colors',
showArchived ? 'bg-accent text-accent-foreground' : 'hover:bg-accent/50',
)}
>
<Archive size={14} />
{showArchived ? 'Showing Archived' : 'Show Archived'}
</button>
</div>
{/* Board Grid */}
{boards.length === 0 ? (
<EmptyState
icon={Kanban}
title={showArchived ? 'No archived boards' : 'No boards yet'}
description={
showArchived
? 'No boards have been archived.'
: 'Create your first board to start managing tasks.'
}
action={
!showArchived && (
<PermissionGate roles={['SUPER_ADMIN', 'ADMIN']}>
<button
onClick={handleCreateBoard}
className="bg-primary text-primary-foreground rounded-lg px-4 py-2 text-sm font-medium hover:bg-primary/90"
>
Create Board
</button>
</PermissionGate>
)
}
/>
) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{boards.map((board) => (
<button
key={board.id}
onClick={() => router.push(`/boards/${board.id}`)}
className="bg-card rounded-xl border p-4 text-left hover:border-primary/30 hover:shadow-md transition-all group"
>
<div className="flex items-start justify-between mb-3">
<div
className="w-10 h-10 rounded-lg flex items-center justify-center text-lg"
style={{ backgroundColor: board.color ? `${board.color}20` : 'hsl(var(--accent))' }}
>
{board.icon || '📋'}
</div>
<span className="text-[10px] font-mono text-muted-foreground bg-muted px-1.5 py-0.5 rounded">
{board.key}
</span>
</div>
<h3 className="font-semibold text-sm group-hover:text-primary transition-colors">
{board.name}
</h3>
{board.description && (
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">
{board.description}
</p>
)}
<div className="flex items-center gap-3 mt-3 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<Users size={12} />
{board.memberCount || 0}
</span>
{board.isArchived && (
<span className="text-yellow-500 flex items-center gap-1">
<Archive size={12} />
Archived
</span>
)}
</div>
</button>
))}
</div>
)}
</div>
);
}
\ No newline at end of file
'use client';
import { useEffect, useState, useRef } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { apiGet, apiPost } from '@/lib/api';
import { useAuthStore } from '@/stores/auth.store';
import { UserAvatar } from '@/components/shared/user-avatar';
import { relativeTime, formatTime } from '@/lib/date';
import { cn } from '@/lib/utils';
import { ArrowLeft, Send, Loader2 } from 'lucide-react';
export default function ConversationPage() {
const { conversationId } = useParams<{ conversationId: string }>();
const router = useRouter();
const user = useAuthStore((s) => s.user);
const [conversation, setConversation] = useState<any>(null);
const [messages, setMessages] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [newMessage, setNewMessage] = useState('');
const [isSending, setIsSending] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
useEffect(() => {
loadConversation();
const interval = setInterval(loadMessages, 5000); // Poll every 5s
return () => clearInterval(interval);
}, [conversationId]);
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
const loadConversation = async () => {
try {
const res = await apiGet(`/conversations/${conversationId}`);
setConversation(res.data);
setMessages(res.data?.messages || []);
} catch (err) {
console.error('Failed to load conversation:', err);
} finally {
setIsLoading(false);
}
};
const loadMessages = async () => {
try {
const res = await apiGet(`/conversations/${conversationId}`);
setMessages(res.data?.messages || []);
} catch { /* ok */ }
};
const handleSend = async () => {
if (!newMessage.trim()) return;
setIsSending(true);
try {
await apiPost(`/conversations/${conversationId}/messages`, {
content: newMessage.trim(),
});
setNewMessage('');
loadMessages();
} catch (err: any) {
console.error('Failed to send message:', err);
} finally {
setIsSending(false);
}
};
if (isLoading) {
return (
<div className="flex items-center justify-center h-[calc(100vh-12rem)]">
<Loader2 className="animate-spin text-muted-foreground" size={24} />
</div>
);
}
const otherParticipant = conversation?.participants?.find((p: any) => p.id !== user?.id);
return (
<div className="flex flex-col h-[calc(100vh-8rem)] max-w-3xl mx-auto -mb-6">
{/* Header */}
<div className="flex items-center gap-3 pb-4 border-b">
<button onClick={() => router.push('/messages')} className="p-2 rounded-md hover:bg-accent">
<ArrowLeft size={16} />
</button>
{otherParticipant && (
<>
<UserAvatar
firstName={otherParticipant.firstName}
lastName={otherParticipant.lastName}
avatar={otherParticipant.avatar}
size="sm"
/>
<div>
<p className="text-sm font-semibold">
{conversation.name || `${otherParticipant.firstName} ${otherParticipant.lastName}`}
</p>
<p className="text-[10px] text-muted-foreground">
{conversation.type === 'GROUP' ? `${conversation.participants?.length || 0} members` : otherParticipant.role?.replace('_', ' ')}
</p>
</div>
</>
)}
</div>
{/* Messages */}
<div className="flex-1 overflow-y-auto py-4 space-y-4">
{messages.map((msg: any) => {
const isOwn = msg.senderId === user?.id;
return (
<div key={msg.id} className={cn('flex gap-2', isOwn ? 'justify-end' : 'justify-start')}>
{!isOwn && (
<UserAvatar
firstName={msg.sender?.firstName || '?'}
lastName={msg.sender?.lastName || '?'}
avatar={msg.sender?.avatar}
size="xs"
className="mt-1"
/>
)}
<div className={cn('max-w-[70%]', isOwn && 'order-first')}>
{!isOwn && (
<p className="text-[10px] text-muted-foreground mb-0.5">
{msg.sender?.firstName}
</p>
)}
<div
className={cn(
'px-3 py-2 rounded-xl text-sm',
isOwn
? 'bg-primary text-primary-foreground rounded-tr-sm'
: 'bg-muted rounded-tl-sm',
)}
>
<p className="whitespace-pre-wrap">{msg.content}</p>
</div>
<p className={cn('text-[9px] text-muted-foreground mt-0.5', isOwn && 'text-right')}>
{formatTime(msg.createdAt)}
</p>
</div>
</div>
);
})}
<div ref={messagesEndRef} />
</div>
{/* Input */}
<div className="border-t pt-4 pb-2">
<div className="flex gap-2">
<input
type="text"
value={newMessage}
onChange={(e) => setNewMessage(e.target.value)}
placeholder="Type a message..."
className="flex-1 px-4 py-2.5 rounded-xl border bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
}}
/>
<button
onClick={handleSend}
disabled={!newMessage.trim() || isSending}
className="p-2.5 bg-primary text-primary-foreground rounded-xl disabled:opacity-50 hover:bg-primary/90 transition-colors"
>
{isSending ? <Loader2 size={18} className="animate-spin" /> : <Send size={18} />}
</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 { EmptyState } from '@/components/shared/empty-state';
import { UserAvatar } from '@/components/shared/user-avatar';
import { relativeTime } from '@/lib/date';
import { truncate, cn } from '@/lib/utils';
import { MessageSquare, Plus, Search } from 'lucide-react';
import { toast } from 'sonner';
export default function MessagesPage() {
const router = useRouter();
const [conversations, setConversations] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [showNewDm, setShowNewDm] = useState(false);
const [users, setUsers] = useState<any[]>([]);
const [userSearch, setUserSearch] = useState('');
useEffect(() => {
loadConversations();
}, []);
const loadConversations = async () => {
try {
const res = await apiGet('/conversations');
setConversations(res.data || []);
} catch (err) {
console.error('Failed to load conversations:', err);
} finally {
setIsLoading(false);
}
};
const handleStartDm = async (userId: string) => {
try {
const res = await apiPost('/conversations', {
participantIds: [userId],
type: 'DIRECT',
});
setShowNewDm(false);
router.push(`/messages/${res.data.id}`);
} catch (err: any) {
toast.error(err.message || 'Failed to start conversation');
}
};
const handleSearchUsers = async (query: string) => {
setUserSearch(query);
if (query.length < 2) { setUsers([]); return; }
try {
const res = await apiGet('/users', { search: query, limit: 10 });
setUsers(res.data || []);
} catch { /* ok */ }
};
if (isLoading) return <PageLoadingSkeleton />;
return (
<div className="space-y-6 max-w-3xl mx-auto">
<PageHeader
title="Messages"
description="Direct and group conversations"
actions={
<button
onClick={() => setShowNewDm(true)}
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} />
New Message
</button>
}
/>
{/* New DM dialog */}
{showNewDm && (
<div className="bg-card rounded-xl border p-4 space-y-3">
<div className="relative">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" />
<input
type="text"
placeholder="Search for a user..."
value={userSearch}
onChange={(e) => handleSearchUsers(e.target.value)}
autoFocus
className="w-full pl-9 pr-3 py-2 rounded-lg border bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
{users.length > 0 && (
<div className="space-y-1">
{users.map((u) => (
<button
key={u.id}
onClick={() => handleStartDm(u.id)}
className="w-full flex items-center gap-3 p-2 rounded-lg hover:bg-accent transition-colors text-left"
>
<UserAvatar firstName={u.firstName} lastName={u.lastName} avatar={u.avatar} size="sm" />
<div>
<p className="text-sm font-medium">{u.firstName} {u.lastName}</p>
<p className="text-xs text-muted-foreground">@{u.username}</p>
</div>
</button>
))}
</div>
)}
<button
onClick={() => { setShowNewDm(false); setUserSearch(''); setUsers([]); }}
className="text-xs text-muted-foreground hover:text-foreground"
>
Cancel
</button>
</div>
)}
{/* Conversation list */}
{conversations.length === 0 ? (
<EmptyState
icon={MessageSquare}
title="No conversations"
description="Start a new message to begin a conversation."
/>
) : (
<div className="bg-card rounded-xl border divide-y">
{conversations.map((conv) => {
const other = conv.participants?.find((p: any) => p.id !== conv.currentUserId) || conv.participants?.[0];
return (
<button
key={conv.id}
onClick={() => router.push(`/messages/${conv.id}`)}
className={cn(
'w-full flex items-center gap-3 p-4 text-left hover:bg-accent/50 transition-colors',
conv.unreadCount > 0 && 'bg-accent/20',
)}
>
<UserAvatar
firstName={other?.firstName || '?'}
lastName={other?.lastName || '?'}
avatar={other?.avatar}
size="md"
/>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between">
<span className={cn('text-sm', conv.unreadCount > 0 && 'font-semibold')}>
{conv.name || `${other?.firstName || '?'} ${other?.lastName || '?'}`}
</span>
<span className="text-[10px] text-muted-foreground">
{conv.lastMessageAt ? relativeTime(conv.lastMessageAt) : ''}
</span>
</div>
{conv.lastMessagePreview && (
<p className="text-xs text-muted-foreground truncate mt-0.5">
{truncate(conv.lastMessagePreview, 60)}
</p>
)}
</div>
{conv.unreadCount > 0 && (
<span className="min-w-[20px] h-5 bg-primary text-primary-foreground rounded-full text-[10px] font-bold flex items-center justify-center px-1">
{conv.unreadCount}
</span>
)}
</button>
);
})}
</div>
)}
</div>
);
}
\ No newline at end of file
'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 { StatusBadge } from '@/components/shared/status-badge';
import { formatShortDate, isOverdue } from '@/lib/date';
import { formatEgp, cn } from '@/lib/utils';
import { ListTodo, Clock, Coins, AlertTriangle } from 'lucide-react';
import Link from 'next/link';
export default function MyTasksPage() {
const [data, setData] = useState<any>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
apiGet('/cards/my-tasks')
.then((res) => setData(res.data))
.catch(console.error)
.finally(() => setIsLoading(false));
}, []);
if (isLoading) return <PageLoadingSkeleton />;
if (!data || data.totalCards === 0) {
return (
<div>
<PageHeader title="My Tasks" description="Cards assigned to you across all boards" />
<EmptyState
icon={ListTodo}
title="No tasks assigned"
description="You don't have any cards assigned to you yet."
/>
</div>
);
}
return (
<div className="space-y-6">
<PageHeader
title="My Tasks"
description={`${data.totalCards} cards across ${data.boards?.length || 0} boards`}
actions={
data.totalOverdue > 0 && (
<span className="flex items-center gap-1 text-sm text-red-500 font-medium">
<AlertTriangle size={14} />
{data.totalOverdue} overdue
</span>
)
}
/>
{data.boards?.map((group: any) => (
<div key={group.board.id} className="space-y-2">
<div className="flex items-center justify-between">
<Link
href={`/boards/${group.board.id}`}
className="flex items-center gap-2 text-sm font-semibold hover:text-primary transition-colors"
>
<span>{group.board.name}</span>
<span className="text-xs font-mono text-muted-foreground bg-muted px-1.5 py-0.5 rounded">
{group.board.key}
</span>
</Link>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span>{group.cardCount} cards</span>
{group.overdueCount > 0 && (
<span className="text-red-500">{group.overdueCount} overdue</span>
)}
</div>
</div>
<div className="bg-card rounded-xl border divide-y">
{group.cards.map((card: any) => (
<Link
key={card.id}
href={`/boards/${group.board.id}`}
className="flex items-center justify-between p-3 hover:bg-accent/50 transition-colors"
>
<div className="flex items-center gap-3 min-w-0">
<StatusBadge status={card.columnType} />
<div className="min-w-0">
<p className="text-sm font-medium truncate">{card.title}</p>
<p className="text-[10px] text-muted-foreground">{card.cardNumber}</p>
</div>
</div>
<div className="flex items-center gap-3 ml-4 shrink-0">
{card.bountyPiasters > 0 && (
<span className="flex items-center gap-0.5 text-xs text-amber-500">
<Coins size={12} />
{formatEgp(card.bountyPiasters)}
</span>
)}
{card.dueDate && (
<span
className={cn(
'flex items-center gap-0.5 text-xs',
card.isOverdue ? 'text-red-500 font-medium' : 'text-muted-foreground',
)}
>
<Clock size={12} />
{formatShortDate(card.dueDate)}
</span>
)}
{card.priority && card.priority !== 'NONE' && (
<StatusBadge status={card.priority} />
)}
</div>
</Link>
))}
</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 { EmptyState } from '@/components/shared/empty-state';
import { relativeTime } from '@/lib/date';
import { Bell, Check, CheckCheck, AlertTriangle, Info, AlertCircle } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useNotificationStore } from '@/stores/notification.store';
export default function NotificationsPage() {
const [notifications, setNotifications] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [filter, setFilter] = useState<'all' | 'unread'>('all');
const { fetchUnreadCount } = useNotificationStore();
useEffect(() => {
loadNotifications();
}, [filter]);
const loadNotifications = async () => {
try {
const params: any = { limit: 100, sortOrder: 'desc' };
if (filter === 'unread') params.isRead = false;
const res = await apiGet('/notifications', params);
setNotifications(res.data || []);
} catch (err) {
console.error('Failed to load notifications:', err);
} finally {
setIsLoading(false);
}
};
const handleMarkAllRead = async () => {
try {
await apiPut('/notifications/read-all');
loadNotifications();
fetchUnreadCount();
} catch (err) {
console.error('Failed to mark all as read:', err);
}
};
const handleMarkRead = async (id: string) => {
try {
await apiPut(`/notifications/${id}/read`);
setNotifications((prev) =>
prev.map((n) => (n.id === id ? { ...n, isRead: true } : n)),
);
fetchUnreadCount();
} catch { /* ok */ }
};
const getIcon = (type: string) => {
switch (type) {
case 'BLOCKING': return <AlertTriangle size={16} className="text-red-500" />;
case 'IMPORTANT': return <AlertCircle size={16} className="text-yellow-500" />;
default: return <Info size={16} className="text-blue-500" />;
}
};
if (isLoading) return <PageLoadingSkeleton />;
return (
<div className="space-y-6 max-w-3xl mx-auto">
<PageHeader
title="Notifications"
description="All your notifications"
actions={
<button
onClick={handleMarkAllRead}
className="flex items-center gap-2 px-3 py-2 text-sm rounded-lg border hover:bg-accent transition-colors"
>
<CheckCheck size={14} />
Mark all read
</button>
}
/>
{/* Filter */}
<div className="flex gap-2">
<button
onClick={() => setFilter('all')}
className={cn(
'px-3 py-1.5 text-sm rounded-lg transition-colors',
filter === 'all' ? 'bg-accent font-medium' : 'hover:bg-accent/50',
)}
>
All
</button>
<button
onClick={() => setFilter('unread')}
className={cn(
'px-3 py-1.5 text-sm rounded-lg transition-colors',
filter === 'unread' ? 'bg-accent font-medium' : 'hover:bg-accent/50',
)}
>
Unread
</button>
</div>
{notifications.length === 0 ? (
<EmptyState
icon={Bell}
title="No notifications"
description={filter === 'unread' ? "You're all caught up!" : 'No notifications yet.'}
/>
) : (
<div className="bg-card rounded-xl border divide-y">
{notifications.map((notif) => (
<div
key={notif.id}
className={cn(
'p-4 flex gap-3 transition-colors',
!notif.isRead && 'bg-accent/30',
)}
>
<div className="mt-0.5">{getIcon(notif.type)}</div>
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<h4 className={cn('text-sm', !notif.isRead && 'font-semibold')}>
{notif.title}
</h4>
<span className="text-[10px] text-muted-foreground whitespace-nowrap">
{relativeTime(notif.createdAt)}
</span>
</div>
{notif.message && (
<p className="text-sm text-muted-foreground mt-0.5 line-clamp-2">
{notif.message}
</p>
)}
{!notif.isRead && (
<button
onClick={() => handleMarkRead(notif.id)}
className="text-xs text-primary mt-1 hover:underline"
>
Mark as read
</button>
)}
</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 { StatusBadge } from '@/components/shared/status-badge';
import { formatEgp, cn } from '@/lib/utils';
import { formatDate, formatMonthYear } from '@/lib/date';
import {
Wallet, TrendingDown, TrendingUp, Calendar, ChevronLeft, ChevronRight,
} from 'lucide-react';
export default function SalaryPage() {
const user = useAuthStore((s) => s.user);
const [hudData, setHudData] = useState<any>(null);
const [deductions, setDeductions] = useState<any[]>([]);
const [bounties, setBounties] = useState<any[]>([]);
const [adjustments, setAdjustments] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [month, setMonth] = useState(new Date().getMonth() + 1);
const [year, setYear] = useState(new Date().getFullYear());
useEffect(() => {
loadData();
}, [month, year]);
const loadData = async () => {
setIsLoading(true);
try {
const [hudRes, dedRes, bountyRes, adjRes] = await Promise.all([
apiGet(`/salary/hud`),
apiGet('/deductions', { payrollMonth: month, payrollYear: year, limit: 50 }),
apiGet(`/bounties/my`, { month, year }),
apiGet('/adjustments', { limit: 50 }),
]);
setHudData(hudRes.data);
setDeductions(dedRes.data || []);
setBounties(bountyRes.data || []);
setAdjustments(adjRes.data || []);
} catch (err) {
console.error('Failed to load salary data:', err);
} finally {
setIsLoading(false);
}
};
const prevMonth = () => {
if (month === 1) { setMonth(12); setYear((y) => y - 1); }
else setMonth((m) => m - 1);
};
const nextMonth = () => {
const now = new Date();
if (year === now.getFullYear() && month >= now.getMonth() + 1) return;
if (month === 12) { setMonth(1); setYear((y) => y + 1); }
else setMonth((m) => m + 1);
};
if (isLoading) return <PageLoadingSkeleton />;
const totalDeductions = deductions
.filter((d) => ['UPHELD', 'REDUCED', 'AUTO_APPLIED'].includes(d.status))
.reduce((sum, d) => sum + (d.appliedAmountPiasters || d.amountPiasters || 0), 0);
const totalBounties = bounties.reduce((sum, b) => sum + (b.amountPiasters || 0), 0);
return (
<div className="space-y-6">
<PageHeader
title="Salary & Earnings"
description="Your financial overview"
/>
{/* Month Selector */}
<div className="flex items-center justify-center gap-4">
<button onClick={prevMonth} className="p-2 rounded-md hover:bg-accent">
<ChevronLeft size={16} />
</button>
<span className="text-sm font-semibold w-40 text-center">
{formatMonthYear(month, year)}
</span>
<button onClick={nextMonth} className="p-2 rounded-md hover:bg-accent">
<ChevronRight size={16} />
</button>
</div>
{/* Summary Cards */}
<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-2">
<Wallet size={16} />
<span className="text-xs font-medium uppercase tracking-wider">Actual Salary</span>
</div>
<p className="text-2xl font-bold">{formatEgp(hudData?.actualSalaryPiasters || 0)}</p>
</div>
<div className="bg-card rounded-xl border p-4">
<div className="flex items-center gap-2 text-red-500 mb-2">
<TrendingDown size={16} />
<span className="text-xs font-medium uppercase tracking-wider">Deductions</span>
</div>
<p className="text-2xl font-bold text-red-500">-{formatEgp(totalDeductions)}</p>
<p className="text-xs text-muted-foreground">{deductions.length} this month</p>
</div>
<div className="bg-card rounded-xl border p-4">
<div className="flex items-center gap-2 text-emerald-500 mb-2">
<TrendingUp size={16} />
<span className="text-xs font-medium uppercase tracking-wider">Bounties</span>
</div>
<p className="text-2xl font-bold text-emerald-500">+{formatEgp(totalBounties)}</p>
<p className="text-xs text-muted-foreground">{bounties.length} earned</p>
</div>
</div>
{/* Deductions */}
{deductions.length > 0 && (
<div className="bg-card rounded-xl border">
<div className="p-4 border-b">
<h3 className="font-semibold flex items-center gap-2">
<TrendingDown size={16} className="text-red-500" />
Deductions
</h3>
</div>
<div className="divide-y">
{deductions.map((d) => (
<div key={d.id} className="p-4 flex items-center justify-between">
<div>
<div className="flex items-center gap-2 mb-1">
<StatusBadge status={d.status} />
<span className="text-xs text-muted-foreground">
{d.category}{d.subCategory}
</span>
</div>
<p className="text-sm">{d.description?.substring(0, 100)}...</p>
<p className="text-xs text-muted-foreground mt-1">
{d.violationDate ? formatDate(d.violationDate) : ''}
</p>
</div>
<span className="text-sm font-bold text-red-500 shrink-0 ml-4">
-{formatEgp(d.appliedAmountPiasters || d.amountPiasters || 0)}
</span>
</div>
))}
</div>
</div>
)}
{/* Bounties */}
{bounties.length > 0 && (
<div className="bg-card rounded-xl border">
<div className="p-4 border-b">
<h3 className="font-semibold flex items-center gap-2">
<TrendingUp size={16} className="text-emerald-500" />
Bounties Earned
</h3>
</div>
<div className="divide-y">
{bounties.map((b) => (
<div key={b.id} className="p-4 flex items-center justify-between">
<div>
<p className="text-sm font-medium">{b.cardTitle || b.cardNumber}</p>
<p className="text-xs text-muted-foreground">
{b.paidAt ? formatDate(b.paidAt) : ''}
{b.splitPercentage && b.splitPercentage < 100 && ` · ${b.splitPercentage}% split`}
</p>
</div>
<span className="text-sm font-bold text-emerald-500 shrink-0 ml-4">
+{formatEgp(b.amountPiasters)}
</span>
</div>
))}
</div>
</div>
)}
</div>
);
}
\ No newline at end of file
'use client';
import { useRouter } from 'next/navigation';
import { Users, Settings, List, Calendar, Activity, LayoutGrid } from 'lucide-react';
import { PermissionGate } from '@/components/shared/permission-gate';
import { cn } from '@/lib/utils';
interface BoardHeaderProps {
board: any;
onRefresh: () => void;
}
export function BoardHeader({ board, onRefresh }: BoardHeaderProps) {
const router = useRouter();
return (
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div
className="w-8 h-8 rounded-lg flex items-center justify-center text-sm"
style={{ backgroundColor: board.color ? `${board.color}20` : 'hsl(var(--accent))' }}
>
{board.icon || '📋'}
</div>
<div>
<h1 className="text-lg font-bold">{board.name}</h1>
{board.description && (
<p className="text-xs text-muted-foreground">{board.description}</p>
)}
</div>
<span className="text-[10px] font-mono text-muted-foreground bg-muted px-1.5 py-0.5 rounded">
{board.key}
</span>
</div>
<div className="flex items-center gap-1">
<button
onClick={() => router.push(`/boards/${board.id}`)}
className="p-2 rounded-md bg-accent text-accent-foreground text-sm"
title="Board View"
>
<LayoutGrid size={16} />
</button>
<button
onClick={() => router.push(`/boards/${board.id}/list`)}
className="p-2 rounded-md hover:bg-accent text-muted-foreground hover:text-foreground transition-colors text-sm"
title="List View"
>
<List size={16} />
</button>
<button
onClick={() => router.push(`/boards/${board.id}/calendar`)}
className="p-2 rounded-md hover:bg-accent text-muted-foreground hover:text-foreground transition-colors text-sm"
title="Calendar View"
>
<Calendar size={16} />
</button>
<button
onClick={() => router.push(`/boards/${board.id}/activity`)}
className="p-2 rounded-md hover:bg-accent text-muted-foreground hover:text-foreground transition-colors text-sm"
title="Activity"
>
<Activity size={16} />
</button>
<div className="w-px h-6 bg-border mx-1" />
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<Users size={14} />
<span>{board.memberCount}</span>
</div>
<PermissionGate roles={['SUPER_ADMIN', 'ADMIN']}>
<button
onClick={() => router.push(`/boards/${board.id}/settings`)}
className="p-2 rounded-md hover:bg-accent text-muted-foreground hover:text-foreground transition-colors ml-1"
title="Board Settings"
>
<Settings size={16} />
</button>
</PermissionGate>
</div>
</div>
);
}
\ No newline at end of file
'use client';
import { useState, useMemo } from 'react';
import { KanbanColumn } from '@/components/kanban/column';
import { CardDetailPanel } from '@/components/kanban/card-detail';
import { useAuthStore } from '@/stores/auth.store';
interface KanbanBoardProps {
board: any;
cards: any[];
onCardMoved: (cardId: string, columnId: string, position: number, frozenReason?: string) => Promise<void>;
onCardCreated: () => void;
onRefresh: () => void;
}
export function KanbanBoard({ board, cards, onCardMoved, onCardCreated, onRefresh }: KanbanBoardProps) {
const user = useAuthStore((s) => s.user);
const [selectedCardId, setSelectedCardId] = useState<string | null>(null);
const [dragState, setDragState] = useState<{ cardId: string; sourceColumnId: string } | null>(null);
const columns = useMemo(() => board.columns || [], [board.columns]);
const cardsByColumn = useMemo(() => {
const map: Record<string, any[]> = {};
for (const col of columns) {
map[col.id] = [];
}
for (const card of cards) {
if (map[card.columnId]) {
map[card.columnId].push(card);
}
}
// Sort by position
for (const colId of Object.keys(map)) {
map[colId].sort((a, b) => (a.position || 0) - (b.position || 0));
}
return map;
}, [cards, columns]);
const handleDragStart = (cardId: string, sourceColumnId: string) => {
setDragState({ cardId, sourceColumnId });
};
const handleDrop = async (targetColumnId: string) => {
if (!dragState) return;
const { cardId, sourceColumnId } = dragState;
if (sourceColumnId === targetColumnId) {
setDragState(null);
return;
}
const targetCol = columns.find((c: any) => c.id === targetColumnId);
if (!targetCol) return;
// Check if moving to Frozen — need reason
if (targetCol.type === 'FROZEN') {
const reason = prompt('Why is this card frozen? (min 20 characters)');
if (!reason || reason.length < 20) {
setDragState(null);
return;
}
const targetCards = cardsByColumn[targetColumnId] || [];
const position = targetCards.length > 0 ? Math.max(...targetCards.map((c) => c.position || 0)) + 1 : 1;
await onCardMoved(cardId, targetColumnId, position, reason);
} else {
// Check contractor can't move to Done
if (targetCol.type === 'DONE' && user?.role === 'CONTRACTOR') {
setDragState(null);
return;
}
const targetCards = cardsByColumn[targetColumnId] || [];
const position = targetCards.length > 0 ? Math.max(...targetCards.map((c) => c.position || 0)) + 1 : 1;
await onCardMoved(cardId, targetColumnId, position);
}
setDragState(null);
};
return (
<>
<div className="flex gap-4 overflow-x-auto px-6 pb-6 min-h-[calc(100vh-12rem)]">
{columns.map((column: any) => (
<KanbanColumn
key={column.id}
column={column}
cards={cardsByColumn[column.id] || []}
board={board}
isDragOver={false}
onCardClick={(cardId) => setSelectedCardId(cardId)}
onCardCreated={onCardCreated}
onDragStart={handleDragStart}
onDrop={() => handleDrop(column.id)}
/>
))}
</div>
{selectedCardId && (
<CardDetailPanel
cardId={selectedCardId}
boardId={board.id}
onClose={() => setSelectedCardId(null)}
onUpdated={onRefresh}
/>
)}
</>
);
}
\ No newline at end of file
'use client';
import { useEffect, useState } from 'react';
import { apiGet, apiPut, apiPost, apiDelete } from '@/lib/api';
import { useAuthStore } from '@/stores/auth.store';
import { StatusBadge } from '@/components/shared/status-badge';
import { UserAvatar } from '@/components/shared/user-avatar';
import { formatDateTime, relativeTime } from '@/lib/date';
import { formatEgp } from '@/lib/utils';
import {
X, MessageSquare, Paperclip, CheckSquare, Clock, User,
Tag, Calendar, AlertTriangle, Eye, EyeOff, Send, Loader2,
} from 'lucide-react';
import { toast } from 'sonner';
interface CardDetailPanelProps {
cardId: string;
boardId: string;
onClose: () => void;
onUpdated: () => void;
}
export function CardDetailPanel({ cardId, boardId, onClose, onUpdated }: CardDetailPanelProps) {
const user = useAuthStore((s) => s.user);
const [card, setCard] = useState<any>(null);
const [comments, setComments] = useState<any[]>([]);
const [activity, setActivity] = useState<any[]>([]);
const [checklists, setChecklists] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [newComment, setNewComment] = useState('');
const [isSendingComment, setIsSendingComment] = useState(false);
const [activeTab, setActiveTab] = useState<'comments' | 'activity' | 'all'>('all');
useEffect(() => {
loadCard();
loadComments();
loadActivity();
loadChecklists();
}, [cardId]);
const loadCard = async () => {
try {
const res = await apiGet(`/cards/${cardId}`);
setCard(res.data);
} catch (err) {
toast.error('Failed to load card');
} finally {
setIsLoading(false);
}
};
const loadComments = async () => {
try {
const res = await apiGet(`/comments/card/${cardId}`);
setComments(res.data || []);
} catch { /* ok */ }
};
const loadActivity = async () => {
try {
const res = await apiGet(`/cards/${cardId}/activity`);
setActivity(res.data || []);
} catch { /* ok */ }
};
const loadChecklists = async () => {
try {
const res = await apiGet(`/checklists/card/${cardId}`);
setChecklists(res.data || []);
} catch { /* ok */ }
};
const handleComment = async () => {
if (!newComment.trim()) return;
setIsSendingComment(true);
try {
await apiPost('/comments', { cardId, content: newComment.trim() });
setNewComment('');
loadComments();
} catch (err: any) {
toast.error(err.message || 'Failed to post comment');
} finally {
setIsSendingComment(false);
}
};
const handleToggleWatch = async () => {
try {
const isWatching = card.watchers?.some((w: any) => w.id === user?.id);
if (isWatching) {
await apiDelete(`/cards/${cardId}/watch`);
} else {
await apiPost(`/cards/${cardId}/watch`);
}
loadCard();
} catch (err: any) {
toast.error(err.message || 'Failed to update watch status');
}
};
const handleToggleChecklistItem = async (itemId: string, isCompleted: boolean) => {
try {
await apiPut(`/checklists/items/${itemId}/toggle`, { isCompleted });
loadChecklists();
onUpdated();
} catch (err: any) {
toast.error(err.message || 'Failed to toggle item');
}
};
if (isLoading || !card) {
return (
<div className="fixed inset-0 z-50 bg-black/50 flex justify-end" onClick={onClose}>
<div className="w-full max-w-2xl bg-card h-full animate-slide-in-right p-6">
<div className="animate-pulse space-y-4">
<div className="h-6 bg-muted rounded w-1/3" />
<div className="h-4 bg-muted rounded w-2/3" />
<div className="h-32 bg-muted rounded" />
</div>
</div>
</div>
);
}
const isWatching = card.watchers?.some((w: any) => w.id === user?.id);
return (
<div className="fixed inset-0 z-50 bg-black/50 flex justify-end" onClick={onClose}>
<div
className="w-full max-w-2xl bg-card h-full overflow-y-auto shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="sticky top-0 bg-card border-b px-6 py-4 flex items-start justify-between z-10">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 text-xs text-muted-foreground mb-1">
<span>{card.boardKey}</span>
<span>·</span>
<span>{card.cardNumber}</span>
<span>·</span>
<span>{card.columnName}</span>
</div>
<h2 className="text-lg font-bold leading-tight">{card.title}</h2>
</div>
<div className="flex items-center gap-1 ml-4">
<button
onClick={handleToggleWatch}
className="p-2 rounded-md hover:bg-accent text-muted-foreground hover:text-foreground"
title={isWatching ? 'Stop watching' : 'Watch this card'}
>
{isWatching ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
<button
onClick={onClose}
className="p-2 rounded-md hover:bg-accent text-muted-foreground hover:text-foreground"
>
<X size={16} />
</button>
</div>
</div>
<div className="flex">
{/* Main content */}
<div className="flex-1 p-6 space-y-6">
{/* Description */}
{card.description && (
<div>
<h3 className="text-sm font-semibold mb-2">Description</h3>
<div className="text-sm text-muted-foreground whitespace-pre-wrap">
{card.description}
</div>
</div>
)}
{/* Checklists */}
{checklists.length > 0 && (
<div className="space-y-4">
{checklists.map((cl) => (
<div key={cl.id}>
<div className="flex items-center justify-between mb-2">
<h3 className="text-sm font-semibold flex items-center gap-2">
<CheckSquare size={14} />
{cl.title}
</h3>
<span className="text-xs text-muted-foreground">
{cl.progress.completed}/{cl.progress.total}
</span>
</div>
<div className="h-1.5 bg-muted rounded-full overflow-hidden mb-2">
<div
className="h-full bg-emerald-500 rounded-full transition-all"
style={{
width: cl.progress.total > 0
? `${(cl.progress.completed / cl.progress.total) * 100}%`
: '0%',
}}
/>
</div>
<div className="space-y-1">
{cl.items.map((item: any) => (
<label
key={item.id}
className="flex items-center gap-2 p-1.5 rounded hover:bg-accent/50 cursor-pointer"
>
<input
type="checkbox"
checked={item.isCompleted}
onChange={(e) => handleToggleChecklistItem(item.id, e.target.checked)}
className="rounded"
/>
<span className={`text-sm ${item.isCompleted ? 'line-through text-muted-foreground' : ''}`}>
{item.title}
</span>
</label>
))}
</div>
</div>
))}
</div>
)}
{/* Comments & Activity */}
<div>
<div className="flex items-center gap-2 mb-3">
<button
onClick={() => setActiveTab('all')}
className={`text-xs font-medium px-2 py-1 rounded ${activeTab === 'all' ? 'bg-accent' : 'hover:bg-accent/50'}`}
>
All
</button>
<button
onClick={() => setActiveTab('comments')}
className={`text-xs font-medium px-2 py-1 rounded ${activeTab === 'comments' ? 'bg-accent' : 'hover:bg-accent/50'}`}
>
Comments ({comments.length})
</button>
<button
onClick={() => setActiveTab('activity')}
className={`text-xs font-medium px-2 py-1 rounded ${activeTab === 'activity' ? 'bg-accent' : 'hover:bg-accent/50'}`}
>
Activity ({activity.length})
</button>
</div>
{/* Comment input */}
<div className="flex gap-2 mb-4">
<textarea
value={newComment}
onChange={(e) => setNewComment(e.target.value)}
placeholder="Write a comment..."
rows={2}
className="flex-1 px-3 py-2 rounded-lg border bg-background text-sm resize-none focus:outline-none focus:ring-2 focus:ring-ring"
onKeyDown={(e) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
handleComment();
}
}}
/>
<button
onClick={handleComment}
disabled={!newComment.trim() || isSendingComment}
className="self-end p-2.5 bg-primary text-primary-foreground rounded-lg disabled:opacity-50 hover:bg-primary/90"
>
{isSendingComment ? <Loader2 size={16} className="animate-spin" /> : <Send size={16} />}
</button>
</div>
{/* Feed */}
<div className="space-y-3">
{(activeTab === 'comments' || activeTab === 'all') &&
comments.map((comment) => (
<div key={comment.id} className="flex gap-3">
<UserAvatar
firstName={comment.user?.firstName || '?'}
lastName={comment.user?.lastName || '?'}
avatar={comment.user?.avatar}
size="sm"
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">
{comment.user?.firstName} {comment.user?.lastName}
</span>
<span className="text-[10px] text-muted-foreground">
{relativeTime(comment.createdAt)}
</span>
{comment.isEdited && (
<span className="text-[10px] text-muted-foreground">(edited)</span>
)}
</div>
<p className="text-sm text-muted-foreground mt-0.5 whitespace-pre-wrap">
{comment.content}
</p>
</div>
</div>
))}
{(activeTab === 'activity' || activeTab === 'all') &&
activity.slice(0, 20).map((act) => (
<div key={act.id} className="flex gap-3 text-xs text-muted-foreground">
<div className="w-8 flex justify-center pt-0.5">
<div className="w-1.5 h-1.5 rounded-full bg-muted-foreground/40" />
</div>
<div>
<span className="font-medium text-foreground">
{act.user?.firstName} {act.user?.lastName}
</span>{' '}
{act.action.toLowerCase().replace(/_/g, ' ')}
{act.metadata?.fromColumn && act.metadata?.toColumn && (
<span>
{' '}from <strong>{act.metadata.fromColumn}</strong> to{' '}
<strong>{act.metadata.toColumn}</strong>
</span>
)}
<span className="ml-2">{relativeTime(act.createdAt)}</span>
</div>
</div>
))}
</div>
</div>
</div>
{/* Sidebar */}
<div className="w-56 border-l p-4 space-y-5 bg-muted/20">
{/* Assignees */}
<div>
<h4 className="text-[10px] font-semibold uppercase text-muted-foreground mb-2">Assignees</h4>
{card.assignees?.length > 0 ? (
<div className="space-y-1.5">
{card.assignees.map((a: any) => (
<div key={a.id} className="flex items-center gap-2">
<UserAvatar firstName={a.firstName} lastName={a.lastName} avatar={a.avatar} size="xs" />
<span className="text-xs truncate">{a.firstName} {a.lastName}</span>
</div>
))}
</div>
) : (
<p className="text-xs text-muted-foreground">Unassigned</p>
)}
</div>
{/* Labels */}
<div>
<h4 className="text-[10px] font-semibold uppercase text-muted-foreground mb-2">Labels</h4>
{card.labels?.length > 0 ? (
<div className="flex flex-wrap gap-1">
{card.labels.map((l: any) => (
<span
key={l.id}
className="px-1.5 py-0.5 rounded text-[10px] font-medium"
style={{ backgroundColor: `${l.color}20`, color: l.color }}
>
{l.name}
</span>
))}
</div>
) : (
<p className="text-xs text-muted-foreground">No labels</p>
)}
</div>
{/* Deadline */}
<div>
<h4 className="text-[10px] font-semibold uppercase text-muted-foreground mb-2">Deadline</h4>
{card.dueDate ? (
<span className={`text-xs ${card.isOverdue ? 'text-red-500 font-medium' : ''}`}>
{formatDateTime(card.dueDate)}
</span>
) : (
<p className="text-xs text-muted-foreground">No deadline</p>
)}
</div>
{/* Priority */}
<div>
<h4 className="text-[10px] font-semibold uppercase text-muted-foreground mb-2">Priority</h4>
<StatusBadge status={card.priority || 'NONE'} />
</div>
{/* Bounty */}
{card.bountyPiasters > 0 && (
<div>
<h4 className="text-[10px] font-semibold uppercase text-muted-foreground mb-2">Bounty</h4>
<span className="text-sm font-bold text-amber-500">{formatEgp(card.bountyPiasters)}</span>
</div>
)}
{/* Meta */}
<div className="space-y-2 pt-3 border-t">
<div className="text-[10px] text-muted-foreground">
<span className="font-medium">Created by:</span>{' '}
{card.createdBy?.firstName} {card.createdBy?.lastName}
</div>
<div className="text-[10px] text-muted-foreground">
<span className="font-medium">Created:</span>{' '}
{formatDateTime(card.createdAt)}
</div>
{card.completedAt && (
<div className="text-[10px] text-muted-foreground">
<span className="font-medium">Completed:</span>{' '}
{formatDateTime(card.completedAt)}
</div>
)}
{card.cycleTimeHours != null && (
<div className="text-[10px] text-muted-foreground">
<span className="font-medium">Cycle time:</span>{' '}
{Math.round(card.cycleTimeHours * 10) / 10}h
</div>
)}
</div>
</div>
</div>
</div>
</div>
);
}
\ No newline at end of file
'use client';
import { StatusBadge } from '@/components/shared/status-badge';
import { UserAvatar } from '@/components/shared/user-avatar';
import { formatEgp, cn } from '@/lib/utils';
import { formatShortDate, isOverdue } from '@/lib/date';
import { MessageSquare, Paperclip, CheckSquare, Coins, Clock, Snowflake } from 'lucide-react';
interface KanbanCardProps {
card: any;
onClick: () => void;
onDragStart: () => void;
}
export function KanbanCard({ card, onClick, onDragStart }: KanbanCardProps) {
const cardIsOverdue = card.dueDate && isOverdue(card.dueDate) && !card.completedAt;
return (
<div
className="bg-card rounded-lg border p-3 cursor-pointer hover:border-primary/30 hover:shadow-sm transition-all group"
onClick={onClick}
draggable
onDragStart={(e) => {
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', card.id);
onDragStart();
}}
>
{/* Cover image */}
{card.coverImage && (
<div className="rounded-md overflow-hidden mb-2 -mx-1 -mt-1">
<img src={card.coverImage} alt="" className="w-full h-24 object-cover" />
</div>
)}
{/* Labels */}
{card.labels?.length > 0 && (
<div className="flex flex-wrap gap-1 mb-2">
{card.labels.slice(0, 3).map((label: any) => (
<span
key={label.id}
className="px-1.5 py-0.5 rounded text-[10px] font-medium"
style={{
backgroundColor: `${label.color}20`,
color: label.color,
}}
>
{label.name}
</span>
))}
{card.labels.length > 3 && (
<span className="text-[10px] text-muted-foreground">
+{card.labels.length - 3}
</span>
)}
</div>
)}
{/* Frozen banner */}
{card.frozenReason && (
<div className="flex items-center gap-1 text-[10px] text-blue-500 bg-blue-500/10 rounded px-1.5 py-0.5 mb-2">
<Snowflake size={10} />
Frozen
</div>
)}
{/* Title */}
<p className="text-sm font-medium leading-snug">{card.title}</p>
<p className="text-[10px] text-muted-foreground mt-0.5">{card.cardNumber}</p>
{/* Metadata row */}
<div className="flex items-center gap-2 mt-2 text-[10px] text-muted-foreground flex-wrap">
{card.commentCount > 0 && (
<span className="flex items-center gap-0.5">
<MessageSquare size={10} /> {card.commentCount}
</span>
)}
{card.attachmentCount > 0 && (
<span className="flex items-center gap-0.5">
<Paperclip size={10} /> {card.attachmentCount}
</span>
)}
{card.checklistProgress && (
<span className="flex items-center gap-0.5">
<CheckSquare size={10} /> {card.checklistProgress.completed}/{card.checklistProgress.total}
</span>
)}
{card.bountyPiasters > 0 && (
<span className="flex items-center gap-0.5 text-amber-500 font-medium">
<Coins size={10} /> {formatEgp(card.bountyPiasters)}
</span>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-between mt-2">
<div className="flex items-center gap-1">
{card.dueDate && (
<span
className={cn(
'flex items-center gap-0.5 text-[10px] px-1.5 py-0.5 rounded',
cardIsOverdue
? 'bg-red-500/10 text-red-500 font-medium'
: 'text-muted-foreground',
)}
>
<Clock size={10} />
{formatShortDate(card.dueDate)}
</span>
)}
{card.priority && card.priority !== 'NONE' && (
<StatusBadge status={card.priority} className="text-[10px]" />
)}
</div>
{card.assignees?.length > 0 && (
<div className="flex -space-x-1">
{card.assignees.slice(0, 3).map((a: any) => (
<UserAvatar
key={a.id}
firstName={a.firstName}
lastName={a.lastName}
avatar={a.avatar}
size="xs"
className="ring-2 ring-card"
/>
))}
{card.assignees.length > 3 && (
<span className="w-6 h-6 rounded-full bg-muted text-[9px] font-bold flex items-center justify-center ring-2 ring-card">
+{card.assignees.length - 3}
</span>
)}
</div>
)}
</div>
</div>
);
}
\ No newline at end of file
'use client';
import { useState } from 'react';
import { KanbanCard } from '@/components/kanban/card';
import { apiPost } from '@/lib/api';
import { useAuthStore } from '@/stores/auth.store';
import { Plus } from 'lucide-react';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
interface KanbanColumnProps {
column: any;
cards: any[];
board: any;
isDragOver: boolean;
onCardClick: (cardId: string) => void;
onCardCreated: () => void;
onDragStart: (cardId: string, columnId: string) => void;
onDrop: () => void;
}
export function KanbanColumn({
column,
cards,
board,
isDragOver,
onCardClick,
onCardCreated,
onDragStart,
onDrop,
}: KanbanColumnProps) {
const user = useAuthStore((s) => s.user);
const [isAdding, setIsAdding] = useState(false);
const [newTitle, setNewTitle] = useState('');
const canAdd =
column.type === 'BACKLOG' &&
(user?.role !== 'CONTRACTOR' || board.allowContractorCreation);
const handleQuickAdd = async () => {
if (!newTitle.trim()) return;
try {
await apiPost('/cards', {
boardId: board.id,
columnId: column.id,
title: newTitle.trim(),
});
setNewTitle('');
setIsAdding(false);
onCardCreated();
toast.success('Card created');
} catch (err: any) {
toast.error(err.message || 'Failed to create card');
}
};
const wipDisplay = column.wipLimitTotal
? `${cards.length}/${column.wipLimitTotal}`
: null;
const isOverWip = column.wipLimitTotal && cards.length >= column.wipLimitTotal;
return (
<div
className={cn(
'flex-shrink-0 w-72 bg-muted/30 rounded-xl flex flex-col max-h-[calc(100vh-14rem)]',
isDragOver && 'ring-2 ring-primary/50',
)}
onDragOver={(e) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
}}
onDrop={(e) => {
e.preventDefault();
onDrop();
}}
>
{/* Column Header */}
<div className="p-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-sm">{column.icon || '📂'}</span>
<h3 className="text-sm font-semibold">{column.name}</h3>
<span className="text-xs text-muted-foreground bg-muted rounded-full px-1.5 py-0.5">
{cards.length}
</span>
</div>
{wipDisplay && (
<span
className={cn(
'text-[10px] font-mono px-1.5 py-0.5 rounded',
isOverWip ? 'bg-red-500/10 text-red-500' : 'bg-muted text-muted-foreground',
)}
>
WIP: {wipDisplay}
</span>
)}
</div>
{/* Cards */}
<div className="flex-1 overflow-y-auto px-2 pb-2 space-y-2">
{cards.map((card) => (
<KanbanCard
key={card.id}
card={card}
onClick={() => onCardClick(card.id)}
onDragStart={() => onDragStart(card.id, column.id)}
/>
))}
{/* Quick Add */}
{canAdd && (
<div className="pt-1">
{isAdding ? (
<div className="space-y-2">
<textarea
value={newTitle}
onChange={(e) => setNewTitle(e.target.value)}
placeholder="Enter card title..."
autoFocus
rows={2}
className="w-full px-3 py-2 rounded-lg border bg-card text-sm resize-none focus:outline-none focus:ring-2 focus:ring-ring"
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleQuickAdd();
}
if (e.key === 'Escape') {
setIsAdding(false);
setNewTitle('');
}
}}
/>
<div className="flex gap-2">
<button
onClick={handleQuickAdd}
disabled={!newTitle.trim()}
className="px-3 py-1.5 text-xs bg-primary text-primary-foreground rounded-md font-medium hover:bg-primary/90 disabled:opacity-50"
>
Add Card
</button>
<button
onClick={() => { setIsAdding(false); setNewTitle(''); }}
className="px-3 py-1.5 text-xs rounded-md hover:bg-accent"
>
Cancel
</button>
</div>
</div>
) : (
<button
onClick={() => setIsAdding(true)}
className="w-full flex items-center gap-1 px-3 py-2 text-xs text-muted-foreground hover:text-foreground hover:bg-accent/50 rounded-lg transition-colors"
>
<Plus size={14} />
Add a card
</button>
)}
</div>
)}
</div>
</div>
);
}
\ No newline at end of file
'use client';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { useNotificationStore } from '@/stores/notification.store'; import { useNotificationStore } from '@/stores/notification.store';
import { useSocket } from './use-socket'; import { getSocket } from '@/lib/socket';
import { useAuthStore } from '@/stores/auth.store'; import { useAuthStore } from '@/stores/auth.store';
import { apiGet } from '@/lib/api';
export function useNotifications() { export function useNotifications() {
const { on } = useSocket(); const { isAuthenticated } = useAuthStore();
const user = useAuthStore((s) => s.user); const { fetchUnreadCount, fetchBlockingNotifications, addBlocking, setUnreadCount } =
const { setNotifications, addNotification, setUnreadCount } = useNotificationStore(); useNotificationStore();
// Load initial notifications
useEffect(() => { useEffect(() => {
if (!user) return; if (!isAuthenticated) return;
apiGet('/notifications', { limit: 50 })
.then((res) => {
if (res.data?.data) {
setNotifications(res.data.data);
} else if (Array.isArray(res.data)) {
setNotifications(res.data);
}
})
.catch((err) => {
console.error('Failed to load notifications:', err);
});
// Get unread count fetchUnreadCount();
apiGet('/notifications', { limit: 1 }) fetchBlockingNotifications();
.then((res) => {
if (res.meta?.total !== undefined) {
// This is rough - ideally we'd have a dedicated unread count endpoint
}
})
.catch(() => {});
}, [user, setNotifications, setUnreadCount]);
// Listen for real-time notifications try {
useEffect(() => { const socket = getSocket();
if (!user) return;
const unsub1 = on('notification:new', (notification: any) => { socket.on('notification:new', (data: any) => {
addNotification(notification); setUnreadCount(useNotificationStore.getState().unreadCount + 1);
}); });
const unsub2 = on('notification:blocking', (notification: any) => { socket.on('notification:blocking', (data: any) => {
addNotification({ ...notification, isBlocking: true }); addBlocking({
}); id: data.id,
title: data.title,
message: data.message,
type: data.type,
});
});
return () => { return () => {
unsub1(); socket.off('notification:new');
unsub2(); socket.off('notification:blocking');
}; };
}, [user, on, addNotification]); } catch {
// Socket not available
}
}, [isAuthenticated]);
} }
\ No newline at end of file
import { create } from 'zustand'; import { create } from 'zustand';
import { apiGet } from '@/lib/api';
interface Notification { interface BlockingNotification {
id: string; id: string;
type: 'BLOCKING' | 'IMPORTANT' | 'INFORMATIONAL';
category: string;
title: string; title: string;
message: string; message: string;
actionUrl?: string; type: string;
isRead: boolean;
isBlocking: boolean;
acknowledgedAt: string | null;
entityType?: string;
entityId?: string;
createdAt: string;
} }
interface NotificationState { interface NotificationState {
notifications: Notification[];
unreadCount: number; unreadCount: number;
blockingQueue: Notification[]; blockingQueue: BlockingNotification[];
isDropdownOpen: boolean;
setNotifications: (notifications: Notification[]) => void;
addNotification: (notification: Notification) => void;
markAsRead: (id: string) => void;
markAllAsRead: () => void;
acknowledgeBlocking: (id: string) => void;
setUnreadCount: (count: number) => void; setUnreadCount: (count: number) => void;
toggleDropdown: () => void; addBlocking: (notification: BlockingNotification) => void;
closeDropdown: () => void; acknowledgeBlocking: (id: string) => void;
reset: () => void; fetchUnreadCount: () => Promise<void>;
fetchBlockingNotifications: () => Promise<void>;
} }
export const useNotificationStore = create<NotificationState>((set, get) => ({ export const useNotificationStore = create<NotificationState>((set, get) => ({
notifications: [],
unreadCount: 0, unreadCount: 0,
blockingQueue: [], blockingQueue: [],
isDropdownOpen: false,
setNotifications: (notifications) => { setUnreadCount: (count) => set({ unreadCount: count }),
const unreadCount = notifications.filter((n) => !n.isRead).length;
const blockingQueue = notifications.filter(
(n) => n.isBlocking && !n.acknowledgedAt,
);
set({ notifications, unreadCount, blockingQueue });
},
addNotification: (notification) => { addBlocking: (notification) =>
set((state) => { set((state) => {
const notifications = [notification, ...state.notifications]; const exists = state.blockingQueue.some((n) => n.id === notification.id);
const unreadCount = state.unreadCount + (notification.isRead ? 0 : 1); if (exists) return state;
const blockingQueue = return { blockingQueue: [...state.blockingQueue, notification] };
notification.isBlocking && !notification.acknowledgedAt }),
? [...state.blockingQueue, notification]
: state.blockingQueue;
return { notifications, unreadCount, blockingQueue };
});
},
markAsRead: (id) => { acknowledgeBlocking: (id) =>
set((state) => ({ set((state) => ({
notifications: state.notifications.map((n) => blockingQueue: state.blockingQueue.filter((n) => n.id !== id),
n.id === id ? { ...n, isRead: true } : n, })),
),
unreadCount: Math.max(0, state.unreadCount - 1),
}));
},
markAllAsRead: () => { fetchUnreadCount: async () => {
set((state) => ({ try {
notifications: state.notifications.map((n) => ({ ...n, isRead: true })), const res = await apiGet('/notifications', { isRead: false, limit: 1 });
unreadCount: 0, set({ unreadCount: res.meta?.total || 0 });
})); } catch {
// silent
}
}, },
acknowledgeBlocking: (id) => { fetchBlockingNotifications: async () => {
set((state) => ({ try {
blockingQueue: state.blockingQueue.filter((n) => n.id !== id), const res = await apiGet('/notifications', {
notifications: state.notifications.map((n) => isBlocking: true,
n.id === id ? { ...n, acknowledgedAt: new Date().toISOString(), isRead: true } : n, isAcknowledged: false,
), limit: 10,
})); });
const blocking = (res.data || []).map((n: any) => ({
id: n.id,
title: n.title,
message: n.message,
type: n.type,
}));
set({ blockingQueue: blocking });
} catch {
// silent
}
}, },
setUnreadCount: (count) => set({ unreadCount: count }),
toggleDropdown: () => set((state) => ({ isDropdownOpen: !state.isDropdownOpen })),
closeDropdown: () => set({ isDropdownOpen: false }),
reset: () =>
set({
notifications: [],
unreadCount: 0,
blockingQueue: [],
isDropdownOpen: false,
}),
})); }));
\ 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