diff --git a/frontend/app/(dashboard)/depreciation/page.tsx b/frontend/app/(dashboard)/depreciation/page.tsx index 5fd7c891..349b3320 100644 --- a/frontend/app/(dashboard)/depreciation/page.tsx +++ b/frontend/app/(dashboard)/depreciation/page.tsx @@ -1,67 +1,471 @@ 'use client'; -import { useState } from 'react'; +import { useState, useMemo } from 'react'; interface DepreciationRow { id: string; assetName: string; + category: 'electronics' | 'furniture' | 'equipment' | 'vehicle' | 'other'; purchaseDate: string; purchaseCost: number; + salvageValue: number; usefulLifeYears: number; - bookValue: number; - annualDepreciation: number; + method: 'straight-line' | 'declining-balance'; } const MOCK: DepreciationRow[] = [ - { id: '1', assetName: 'Dell Laptop #1', purchaseDate: '2022-01-01', purchaseCost: 1200, usefulLifeYears: 4, bookValue: 600, annualDepreciation: 300 }, - { id: '2', assetName: 'HP Printer', purchaseDate: '2021-06-15', purchaseCost: 800, usefulLifeYears: 5, bookValue: 320, annualDepreciation: 160 }, - { id: '3', assetName: 'Office Chair', purchaseDate: '2023-03-01', purchaseCost: 400, usefulLifeYears: 7, bookValue: 343, annualDepreciation: 57 }, + { id: '1', assetName: 'Dell Laptop #1', category: 'electronics', purchaseDate: '2022-01-01', purchaseCost: 1200, salvageValue: 0, usefulLifeYears: 4, method: 'straight-line' }, + { id: '2', assetName: 'HP Printer', category: 'electronics', purchaseDate: '2021-06-15', purchaseCost: 800, salvageValue: 0, usefulLifeYears: 5, method: 'straight-line' }, + { id: '3', assetName: 'Office Chair', category: 'furniture', purchaseDate: '2023-03-01', purchaseCost: 400, salvageValue: 0, usefulLifeYears: 7, method: 'straight-line' }, + { id: '4', assetName: 'MacBook Pro', category: 'electronics', purchaseDate: '2023-09-01', purchaseCost: 2400, salvageValue: 200, usefulLifeYears: 4, method: 'declining-balance' }, + { id: '5', assetName: 'Standing Desk', category: 'furniture', purchaseDate: '2022-11-01', purchaseCost: 950, salvageValue: 50, usefulLifeYears: 10, method: 'straight-line' }, ]; +const CATEGORY_COLORS: Record = { + electronics: '#6366f1', + furniture: '#f59e0b', + equipment: '#10b981', + vehicle: '#3b82f6', + other: '#8b5cf6', +}; + +const CATEGORY_LABELS: Record = { + electronics: 'Electronics', + furniture: 'Furniture', + equipment: 'Equipment', + vehicle: 'Vehicle', + other: 'Other', +}; + +function calcDerived(row: DepreciationRow, today = new Date()) { + const purchaseDate = new Date(row.purchaseDate); + const yearsElapsed = (today.getTime() - purchaseDate.getTime()) / (1000 * 60 * 60 * 24 * 365.25); + const clampedYears = Math.min(yearsElapsed, row.usefulLifeYears); + + let bookValue: number; + let annualDepreciation: number; + + if (row.method === 'straight-line') { + annualDepreciation = (row.purchaseCost - row.salvageValue) / row.usefulLifeYears; + bookValue = Math.max(row.purchaseCost - annualDepreciation * clampedYears, row.salvageValue); + } else { + // Double declining balance + const rate = (2 / row.usefulLifeYears); + bookValue = row.purchaseCost * Math.pow(1 - rate, clampedYears); + bookValue = Math.max(bookValue, row.salvageValue); + annualDepreciation = row.purchaseCost * rate * Math.pow(1 - rate, Math.max(0, clampedYears - 1)); + } + + const depPct = ((row.purchaseCost - bookValue) / row.purchaseCost) * 100; + const remainingLife = Math.max(0, row.usefulLifeYears - yearsElapsed); + const isFullyDepreciated = bookValue <= row.salvageValue + 1; + + return { bookValue, annualDepreciation, depPct, remainingLife, yearsElapsed: clampedYears, isFullyDepreciated }; +} + +function fmt(n: number) { + return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }).format(n); +} + +function DepreciationBar({ pct, color }: { pct: number; color: string }) { + return ( +
+
+
+ ); +} + +function Badge({ label, color }: { label: string; color: string }) { + return ( + + {label} + + ); +} + +type SortKey = 'assetName' | 'purchaseCost' | 'bookValue' | 'annualDepreciation' | 'depPct' | 'remainingLife'; + +const MODAL_EMPTY: DepreciationRow = { + id: '', + assetName: '', + category: 'electronics', + purchaseDate: new Date().toISOString().split('T')[0], + purchaseCost: 0, + salvageValue: 0, + usefulLifeYears: 5, + method: 'straight-line', +}; + export default function DepreciationPage() { - const [rows] = useState(MOCK); - const totalBookValue = rows.reduce((s, r) => s + r.bookValue, 0); - const totalDepreciation = rows.reduce((s, r) => s + r.annualDepreciation, 0); + const [rows, setRows] = useState(MOCK); + const [sortKey, setSortKey] = useState('assetName'); + const [sortAsc, setSortAsc] = useState(true); + const [filterCat, setFilterCat] = useState('all'); + const [search, setSearch] = useState(''); + const [showModal, setShowModal] = useState(false); + const [editRow, setEditRow] = useState(MODAL_EMPTY); + const [activeTab, setActiveTab] = useState<'table' | 'schedule'>('table'); + const [expandedId, setExpandedId] = useState(null); + + const enriched = useMemo(() => rows.map(r => ({ ...r, ...calcDerived(r) })), [rows]); + + const filtered = useMemo(() => { + let out = enriched; + if (filterCat !== 'all') out = out.filter(r => r.category === filterCat); + if (search.trim()) out = out.filter(r => r.assetName.toLowerCase().includes(search.toLowerCase())); + out = [...out].sort((a, b) => { + const av = a[sortKey] as number | string; + const bv = b[sortKey] as number | string; + return sortAsc + ? av < bv ? -1 : av > bv ? 1 : 0 + : av > bv ? -1 : av < bv ? 1 : 0; + }); + return out; + }, [enriched, filterCat, search, sortKey, sortAsc]); + + const totalCost = filtered.reduce((s, r) => s + r.purchaseCost, 0); + const totalBook = filtered.reduce((s, r) => s + r.bookValue, 0); + const totalDep = filtered.reduce((s, r) => s + r.annualDepreciation, 0); + const totalAccumulated = filtered.reduce((s, r) => s + (r.purchaseCost - r.bookValue), 0); + const avgDepPct = filtered.length ? filtered.reduce((s, r) => s + r.depPct, 0) / filtered.length : 0; + + function handleSort(key: SortKey) { + if (key === sortKey) setSortAsc(a => !a); + else { setSortKey(key); setSortAsc(true); } + } + + function openAdd() { setEditRow({ ...MODAL_EMPTY, id: Date.now().toString() }); setShowModal(true); } + function openEdit(row: DepreciationRow) { setEditRow(row); setShowModal(true); } + function deleteRow(id: string) { setRows(rs => rs.filter(r => r.id !== id)); } + function saveRow() { + if (!editRow.assetName || editRow.purchaseCost <= 0) return; + setRows(rs => rs.some(r => r.id === editRow.id) ? rs.map(r => r.id === editRow.id ? editRow : r) : [...rs, editRow]); + setShowModal(false); + } + + // Generate depreciation schedule for expanded row + function getSchedule(row: DepreciationRow) { + const rows: { year: number; openingBV: number; depreciation: number; closingBV: number }[] = []; + let bv = row.purchaseCost; + const rate = row.method === 'declining-balance' ? 2 / row.usefulLifeYears : 0; + const slDep = (row.purchaseCost - row.salvageValue) / row.usefulLifeYears; + for (let y = 1; y <= row.usefulLifeYears; y++) { + const dep = row.method === 'straight-line' ? slDep : bv * rate; + const closingBV = Math.max(bv - dep, row.salvageValue); + rows.push({ year: y, openingBV: bv, depreciation: bv - closingBV, closingBV }); + bv = closingBV; + if (bv <= row.salvageValue) break; + } + return rows; + } + + const SortIcon = ({ k }: { k: SortKey }) => ( + + {sortKey === k ? (sortAsc ? 'โ–ฒ' : 'โ–ผ') : 'โ‡…'} + + ); + + const categories = Array.from(new Set(rows.map(r => r.category))); return ( -
-
-

Depreciation Report

-

Schedule table and book value per asset

-
-
-
-

Total Book Value

-

+
+
+ + {/* Header */} +
+
+
+
๐Ÿ“Š
+

Asset Depreciation

+
+

Track book value, depreciation schedules, and asset aging across your portfolio.

+
+
-
-

Annual Depreciation

-

+ + {/* KPI Cards */} +
+ {[ + { label: 'Total Asset Cost', value: fmt(totalCost), sub: `${filtered.length} assets`, accent: '#6366f1' }, + { label: 'Total Book Value', value: fmt(totalBook), sub: `${(100 - avgDepPct).toFixed(0)}% remaining`, accent: '#10b981' }, + { label: 'Accumulated Depreciation', value: fmt(totalAccumulated), sub: `${avgDepPct.toFixed(0)}% avg depreciated`, accent: '#f59e0b' }, + { label: 'Annual Depreciation', value: fmt(totalDep), sub: 'Current year expense', accent: '#ef4444' }, + ].map(c => ( +
+
+

{c.label}

+

{c.value}

+

{c.sub}

+
+ ))}
-
-
- - - - {['Asset','Purchase Date','Cost','Useful Life','Book Value','Annual Dep.','Dep. %'].map(h => ( - + + {/* Filters + Tabs */} +
+
+ {/* Search */} +
+ ๐Ÿ” + setSearch(e.target.value)} + placeholder="Search assetsโ€ฆ" + style={{ width: '100%', padding: '7px 10px 7px 30px', border: '1px solid #e2e8f0', borderRadius: 8, fontSize: 13, outline: 'none', boxSizing: 'border-box', background: '#f8fafc', color: '#0f172a' }} + /> +
+ {/* Category filter */} +
+ {(['all', ...categories] as const).map(cat => ( + ))} -
- - - {rows.map(r => ( - - - - - - - - - - ))} - -
{h}
{r.assetName}{r.purchaseDate}{r.usefulLifeYears}y/yr{Math.round((r.annualDepreciation / r.purchaseCost) * 100)}%
+
+ {/* Tabs */} +
+ {(['table', 'schedule'] as const).map(t => ( + + ))} +
+
+ + {/* Asset Table */} + {activeTab === 'table' && ( +
+ + + + {[ + { label: 'Asset', key: 'assetName' as SortKey }, + { label: 'Category', key: null }, + { label: 'Purchase Date', key: null }, + { label: 'Cost', key: 'purchaseCost' as SortKey }, + { label: 'Book Value', key: 'bookValue' as SortKey }, + { label: 'Annual Dep.', key: 'annualDepreciation' as SortKey }, + { label: 'Depreciated', key: 'depPct' as SortKey }, + { label: 'Remaining Life', key: 'remainingLife' as SortKey }, + { label: '', key: null }, + ].map((col, i) => ( + + ))} + + + + {filtered.length === 0 && ( + + )} + {filtered.map(r => { + const color = CATEGORY_COLORS[r.category]; + const isExpanded = expandedId === r.id; + return ( + <> + (e.currentTarget.style.background = '#f8fafc')} + onMouseLeave={e => (e.currentTarget.style.background = isExpanded ? '#fafbff' : '')}> + + + + + + + + + + + {isExpanded && ( + + + + )} + + ); + })} + + {filtered.length > 0 && ( + + + + + + + + + )} +
handleSort(col.key!) : undefined} + style={{ padding: '10px 16px', textAlign: 'left', fontWeight: 600, fontSize: 11, color: '#64748b', letterSpacing: '0.04em', textTransform: 'uppercase', cursor: col.key ? 'pointer' : 'default', whiteSpace: 'nowrap', borderBottom: '1px solid #e2e8f0' }}> + {col.label}{col.key && } +
No assets match your filters.
+
+
+
+
{r.assetName}
+
{r.method === 'straight-line' ? 'SL' : 'DDB'} ยท {r.usefulLifeYears}yr life
+
+
+
{new Date(r.purchaseDate).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}{fmt(r.purchaseCost)} +
{fmt(r.bookValue)}
+ {r.isFullyDepreciated &&
FULLY DEPRECIATED
} +
{fmt(r.annualDepreciation)}/yr +
+
80 ? '#ef4444' : r.depPct > 50 ? '#f59e0b' : '#10b981'} />
+ {r.depPct.toFixed(0)}% +
+
+ {r.isFullyDepreciated ? 'โ€”' : `${r.remainingLife.toFixed(1)}y`} + +
+ + + +
+
+
Year-by-Year Schedule โ€” {r.assetName}
+
+ + + + {['Year', 'Opening BV', 'Depreciation', 'Closing BV'].map(h => ( + + ))} + + + + {getSchedule(r).map(s => ( + + + + + + + ))} + +
{h}
Y{s.year}{fmt(s.openingBV)}({fmt(s.depreciation)}){fmt(s.closingBV)}
+
+
Totals{fmt(totalCost)}{fmt(totalBook)}{fmt(totalDep)}/yr +
+
+ )} + + {/* Schedule Summary Tab */} + {activeTab === 'schedule' && ( +
+

Consolidated depreciation by year across all filtered assets.

+ {(() => { + const maxYear = Math.max(...filtered.map(r => r.usefulLifeYears)); + const yearTotals: { year: number; totalDep: number; totalBV: number }[] = []; + for (let y = 1; y <= maxYear; y++) { + let dep = 0, bv = 0; + filtered.forEach(r => { + const sched = getSchedule(r); + const row = sched.find(s => s.year === y); + if (row) { dep += row.depreciation; bv += row.closingBV; } + }); + if (dep > 0) yearTotals.push({ year: y, totalDep: dep, totalBV: bv }); + } + const maxDep = Math.max(...yearTotals.map(y => y.totalDep)); + return ( +
+ {yearTotals.map(y => ( +
+
Y{y.year}
+
+
+
+
{fmt(y.totalDep)}
+
BV {fmt(y.totalBV)}
+
+ ))} +
+ ); + })()} +
+ )} +
+ + {/* Add/Edit Modal */} + {showModal && ( +
e.target === e.currentTarget && setShowModal(false)}> +
+

{rows.some(r => r.id === editRow.id) ? 'Edit Asset' : 'Add New Asset'}

+
+ {[ + { label: 'Asset Name', key: 'assetName', type: 'text', colSpan: 2 }, + { label: 'Purchase Date', key: 'purchaseDate', type: 'date' }, + { label: 'Purchase Cost ($)', key: 'purchaseCost', type: 'number' }, + { label: 'Salvage Value ($)', key: 'salvageValue', type: 'number' }, + { label: 'Useful Life (years)', key: 'usefulLifeYears', type: 'number' }, + ].map(f => ( +
+ + setEditRow(r => ({ ...r, [f.key]: f.type === 'number' ? parseFloat(e.target.value) || 0 : e.target.value }))} + style={{ width: '100%', padding: '8px 12px', border: '1px solid #e2e8f0', borderRadius: 8, fontSize: 13, outline: 'none', boxSizing: 'border-box', color: '#0f172a' }} + /> +
+ ))} +
+ + +
+
+ + +
+
+
+ + +
+
+
+ )}
); diff --git a/frontend/app/(dashboard)/notifications/page.tsx b/frontend/app/(dashboard)/notifications/page.tsx index c2e109b6..3cae2899 100644 --- a/frontend/app/(dashboard)/notifications/page.tsx +++ b/frontend/app/(dashboard)/notifications/page.tsx @@ -1,57 +1,303 @@ 'use client'; -import { useState } from 'react'; -import { Bell, Check, X } from 'lucide-react'; +import { useMemo, useState } from 'react'; +import { + Bell, + Check, + CheckCheck, + Clock, + Filter, + Info, + Trash2, + X, +} from 'lucide-react'; -interface AppNotification { id: string; title: string; message: string; read: boolean; timestamp: string } +interface AppNotification { + id: string; + title: string; + message: string; + read: boolean; + timestamp: string; + type: 'info' | 'warning' | 'success'; +} const MOCK: AppNotification[] = [ - { id:'1', title:'Asset Checked Out', message:'Dell Laptop #7 checked out by Alice M.', read:false, timestamp:'2024-01-22T09:15:00Z' }, - { id:'2', title:'Maintenance Due', message:'Annual service for HVAC Unit due tomorrow', read:false, timestamp:'2024-01-22T08:00:00Z' }, - { id:'3', title:'Low Stock Alert', message:'Printer Ink Cartridges below reorder level', read:true, timestamp:'2024-01-21T14:30:00Z' }, + { + id: '1', + title: 'Asset Checked Out', + message: 'Dell Laptop #7 checked out by Alice M.', + read: false, + timestamp: '2024-01-22T09:15:00Z', + type: 'success', + }, + { + id: '2', + title: 'Maintenance Due', + message: 'Annual service for HVAC Unit due tomorrow.', + read: false, + timestamp: '2024-01-22T08:00:00Z', + type: 'warning', + }, + { + id: '3', + title: 'Low Stock Alert', + message: 'Printer Ink Cartridges below reorder level.', + read: true, + timestamp: '2024-01-21T14:30:00Z', + type: 'info', + }, ]; +type FilterType = 'all' | 'unread' | 'read'; + export default function NotificationsPanelPage() { - const [notifications, setNotifications] = useState(MOCK); + const [notifications, setNotifications] = + useState(MOCK); + const [panelOpen, setPanelOpen] = useState(true); - const unreadCount = notifications.filter(n => !n.read).length; + const [filter, setFilter] = useState('all'); + + const unreadCount = notifications.filter((n) => !n.read).length; + + const filteredNotifications = useMemo(() => { + switch (filter) { + case 'read': + return notifications.filter((n) => n.read); + + case 'unread': + return notifications.filter((n) => !n.read); + + default: + return notifications; + } + }, [notifications, filter]); + + const markRead = (id: string) => { + setNotifications((prev) => + prev.map((n) => + n.id === id ? { ...n, read: true } : n + ) + ); + }; + + const toggleRead = (id: string) => { + setNotifications((prev) => + prev.map((n) => + n.id === id ? { ...n, read: !n.read } : n + ) + ); + }; + + const markAllRead = () => { + setNotifications((prev) => + prev.map((n) => ({ + ...n, + read: true, + })) + ); + }; + + const dismiss = (id: string) => { + setNotifications((prev) => + prev.filter((n) => n.id !== id) + ); + }; - const markRead = (id: string) => setNotifications(prev => prev.map(n => n.id === id ? { ...n, read: true } : n)); - const markAllRead = () => setNotifications(prev => prev.map(n => ({ ...n, read: true }))); - const dismiss = (id: string) => setNotifications(prev => prev.filter(n => n.id !== id)); + const formatTime = (date: string) => { + const diff = + Date.now() - new Date(date).getTime(); + + const mins = Math.floor(diff / 60000); + + if (mins < 1) return 'Just now'; + if (mins < 60) return `${mins}m ago`; + + const hrs = Math.floor(mins / 60); + + if (hrs < 24) return `${hrs}h ago`; + + const days = Math.floor(hrs / 24); + + return `${days}d ago`; + }; + + const icon = (type: AppNotification['type']) => { + switch (type) { + case 'success': + return ( + + ); + + case 'warning': + return ( + + ); + + default: + return ( + + ); + } + }; return ( -
-
-

Notifications

In-app notification centre

-
- +
+ {/* Header */} + +
+
+

+ Notifications +

+ +

+ Stay updated with recent activity +

+ +
+ {panelOpen && ( -
-
- All Notifications - {unreadCount > 0 && } +
+ + {/* Toolbar */} + +
+
+ {(['all', 'unread', 'read'] as FilterType[]).map( + (item) => ( + + ) + )} +
+ +
+ {unreadCount > 0 && ( + + )} + + +
- {notifications.length === 0 - ?
No notifications
- : notifications.map(n => ( -
-
markRead(n.id)}> - {!n.read &&
} -
-

{n.title}

-

{n.message}

-

{new Date(n.timestamp).toLocaleString()}

+ + {/* Notifications */} + + {filteredNotifications.length === 0 ? ( +
+ + +

+ No notifications found +

+
+ ) : ( + filteredNotifications.map((n) => ( +
+
markRead(n.id)} + > +
+ {icon(n.type)}
+ +
+
+

+ {n.title} +

+ + {!n.read && ( + + )} +
+ +

+ {n.message} +

+ +

+ {formatTime(n.timestamp)} +

+
+
+ +
+ + +
-
- ))} + )) + )}
)}