Commit 78d69125 authored by Administrator's avatar Administrator

Update 11 files via Son of Anton

parent 83d8f490
'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 { ConfirmDialog } from '@/components/shared/confirm-dialog';
import { StatusBadge } from '@/components/shared/status-badge';
import { formatDate, relativeTime } from '@/lib/date';
import { Key, Plus, Copy, Trash2, Loader2, Eye, EyeOff, Shield } from 'lucide-react';
import { toast } from 'sonner';
export default function ApiKeysPage() {
const [keys, setKeys] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [showCreate, setShowCreate] = useState(false);
const [isCreating, setIsCreating] = useState(false);
const [newKey, setNewKey] = useState<string | null>(null);
const [deleteTarget, setDeleteTarget] = useState<any>(null);
const [form, setForm] = useState({ name: '', scope: 'READ_ONLY', description: '', expiresInDays: 90 });
useEffect(() => { loadKeys(); }, []);
const loadKeys = async () => {
try {
const res = await apiGet('/api-keys');
setKeys(res.data || []);
} catch (err) {
console.error('Failed to load API keys:', err);
} finally {
setIsLoading(false);
}
};
const handleCreate = async () => {
if (!form.name) { toast.error('Name is required'); return; }
setIsCreating(true);
try {
const res = await apiPost('/api-keys', form);
setNewKey(res.data.key);
toast.success('API key created');
setShowCreate(false);
loadKeys();
} catch (err: any) {
toast.error(err.message || 'Failed to create API key');
} finally {
setIsCreating(false);
}
};
const handleDelete = async () => {
if (!deleteTarget) return;
try {
await apiDelete(`/api-keys/${deleteTarget.id}`);
toast.success('API key deleted');
setDeleteTarget(null);
loadKeys();
} catch (err: any) {
toast.error(err.message || 'Failed to delete');
}
};
const copyKey = (key: string) => {
navigator.clipboard.writeText(key);
toast.success('Copied to clipboard');
};
if (isLoading) return <PageLoadingSkeleton />;
return (
<div className="space-y-6">
<PageHeader
title="API Keys"
description="Manage programmatic access to the platform"
actions={
<button onClick={() => setShowCreate(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} /> Create Key
</button>
}
/>
{/* New key display */}
{newKey && (
<div className="bg-emerald-500/10 border border-emerald-500/30 rounded-xl p-4 space-y-2">
<p className="text-sm font-medium text-emerald-600">🔑 Your new API key (shown ONCE — save it now!):</p>
<div className="flex items-center gap-2 bg-background rounded-lg p-3 border">
<code className="text-xs font-mono flex-1 break-all">{newKey}</code>
<button onClick={() => copyKey(newKey)} className="p-2 hover:bg-accent rounded-md"><Copy size={14} /></button>
</div>
<button onClick={() => setNewKey(null)} className="text-xs text-muted-foreground hover:text-foreground">Dismiss</button>
</div>
)}
{/* Create form */}
{showCreate && (
<div className="bg-card rounded-xl border p-4 space-y-4">
<h3 className="font-semibold">Create API Key</h3>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1">
<label className="text-sm font-medium">Name *</label>
<input type="text" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder="e.g. CI/CD Pipeline" 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">Scope</label>
<select value={form.scope} onChange={(e) => setForm({ ...form, scope: e.target.value })} className="w-full px-3 py-2 rounded-lg border bg-background text-sm">
<option value="READ_ONLY">Read Only</option>
<option value="READ_WRITE">Read & Write</option>
<option value="ADMIN">Admin</option>
</select>
</div>
<div className="space-y-1">
<label className="text-sm font-medium">Expires In (days)</label>
<input type="number" value={form.expiresInDays} onChange={(e) => setForm({ ...form, expiresInDays: Number(e.target.value) })} min={1} max={365} 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">Description</label>
<input type="text" value={form.description} onChange={(e) => setForm({ ...form, description: 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>
<div className="flex justify-end gap-2">
<button onClick={() => setShowCreate(false)} className="px-4 py-2 text-sm rounded-lg border hover:bg-accent">Cancel</button>
<button onClick={handleCreate} disabled={isCreating} className="flex items-center gap-2 px-4 py-2 text-sm bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 disabled:opacity-50">
{isCreating ? <Loader2 size={14} className="animate-spin" /> : <Key size={14} />}
Create
</button>
</div>
</div>
)}
{/* Keys list */}
{keys.length === 0 ? (
<EmptyState icon={Key} title="No API keys" description="Create an API key to enable programmatic access." />
) : (
<div className="bg-card rounded-xl border divide-y">
{keys.map((k) => (
<div key={k.id} className="p-4 flex items-center justify-between">
<div>
<div className="flex items-center gap-2">
<p className="text-sm font-semibold">{k.name}</p>
<StatusBadge status={k.isActive ? 'ACTIVE' : 'SUSPENDED'} />
<span className="text-[10px] font-mono bg-muted px-1.5 py-0.5 rounded">{k.scope}</span>
</div>
<p className="text-xs text-muted-foreground mt-0.5">
Prefix: <code className="font-mono">{k.keyPrefix}...</code>
{k.lastUsedAt && ` · Last used ${relativeTime(k.lastUsedAt)}`}
{k.expiresAt && ` · Expires ${formatDate(k.expiresAt)}`}
{k.isExpired && <span className="text-red-500 ml-1">EXPIRED</span>}
</p>
</div>
<button onClick={() => setDeleteTarget(k)} className="p-2 text-muted-foreground hover:text-destructive"><Trash2 size={14} /></button>
</div>
))}
</div>
)}
<ConfirmDialog open={!!deleteTarget} onClose={() => setDeleteTarget(null)} onConfirm={handleDelete} title="Delete API Key" description={`Permanently delete "${deleteTarget?.name}"? Any integrations using this key will stop working immediately.`} confirmLabel="Delete" destructive />
</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 { UserAvatar } from '@/components/shared/user-avatar';
import { formatEgp } from '@/lib/utils';
import { formatDate } from '@/lib/date';
import { Coins, TrendingUp, Trophy, DollarSign } from 'lucide-react';
export default function BountiesPage() {
const [payouts, setPayouts] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [month, setMonth] = useState(new Date().getMonth() + 1);
const [year, setYear] = useState(new Date().getFullYear());
const [stats, setStats] = useState<any>(null);
useEffect(() => {
loadData();
}, [month, year]);
const loadData = async () => {
setIsLoading(true);
try {
const [payoutsRes, statsRes] = await Promise.all([
apiGet('/bounties', { month, year, limit: 100 }),
apiGet('/bounties/stats', { month, year }).catch(() => ({ data: null })),
]);
setPayouts(payoutsRes.data || []);
setStats(statsRes.data);
} catch (err) {
console.error('Failed to load bounties:', err);
} finally {
setIsLoading(false);
}
};
if (isLoading) return <PageLoadingSkeleton />;
const totalPaid = payouts.reduce((sum, p) => sum + (p.amountPiasters || 0), 0);
const uniqueRecipients = new Set(payouts.map((p) => p.userId)).size;
return (
<div className="space-y-6">
<PageHeader title="Bounty Dashboard" description="Track bounty payouts and performance incentives" />
{/* 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-amber-500 mb-2">
<Coins size={16} />
<span className="text-xs font-medium uppercase tracking-wider">Total Paid</span>
</div>
<p className="text-2xl font-bold text-amber-500">{formatEgp(totalPaid)}</p>
<p className="text-xs text-muted-foreground">{payouts.length} payouts</p>
</div>
<div className="bg-card rounded-xl border p-4">
<div className="flex items-center gap-2 text-muted-foreground mb-2">
<Trophy size={16} />
<span className="text-xs font-medium uppercase tracking-wider">Recipients</span>
</div>
<p className="text-2xl font-bold">{uniqueRecipients}</p>
<p className="text-xs text-muted-foreground">unique contractors</p>
</div>
<div className="bg-card rounded-xl border p-4">
<div className="flex items-center gap-2 text-muted-foreground mb-2">
<DollarSign size={16} />
<span className="text-xs font-medium uppercase tracking-wider">Avg Bounty</span>
</div>
<p className="text-2xl font-bold">{payouts.length > 0 ? formatEgp(Math.round(totalPaid / payouts.length)) : '—'}</p>
<p className="text-xs text-muted-foreground">per payout</p>
</div>
</div>
{/* Payouts Table */}
{payouts.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-left px-4 py-3 font-medium text-muted-foreground">Card</th>
<th className="text-right px-4 py-3 font-medium text-muted-foreground">Amount</th>
<th className="text-left px-4 py-3 font-medium text-muted-foreground">Split</th>
<th className="text-left px-4 py-3 font-medium text-muted-foreground">Paid</th>
</tr>
</thead>
<tbody className="divide-y">
{payouts.map((p) => (
<tr key={p.id} className="hover:bg-accent/50">
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<UserAvatar firstName={p.user?.firstName || '?'} lastName={p.user?.lastName || '?'} avatar={p.user?.avatar} size="xs" />
<span>{p.user?.firstName} {p.user?.lastName}</span>
</div>
</td>
<td className="px-4 py-3 text-xs font-mono text-muted-foreground">{p.cardNumber || p.cardId?.slice(0, 8)}</td>
<td className="px-4 py-3 text-right font-mono font-bold text-amber-500">+{formatEgp(p.amountPiasters)}</td>
<td className="px-4 py-3 text-xs text-muted-foreground">{p.splitPercentage ? `${p.splitPercentage}%` : '100%'}</td>
<td className="px-4 py-3 text-xs text-muted-foreground">{p.paidAt ? formatDate(p.paidAt) : '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
) : (
<div className="text-center py-16 text-muted-foreground">
<Coins size={48} className="mx-auto mb-4 opacity-30" />
<p>No bounties paid out this month.</p>
</div>
)}
</div>
);
}
\ No newline at end of file
'use client';
import { useEffect, useState } from 'react';
import { apiGet, apiPost, apiPut, 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 { ConfirmDialog } from '@/components/shared/confirm-dialog';
import { formatDate } from '@/lib/date';
import { Calendar, Plus, Edit2, Trash2, Loader2 } from 'lucide-react';
import { toast } from 'sonner';
export default function HolidaysPage() {
const [holidays, setHolidays] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [showForm, setShowForm] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<any>(null);
const [form, setForm] = useState({ name: '', startDate: '', endDate: '', isRecurring: false, notes: '' });
useEffect(() => { loadData(); }, []);
const loadData = async () => {
try {
const res = await apiGet('/holidays', { limit: 100, sortBy: 'startDate', sortOrder: 'asc' });
setHolidays(res.data || []);
} catch (err) {
console.error('Failed to load holidays:', err);
} finally {
setIsLoading(false);
}
};
const resetForm = () => {
setForm({ name: '', startDate: '', endDate: '', isRecurring: false, notes: '' });
setEditingId(null);
setShowForm(false);
};
const handleSubmit = async () => {
if (!form.name || !form.startDate) { toast.error('Name and start date are required.'); return; }
setIsSubmitting(true);
try {
if (editingId) {
await apiPut(`/holidays/${editingId}`, { ...form, endDate: form.endDate || form.startDate });
toast.success('Holiday updated');
} else {
await apiPost('/holidays', { ...form, endDate: form.endDate || form.startDate });
toast.success('Holiday created');
}
resetForm();
loadData();
} catch (err: any) {
toast.error(err.message || 'Failed to save holiday');
} finally {
setIsSubmitting(false);
}
};
const handleEdit = (h: any) => {
setForm({
name: h.name,
startDate: h.startDate?.split('T')[0] || '',
endDate: h.endDate?.split('T')[0] || '',
isRecurring: h.isRecurring || false,
notes: h.notes || '',
});
setEditingId(h.id);
setShowForm(true);
};
const handleDelete = async () => {
if (!deleteTarget) return;
try {
await apiDelete(`/holidays/${deleteTarget.id}`);
toast.success('Holiday deleted');
setDeleteTarget(null);
loadData();
} catch (err: any) {
toast.error(err.message || 'Failed to delete');
}
};
if (isLoading) return <PageLoadingSkeleton />;
return (
<div className="space-y-6">
<PageHeader
title="Holiday Calendar"
description="Manage public holidays"
actions={
<button onClick={() => { resetForm(); setShowForm(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} /> Add Holiday
</button>
}
/>
{showForm && (
<div className="bg-card rounded-xl border p-4 space-y-4">
<h3 className="font-semibold">{editingId ? 'Edit Holiday' : 'New Holiday'}</h3>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1">
<label className="text-sm font-medium">Name *</label>
<input type="text" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder="e.g. Eid Al-Fitr" 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">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 className="space-y-1">
<label className="text-sm font-medium">Notes</label>
<input type="text" value={form.notes} onChange={(e) => setForm({ ...form, notes: 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>
<label className="flex items-center gap-2 cursor-pointer">
<input type="checkbox" checked={form.isRecurring} onChange={(e) => setForm({ ...form, isRecurring: e.target.checked })} className="rounded" />
<span className="text-sm">Recurring annually</span>
</label>
<div className="flex justify-end gap-2">
<button onClick={resetForm} 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 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 disabled:opacity-50">
{isSubmitting ? <Loader2 size={14} className="animate-spin" /> : null}
{editingId ? 'Update' : 'Create'}
</button>
</div>
</div>
)}
{holidays.length === 0 ? (
<EmptyState icon={Calendar} title="No holidays configured" description="Add public holidays to exclude them from working day calculations." />
) : (
<div className="bg-card rounded-xl border divide-y">
{holidays.map((h) => (
<div key={h.id} className="p-4 flex items-center justify-between">
<div>
<p className="text-sm font-semibold">{h.name}</p>
<p className="text-xs text-muted-foreground">
{formatDate(h.startDate)}
{h.endDate && h.endDate !== h.startDate && ` — ${formatDate(h.endDate)}`}
{h.isRecurring && ' · 🔄 Recurring'}
</p>
{h.notes && <p className="text-xs text-muted-foreground mt-0.5">{h.notes}</p>}
</div>
<div className="flex gap-1">
<button onClick={() => handleEdit(h)} className="p-2 text-muted-foreground hover:text-foreground"><Edit2 size={14} /></button>
<button onClick={() => setDeleteTarget(h)} className="p-2 text-muted-foreground hover:text-destructive"><Trash2 size={14} /></button>
</div>
</div>
))}
</div>
)}
<ConfirmDialog open={!!deleteTarget} onClose={() => setDeleteTarget(null)} onConfirm={handleDelete} title="Delete Holiday" description={`Delete "${deleteTarget?.name}"? This may affect salary calculations.`} confirmLabel="Delete" destructive />
</div>
);
}
\ No newline at end of file
'use client';
import { useEffect, useState } from 'react';
import { apiGet, apiPost, apiPut, 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 { StatusBadge } from '@/components/shared/status-badge';
import { formatDate } from '@/lib/date';
import { Bell, Plus, Edit2, Trash2, Loader2, Send } from 'lucide-react';
import { toast } from 'sonner';
export default function NoticesPage() {
const [notices, setNotices] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [showForm, setShowForm] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [form, setForm] = useState({
title: '',
content: '',
type: 'GENERAL_ANNOUNCEMENT',
isBlocking: false,
targetRoles: [] as string[],
});
useEffect(() => { loadData(); }, []);
const loadData = async () => {
try {
const res = await apiGet('/notices', { limit: 50, sortOrder: 'desc' });
setNotices(res.data || []);
} catch (err) {
console.error('Failed to load notices:', err);
} finally {
setIsLoading(false);
}
};
const handleSubmit = async () => {
if (!form.title || !form.content) { toast.error('Title and content are required'); return; }
setIsSubmitting(true);
try {
await apiPost('/notices', form);
toast.success('Notice published');
setShowForm(false);
setForm({ title: '', content: '', type: 'GENERAL_ANNOUNCEMENT', isBlocking: false, targetRoles: [] });
loadData();
} catch (err: any) {
toast.error(err.message || 'Failed to publish notice');
} finally {
setIsSubmitting(false);
}
};
const handleDelete = async (id: string) => {
try {
await apiDelete(`/notices/${id}`);
toast.success('Notice deleted');
loadData();
} catch (err: any) {
toast.error(err.message || 'Failed to delete');
}
};
if (isLoading) return <PageLoadingSkeleton />;
return (
<div className="space-y-6">
<PageHeader
title="Notices & Announcements"
description="Publish notices to contractors"
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} /> New Notice
</button>
}
/>
{showForm && (
<div className="bg-card rounded-xl border p-4 space-y-4">
<div className="space-y-1">
<label className="text-sm font-medium">Title *</label>
<input type="text" value={form.title} onChange={(e) => setForm({ ...form, title: e.target.value })} placeholder="Notice title" 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">Content *</label>
<textarea value={form.content} onChange={(e) => setForm({ ...form, content: e.target.value })} rows={4} placeholder="Full notice text..." 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-1">
<label className="text-sm font-medium">Type</label>
<select value={form.type} onChange={(e) => setForm({ ...form, type: e.target.value, isBlocking: e.target.value === 'OFFICIAL_WARNING' || e.target.value === 'POLICY_UPDATE' })} className="w-full px-3 py-2 rounded-lg border bg-background text-sm">
<option value="GENERAL_ANNOUNCEMENT">General Announcement</option>
<option value="OFFICIAL_WARNING">Official Warning</option>
<option value="POLICY_UPDATE">Policy Update</option>
<option value="CUSTOM">Custom</option>
</select>
</div>
</div>
<label className="flex items-center gap-2 cursor-pointer">
<input type="checkbox" checked={form.isBlocking} onChange={(e) => setForm({ ...form, isBlocking: e.target.checked })} className="rounded" />
<span className="text-sm">Blocking (requires acknowledgment)</span>
</label>
<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 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 disabled:opacity-50">
{isSubmitting ? <Loader2 size={14} className="animate-spin" /> : <Send size={14} />}
Publish
</button>
</div>
</div>
)}
{notices.length === 0 ? (
<EmptyState icon={Bell} title="No notices" description="Publish your first notice or announcement." />
) : (
<div className="bg-card rounded-xl border divide-y">
{notices.map((n) => (
<div key={n.id} className="p-4">
<div className="flex items-start justify-between">
<div>
<div className="flex items-center gap-2">
<h4 className="text-sm font-semibold">{n.title}</h4>
<StatusBadge status={n.type || 'CUSTOM'} />
{n.isBlocking && <span className="text-[10px] bg-red-500/10 text-red-500 px-1.5 py-0.5 rounded">Blocking</span>}
</div>
<p className="text-sm text-muted-foreground mt-1 line-clamp-2">{n.content}</p>
<p className="text-xs text-muted-foreground mt-1">
Published {formatDate(n.createdAt)}
{n.createdBy && ` by ${n.createdBy.firstName} ${n.createdBy.lastName}`}
{n.acknowledgedCount != null && ` · ${n.acknowledgedCount} acknowledged`}
</p>
</div>
<button onClick={() => handleDelete(n.id)} className="p-2 text-muted-foreground hover:text-destructive shrink-0"><Trash2 size={14} /></button>
</div>
</div>
))}
</div>
)}
</div>
);
}
\ No newline at end of file
'use client';
import { useEffect, useState } from 'react';
import { apiGet, apiPost, apiPut, 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 { BookOpen, Plus, Edit2, Trash2, Loader2, CheckCircle2 } from 'lucide-react';
import { toast } from 'sonner';
export default function PoliciesPage() {
const [policies, setPolicies] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [showForm, setShowForm] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [form, setForm] = useState({ title: '', content: '', requiresAcknowledgment: true });
useEffect(() => { loadData(); }, []);
const loadData = async () => {
try {
const res = await apiGet('/policies', { limit: 50, sortOrder: 'desc' });
setPolicies(res.data || []);
} catch (err) {
console.error('Failed to load policies:', err);
} finally {
setIsLoading(false);
}
};
const resetForm = () => {
setForm({ title: '', content: '', requiresAcknowledgment: true });
setEditingId(null);
setShowForm(false);
};
const handleSubmit = async () => {
if (!form.title || !form.content) { toast.error('Title and content are required'); return; }
setIsSubmitting(true);
try {
if (editingId) {
await apiPut(`/policies/${editingId}`, form);
toast.success('Policy updated (new version created)');
} else {
await apiPost('/policies', form);
toast.success('Policy created');
}
resetForm();
loadData();
} catch (err: any) {
toast.error(err.message || 'Failed to save policy');
} finally {
setIsSubmitting(false);
}
};
const handleEdit = (p: any) => {
setForm({ title: p.title, content: p.content || '', requiresAcknowledgment: p.requiresAcknowledgment ?? true });
setEditingId(p.id);
setShowForm(true);
};
const handleDelete = async (id: string) => {
try {
await apiDelete(`/policies/${id}`);
toast.success('Policy deleted');
loadData();
} catch (err: any) {
toast.error(err.message || 'Failed to delete');
}
};
if (isLoading) return <PageLoadingSkeleton />;
return (
<div className="space-y-6">
<PageHeader
title="Policy Management"
description="Manage organizational policies and acknowledgments"
actions={
<button onClick={() => { resetForm(); setShowForm(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 Policy
</button>
}
/>
{showForm && (
<div className="bg-card rounded-xl border p-4 space-y-4">
<h3 className="font-semibold">{editingId ? 'Edit Policy (creates new version)' : 'New Policy'}</h3>
<div className="space-y-1">
<label className="text-sm font-medium">Title *</label>
<input type="text" value={form.title} onChange={(e) => setForm({ ...form, title: e.target.value })} placeholder="e.g. Code of Conduct" 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">Content *</label>
<textarea value={form.content} onChange={(e) => setForm({ ...form, content: e.target.value })} rows={10} placeholder="Full policy text..." className="w-full px-3 py-2 rounded-lg border bg-background text-sm resize-y focus:outline-none focus:ring-2 focus:ring-ring" />
</div>
<label className="flex items-center gap-2 cursor-pointer">
<input type="checkbox" checked={form.requiresAcknowledgment} onChange={(e) => setForm({ ...form, requiresAcknowledgment: e.target.checked })} className="rounded" />
<span className="text-sm">Requires acknowledgment from all contractors</span>
</label>
<div className="flex justify-end gap-2">
<button onClick={resetForm} 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 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 disabled:opacity-50">
{isSubmitting ? <Loader2 size={14} className="animate-spin" /> : null}
{editingId ? 'Publish New Version' : 'Create'}
</button>
</div>
</div>
)}
{policies.length === 0 ? (
<EmptyState icon={BookOpen} title="No policies" description="Create organizational policies for your team." />
) : (
<div className="bg-card rounded-xl border divide-y">
{policies.map((p) => (
<div key={p.id} className="p-4 flex items-start justify-between">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h4 className="text-sm font-semibold">{p.title}</h4>
{p.version && <span className="text-[10px] font-mono bg-muted px-1.5 py-0.5 rounded">v{p.version}</span>}
{p.requiresAcknowledgment && (
<span className="text-[10px] flex items-center gap-0.5 text-blue-500">
<CheckCircle2 size={10} /> Requires Ack
</span>
)}
</div>
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-2">{p.content?.substring(0, 200)}...</p>
<p className="text-[10px] text-muted-foreground mt-1">
Published {formatDate(p.publishedAt || p.createdAt)}
{p.acknowledgmentCount != null && ` · ${p.acknowledgmentCount} acknowledged`}
</p>
</div>
<div className="flex gap-1 shrink-0 ml-4">
<button onClick={() => handleEdit(p)} className="p-2 text-muted-foreground hover:text-foreground"><Edit2 size={14} /></button>
<button onClick={() => handleDelete(p.id)} className="p-2 text-muted-foreground hover:text-destructive"><Trash2 size={14} /></button>
</div>
</div>
))}
</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 { Cpu, HardDrive, Users, AlertTriangle, Clock, Database, Activity, Server } from 'lucide-react';
export default function SystemHealthPage() {
const [health, setHealth] = useState<any>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
loadHealth();
const interval = setInterval(loadHealth, 30000);
return () => clearInterval(interval);
}, []);
const loadHealth = async () => {
try {
const res = await apiGet('/analytics/system-health');
setHealth(res.data);
} catch (err) {
console.error('Failed to load system health:', err);
} finally {
setIsLoading(false);
}
};
if (isLoading) return <PageLoadingSkeleton />;
if (!health) return <p className="p-6 text-muted-foreground">Failed to load system health.</p>;
const uptimeHours = Math.floor((health.uptime || 0) / 3600);
const uptimeMinutes = Math.floor(((health.uptime || 0) % 3600) / 60);
const memUsage = health.memoryUsage || {};
const memMB = Math.round((memUsage.heapUsed || 0) / 1048576);
const memTotalMB = Math.round((memUsage.heapTotal || 0) / 1048576);
return (
<div className="space-y-6">
<PageHeader title="System Health" description="Real-time platform monitoring (auto-refreshes every 30s)" />
{/* Top Stats */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<HealthCard icon={Clock} label="Uptime" value={`${uptimeHours}h ${uptimeMinutes}m`} status="ok" />
<HealthCard icon={Users} label="Active Sessions" value={health.activeSessions || 0} status="ok" />
<HealthCard icon={AlertTriangle} label="Errors (24h)" value={health.recentErrors24h || 0} status={health.recentErrors24h > 10 ? 'warn' : health.recentErrors24h > 50 ? 'critical' : 'ok'} />
<HealthCard icon={Server} label="Node.js" value={health.nodeVersion || 'Unknown'} status="ok" />
</div>
{/* Memory */}
<div className="bg-card rounded-xl border p-4">
<h3 className="font-semibold flex items-center gap-2 mb-3"><Cpu size={16} /> Memory Usage</h3>
<div className="grid gap-4 sm:grid-cols-3">
<div>
<p className="text-xs text-muted-foreground">Heap Used</p>
<p className="text-lg font-bold">{memMB} MB</p>
</div>
<div>
<p className="text-xs text-muted-foreground">Heap Total</p>
<p className="text-lg font-bold">{memTotalMB} MB</p>
</div>
<div>
<p className="text-xs text-muted-foreground">RSS</p>
<p className="text-lg font-bold">{Math.round((memUsage.rss || 0) / 1048576)} MB</p>
</div>
</div>
<div className="mt-3 h-2 bg-muted rounded-full overflow-hidden">
<div className="h-full bg-primary rounded-full" style={{ width: `${memTotalMB > 0 ? (memMB / memTotalMB) * 100 : 0}%` }} />
</div>
</div>
{/* Storage */}
<div className="bg-card rounded-xl border p-4">
<h3 className="font-semibold flex items-center gap-2 mb-3"><HardDrive size={16} /> Storage</h3>
<div className="grid gap-4 sm:grid-cols-3">
<div>
<p className="text-xs text-muted-foreground">Total Attachments</p>
<p className="text-lg font-bold">{health.storage?.totalAttachments || 0}</p>
</div>
<div>
<p className="text-xs text-muted-foreground">Total Size</p>
<p className="text-lg font-bold">{health.storage?.totalSizeMB || 0} MB</p>
</div>
</div>
</div>
{/* Users by Status */}
{health.usersByStatus && (
<div className="bg-card rounded-xl border p-4">
<h3 className="font-semibold flex items-center gap-2 mb-3"><Users size={16} /> Users by Status</h3>
<div className="grid gap-2 sm:grid-cols-3 lg:grid-cols-5">
{health.usersByStatus.map((s: any) => (
<div key={s.status} className="bg-muted/30 rounded-lg p-3 text-center">
<p className="text-2xl font-bold">{s.count}</p>
<p className="text-[10px] text-muted-foreground uppercase">{s.status}</p>
</div>
))}
</div>
</div>
)}
{/* Entity Counts */}
{health.entityCounts && (
<div className="bg-card rounded-xl border p-4">
<h3 className="font-semibold flex items-center gap-2 mb-3"><Database size={16} /> Entity Counts</h3>
<div className="grid gap-2 sm:grid-cols-3 lg:grid-cols-5">
{Object.entries(health.entityCounts).map(([key, count]) => (
<div key={key} className="bg-muted/30 rounded-lg p-3 text-center">
<p className="text-lg font-bold">{String(count)}</p>
<p className="text-[10px] text-muted-foreground capitalize">{key}</p>
</div>
))}
</div>
</div>
)}
<p className="text-[10px] text-muted-foreground text-center">
Last refreshed: {health.timestamp ? new Date(health.timestamp).toLocaleString() : 'Unknown'}
</p>
</div>
);
}
function HealthCard({ icon: Icon, label, value, status }: {
icon: React.ElementType; label: string; value: string | number;
status: 'ok' | 'warn' | 'critical';
}) {
const colors = {
ok: 'text-emerald-500',
warn: 'text-yellow-500',
critical: 'text-red-500',
};
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 ${colors[status]}`}>{value}</p>
</div>
);
}
\ No newline at end of file
'use client';
import { useEffect, useState } from 'react';
import { apiGet, apiPost, apiPut, 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 { ConfirmDialog } from '@/components/shared/confirm-dialog';
import { StatusBadge } from '@/components/shared/status-badge';
import { relativeTime } from '@/lib/date';
import { Webhook, Plus, Edit2, Trash2, Loader2, Zap } from 'lucide-react';
import { toast } from 'sonner';
const WEBHOOK_EVENTS = [
'card.created', 'card.moved', 'card.assigned', 'card.done', 'card.overdue',
'report.submitted', 'report.missed',
'deduction.created', 'deduction.applied',
'bounty.paid',
'contractor.activated', 'contractor.terminated',
'payroll.approved',
'evaluation.compiled',
];
export default function WebhooksPage() {
const [webhooks, setWebhooks] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [showForm, setShowForm] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<any>(null);
const [form, setForm] = useState({ url: '', secret: '', events: [] as string[], isActive: true });
useEffect(() => { loadData(); }, []);
const loadData = async () => {
try {
const res = await apiGet('/webhooks');
setWebhooks(res.data || []);
} catch (err) {
console.error('Failed to load webhooks:', err);
} finally {
setIsLoading(false);
}
};
const resetForm = () => {
setForm({ url: '', secret: '', events: [], isActive: true });
setEditingId(null);
setShowForm(false);
};
const handleSubmit = async () => {
if (!form.url || form.events.length === 0) {
toast.error('URL and at least one event are required');
return;
}
setIsSubmitting(true);
try {
if (editingId) {
await apiPut(`/webhooks/${editingId}`, form);
toast.success('Webhook updated');
} else {
await apiPost('/webhooks', form);
toast.success('Webhook created');
}
resetForm();
loadData();
} catch (err: any) {
toast.error(err.message || 'Failed to save webhook');
} finally {
setIsSubmitting(false);
}
};
const handleEdit = (w: any) => {
setForm({ url: w.url, secret: w.secret || '', events: w.events || [], isActive: w.isActive });
setEditingId(w.id);
setShowForm(true);
};
const toggleEvent = (event: string) => {
setForm((prev) => ({
...prev,
events: prev.events.includes(event)
? prev.events.filter((e) => e !== event)
: [...prev.events, event],
}));
};
const handleDelete = async () => {
if (!deleteTarget) return;
try {
await apiDelete(`/webhooks/${deleteTarget.id}`);
toast.success('Webhook deleted');
setDeleteTarget(null);
loadData();
} catch (err: any) {
toast.error(err.message || 'Failed to delete');
}
};
if (isLoading) return <PageLoadingSkeleton />;
return (
<div className="space-y-6">
<PageHeader
title="Webhooks"
description="Configure outgoing webhook integrations"
actions={
<button onClick={() => { resetForm(); setShowForm(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} /> Add Webhook
</button>
}
/>
{showForm && (
<div className="bg-card rounded-xl border p-4 space-y-4">
<h3 className="font-semibold">{editingId ? 'Edit Webhook' : 'New Webhook'}</h3>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1 sm:col-span-2">
<label className="text-sm font-medium">Endpoint URL *</label>
<input type="url" value={form.url} onChange={(e) => setForm({ ...form, url: e.target.value })} placeholder="https://example.com/webhooks" 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">Secret (for signature verification)</label>
<input type="text" value={form.secret} onChange={(e) => setForm({ ...form, secret: e.target.value })} placeholder="Optional" 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-2">
<label className="text-sm font-medium">Events *</label>
<div className="grid gap-1 sm:grid-cols-3">
{WEBHOOK_EVENTS.map((event) => (
<label key={event} className="flex items-center gap-2 p-2 rounded hover:bg-accent/50 cursor-pointer">
<input type="checkbox" checked={form.events.includes(event)} onChange={() => toggleEvent(event)} className="rounded" />
<span className="text-xs font-mono">{event}</span>
</label>
))}
</div>
</div>
<div className="flex justify-end gap-2">
<button onClick={resetForm} 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 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 disabled:opacity-50">
{isSubmitting ? <Loader2 size={14} className="animate-spin" /> : <Zap size={14} />}
{editingId ? 'Update' : 'Create'}
</button>
</div>
</div>
)}
{webhooks.length === 0 ? (
<EmptyState icon={Webhook} title="No webhooks configured" description="Add a webhook to receive real-time event notifications." />
) : (
<div className="bg-card rounded-xl border divide-y">
{webhooks.map((w) => (
<div key={w.id} className="p-4 flex items-center justify-between">
<div>
<div className="flex items-center gap-2">
<span className="text-sm font-mono truncate max-w-[400px]">{w.url}</span>
<StatusBadge status={w.isActive ? 'ACTIVE' : 'SUSPENDED'} />
</div>
<p className="text-xs text-muted-foreground mt-0.5">
{(w.events || []).length} events
{w.lastTriggeredAt && ` · Last triggered ${relativeTime(w.lastTriggeredAt)}`}
</p>
</div>
<div className="flex gap-1">
<button onClick={() => handleEdit(w)} className="p-2 text-muted-foreground hover:text-foreground"><Edit2 size={14} /></button>
<button onClick={() => setDeleteTarget(w)} className="p-2 text-muted-foreground hover:text-destructive"><Trash2 size={14} /></button>
</div>
</div>
))}
</div>
)}
<ConfirmDialog open={!!deleteTarget} onClose={() => setDeleteTarget(null)} onConfirm={handleDelete} title="Delete Webhook" description={`Delete this webhook endpoint? Events will no longer be sent to this URL.`} confirmLabel="Delete" destructive />
</div>
);
}
\ No newline at end of file
'use client';
import { useEffect, useState } from 'react';
import { useParams } from 'next/navigation';
import { apiGet } from '@/lib/api';
import { PageLoadingSkeleton } from '@/components/shared/loading-skeleton';
import { EmptyState } from '@/components/shared/empty-state';
import { UserAvatar } from '@/components/shared/user-avatar';
import { BoardHeader } from '@/components/kanban/board-header';
import { relativeTime } from '@/lib/date';
import { Activity, Search } from 'lucide-react';
import { toast } from 'sonner';
export default function BoardActivityPage() {
const { boardId } = useParams<{ boardId: string }>();
const [board, setBoard] = useState<any>(null);
const [activities, setActivities] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const [actionFilter, setActionFilter] = useState('');
useEffect(() => {
loadBoard();
}, [boardId]);
useEffect(() => {
loadActivities();
}, [boardId, page, actionFilter]);
const loadBoard = async () => {
try {
const res = await apiGet(`/boards/${boardId}`);
setBoard(res.data);
} catch (err: any) {
toast.error(err.message || 'Failed to load board');
}
};
const loadActivities = async () => {
try {
const params: any = { page, limit: 50, sortOrder: 'desc', entityType: 'cards' };
if (actionFilter) params.action = actionFilter;
const res = await apiGet('/audit-trail', params);
setActivities(res.data || []);
setTotal(res.meta?.total || 0);
} catch (err) {
console.error('Failed to load activity:', err);
} finally {
setIsLoading(false);
}
};
if (isLoading) return <PageLoadingSkeleton />;
if (!board) return <div className="p-6 text-muted-foreground">Board not found.</div>;
return (
<div className="space-y-4">
<BoardHeader board={board} onRefresh={loadBoard} />
<div className="flex items-center gap-3">
<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">Created</option>
<option value="UPDATE">Updated</option>
<option value="MOVE">Moved</option>
<option value="ASSIGN">Assigned</option>
<option value="DELETE">Deleted</option>
</select>
<span className="text-xs text-muted-foreground">{total} entries</span>
</div>
{activities.length === 0 ? (
<EmptyState icon={Activity} title="No activity yet" description="Activity will appear here as cards are created and updated." />
) : (
<div className="bg-card rounded-xl border divide-y">
{activities.map((act) => (
<div key={act.id} className="p-4 flex gap-3">
{act.user ? (
<UserAvatar firstName={act.user.firstName} lastName={act.user.lastName} avatar={act.user.avatar} size="sm" className="mt-0.5" />
) : (
<div className="w-8 h-8 rounded-full bg-muted flex items-center justify-center text-xs">SYS</div>
)}
<div className="flex-1 min-w-0">
<p className="text-sm">
<span className="font-medium">
{act.user ? `${act.user.firstName} ${act.user.lastName}` : 'System'}
</span>{' '}
<span className="text-muted-foreground">
{act.action?.toLowerCase().replace(/_/g, ' ')}
</span>
{act.entityType && (
<span className="text-muted-foreground"> on {act.entityType}</span>
)}
</p>
{act.url && (
<p className="text-xs text-muted-foreground mt-0.5 font-mono truncate">
{act.method} {act.url}
</p>
)}
<p className="text-[10px] text-muted-foreground mt-1">
{relativeTime(act.createdAt)}
</p>
</div>
</div>
))}
{total > 50 && (
<div className="flex items-center justify-between px-4 py-3">
<span className="text-xs text-muted-foreground">Page {page} of {Math.ceil(total / 50)}</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 / 50)} 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
This diff is collapsed.
'use client';
import { useEffect, useState } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { apiGet, apiPut, apiPost, apiDelete } from '@/lib/api';
import { useAuthStore } from '@/stores/auth.store';
import { PageHeader } from '@/components/shared/page-header';
import { PageLoadingSkeleton } from '@/components/shared/loading-skeleton';
import { ConfirmDialog } from '@/components/shared/confirm-dialog';
import { UserAvatar } from '@/components/shared/user-avatar';
import { Settings, Save, Loader2, UserPlus, Trash2, Archive, RotateCcw } from 'lucide-react';
import { toast } from 'sonner';
export default function BoardSettingsPage() {
const { boardId } = useParams<{ boardId: string }>();
const router = useRouter();
const user = useAuthStore((s) => s.user);
const [board, setBoard] = useState<any>(null);
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [form, setForm] = useState<any>({});
const [members, setMembers] = useState<any[]>([]);
const [showArchiveConfirm, setShowArchiveConfirm] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
useEffect(() => {
loadBoard();
}, [boardId]);
const loadBoard = async () => {
try {
const res = await apiGet(`/boards/${boardId}`);
setBoard(res.data);
setForm({
name: res.data.name || '',
description: res.data.description || '',
key: res.data.key || '',
visibility: res.data.visibility || 'PRIVATE',
allowContractorCreation: res.data.allowContractorCreation ?? true,
autoArchiveDoneCardsDays: res.data.autoArchiveDoneCardsDays || 30,
});
setMembers(res.data.members || []);
} catch (err: any) {
toast.error(err.message || 'Failed to load board');
} finally {
setIsLoading(false);
}
};
const handleSave = async () => {
setIsSaving(true);
try {
await apiPut(`/boards/${boardId}`, form);
toast.success('Board settings saved');
loadBoard();
} catch (err: any) {
toast.error(err.message || 'Failed to save');
} finally {
setIsSaving(false);
}
};
const handleArchive = async () => {
try {
await apiPut(`/boards/${boardId}`, { isArchived: true });
toast.success('Board archived');
router.push('/boards');
} catch (err: any) {
toast.error(err.message || 'Failed to archive board');
}
};
const handleDelete = async () => {
try {
await apiDelete(`/boards/${boardId}`);
toast.success('Board deleted');
router.push('/boards');
} catch (err: any) {
toast.error(err.message || 'Failed to delete board');
}
};
if (isLoading) return <PageLoadingSkeleton />;
if (!board) return <div className="p-6 text-muted-foreground">Board not found.</div>;
const isSuperAdmin = user?.role === 'SUPER_ADMIN';
return (
<div className="max-w-3xl mx-auto space-y-6">
<PageHeader title={`${board.name} — Settings`} description="Manage board configuration and members" />
{/* General Settings */}
<div className="bg-card rounded-xl border p-6 space-y-4">
<h3 className="font-semibold flex items-center gap-2"><Settings size={16} /> General</h3>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1">
<label className="text-sm font-medium">Board Name</label>
<input type="text" value={form.name} onChange={(e) => setForm({ ...form, name: 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">Board Key</label>
<input type="text" value={form.key} onChange={(e) => setForm({ ...form, key: e.target.value.toUpperCase().replace(/[^A-Z0-9_]/g, '') })} 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" />
</div>
</div>
<div className="space-y-1">
<label className="text-sm font-medium">Description</label>
<textarea value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} 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-1">
<label className="text-sm font-medium">Visibility</label>
<select value={form.visibility} onChange={(e) => setForm({ ...form, visibility: e.target.value })} className="w-full px-3 py-2 rounded-lg border bg-background text-sm">
<option value="PRIVATE">Private</option>
<option value="PUBLIC">Public</option>
</select>
</div>
<div className="space-y-1">
<label className="text-sm font-medium">Auto-Archive Done Cards</label>
<select value={form.autoArchiveDoneCardsDays} onChange={(e) => setForm({ ...form, autoArchiveDoneCardsDays: Number(e.target.value) })} className="w-full px-3 py-2 rounded-lg border bg-background text-sm">
<option value={7}>7 days</option>
<option value={14}>14 days</option>
<option value={30}>30 days</option>
<option value={60}>60 days</option>
<option value={90}>90 days</option>
</select>
</div>
</div>
<label className="flex items-center gap-2 cursor-pointer">
<input type="checkbox" checked={form.allowContractorCreation} onChange={(e) => setForm({ ...form, allowContractorCreation: e.target.checked })} className="rounded" />
<span className="text-sm">Allow contractors to create cards in Backlog</span>
</label>
<div className="flex justify-end">
<button onClick={handleSave} disabled={isSaving} className="flex items-center gap-2 px-4 py-2 text-sm bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 disabled:opacity-50">
{isSaving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
Save Changes
</button>
</div>
</div>
{/* Members */}
<div className="bg-card rounded-xl border p-6 space-y-4">
<h3 className="font-semibold flex items-center gap-2"><UserPlus size={16} /> Members ({members.length})</h3>
{members.length > 0 ? (
<div className="space-y-2">
{members.map((m: any) => (
<div key={m.userId || m.id} className="flex items-center justify-between p-2 rounded-lg hover:bg-accent/50">
<div className="flex items-center gap-3">
<UserAvatar firstName={m.user?.firstName || '?'} lastName={m.user?.lastName || '?'} avatar={m.user?.avatar} size="sm" />
<div>
<p className="text-sm font-medium">{m.user?.firstName} {m.user?.lastName}</p>
<p className="text-[10px] text-muted-foreground">{m.role || m.user?.role}</p>
</div>
</div>
</div>
))}
</div>
) : (
<p className="text-sm text-muted-foreground">No members assigned to this board.</p>
)}
</div>
{/* Danger Zone */}
{isSuperAdmin && (
<div className="bg-card rounded-xl border border-destructive/20 p-6 space-y-4">
<h3 className="font-semibold text-destructive">Danger Zone</h3>
<div className="flex gap-3">
<button onClick={() => setShowArchiveConfirm(true)} className="flex items-center gap-2 px-4 py-2 text-sm rounded-lg border border-yellow-500/30 text-yellow-600 hover:bg-yellow-500/10">
<Archive size={14} />
Archive Board
</button>
<button onClick={() => setShowDeleteConfirm(true)} className="flex items-center gap-2 px-4 py-2 text-sm rounded-lg border border-destructive/30 text-destructive hover:bg-destructive/10">
<Trash2 size={14} />
Delete Board
</button>
</div>
</div>
)}
<ConfirmDialog open={showArchiveConfirm} onClose={() => setShowArchiveConfirm(false)} onConfirm={handleArchive} title="Archive Board" description={`Archive "${board.name}"? It can be restored later.`} confirmLabel="Archive" />
<ConfirmDialog open={showDeleteConfirm} onClose={() => setShowDeleteConfirm(false)} onConfirm={handleDelete} title="Delete Board Permanently" description={`This will permanently delete "${board.name}" and all its cards, comments, and attachments. This cannot be undone.`} confirmLabel="Delete" destructive requireConfirmText="DELETE" />
</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 { UserAvatar } from '@/components/shared/user-avatar';
import { StatusBadge } from '@/components/shared/status-badge';
import { Users, Search } from 'lucide-react';
export default function DirectoryPage() {
const user = useAuthStore((s) => s.user);
const [contractors, setContractors] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [search, setSearch] = useState('');
const [roleFilter, setRoleFilter] = useState('');
useEffect(() => {
loadData();
}, [search, roleFilter]);
const loadData = async () => {
try {
const params: any = { limit: 100, status: 'ACTIVE' };
if (search) params.search = search;
if (roleFilter) params.role = roleFilter;
const res = await apiGet('/users', params);
setContractors(res.data || []);
} catch (err) {
console.error('Failed to load directory:', err);
} finally {
setIsLoading(false);
}
};
if (isLoading) return <PageLoadingSkeleton />;
const isAdmin = user?.role === 'SUPER_ADMIN' || user?.role === 'ADMIN';
return (
<div className="space-y-6">
<PageHeader title="Team Directory" description="Browse all team members" />
<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 by name..."
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>
<select value={roleFilter} onChange={(e) => setRoleFilter(e.target.value)} className="px-3 py-2 rounded-lg border bg-background text-sm">
<option value="">All Roles</option>
<option value="CONTRACTOR">Contractor</option>
<option value="TEAM_LEAD">Team Lead</option>
<option value="ADMIN">Admin</option>
<option value="SUPER_ADMIN">Super Admin</option>
</select>
</div>
{contractors.length === 0 ? (
<EmptyState icon={Users} title="No team members found" description="Try adjusting your search." />
) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{contractors.map((c) => (
<div key={c.id} className="bg-card rounded-xl border p-4 hover:border-primary/30 transition-all">
<div className="flex items-center gap-3 mb-3">
<UserAvatar firstName={c.firstName} lastName={c.lastName} avatar={c.avatar} size="md" />
<div className="min-w-0">
<p className="text-sm font-semibold truncate">{c.firstName} {c.lastName}</p>
<p className="text-[10px] text-muted-foreground">@{c.username}</p>
</div>
</div>
<div className="flex flex-wrap gap-1">
<StatusBadge status={c.role?.replace('_', ' ') || 'CONTRACTOR'} />
{c.contractorType && <StatusBadge status={c.contractorType} />}
<StatusBadge status={c.status || 'ACTIVE'} />
</div>
</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