adds sections as list tasks,log expenses. fees, notifications for sub contrcator
This commit is contained in:
@@ -0,0 +1,197 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { SpotlightCard } from '../SpotlightCard';
|
||||
import {
|
||||
Bell, ClipboardList, MessageSquare, RefreshCcw, CheckCircle, AlertCircle,
|
||||
} from 'lucide-react';
|
||||
|
||||
// Allowed notification types for the subcontractor dashboard.
|
||||
// Keep keys in sync with mockStore notification.type values.
|
||||
const NOTIF_CONFIG = {
|
||||
task_assigned: {
|
||||
label: 'New Task Assigned',
|
||||
tone: 'text-blue-600 bg-blue-100 dark:text-blue-400 dark:bg-blue-500/15',
|
||||
icon: ClipboardList,
|
||||
side: 'border-l-blue-500',
|
||||
},
|
||||
message_received: {
|
||||
label: 'Message Received',
|
||||
tone: 'text-emerald-600 bg-emerald-100 dark:text-emerald-400 dark:bg-emerald-500/15',
|
||||
icon: MessageSquare,
|
||||
side: 'border-l-emerald-500',
|
||||
},
|
||||
task_updated: {
|
||||
label: 'Task Updated',
|
||||
tone: 'text-amber-600 bg-amber-100 dark:text-amber-400 dark:bg-amber-500/15',
|
||||
icon: RefreshCcw,
|
||||
side: 'border-l-amber-500',
|
||||
},
|
||||
status_changed: {
|
||||
label: 'Status Changed',
|
||||
tone: 'text-purple-600 bg-purple-100 dark:text-purple-400 dark:bg-purple-500/15',
|
||||
icon: CheckCircle,
|
||||
side: 'border-l-purple-500',
|
||||
},
|
||||
};
|
||||
|
||||
const ALLOWED_TYPES = Object.keys(NOTIF_CONFIG);
|
||||
|
||||
const formatDateTime = (iso) => {
|
||||
if (!iso) return '—';
|
||||
try {
|
||||
const d = new Date(iso);
|
||||
const dateStr = d.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' });
|
||||
const timeStr = d.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' });
|
||||
return `${dateStr} · ${timeStr}`;
|
||||
} catch { return iso; }
|
||||
};
|
||||
|
||||
const TABS = [
|
||||
{ key: 'all', label: 'All' },
|
||||
{ key: 'task_assigned', label: 'New Task' },
|
||||
{ key: 'message_received', label: 'Message' },
|
||||
{ key: 'task_updated', label: 'Updates' },
|
||||
{ key: 'status_changed', label: 'Status' },
|
||||
];
|
||||
|
||||
/**
|
||||
* NotificationsPanel — global subcontractor notifications.
|
||||
*
|
||||
* Renders a SpotlightCard with tabbed filtering and a list of notification rows.
|
||||
* Each row navigates to its referenced task (if any) when clicked.
|
||||
*/
|
||||
export const NotificationsPanel = ({ notifications, onMarkAllRead, onNotificationClick }) => {
|
||||
const navigate = useNavigate();
|
||||
const [tab, setTab] = useState('all');
|
||||
|
||||
const allowed = useMemo(
|
||||
() => notifications.filter(n => ALLOWED_TYPES.includes(n.type)),
|
||||
[notifications],
|
||||
);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (tab === 'all') return allowed;
|
||||
return allowed.filter(n => n.type === tab);
|
||||
}, [allowed, tab]);
|
||||
|
||||
const unreadCount = allowed.filter(n => !n.isRead).length;
|
||||
|
||||
const handleClick = (n) => {
|
||||
if (onNotificationClick) onNotificationClick(n);
|
||||
if (n.taskId) navigate(`/subcontractor/tasks/${n.taskId}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<SpotlightCard className="p-5 sm:p-6">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
<div className="p-1.5 rounded-lg bg-blue-500/10 text-blue-500 shrink-0">
|
||||
<Bell size={14} />
|
||||
</div>
|
||||
<h3 className="text-sm sm:text-base font-bold text-zinc-900 dark:text-white tracking-wide">
|
||||
Notifications
|
||||
</h3>
|
||||
{unreadCount > 0 && (
|
||||
<span className="text-[10px] font-bold px-2 py-0.5 rounded-full bg-blue-500/10 text-blue-600 dark:text-blue-400 font-mono shrink-0">
|
||||
{unreadCount} new
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onMarkAllRead}
|
||||
disabled={unreadCount === 0}
|
||||
className="text-[11px] font-bold uppercase tracking-wider text-blue-600 dark:text-blue-400 hover:underline disabled:text-zinc-400 disabled:no-underline transition-colors shrink-0"
|
||||
>
|
||||
Mark all read
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tab strip — scrollable on narrow screens */}
|
||||
<div className="mt-4 -mx-1 px-1 overflow-x-auto custom-scrollbar">
|
||||
<div className="inline-flex rounded-xl bg-zinc-100 dark:bg-white/5 border border-zinc-200 dark:border-white/10 p-1 min-w-full">
|
||||
{TABS.map(t => {
|
||||
const count = t.key === 'all' ? allowed.length : allowed.filter(n => n.type === t.key).length;
|
||||
return (
|
||||
<TabPill
|
||||
key={t.key}
|
||||
active={tab === t.key}
|
||||
onClick={() => setTab(t.key)}
|
||||
label={t.label}
|
||||
count={count}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-2 max-h-[36rem] overflow-y-auto pr-1 custom-scrollbar">
|
||||
{filtered.length === 0 ? (
|
||||
<div className="text-center py-10 text-zinc-500">
|
||||
<Bell size={20} className="mx-auto mb-2 opacity-50" />
|
||||
<p className="text-xs">You're all caught up.</p>
|
||||
</div>
|
||||
) : filtered.map(n => (
|
||||
<NotificationRow key={n.id} notification={n} onClick={() => handleClick(n)} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 pt-3 border-t border-zinc-200 dark:border-white/10">
|
||||
<p className="text-[10px] text-zinc-500 flex items-center gap-1.5">
|
||||
<AlertCircle size={10} /> In-app only · No SMS or email unless specified.
|
||||
</p>
|
||||
</div>
|
||||
</SpotlightCard>
|
||||
);
|
||||
};
|
||||
|
||||
const TabPill = ({ active, onClick, label, count }) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={`flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-bold whitespace-nowrap transition-colors
|
||||
${active
|
||||
? 'bg-white dark:bg-zinc-900 text-zinc-900 dark:text-white shadow-sm'
|
||||
: 'text-zinc-600 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
{typeof count === 'number' && (
|
||||
<span className={`text-[10px] font-mono px-1.5 rounded-full ${active ? 'bg-blue-100 text-blue-600 dark:bg-blue-500/20 dark:text-blue-400' : 'bg-zinc-200 dark:bg-white/10 text-zinc-500'}`}>
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
|
||||
const NotificationRow = ({ notification, onClick }) => {
|
||||
const cfg = NOTIF_CONFIG[notification.type];
|
||||
if (!cfg) return null;
|
||||
const Icon = cfg.icon;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={`w-full flex items-start gap-3 p-3 rounded-xl text-left border-l-2 ${cfg.side}
|
||||
${notification.isRead
|
||||
? 'bg-zinc-50 dark:bg-white/[0.02] border-y border-r border-zinc-100 dark:border-white/5'
|
||||
: 'bg-white dark:bg-white/5 border-y border-r border-zinc-200 dark:border-white/10 shadow-sm'
|
||||
}
|
||||
hover:bg-zinc-100 dark:hover:bg-white/10 transition-colors`}
|
||||
>
|
||||
<div className={`p-1.5 rounded-lg shrink-0 ${cfg.tone}`}>
|
||||
<Icon size={14} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-bold text-zinc-900 dark:text-white">{cfg.label}</span>
|
||||
{!notification.isRead && <span className="w-1.5 h-1.5 rounded-full bg-blue-500" />}
|
||||
</div>
|
||||
<p className="text-[11px] text-zinc-600 dark:text-zinc-400 mt-0.5 line-clamp-2">{notification.message}</p>
|
||||
<p className="text-[10px] text-zinc-500 font-mono mt-1">{formatDateTime(notification.createdAt)}</p>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default NotificationsPanel;
|
||||
Reference in New Issue
Block a user