Commit 553c8c4a authored by Administrator's avatar Administrator

Update 14 files via Son of Anton

parent 524d1e1e
'use client';
import { useAuthStore } from '@/stores/auth.store';
import { useRouter, usePathname } from 'next/navigation';
import { useEffect } from 'react';
import {
Users, UserPlus, ClipboardList, AlertTriangle, DollarSign, Star,
Calendar, Bell, BookOpen, Shield, Settings, Webhook, Key, BarChart3,
Database, FileText, Layers, Cpu, Coins, FileCheck,
} from 'lucide-react';
import Link from 'next/link';
import { cn } from '@/lib/utils';
const adminLinks = [
{ href: '/admin/contractors', label: 'Contractors', icon: Users },
{ href: '/admin/invites', label: 'Invites', icon: UserPlus },
{ href: '/admin/onboarding', label: 'Onboarding', icon: ClipboardList },
{ href: '/admin/deductions', label: 'Deductions', icon: AlertTriangle },
{ href: '/admin/adjustments', label: 'Adjustments', icon: DollarSign },
{ href: '/admin/bounties', label: 'Bounties', icon: Coins },
{ href: '/admin/payroll', label: 'Payroll', icon: FileCheck },
{ href: '/admin/evaluations', label: 'Evaluations', icon: Star },
{ href: '/admin/pips', label: 'PIPs', icon: AlertTriangle },
{ href: '/admin/contracts', label: 'Contracts', icon: FileText },
{ href: '/admin/holidays', label: 'Holidays', icon: Calendar },
{ href: '/admin/notices', label: 'Notices', icon: Bell },
{ href: '/admin/policies', label: 'Policies', icon: BookOpen },
{ href: '/admin/templates', label: 'Templates', icon: Layers },
{ href: '/admin/analytics', label: 'Analytics', icon: BarChart3 },
{ href: '/admin/audit-trail', label: 'Audit Trail', icon: Shield },
];
const superAdminLinks = [
{ href: '/admin/settings', label: 'Settings', icon: Settings },
{ href: '/admin/api-keys', label: 'API Keys', icon: Key },
{ href: '/admin/webhooks', label: 'Webhooks', icon: Webhook },
{ href: '/admin/system-health', label: 'System Health', icon: Cpu },
{ href: '/admin/control-panel', label: 'Control Panel', icon: Database },
];
export default function AdminLayout({ children }: { children: React.ReactNode }) {
const user = useAuthStore((s) => s.user);
const router = useRouter();
const pathname = usePathname();
useEffect(() => {
if (user && user.role !== 'SUPER_ADMIN' && user.role !== 'ADMIN') {
router.push('/');
}
}, [user, router]);
if (!user || (user.role !== 'SUPER_ADMIN' && user.role !== 'ADMIN')) {
return null;
}
const isSuperAdmin = user.role === 'SUPER_ADMIN';
const allLinks = isSuperAdmin ? [...adminLinks, ...superAdminLinks] : adminLinks;
return (
<div>
{/* Admin sub-navigation tabs — horizontal scroll */}
<div className="mb-6 -mt-2 overflow-x-auto">
<div className="flex gap-1 min-w-max pb-2">
{allLinks.map((link) => {
const Icon = link.icon;
const isActive = pathname === link.href || pathname.startsWith(link.href + '/');
return (
<Link
key={link.href}
href={link.href}
className={cn(
'flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium whitespace-nowrap transition-colors',
isActive
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:bg-accent hover:text-foreground',
)}
>
<Icon size={14} />
{link.label}
</Link>
);
})}
</div>
</div>
{children}
</div>
);
}
\ No newline at end of file
This diff is collapsed.
'use client';
import { ChevronLeft, ChevronRight } from 'lucide-react';
interface DataTablePaginationProps {
page: number;
totalPages: number;
total: number;
limit: number;
onPageChange: (page: number) => void;
}
export function DataTablePagination({
page,
totalPages,
total,
limit,
onPageChange,
}: DataTablePaginationProps) {
if (totalPages <= 1) return null;
const start = (page - 1) * limit + 1;
const end = Math.min(page * limit, total);
return (
<div className="flex items-center justify-between px-4 py-3 border-t">
<span className="text-xs text-muted-foreground">
{start}{end} of {total}
</span>
<div className="flex gap-2">
<button
onClick={() => onPageChange(page - 1)}
disabled={page <= 1}
className="flex items-center gap-1 px-3 py-1.5 text-xs rounded border hover:bg-accent disabled:opacity-50"
>
<ChevronLeft size={12} /> Previous
</button>
<button
onClick={() => onPageChange(page + 1)}
disabled={page >= totalPages}
className="flex items-center gap-1 px-3 py-1.5 text-xs rounded border hover:bg-accent disabled:opacity-50"
>
Next <ChevronRight size={12} />
</button>
</div>
</div>
);
}
\ No newline at end of file
'use client';
import { useState, useMemo } from 'react';
import { cn } from '@/lib/utils';
import { ArrowUpDown, ChevronLeft, ChevronRight } from 'lucide-react';
export interface Column<T> {
key: string;
header: string;
sortable?: boolean;
className?: string;
render?: (row: T) => React.ReactNode;
}
interface DataTableProps<T> {
data: T[];
columns: Column<T>[];
keyExtractor: (row: T) => string;
onRowClick?: (row: T) => void;
pageSize?: number;
emptyMessage?: string;
className?: string;
selectable?: boolean;
selectedKeys?: Set<string>;
onSelectionChange?: (keys: Set<string>) => void;
}
export function DataTable<T extends Record<string, any>>({
data,
columns,
keyExtractor,
onRowClick,
pageSize = 20,
emptyMessage = 'No data found',
className,
selectable = false,
selectedKeys,
onSelectionChange,
}: DataTableProps<T>) {
const [page, setPage] = useState(1);
const [sortKey, setSortKey] = useState<string | null>(null);
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc');
const sortedData = useMemo(() => {
if (!sortKey) return data;
return [...data].sort((a, b) => {
const aVal = a[sortKey];
const bVal = b[sortKey];
if (aVal == null) return 1;
if (bVal == null) return -1;
if (typeof aVal === 'string') {
return sortDir === 'asc'
? aVal.localeCompare(bVal)
: bVal.localeCompare(aVal);
}
return sortDir === 'asc' ? aVal - bVal : bVal - aVal;
});
}, [data, sortKey, sortDir]);
const totalPages = Math.ceil(sortedData.length / pageSize);
const pagedData = sortedData.slice(
(page - 1) * pageSize,
page * pageSize,
);
const handleSort = (key: string) => {
if (sortKey === key) {
setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'));
} else {
setSortKey(key);
setSortDir('asc');
}
};
const toggleAll = () => {
if (!onSelectionChange || !selectedKeys) return;
if (selectedKeys.size === pagedData.length) {
onSelectionChange(new Set());
} else {
onSelectionChange(new Set(pagedData.map(keyExtractor)));
}
};
const toggleRow = (key: string) => {
if (!onSelectionChange || !selectedKeys) return;
const next = new Set(selectedKeys);
if (next.has(key)) next.delete(key);
else next.add(key);
onSelectionChange(next);
};
return (
<div className={cn('bg-card rounded-xl border overflow-hidden', className)}>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
{selectable && (
<th className="px-3 py-3 w-8">
<input
type="checkbox"
checked={
selectedKeys
? selectedKeys.size === pagedData.length &&
pagedData.length > 0
: false
}
onChange={toggleAll}
className="rounded"
/>
</th>
)}
{columns.map((col) => (
<th
key={col.key}
className={cn(
'text-left px-4 py-3 font-medium text-muted-foreground',
col.sortable && 'cursor-pointer select-none hover:text-foreground',
col.className,
)}
onClick={col.sortable ? () => handleSort(col.key) : undefined}
>
<span className="flex items-center gap-1">
{col.header}
{col.sortable && (
<ArrowUpDown
size={12}
className={
sortKey === col.key ? 'text-foreground' : 'opacity-30'
}
/>
)}
</span>
</th>
))}
</tr>
</thead>
<tbody className="divide-y">
{pagedData.map((row) => {
const key = keyExtractor(row);
return (
<tr
key={key}
onClick={() => onRowClick?.(row)}
className={cn(
'hover:bg-accent/50 transition-colors',
onRowClick && 'cursor-pointer',
selectedKeys?.has(key) && 'bg-accent/30',
)}
>
{selectable && (
<td
className="px-3 py-3"
onClick={(e) => e.stopPropagation()}
>
<input
type="checkbox"
checked={selectedKeys?.has(key) || false}
onChange={() => toggleRow(key)}
className="rounded"
/>
</td>
)}
{columns.map((col) => (
<td key={col.key} className={cn('px-4 py-3', col.className)}>
{col.render ? col.render(row) : String(row[col.key] ?? '—')}
</td>
))}
</tr>
);
})}
{pagedData.length === 0 && (
<tr>
<td
colSpan={columns.length + (selectable ? 1 : 0)}
className="text-center py-12 text-muted-foreground"
>
{emptyMessage}
</td>
</tr>
)}
</tbody>
</table>
</div>
{totalPages > 1 && (
<div className="flex items-center justify-between px-4 py-3 border-t">
<span className="text-xs text-muted-foreground">
Showing {(page - 1) * pageSize + 1}
{Math.min(page * pageSize, sortedData.length)} of{' '}
{sortedData.length}
</span>
<div className="flex gap-1">
<button
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page <= 1}
className="p-1.5 rounded border hover:bg-accent disabled:opacity-50"
>
<ChevronLeft size={14} />
</button>
{Array.from({ length: Math.min(totalPages, 5) }, (_, i) => {
const pageNum =
totalPages <= 5
? i + 1
: page <= 3
? i + 1
: page >= totalPages - 2
? totalPages - 4 + i
: page - 2 + i;
return (
<button
key={pageNum}
onClick={() => setPage(pageNum)}
className={cn(
'min-w-[28px] h-7 text-xs rounded border',
page === pageNum
? 'bg-primary text-primary-foreground'
: 'hover:bg-accent',
)}
>
{pageNum}
</button>
);
})}
<button
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page >= totalPages}
className="p-1.5 rounded border hover:bg-accent disabled:opacity-50"
>
<ChevronRight size={14} />
</button>
</div>
</div>
)}
</div>
);
}
\ No newline at end of file
'use client';
import { cn } from '@/lib/utils';
interface CompetencyArea {
name: string;
shortName?: string;
selfScore: number;
plScore: number | null;
}
interface CompetencyRadarProps {
areas: CompetencyArea[];
className?: string;
size?: number;
}
export function CompetencyRadar({
areas,
className,
size = 300,
}: CompetencyRadarProps) {
if (areas.length === 0) {
return (
<div className={cn('text-center py-8 text-muted-foreground text-sm', className)}>
No competency data available
</div>
);
}
const center = size / 2;
const maxRadius = center - 40;
const levels = 5;
const angleStep = (2 * Math.PI) / areas.length;
const getPoint = (index: number, value: number): { x: number; y: number } => {
const angle = angleStep * index - Math.PI / 2;
const radius = (value / levels) * maxRadius;
return {
x: center + radius * Math.cos(angle),
y: center + radius * Math.sin(angle),
};
};
const getPolygonPoints = (scores: number[]): string => {
return scores
.map((score, i) => {
const point = getPoint(i, score);
return `${point.x},${point.y}`;
})
.join(' ');
};
const selfScores = areas.map((a) => a.selfScore);
const plScores = areas.map((a) => a.plScore ?? 0);
const hasPlScores = areas.some((a) => a.plScore !== null);
return (
<div className={cn('flex flex-col items-center', className)}>
<svg
viewBox={`0 0 ${size} ${size}`}
width={size}
height={size}
className="max-w-full"
>
{/* Background grid */}
{Array.from({ length: levels }, (_, i) => {
const radius = ((i + 1) / levels) * maxRadius;
const points = areas
.map((_, j) => {
const angle = angleStep * j - Math.PI / 2;
return `${center + radius * Math.cos(angle)},${center + radius * Math.sin(angle)}`;
})
.join(' ');
return (
<polygon
key={i}
points={points}
fill="none"
stroke="currentColor"
strokeOpacity={0.1}
strokeWidth={1}
/>
);
})}
{/* Axis lines */}
{areas.map((_, i) => {
const point = getPoint(i, levels);
return (
<line
key={i}
x1={center}
y1={center}
x2={point.x}
y2={point.y}
stroke="currentColor"
strokeOpacity={0.1}
strokeWidth={1}
/>
);
})}
{/* PL assessment polygon */}
{hasPlScores && (
<polygon
points={getPolygonPoints(plScores)}
fill="hsl(var(--primary))"
fillOpacity={0.15}
stroke="hsl(var(--primary))"
strokeWidth={2}
strokeOpacity={0.8}
/>
)}
{/* Self-assessment polygon */}
<polygon
points={getPolygonPoints(selfScores)}
fill="hsl(142, 76%, 36%)"
fillOpacity={0.1}
stroke="hsl(142, 76%, 36%)"
strokeWidth={2}
strokeOpacity={0.6}
strokeDasharray="4 2"
/>
{/* Data points */}
{selfScores.map((score, i) => {
const point = getPoint(i, score);
return (
<circle
key={`self-${i}`}
cx={point.x}
cy={point.y}
r={3}
fill="hsl(142, 76%, 36%)"
stroke="white"
strokeWidth={1}
/>
);
})}
{hasPlScores &&
plScores.map((score, i) => {
if (score === 0) return null;
const point = getPoint(i, score);
return (
<circle
key={`pl-${i}`}
cx={point.x}
cy={point.y}
r={3}
fill="hsl(var(--primary))"
stroke="white"
strokeWidth={1}
/>
);
})}
{/* Labels */}
{areas.map((area, i) => {
const angle = angleStep * i - Math.PI / 2;
const labelRadius = maxRadius + 25;
const x = center + labelRadius * Math.cos(angle);
const y = center + labelRadius * Math.sin(angle);
let textAnchor: 'start' | 'middle' | 'end' = 'middle';
if (Math.cos(angle) > 0.1) textAnchor = 'start';
else if (Math.cos(angle) < -0.1) textAnchor = 'end';
return (
<text
key={i}
x={x}
y={y}
textAnchor={textAnchor}
dominantBaseline="middle"
className="fill-muted-foreground"
fontSize={8}
>
{(area.shortName || area.name).substring(0, 20)}
</text>
);
})}
{/* Level labels */}
{Array.from({ length: levels }, (_, i) => {
const val = i + 1;
const point = getPoint(0, val);
return (
<text
key={`level-${i}`}
x={point.x + 4}
y={point.y - 4}
className="fill-muted-foreground"
fontSize={8}
opacity={0.5}
>
{val}
</text>
);
})}
</svg>
{/* Legend */}
<div className="flex items-center gap-4 mt-3 text-xs">
<span className="flex items-center gap-1.5">
<span
className="w-3 h-0.5 inline-block"
style={{
backgroundColor: 'hsl(142, 76%, 36%)',
borderTop: '2px dashed hsl(142, 76%, 36%)',
}}
/>
Self-Assessment
</span>
{hasPlScores && (
<span className="flex items-center gap-1.5">
<span
className="w-3 h-0.5 inline-block bg-primary"
style={{ borderTop: '2px solid hsl(var(--primary))' }}
/>
PL Assessment
</span>
)}
</div>
</div>
);
}
\ No newline at end of file
'use client';
import { useState, useCallback } from 'react';
import { cn } from '@/lib/utils';
import {
Bold, Italic, List, ListOrdered, Code, Link2, Heading2,
Quote, Minus, Undo, Redo,
} from 'lucide-react';
interface RichTextEditorProps {
value: string;
onChange: (value: string) => void;
placeholder?: string;
minHeight?: string;
className?: string;
disabled?: boolean;
simple?: boolean;
}
/**
* Lightweight rich text editor using contentEditable.
* For production, replace with Tiptap once @tiptap/react is installed.
* This provides a working editor with basic formatting.
*/
export function RichTextEditor({
value,
onChange,
placeholder = 'Write something...',
minHeight = '120px',
className,
disabled = false,
simple = false,
}: RichTextEditorProps) {
const [isFocused, setIsFocused] = useState(false);
const execCommand = useCallback((command: string, value?: string) => {
document.execCommand(command, false, value);
}, []);
const handleInput = useCallback(
(e: React.FormEvent<HTMLDivElement>) => {
const html = (e.target as HTMLDivElement).innerHTML;
onChange(html === '<br>' ? '' : html);
},
[onChange],
);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Tab') {
e.preventDefault();
execCommand('insertText', ' ');
}
if (e.key === 'b' && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
execCommand('bold');
}
if (e.key === 'i' && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
execCommand('italic');
}
},
[execCommand],
);
const toolbarButtons = simple
? [
{ icon: Bold, command: 'bold', title: 'Bold (Ctrl+B)' },
{ icon: Italic, command: 'italic', title: 'Italic (Ctrl+I)' },
{ icon: Code, command: 'insertHTML', value: '<code>', title: 'Code' },
]
: [
{ icon: Bold, command: 'bold', title: 'Bold (Ctrl+B)' },
{ icon: Italic, command: 'italic', title: 'Italic (Ctrl+I)' },
{ icon: Heading2, command: 'formatBlock', value: 'H3', title: 'Heading' },
{ icon: List, command: 'insertUnorderedList', title: 'Bullet List' },
{ icon: ListOrdered, command: 'insertOrderedList', title: 'Numbered List' },
{ icon: Code, command: 'formatBlock', value: 'PRE', title: 'Code Block' },
{ icon: Quote, command: 'formatBlock', value: 'BLOCKQUOTE', title: 'Quote' },
{ icon: Minus, command: 'insertHorizontalRule', title: 'Horizontal Rule' },
{ icon: Undo, command: 'undo', title: 'Undo' },
{ icon: Redo, command: 'redo', title: 'Redo' },
];
return (
<div
className={cn(
'rounded-lg border transition-colors',
isFocused && 'ring-2 ring-ring',
disabled && 'opacity-50 pointer-events-none',
className,
)}
>
{/* Toolbar */}
<div className="flex items-center gap-0.5 p-1.5 border-b bg-muted/30 flex-wrap">
{toolbarButtons.map((btn, i) => {
const Icon = btn.icon;
return (
<button
key={i}
type="button"
onMouseDown={(e) => {
e.preventDefault();
if (btn.value) {
if (btn.command === 'formatBlock') {
execCommand(btn.command, btn.value);
} else if (btn.command === 'insertHTML') {
execCommand(btn.command, `<code>${window.getSelection()?.toString() || ''}</code>`);
}
} else {
execCommand(btn.command);
}
}}
className="p-1.5 rounded hover:bg-accent text-muted-foreground hover:text-foreground transition-colors"
title={btn.title}
>
<Icon size={14} />
</button>
);
})}
</div>
{/* Editor */}
<div
contentEditable={!disabled}
suppressContentEditableWarning
onInput={handleInput}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
onKeyDown={handleKeyDown}
dangerouslySetInnerHTML={{ __html: value || '' }}
className={cn(
'px-3 py-2 text-sm outline-none prose prose-sm dark:prose-invert max-w-none',
!value && 'empty:before:content-[attr(data-placeholder)] empty:before:text-muted-foreground empty:before:pointer-events-none',
)}
data-placeholder={placeholder}
style={{ minHeight }}
/>
</div>
);
}
/**
* Read-only rich text renderer
*/
export function RichTextDisplay({
content,
className,
}: {
content: string;
className?: string;
}) {
if (!content) return null;
return (
<div
className={cn(
'prose prose-sm dark:prose-invert max-w-none',
'prose-headings:font-semibold prose-headings:text-foreground',
'prose-p:text-muted-foreground prose-p:leading-relaxed',
'prose-code:bg-muted prose-code:px-1 prose-code:py-0.5 prose-code:rounded prose-code:text-xs',
'prose-pre:bg-muted prose-pre:rounded-lg',
'prose-blockquote:border-primary/30',
className,
)}
dangerouslySetInnerHTML={{ __html: content }}
/>
);
}
\ No newline at end of file
'use client';
import { useTheme } from 'next-themes';
import { useEffect, useState } from 'react';
import { Sun, Moon, Monitor } from 'lucide-react';
import { cn } from '@/lib/utils';
export function ThemeToggle({ className }: { className?: string }) {
const { theme, setTheme, resolvedTheme } = useTheme();
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
if (!mounted) {
return <div className={cn('w-8 h-8', className)} />;
}
const options = [
{ value: 'light', icon: Sun, label: 'Light' },
{ value: 'dark', icon: Moon, label: 'Dark' },
{ value: 'system', icon: Monitor, label: 'System' },
] as const;
return (
<div className={cn('flex items-center gap-0.5 rounded-lg border p-0.5', className)}>
{options.map((opt) => {
const Icon = opt.icon;
const isActive = theme === opt.value;
return (
<button
key={opt.value}
onClick={() => setTheme(opt.value)}
className={cn(
'p-1.5 rounded-md transition-colors',
isActive
? 'bg-accent text-accent-foreground'
: 'text-muted-foreground hover:text-foreground',
)}
title={opt.label}
>
<Icon size={14} />
</button>
);
})}
</div>
);
}
\ No newline at end of file
'use client';
import { useState, useRef, useEffect } from 'react';
import { Bell } from 'lucide-react';
import { useNotificationStore } from '@/stores/notification.store';
import { NotificationDropdown } from './notification-dropdown';
import { cn } from '@/lib/utils';
export function NotificationBell() {
const [isOpen, setIsOpen] = useState(false);
const { unreadCount } = useNotificationStore();
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) {
setIsOpen(false);
}
};
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [isOpen]);
return (
<div className="relative" ref={ref}>
<button
onClick={() => setIsOpen(!isOpen)}
className={cn(
'relative p-2 rounded-lg transition-colors',
isOpen ? 'bg-accent' : 'hover:bg-accent/50',
)}
title="Notifications"
>
<Bell size={18} />
{unreadCount > 0 && (
<span className="absolute -top-0.5 -right-0.5 min-w-[18px] h-[18px] bg-destructive text-destructive-foreground rounded-full text-[10px] font-bold flex items-center justify-center px-1 animate-fade-in">
{unreadCount > 99 ? '99+' : unreadCount}
</span>
)}
</button>
{isOpen && (
<NotificationDropdown onClose={() => setIsOpen(false)} />
)}
</div>
);
}
\ No newline at end of file
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { apiGet, apiPut } from '@/lib/api';
import { useNotificationStore } from '@/stores/notification.store';
import { relativeTime } from '@/lib/date';
import { cn } from '@/lib/utils';
import {
Bell, CheckCheck, AlertTriangle, AlertCircle, Info,
Loader2, ExternalLink,
} from 'lucide-react';
interface NotificationDropdownProps {
onClose: () => void;
}
export function NotificationDropdown({ onClose }: NotificationDropdownProps) {
const router = useRouter();
const { fetchUnreadCount } = useNotificationStore();
const [notifications, setNotifications] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
loadNotifications();
}, []);
const loadNotifications = async () => {
try {
const res = await apiGet('/notifications', { limit: 20, sortOrder: 'desc' });
setNotifications(res.data || []);
} catch {
// fail silently
} finally {
setIsLoading(false);
}
};
const handleMarkRead = async (id: string) => {
try {
await apiPut(`/notifications/${id}/read`);
setNotifications((prev) =>
prev.map((n) => (n.id === id ? { ...n, isRead: true } : n)),
);
fetchUnreadCount();
} catch {
// fail silently
}
};
const handleMarkAllRead = async () => {
try {
await apiPut('/notifications/read-all');
setNotifications((prev) => prev.map((n) => ({ ...n, isRead: true })));
fetchUnreadCount();
} catch {
// fail silently
}
};
const handleClick = (notif: any) => {
if (!notif.isRead) {
handleMarkRead(notif.id);
}
if (notif.link) {
router.push(notif.link);
onClose();
}
};
const getIcon = (type: string) => {
switch (type) {
case 'BLOCKING':
return <AlertTriangle size={14} className="text-red-500 shrink-0" />;
case 'IMPORTANT':
return <AlertCircle size={14} className="text-yellow-500 shrink-0" />;
default:
return <Info size={14} className="text-blue-500 shrink-0" />;
}
};
const unreadCount = notifications.filter((n) => !n.isRead).length;
return (
<div className="absolute right-0 top-full mt-2 w-96 max-h-[70vh] bg-card rounded-xl border shadow-xl z-50 animate-fade-in overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b">
<h3 className="text-sm font-semibold">Notifications</h3>
<div className="flex items-center gap-2">
{unreadCount > 0 && (
<button
onClick={handleMarkAllRead}
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
>
<CheckCheck size={12} />
Mark all read
</button>
)}
</div>
</div>
{/* Content */}
<div className="overflow-y-auto max-h-[calc(70vh-100px)]">
{isLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 size={20} className="animate-spin text-muted-foreground" />
</div>
) : notifications.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
<Bell size={24} className="mx-auto mb-2 opacity-30" />
<p className="text-sm">No notifications</p>
</div>
) : (
<div className="divide-y">
{notifications.map((notif) => (
<button
key={notif.id}
onClick={() => handleClick(notif)}
className={cn(
'w-full text-left px-4 py-3 flex gap-3 transition-colors hover:bg-accent/50',
!notif.isRead && 'bg-accent/20',
)}
>
<div className="mt-0.5">{getIcon(notif.type)}</div>
<div className="flex-1 min-w-0">
<p
className={cn(
'text-xs line-clamp-2',
!notif.isRead && 'font-medium',
)}
>
{notif.title || notif.message}
</p>
{notif.message && notif.title && (
<p className="text-[10px] text-muted-foreground mt-0.5 line-clamp-1">
{notif.message}
</p>
)}
<p className="text-[10px] text-muted-foreground mt-1">
{relativeTime(notif.createdAt)}
</p>
</div>
{!notif.isRead && (
<div className="w-2 h-2 rounded-full bg-primary shrink-0 mt-1.5" />
)}
</button>
))}
</div>
)}
</div>
{/* Footer */}
<div className="border-t px-4 py-2">
<button
onClick={() => {
router.push('/notifications');
onClose();
}}
className="flex items-center gap-1 text-xs text-primary hover:underline w-full justify-center"
>
View all notifications
<ExternalLink size={10} />
</button>
</div>
</div>
);
}
\ No newline at end of file
'use client';
import { useState, useEffect } from 'react';
import { relativeTime as formatRelativeTime } from '@/lib/date';
import { cn } from '@/lib/utils';
interface RelativeTimeProps {
date: string | Date;
className?: string;
refreshInterval?: number; // ms, default 60000 (1 min)
}
export function RelativeTime({
date,
className,
refreshInterval = 60000,
}: RelativeTimeProps) {
const [display, setDisplay] = useState(() => formatRelativeTime(date));
useEffect(() => {
setDisplay(formatRelativeTime(date));
const interval = setInterval(() => {
setDisplay(formatRelativeTime(date));
}, refreshInterval);
return () => clearInterval(interval);
}, [date, refreshInterval]);
const isoString =
typeof date === 'string' ? date : date.toISOString();
return (
<time
dateTime={isoString}
title={new Date(isoString).toLocaleString()}
className={cn('text-muted-foreground', className)}
>
{display}
</time>
);
}
\ No newline at end of file
'use client';
import { useEffect, useCallback } from 'react';
import { useSocket } from '@/hooks/use-socket';
import { useBoardStore } from '@/stores/board.store';
export function useBoard(boardId: string | null) {
const socket = useSocket();
const {
setActiveBoard,
addCard,
updateCard,
removeCard,
moveCard,
setConnected,
} = useBoardStore();
useEffect(() => {
if (!boardId || !socket) return;
setActiveBoard(boardId);
socket.emit('board:join', { boardId });
setConnected(true);
const handleCardCreated = (data: any) => {
if (data.boardId === boardId) {
addCard(data.card || data);
}
};
const handleCardUpdated = (data: any) => {
if (data.boardId === boardId) {
updateCard(data.cardId || data.id, data.updates || data);
}
};
const handleCardMoved = (data: any) => {
if (data.boardId === boardId) {
moveCard(data.cardId, data.columnId, data.position);
}
};
const handleCardDeleted = (data: any) => {
if (data.boardId === boardId) {
removeCard(data.cardId || data.id);
}
};
const handleCardAssigned = (data: any) => {
if (data.boardId === boardId && data.card) {
updateCard(data.cardId || data.card.id, {
assignees: data.card.assignees || data.assignees,
});
}
};
socket.on('card:created', handleCardCreated);
socket.on('card:updated', handleCardUpdated);
socket.on('card:moved', handleCardMoved);
socket.on('card:deleted', handleCardDeleted);
socket.on('card:archived', handleCardDeleted);
socket.on('card:assigned', handleCardAssigned);
return () => {
socket.emit('board:leave', { boardId });
socket.off('card:created', handleCardCreated);
socket.off('card:updated', handleCardUpdated);
socket.off('card:moved', handleCardMoved);
socket.off('card:deleted', handleCardDeleted);
socket.off('card:archived', handleCardDeleted);
socket.off('card:assigned', handleCardAssigned);
setActiveBoard(null);
setConnected(false);
};
}, [boardId, socket]);
const emitCardMove = useCallback(
(cardId: string, columnId: string, position: number) => {
if (!socket || !boardId) return;
socket.emit('card:move', { boardId, cardId, columnId, position });
},
[socket, boardId],
);
return { emitCardMove };
}
\ No newline at end of file
'use client';
import { useEffect, useCallback, useRef } from 'react';
import { useSocket } from '@/hooks/use-socket';
import { useMessageStore } from '@/stores/message.store';
import { useAuthStore } from '@/stores/auth.store';
import { apiGet } from '@/lib/api';
export function useMessages() {
const socket = useSocket();
const user = useAuthStore((s) => s.user);
const {
setConversations,
addMessage,
updateConversation,
setTypingUser,
clearTypingUser,
markConversationRead,
activeConversationId,
setLoaded,
isLoaded,
} = useMessageStore();
const typingTimeoutsRef = useRef<Record<string, NodeJS.Timeout>>({});
// Load conversations on mount
useEffect(() => {
if (!user || isLoaded) return;
apiGet('/conversations')
.then((res) => setConversations(res.data || []))
.catch(() => setLoaded(true));
}, [user, isLoaded]);
// Socket listeners
useEffect(() => {
if (!socket || !user) return;
const handleNewMessage = (data: any) => {
const message = data.message || data;
const conversationId = message.conversationId;
if (!conversationId) return;
addMessage(conversationId, message);
};
const handleTyping = (data: any) => {
const { conversationId, userId } = data;
if (userId === user.id) return;
setTypingUser(conversationId, userId);
const key = `${conversationId}:${userId}`;
if (typingTimeoutsRef.current[key]) {
clearTimeout(typingTimeoutsRef.current[key]);
}
typingTimeoutsRef.current[key] = setTimeout(() => {
clearTypingUser(conversationId, userId);
delete typingTimeoutsRef.current[key];
}, 3000);
};
const handleMessageRead = (data: any) => {
const { conversationId } = data;
if (conversationId) {
updateConversation(conversationId, { unreadCount: 0 });
}
};
socket.on('message:new', handleNewMessage);
socket.on('message:typing', handleTyping);
socket.on('message:read', handleMessageRead);
return () => {
socket.off('message:new', handleNewMessage);
socket.off('message:typing', handleTyping);
socket.off('message:read', handleMessageRead);
Object.values(typingTimeoutsRef.current).forEach(clearTimeout);
typingTimeoutsRef.current = {};
};
}, [socket, user]);
const sendTypingIndicator = useCallback(
(conversationId: string) => {
if (!socket) return;
socket.emit('message:typing', { conversationId });
},
[socket],
);
const markRead = useCallback(
(conversationId: string) => {
if (!socket) return;
markConversationRead(conversationId);
socket.emit('message:read', { conversationId });
},
[socket, markConversationRead],
);
return { sendTypingIndicator, markRead };
}
\ No newline at end of file
import { create } from 'zustand';
interface CardData {
id: string;
title: string;
cardNumber: string;
columnId: string;
position: number;
priority: string;
dueDate: string | null;
bountyPiasters: number;
isArchived: boolean;
assignees: any[];
labels: any[];
commentCount: number;
attachmentCount: number;
checklistProgress: { completed: number; total: number } | null;
completedAt: string | null;
version: number;
}
interface BoardState {
activeBoardId: string | null;
cards: Record<string, CardData>;
columns: any[];
isConnected: boolean;
dragState: { cardId: string; sourceColumnId: string } | null;
setActiveBoard: (boardId: string | null) => void;
setColumns: (columns: any[]) => void;
setCards: (cards: CardData[]) => void;
addCard: (card: CardData) => void;
updateCard: (cardId: string, updates: Partial<CardData>) => void;
removeCard: (cardId: string) => void;
moveCard: (cardId: string, columnId: string, position: number) => void;
setDragState: (state: { cardId: string; sourceColumnId: string } | null) => void;
setConnected: (connected: boolean) => void;
getCardsByColumn: (columnId: string) => CardData[];
getCard: (cardId: string) => CardData | undefined;
}
export const useBoardStore = create<BoardState>((set, get) => ({
activeBoardId: null,
cards: {},
columns: [],
isConnected: false,
dragState: null,
setActiveBoard: (boardId) => set({ activeBoardId: boardId }),
setColumns: (columns) => set({ columns }),
setCards: (cards) => {
const cardMap: Record<string, CardData> = {};
for (const card of cards) {
cardMap[card.id] = card;
}
set({ cards: cardMap });
},
addCard: (card) =>
set((state) => ({
cards: { ...state.cards, [card.id]: card },
})),
updateCard: (cardId, updates) =>
set((state) => {
const existing = state.cards[cardId];
if (!existing) return state;
return {
cards: {
...state.cards,
[cardId]: { ...existing, ...updates },
},
};
}),
removeCard: (cardId) =>
set((state) => {
const next = { ...state.cards };
delete next[cardId];
return { cards: next };
}),
moveCard: (cardId, columnId, position) =>
set((state) => {
const existing = state.cards[cardId];
if (!existing) return state;
return {
cards: {
...state.cards,
[cardId]: { ...existing, columnId, position },
},
};
}),
setDragState: (dragState) => set({ dragState }),
setConnected: (connected) => set({ isConnected: connected }),
getCardsByColumn: (columnId) => {
const { cards } = get();
return Object.values(cards)
.filter((c) => c.columnId === columnId && !c.isArchived)
.sort((a, b) => (a.position || 0) - (b.position || 0));
},
getCard: (cardId) => get().cards[cardId],
}));
\ No newline at end of file
import { create } from 'zustand';
interface Conversation {
id: string;
name: string | null;
type: 'DIRECT' | 'GROUP';
participants: any[];
lastMessageAt: string | null;
lastMessagePreview: string | null;
unreadCount: number;
}
interface Message {
id: string;
conversationId: string;
senderId: string;
sender?: any;
content: string;
attachments: any[];
createdAt: string;
isRead: boolean;
}
interface MessageState {
conversations: Conversation[];
activeConversationId: string | null;
messages: Record<string, Message[]>;
typingUsers: Record<string, string[]>;
totalUnread: number;
isLoaded: boolean;
setConversations: (conversations: Conversation[]) => void;
setActiveConversation: (id: string | null) => void;
addConversation: (conversation: Conversation) => void;
updateConversation: (id: string, updates: Partial<Conversation>) => void;
setMessages: (conversationId: string, messages: Message[]) => void;
addMessage: (conversationId: string, message: Message) => void;
markConversationRead: (conversationId: string) => void;
setTypingUser: (conversationId: string, userId: string) => void;
clearTypingUser: (conversationId: string, userId: string) => void;
setLoaded: (loaded: boolean) => void;
computeTotalUnread: () => void;
}
export const useMessageStore = create<MessageState>((set, get) => ({
conversations: [],
activeConversationId: null,
messages: {},
typingUsers: {},
totalUnread: 0,
isLoaded: false,
setConversations: (conversations) => {
const totalUnread = conversations.reduce((sum, c) => sum + (c.unreadCount || 0), 0);
set({ conversations, totalUnread, isLoaded: true });
},
setActiveConversation: (id) => set({ activeConversationId: id }),
addConversation: (conversation) =>
set((state) => {
const exists = state.conversations.find((c) => c.id === conversation.id);
if (exists) {
return {
conversations: state.conversations.map((c) =>
c.id === conversation.id ? { ...c, ...conversation } : c,
),
};
}
return { conversations: [conversation, ...state.conversations] };
}),
updateConversation: (id, updates) =>
set((state) => ({
conversations: state.conversations.map((c) =>
c.id === id ? { ...c, ...updates } : c,
),
})),
setMessages: (conversationId, messages) =>
set((state) => ({
messages: { ...state.messages, [conversationId]: messages },
})),
addMessage: (conversationId, message) =>
set((state) => {
const existing = state.messages[conversationId] || [];
const alreadyExists = existing.some((m) => m.id === message.id);
if (alreadyExists) return state;
const updatedMessages = {
...state.messages,
[conversationId]: [...existing, message],
};
const updatedConversations = state.conversations.map((c) => {
if (c.id === conversationId) {
return {
...c,
lastMessageAt: message.createdAt,
lastMessagePreview: message.content?.substring(0, 60) || '',
unreadCount:
state.activeConversationId === conversationId
? c.unreadCount
: c.unreadCount + 1,
};
}
return c;
});
const sortedConversations = updatedConversations.sort((a, b) => {
const aTime = a.lastMessageAt ? new Date(a.lastMessageAt).getTime() : 0;
const bTime = b.lastMessageAt ? new Date(b.lastMessageAt).getTime() : 0;
return bTime - aTime;
});
return {
messages: updatedMessages,
conversations: sortedConversations,
};
}),
markConversationRead: (conversationId) =>
set((state) => {
const conv = state.conversations.find((c) => c.id === conversationId);
const unreadDelta = conv?.unreadCount || 0;
return {
conversations: state.conversations.map((c) =>
c.id === conversationId ? { ...c, unreadCount: 0 } : c,
),
totalUnread: Math.max(0, state.totalUnread - unreadDelta),
};
}),
setTypingUser: (conversationId, userId) =>
set((state) => {
const current = state.typingUsers[conversationId] || [];
if (current.includes(userId)) return state;
return {
typingUsers: {
...state.typingUsers,
[conversationId]: [...current, userId],
},
};
}),
clearTypingUser: (conversationId, userId) =>
set((state) => ({
typingUsers: {
...state.typingUsers,
[conversationId]: (state.typingUsers[conversationId] || []).filter(
(id) => id !== userId,
),
},
})),
setLoaded: (loaded) => set({ isLoaded: loaded }),
computeTotalUnread: () =>
set((state) => ({
totalUnread: state.conversations.reduce(
(sum, c) => sum + (c.unreadCount || 0),
0,
),
})),
}));
\ 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