Commit 731cd6e6 authored by Administrator's avatar Administrator

Update 10 files via Son of Anton

parent 78d69125
This diff is collapsed.
'use client';
import { useEffect, useState } from 'react';
import { apiGet, apiDelete } 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 { ConfirmDialog } from '@/components/shared/confirm-dialog';
import { formatDate } from '@/lib/date';
import { formatEgp, cn } from '@/lib/utils';
import {
Users, Kanban, FileText, AlertTriangle, DollarSign, Star,
Calendar, Bell, BookOpen, Shield, Webhook, Key, Search, Trash2,
ChevronLeft, ChevronRight, Database,
} from 'lucide-react';
import { toast } from 'sonner';
const ENTITIES = [
{ key: 'users', label: 'Users', icon: Users, endpoint: '/users', columns: ['firstName', 'lastName', 'username', 'role', 'status'] },
{ key: 'boards', label: 'Boards', icon: Kanban, endpoint: '/boards', columns: ['name', 'key', 'memberCount', 'isArchived'] },
{ key: 'cards', label: 'Cards', icon: FileText, endpoint: '/cards', params: { limit: 20 }, columns: ['cardNumber', 'title', 'priority', 'isArchived'] },
{ key: 'deductions', label: 'Deductions', icon: AlertTriangle, endpoint: '/deductions', columns: ['category', 'subCategory', 'status', 'amountPiasters'] },
{ key: 'adjustments', label: 'Adjustments', icon: DollarSign, endpoint: '/adjustments', columns: ['type', 'category', 'amountPiasters', 'status'] },
{ key: 'evaluations', label: 'Evaluations', icon: Star, endpoint: '/evaluations', columns: ['month', 'year', 'overallScore', 'status'] },
{ key: 'pips', label: 'PIPs', icon: AlertTriangle, endpoint: '/pips', columns: ['status', 'startDate', 'endDate'] },
{ key: 'holidays', label: 'Holidays', icon: Calendar, endpoint: '/holidays', columns: ['name', 'startDate', 'endDate', 'isRecurring'] },
{ key: 'notices', label: 'Notices', icon: Bell, endpoint: '/notices', columns: ['title', 'type', 'isBlocking'] },
{ key: 'policies', label: 'Policies', icon: BookOpen, endpoint: '/policies', columns: ['title', 'version', 'requiresAcknowledgment'] },
{ key: 'api-keys', label: 'API Keys', icon: Key, endpoint: '/api-keys', columns: ['name', 'scope', 'isActive'] },
{ key: 'webhooks', label: 'Webhooks', icon: Webhook, endpoint: '/webhooks', columns: ['url', 'isActive'] },
{ key: 'audit', label: 'Audit Trail', icon: Shield, endpoint: '/audit-trail', columns: ['action', 'entityType', 'method', 'ipAddress'] },
];
export default function ControlPanelPage() {
const [activeEntity, setActiveEntity] = useState(ENTITIES[0]);
const [data, setData] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const [search, setSearch] = useState('');
const [deleteTarget, setDeleteTarget] = useState<any>(null);
useEffect(() => {
loadData();
}, [activeEntity, page, search]);
const loadData = async () => {
setIsLoading(true);
try {
const params: any = { page, limit: 20, ...(activeEntity.params || {}) };
if (search) params.search = search;
const res = await apiGet(activeEntity.endpoint, params);
setData(res.data || []);
setTotal(res.meta?.total || 0);
} catch (err) {
console.error('Failed to load data:', err);
setData([]);
} finally {
setIsLoading(false);
}
};
const handleDelete = async () => {
if (!deleteTarget) return;
try {
await apiDelete(`${activeEntity.endpoint}/${deleteTarget.id}`);
toast.success('Record deleted');
setDeleteTarget(null);
loadData();
} catch (err: any) {
toast.error(err.message || 'Failed to delete');
}
};
const renderCellValue = (row: any, col: string): string => {
const val = row[col];
if (val === null || val === undefined) return '—';
if (typeof val === 'boolean') return val ? '✅' : '❌';
if (col.includes('Piasters') || col.includes('piasters')) return formatEgp(val);
if (col.includes('Date') || col.includes('At')) {
try { return formatDate(val); } catch { return String(val); }
}
if (typeof val === 'object') return JSON.stringify(val).slice(0, 50);
return String(val);
};
return (
<div className="space-y-6">
<PageHeader
title="Control Panel"
description="Super Admin — Full entity management (god mode)"
actions={
<div className="flex items-center gap-2">
<Database size={16} className="text-muted-foreground" />
<span className="text-xs text-muted-foreground">{total} records</span>
</div>
}
/>
{/* Entity Tabs */}
<div className="flex gap-1 overflow-x-auto pb-2">
{ENTITIES.map(entity => {
const Icon = entity.icon;
const isActive = activeEntity.key === entity.key;
return (
<button
key={entity.key}
onClick={() => { setActiveEntity(entity); setPage(1); setSearch(''); }}
className={cn(
'flex items-center gap-1.5 px-3 py-2 rounded-lg text-xs font-medium whitespace-nowrap transition-colors',
isActive ? 'bg-primary text-primary-foreground' : 'hover:bg-accent text-muted-foreground',
)}
>
<Icon size={14} />
{entity.label}
</button>
);
})}
</div>
{/* Search */}
<div className="relative 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 ${activeEntity.label.toLowerCase()}...`}
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>
{/* Table */}
{isLoading ? (
<PageLoadingSkeleton />
) : (
<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 w-16">ID</th>
{activeEntity.columns.map(col => (
<th key={col} className="text-left px-4 py-3 font-medium text-muted-foreground capitalize">
{col.replace(/([A-Z])/g, ' $1').trim()}
</th>
))}
<th className="text-left px-4 py-3 font-medium text-muted-foreground w-20">Actions</th>
</tr>
</thead>
<tbody className="divide-y">
{data.map((row) => (
<tr key={row.id} className="hover:bg-accent/50">
<td className="px-4 py-3 text-xs font-mono text-muted-foreground">{row.id?.slice(0, 8)}...</td>
{activeEntity.columns.map(col => (
<td key={col} className="px-4 py-3 text-xs max-w-[200px] truncate">
{col === 'status' || col === 'role' || col === 'priority' || col === 'type' || col === 'category' ? (
<StatusBadge status={row[col] || '—'} />
) : (
renderCellValue(row, col)
)}
</td>
))}
<td className="px-4 py-3">
{activeEntity.key !== 'audit' && (
<button onClick={() => setDeleteTarget(row)} className="p-1 text-muted-foreground hover:text-destructive">
<Trash2 size={14} />
</button>
)}
</td>
</tr>
))}
{data.length === 0 && (
<tr>
<td colSpan={activeEntity.columns.length + 2} className="text-center py-12 text-muted-foreground">
No {activeEntity.label.toLowerCase()} found.
</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">
<ChevronLeft size={14} />
</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">
<ChevronRight size={14} />
</button>
</div>
</div>
)}
</div>
)}
<ConfirmDialog
open={!!deleteTarget}
onClose={() => setDeleteTarget(null)}
onConfirm={handleDelete}
title={`Delete ${activeEntity.label.slice(0, -1)}`}
description={`Permanently delete this record? This action cannot be undone.`}
confirmLabel="Delete"
destructive
/>
</div>
);
}
\ No newline at end of file
'use client';
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { apiGet, apiPost } from '@/lib/api';
import { PageHeader } from '@/components/shared/page-header';
import { formatEgp } from '@/lib/utils';
import { toast } from 'sonner';
import { Send, Loader2 } from 'lucide-react';
const CATEGORIES = [
{ value: 'A', label: 'A — Deadline Violations', subs: [
{ value: 'A1', label: 'A1 — Slight Delay (1-3 days)' },
{ value: 'A2', label: 'A2 — Moderate Delay (4-7 days)' },
{ value: 'A3', label: 'A3 — Severe Delay (8-14 days)' },
{ value: 'A4', label: 'A4 — Critical Delay (15+ days)' },
{ value: 'A5', label: 'A5 — Complete Failure' },
]},
{ value: 'B', label: 'B — Reporting Violations', subs: [
{ value: 'B1', label: 'B1 — Late Report' },
{ value: 'B2', label: 'B2 — Unreported Day' },
{ value: 'B3', label: 'B3 — Vague/Useless Report' },
{ value: 'B4', label: 'B4 — Falsified Report' },
]},
{ value: 'C', label: 'C — Quality Violations', subs: [
{ value: 'C1', label: 'C1 — Minor Quality Issues' },
{ value: 'C2', label: 'C2 — Significant Quality Issues' },
{ value: 'C3', label: 'C3 — Critical Quality Issues' },
{ value: 'C4', label: 'C4 — Regression' },
]},
{ value: 'D', label: 'D — Communication Violations', subs: [
{ value: 'D1', label: 'D1 — Slow Response' },
{ value: 'D2', label: 'D2 — No-Show Meeting' },
{ value: 'D3', label: 'D3 — Disappeared' },
{ value: 'D4', label: 'D4 — Unprofessional Conduct' },
]},
];
export default function CreateDeductionPage() {
const router = useRouter();
const [contractors, setContractors] = useState<any[]>([]);
const [isSubmitting, setIsSubmitting] = useState(false);
const [form, setForm] = useState({
userId: '',
category: 'A',
subCategory: 'A1',
cardId: '',
violationDate: new Date().toISOString().split('T')[0],
description: '',
amountPiasters: 0,
});
useEffect(() => {
apiGet('/users', { role: 'CONTRACTOR', status: 'ACTIVE', limit: 100 })
.then(res => setContractors(res.data || []))
.catch(console.error);
}, []);
const selectedCat = CATEGORIES.find(c => c.value === form.category);
const handleSubmit = async () => {
if (!form.userId) { toast.error('Select a contractor'); return; }
if (form.description.length < 100) { toast.error('Description must be at least 100 characters'); return; }
setIsSubmitting(true);
try {
await apiPost('/deductions', {
userId: form.userId,
category: form.category,
subCategory: form.subCategory,
cardId: form.cardId || undefined,
violationDate: form.violationDate,
description: form.description,
amountPiasters: form.amountPiasters > 0 ? form.amountPiasters : undefined,
});
toast.success('Deduction created');
router.push('/admin/deductions');
} catch (err: any) {
toast.error(err.message || 'Failed to create deduction');
} finally {
setIsSubmitting(false);
}
};
return (
<div className="max-w-3xl mx-auto space-y-6">
<PageHeader title="Create Deduction" description="Initiate a new deduction for a contractor" />
<div className="bg-card rounded-xl border p-6 space-y-5">
{/* Contractor */}
<div className="space-y-1">
<label className="text-sm font-medium">Contractor *</label>
<select value={form.userId} onChange={e => setForm({ ...form, userId: e.target.value })} className="w-full px-3 py-2 rounded-lg border bg-background text-sm">
<option value="">Select contractor</option>
{contractors.map(c => (
<option key={c.id} value={c.id}>{c.firstName} {c.lastName} (@{c.username})</option>
))}
</select>
</div>
{/* Category */}
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1">
<label className="text-sm font-medium">Category *</label>
<select value={form.category} onChange={e => {
const cat = e.target.value;
const firstSub = CATEGORIES.find(c => c.value === cat)?.subs[0]?.value || cat + '1';
setForm({ ...form, category: cat, subCategory: firstSub });
}} className="w-full px-3 py-2 rounded-lg border bg-background text-sm">
{CATEGORIES.map(c => <option key={c.value} value={c.value}>{c.label}</option>)}
</select>
</div>
<div className="space-y-1">
<label className="text-sm font-medium">Sub-Category *</label>
<select value={form.subCategory} onChange={e => setForm({ ...form, subCategory: e.target.value })} className="w-full px-3 py-2 rounded-lg border bg-background text-sm">
{selectedCat?.subs.map(s => <option key={s.value} value={s.value}>{s.label}</option>)}
</select>
</div>
</div>
{/* Date + Amount */}
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1">
<label className="text-sm font-medium">Violation Date *</label>
<input type="date" value={form.violationDate} onChange={e => setForm({ ...form, violationDate: e.target.value })} max={new Date().toISOString().split('T')[0]} className="w-full px-3 py-2 rounded-lg border bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring" />
</div>
<div className="space-y-1">
<label className="text-sm font-medium">Amount (piasters) — 0 = auto-calculate</label>
<input type="number" value={form.amountPiasters} onChange={e => setForm({ ...form, amountPiasters: Number(e.target.value) })} min={0} className="w-full px-3 py-2 rounded-lg border bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring" />
{form.amountPiasters > 0 && <p className="text-xs text-muted-foreground">{formatEgp(form.amountPiasters)}</p>}
</div>
</div>
{/* Description */}
<div className="space-y-1">
<label className="text-sm font-medium">Description * (min 100 chars)</label>
<textarea value={form.description} onChange={e => setForm({ ...form, description: e.target.value })} rows={5} placeholder="Detailed explanation of the violation..." 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" />
<p className="text-xs text-muted-foreground">{form.description.length}/100 min characters</p>
</div>
{/* Actions */}
<div className="flex justify-end gap-3 pt-4 border-t">
<button onClick={() => router.back()} className="px-4 py-2 text-sm rounded-lg border hover:bg-accent">Cancel</button>
<button onClick={handleSubmit} disabled={isSubmitting} className="flex items-center gap-2 px-6 py-2.5 bg-primary text-primary-foreground rounded-lg font-medium hover:bg-primary/90 disabled:opacity-50">
{isSubmitting ? <Loader2 size={14} className="animate-spin" /> : <Send size={14} />}
Create Deduction
</button>
</div>
</div>
</div>
);
}
\ No newline at end of file
'use client';
import { useEffect, useState } from 'react';
import { apiGet, apiPost, apiDelete } from '@/lib/api';
import { PageHeader } from '@/components/shared/page-header';
import { PageLoadingSkeleton } from '@/components/shared/loading-skeleton';
import { EmptyState } from '@/components/shared/empty-state';
import { StatusBadge } from '@/components/shared/status-badge';
import { formatDate, relativeTime } from '@/lib/date';
import { Send, Plus, Copy, Trash2, Loader2, UserPlus } from 'lucide-react';
import { toast } from 'sonner';
export default function InvitesPage() {
const [invites, setInvites] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [showCreate, setShowCreate] = useState(false);
const [isCreating, setIsCreating] = useState(false);
const [form, setForm] = useState({
contractorType: 'FULL_TIME',
expiresInDays: 7,
welcomeNote: '',
});
useEffect(() => { loadInvites(); }, []);
const loadInvites = async () => {
try {
const res = await apiGet('/onboarding/invites', { limit: 100 });
setInvites(res.data || []);
} catch (err) {
console.error('Failed to load invites:', err);
} finally {
setIsLoading(false);
}
};
const handleCreate = async () => {
setIsCreating(true);
try {
const res = await apiPost('/onboarding/invites', form);
const code = res.data?.code || res.data?.inviteCode;
toast.success(`Invite created: ${code}`, { duration: 15000 });
setShowCreate(false);
loadInvites();
} catch (err: any) {
toast.error(err.message || 'Failed to create invite');
} finally {
setIsCreating(false);
}
};
const handleRevoke = async (id: string) => {
try {
await apiDelete(`/onboarding/invites/${id}`);
toast.success('Invite revoked');
loadInvites();
} catch (err: any) {
toast.error(err.message || 'Failed to revoke');
}
};
const copyLink = (code: string) => {
const url = `${window.location.origin}/register/${code}`;
navigator.clipboard.writeText(url);
toast.success('Invite link copied!');
};
if (isLoading) return <PageLoadingSkeleton />;
return (
<div className="space-y-6">
<PageHeader
title="Invite Management"
description="Create and manage contractor invitations"
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 Invite
</button>
}
/>
{showCreate && (
<div className="bg-card rounded-xl border p-4 space-y-4">
<h3 className="font-semibold">New Invitation</h3>
<div className="grid gap-4 sm:grid-cols-3">
<div className="space-y-1">
<label className="text-sm font-medium">Contractor Type *</label>
<select value={form.contractorType} onChange={e => setForm({ ...form, contractorType: e.target.value })} className="w-full px-3 py-2 rounded-lg border bg-background text-sm">
<option value="FULL_TIME">Full-Timer</option>
<option value="INTERN">Intern</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={30} 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">Welcome Note</label>
<input type="text" value={form.welcomeNote} onChange={e => setForm({ ...form, welcomeNote: e.target.value })} placeholder="Optional message" 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" /> : <Send size={14} />}
Create
</button>
</div>
</div>
)}
{invites.length === 0 ? (
<EmptyState icon={UserPlus} title="No invitations" description="Create an invite to onboard a new contractor." />
) : (
<div className="bg-card rounded-xl border divide-y">
{invites.map(inv => (
<div key={inv.id} className="p-4 flex items-center justify-between">
<div>
<div className="flex items-center gap-2">
<code className="text-sm font-mono bg-muted px-2 py-0.5 rounded">{inv.code || inv.inviteCode}</code>
<StatusBadge status={inv.status || 'ACTIVE'} />
<span className="text-xs text-muted-foreground">{inv.contractorType?.replace('_', ' ')}</span>
</div>
<p className="text-xs text-muted-foreground mt-1">
Created {inv.createdAt ? relativeTime(inv.createdAt) : '—'}
{inv.expiresAt && ` · Expires ${formatDate(inv.expiresAt)}`}
{inv.usedBy && ` · Used by ${inv.usedBy.firstName} ${inv.usedBy.lastName}`}
</p>
</div>
<div className="flex gap-1">
{(inv.status === 'ACTIVE' || !inv.status) && (
<>
<button onClick={() => copyLink(inv.code || inv.inviteCode)} className="p-2 text-muted-foreground hover:text-foreground" title="Copy link"><Copy size={14} /></button>
<button onClick={() => handleRevoke(inv.id)} className="p-2 text-muted-foreground hover:text-destructive" title="Revoke"><Trash2 size={14} /></button>
</>
)}
</div>
</div>
))}
</div>
)}
</div>
);
}
\ No newline at end of file
'use client';
import { useEffect, useState, useMemo } from 'react';
import { useParams } from 'next/navigation';
import { apiGet } from '@/lib/api';
import { PageLoadingSkeleton } from '@/components/shared/loading-skeleton';
import { BoardHeader } from '@/components/kanban/board-header';
import { StatusBadge } from '@/components/shared/status-badge';
import { UserAvatar } from '@/components/shared/user-avatar';
import { formatEgp, cn } from '@/lib/utils';
import { ChevronLeft, ChevronRight, Coins, Clock } from 'lucide-react';
import { toast } from 'sonner';
export default function BoardCalendarPage() {
const { boardId } = useParams<{ boardId: string }>();
const [board, setBoard] = useState<any>(null);
const [cards, setCards] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [currentDate, setCurrentDate] = useState(new Date());
useEffect(() => {
loadData();
}, [boardId]);
const loadData = async () => {
try {
const [boardRes, cardsRes] = await Promise.all([
apiGet(`/boards/${boardId}`),
apiGet('/cards', { boardId, limit: 500, isArchived: false }),
]);
setBoard(boardRes.data);
setCards(cardsRes.data || []);
} catch (err: any) {
toast.error(err.message || 'Failed to load board');
} finally {
setIsLoading(false);
}
};
const year = currentDate.getFullYear();
const month = currentDate.getMonth();
const calendarDays = useMemo(() => {
const firstDay = new Date(year, month, 1);
const lastDay = new Date(year, month + 1, 0);
const startDayOfWeek = firstDay.getDay();
const totalDays = lastDay.getDate();
const days: { date: Date | null; cards: any[] }[] = [];
// Padding for days before the 1st
for (let i = 0; i < startDayOfWeek; i++) {
days.push({ date: null, cards: [] });
}
// Days of the month
for (let d = 1; d <= totalDays; d++) {
const date = new Date(year, month, d);
const dateStr = date.toISOString().split('T')[0];
const dayCards = cards.filter(card => {
if (!card.dueDate) return false;
const dueDateStr = new Date(card.dueDate).toISOString().split('T')[0];
return dueDateStr === dateStr;
});
days.push({ date, cards: dayCards });
}
return days;
}, [cards, year, month]);
const prevMonth = () => setCurrentDate(new Date(year, month - 1, 1));
const nextMonth = () => setCurrentDate(new Date(year, month + 1, 1));
const today = new Date();
const isToday = (date: Date | null) => date && date.toDateString() === today.toDateString();
const isPast = (date: Date | null) => date && date < today && !isToday(date);
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={loadData} />
{/* Month Navigation */}
<div className="flex items-center justify-between">
<button onClick={prevMonth} className="p-2 rounded-md hover:bg-accent"><ChevronLeft size={16} /></button>
<h2 className="text-lg font-bold">
{currentDate.toLocaleString('default', { month: 'long', year: 'numeric' })}
</h2>
<button onClick={nextMonth} className="p-2 rounded-md hover:bg-accent"><ChevronRight size={16} /></button>
</div>
{/* Calendar Grid */}
<div className="bg-card rounded-xl border overflow-hidden">
{/* Day Headers */}
<div className="grid grid-cols-7 border-b">
{['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].map(day => (
<div key={day} className="px-2 py-2 text-xs font-medium text-muted-foreground text-center bg-muted/50">
{day}
</div>
))}
</div>
{/* Calendar Cells */}
<div className="grid grid-cols-7">
{calendarDays.map((day, i) => (
<div
key={i}
className={cn(
'min-h-[100px] border-b border-r p-1.5 transition-colors',
!day.date && 'bg-muted/20',
isToday(day.date) && 'bg-primary/5',
isPast(day.date) && 'opacity-60',
)}
>
{day.date && (
<>
<div className={cn(
'text-xs font-medium mb-1',
isToday(day.date) && 'text-primary font-bold',
)}>
{day.date.getDate()}
</div>
<div className="space-y-0.5">
{day.cards.slice(0, 3).map(card => {
const isOverdue = !card.completedAt && new Date(card.dueDate) < today;
return (
<div
key={card.id}
className={cn(
'text-[10px] px-1.5 py-0.5 rounded truncate cursor-pointer hover:opacity-80 transition-opacity',
isOverdue
? 'bg-red-500/10 text-red-600'
: card.completedAt
? 'bg-emerald-500/10 text-emerald-600 line-through'
: 'bg-blue-500/10 text-blue-600',
)}
title={`${card.cardNumber}: ${card.title}`}
>
<span className="font-mono">{card.cardNumber}</span> {card.title}
</div>
);
})}
{day.cards.length > 3 && (
<div className="text-[10px] text-muted-foreground px-1.5">
+{day.cards.length - 3} more
</div>
)}
</div>
</>
)}
</div>
))}
</div>
</div>
{/* Legend */}
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<span className="flex items-center gap-1"><span className="w-3 h-3 rounded bg-blue-500/20" /> Due</span>
<span className="flex items-center gap-1"><span className="w-3 h-3 rounded bg-red-500/20" /> Overdue</span>
<span className="flex items-center gap-1"><span className="w-3 h-3 rounded bg-emerald-500/20" /> Completed</span>
</div>
</div>
);
}
\ No newline at end of file
......@@ -5,7 +5,10 @@ import { Bell, MessageSquare, Search, LogOut, User, Moon, Sun } from 'lucide-rea
import { useTheme } from 'next-themes';
import { useAuthStore } from '@/stores/auth.store';
import { useNotificationStore } from '@/stores/notification.store';
import { useSearchStore } from '@/stores/search.store';
import { HudBar } from '@/components/hud/hud-bar';
import { CommandPalette } from '@/components/search/command-palette';
import { useKeyboardShortcut } from '@/hooks/use-keyboard-shortcut';
import { cn } from '@/lib/utils';
export function Topbar() {
......@@ -13,10 +16,15 @@ export function Topbar() {
const { user, logout } = useAuthStore();
const { unreadCount } = useNotificationStore();
const { theme, setTheme } = useTheme();
const { isOpen, openSearch, closeSearch, toggleSearch } = useSearchStore();
const isContractor = user?.role === 'CONTRACTOR';
// Ctrl+K / Cmd+K to open search
useKeyboardShortcut('k', () => toggleSearch(), { ctrl: true });
return (
<>
<header className="sticky top-0 z-30 h-14 bg-background/80 backdrop-blur-sm border-b flex items-center justify-between px-6">
{/* Left: HUD (for contractors) or search */}
<div className="flex items-center gap-4 flex-1">
......@@ -24,17 +32,31 @@ export function Topbar() {
<HudBar />
) : (
<button
onClick={() => router.push('/admin/analytics')}
onClick={openSearch}
className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<Search size={16} />
<span className="hidden sm:inline">Search... (Ctrl+K)</span>
<span className="hidden sm:inline">Search... </span>
<kbd className="hidden sm:inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] bg-muted rounded border font-mono">
⌘K
</kbd>
</button>
)}
</div>
{/* Right: Actions */}
<div className="flex items-center gap-1">
{/* Search button for contractors too */}
{isContractor && (
<button
onClick={openSearch}
className="p-2 rounded-md hover:bg-accent text-muted-foreground hover:text-foreground transition-colors"
title="Search (Ctrl+K)"
>
<Search size={18} />
</button>
)}
{/* Theme toggle */}
<button
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
......@@ -86,5 +108,9 @@ export function Topbar() {
</div>
</div>
</header>
{/* Command Palette */}
<CommandPalette open={isOpen} onClose={closeSearch} />
</>
);
}
\ No newline at end of file
'use client';
import { useState, useEffect, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { apiGet } from '@/lib/api';
import { useAuthStore } from '@/stores/auth.store';
import {
Search, Kanban, ListTodo, FileText, Users, DollarSign,
MessageSquare, Bell, Settings, User, X, Loader2,
Star, Calendar, GraduationCap,
} from 'lucide-react';
const QUICK_ACTIONS = [
{ label: 'Submit Report', href: '/reports/submit', icon: FileText, keywords: ['report', 'submit', 'daily'] },
{ label: 'My Tasks', href: '/my-tasks', icon: ListTodo, keywords: ['tasks', 'my tasks', 'assigned'] },
{ label: 'Boards', href: '/boards', icon: Kanban, keywords: ['boards', 'kanban', 'projects'] },
{ label: 'Messages', href: '/messages', icon: MessageSquare, keywords: ['messages', 'dm', 'chat'] },
{ label: 'Notifications', href: '/notifications', icon: Bell, keywords: ['notifications', 'alerts'] },
{ label: 'Salary', href: '/salary', icon: DollarSign, keywords: ['salary', 'pay', 'money', 'hud'] },
{ label: 'Profile', href: '/profile', icon: User, keywords: ['profile', 'me', 'settings'] },
{ label: 'Directory', href: '/directory', icon: Users, keywords: ['directory', 'people', 'team'] },
{ label: 'Schedule', href: '/schedule', icon: Calendar, keywords: ['schedule', 'calendar'] },
{ label: 'Evaluations', href: '/evaluations', icon: Star, keywords: ['evaluations', 'eval', 'review'] },
{ label: 'Learning', href: '/learning', icon: GraduationCap, keywords: ['learning', 'goals', 'competency'] },
];
const ADMIN_ACTIONS = [
{ label: 'Contractors', href: '/admin/contractors', icon: Users, keywords: ['contractors', 'manage', 'admin'] },
{ label: 'Deductions', href: '/admin/deductions', icon: DollarSign, keywords: ['deductions', 'admin'] },
{ label: 'Payroll', href: '/admin/payroll', icon: DollarSign, keywords: ['payroll', 'payment'] },
{ label: 'Analytics', href: '/admin/analytics', icon: Star, keywords: ['analytics', 'reports', 'dashboard'] },
{ label: 'Settings', href: '/admin/settings', icon: Settings, keywords: ['settings', 'config'] },
{ label: 'Audit Trail', href: '/admin/audit-trail', icon: FileText, keywords: ['audit', 'trail', 'log'] },
{ label: 'Invites', href: '/admin/invites', icon: Users, keywords: ['invites', 'onboarding'] },
];
interface CommandPaletteProps {
open: boolean;
onClose: () => void;
}
export function CommandPalette({ open, onClose }: CommandPaletteProps) {
const router = useRouter();
const user = useAuthStore(s => s.user);
const [query, setQuery] = useState('');
const [isSearching, setIsSearching] = useState(false);
const [searchResults, setSearchResults] = useState<any[]>([]);
const [selectedIndex, setSelectedIndex] = useState(0);
const isAdmin = user?.role === 'SUPER_ADMIN' || user?.role === 'ADMIN';
// Reset on open
useEffect(() => {
if (open) {
setQuery('');
setSearchResults([]);
setSelectedIndex(0);
}
}, [open]);
// Search API debounced
useEffect(() => {
if (query.length < 2) { setSearchResults([]); return; }
const timer = setTimeout(async () => {
setIsSearching(true);
try {
const res = await apiGet('/search', { q: query, limit: 10 });
setSearchResults(res.data || []);
} catch { /* ok */ }
finally { setIsSearching(false); }
}, 300);
return () => clearTimeout(timer);
}, [query]);
// Filtered quick actions
const q = query.toLowerCase();
const filteredActions = [...QUICK_ACTIONS, ...(isAdmin ? ADMIN_ACTIONS : [])].filter(action =>
!query || action.label.toLowerCase().includes(q) || action.keywords.some(k => k.includes(q))
);
// All items for keyboard nav
const allItems = [
...filteredActions.map(a => ({ type: 'action' as const, ...a })),
...searchResults.map(r => ({ type: 'result' as const, ...r })),
];
const handleSelect = (item: any) => {
if (item.type === 'action') {
router.push(item.href);
} else if (item.type === 'result') {
if (item.entityType === 'cards') router.push(`/boards/${item.boardId || ''}`);
else if (item.entityType === 'users') router.push(`/admin/contractors/${item.id}`);
else if (item.entityType === 'boards') router.push(`/boards/${item.id}`);
else if (item.href) router.push(item.href);
}
onClose();
};
// Keyboard navigation
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
setSelectedIndex(i => Math.min(i + 1, allItems.length - 1));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setSelectedIndex(i => Math.max(i - 1, 0));
} else if (e.key === 'Enter' && allItems[selectedIndex]) {
e.preventDefault();
handleSelect(allItems[selectedIndex]);
} else if (e.key === 'Escape') {
onClose();
}
}, [allItems, selectedIndex, onClose]);
if (!open) return null;
return (
<div className="fixed inset-0 z-[60] bg-black/50 flex items-start justify-center pt-[15vh] p-4" onClick={onClose}>
<div className="bg-card rounded-xl border shadow-2xl max-w-xl w-full overflow-hidden" onClick={e => e.stopPropagation()}>
{/* Input */}
<div className="flex items-center gap-3 px-4 border-b">
<Search size={18} className="text-muted-foreground shrink-0" />
<input
type="text"
value={query}
onChange={e => { setQuery(e.target.value); setSelectedIndex(0); }}
onKeyDown={handleKeyDown}
placeholder="Search or type a command..."
autoFocus
className="w-full py-3.5 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
/>
{isSearching && <Loader2 size={16} className="animate-spin text-muted-foreground" />}
<button onClick={onClose} className="p-1 text-muted-foreground hover:text-foreground">
<X size={16} />
</button>
</div>
{/* Results */}
<div className="max-h-80 overflow-y-auto">
{/* Quick Actions */}
{filteredActions.length > 0 && (
<div className="p-2">
<p className="px-2 py-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
{query ? 'Matching Actions' : 'Quick Actions'}
</p>
{filteredActions.map((action, i) => {
const Icon = action.icon;
const isSelected = i === selectedIndex;
return (
<button
key={action.href}
onClick={() => handleSelect({ type: 'action', ...action })}
className={`w-full flex items-center gap-3 px-3 py-2 rounded-lg text-left text-sm transition-colors ${
isSelected ? 'bg-accent' : 'hover:bg-accent/50'
}`}
>
<Icon size={16} className="text-muted-foreground shrink-0" />
<span>{action.label}</span>
</button>
);
})}
</div>
)}
{/* API Results */}
{searchResults.length > 0 && (
<div className="p-2 border-t">
<p className="px-2 py-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">Search Results</p>
{searchResults.map((result, i) => {
const globalIndex = filteredActions.length + i;
const isSelected = globalIndex === selectedIndex;
return (
<button
key={`${result.entityType}-${result.id}`}
onClick={() => handleSelect({ type: 'result', ...result })}
className={`w-full flex items-center gap-3 px-3 py-2 rounded-lg text-left text-sm transition-colors ${
isSelected ? 'bg-accent' : 'hover:bg-accent/50'
}`}
>
<span className="text-[10px] font-mono bg-muted px-1.5 py-0.5 rounded text-muted-foreground shrink-0">
{result.entityType}
</span>
<div className="min-w-0">
<p className="truncate font-medium">{result.title || result.name || result.cardNumber || result.id?.slice(0, 8)}</p>
{result.snippet && <p className="text-xs text-muted-foreground truncate">{result.snippet}</p>}
</div>
</button>
);
})}
</div>
)}
{query.length >= 2 && searchResults.length === 0 && !isSearching && (
<p className="text-center py-8 text-sm text-muted-foreground">No results for "{query}"</p>
)}
</div>
{/* Footer */}
<div className="px-4 py-2 border-t text-[10px] text-muted-foreground flex items-center gap-4">
<span>↑↓ Navigate</span>
<span>↵ Select</span>
<span>Esc Close</span>
</div>
</div>
</div>
);
}
\ No newline at end of file
'use client';
import { useEffect, useCallback } from 'react';
type KeyHandler = (event: KeyboardEvent) => void;
interface ShortcutOptions {
ctrl?: boolean;
meta?: boolean;
shift?: boolean;
alt?: boolean;
enabled?: boolean;
}
export function useKeyboardShortcut(
key: string,
handler: KeyHandler,
options: ShortcutOptions = {},
): void {
const { ctrl, meta, shift, alt, enabled = true } = options;
const handleKeyDown = useCallback(
(event: KeyboardEvent) => {
if (!enabled) return;
// Don't fire shortcuts when typing in inputs
const target = event.target as HTMLElement;
if (
target.tagName === 'INPUT' ||
target.tagName === 'TEXTAREA' ||
target.tagName === 'SELECT' ||
target.isContentEditable
) {
// Exception: allow Escape and Ctrl/Cmd+K even in inputs
const isEscape = event.key === 'Escape';
const isCmdK = (event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k';
if (!isEscape && !isCmdK) return;
}
const keyMatch = event.key.toLowerCase() === key.toLowerCase();
const ctrlMatch = ctrl ? (event.ctrlKey || event.metaKey) : true;
const metaMatch = meta ? event.metaKey : true;
const shiftMatch = shift ? event.shiftKey : !event.shiftKey;
const altMatch = alt ? event.altKey : !event.altKey;
// For Ctrl/Cmd shortcuts, require the modifier
if ((ctrl || meta) && !(event.ctrlKey || event.metaKey)) return;
if (keyMatch && ctrlMatch && metaMatch && shiftMatch && altMatch) {
event.preventDefault();
handler(event);
}
},
[key, handler, ctrl, meta, shift, alt, enabled],
);
useEffect(() => {
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [handleKeyDown]);
}
\ No newline at end of file
import { create } from 'zustand';
interface SearchState {
isOpen: boolean;
openSearch: () => void;
closeSearch: () => void;
toggleSearch: () => void;
}
export const useSearchStore = create<SearchState>((set) => ({
isOpen: false,
openSearch: () => set({ isOpen: true }),
closeSearch: () => set({ isOpen: false }),
toggleSearch: () => set((state) => ({ isOpen: !state.isOpen })),
}));
\ 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