596 lines
23 KiB
TypeScript
596 lines
23 KiB
TypeScript
import { useEffect, useState, useMemo } from 'react'
|
||
import { InboxItem, Page } from '../types'
|
||
import { getInboxItems, respondToDelegation } from '../api'
|
||
import { colors } from '../constants'
|
||
import Pagination, { PAGE_SIZE } from '../components/Pagination'
|
||
|
||
interface InboxPageProps {
|
||
onNavigate: (page: Page, name?: string) => void
|
||
onRefresh?: () => void
|
||
}
|
||
|
||
const typeToPage: Record<string, Page> = {
|
||
process: 'processes-form',
|
||
order: 'orders-form',
|
||
incident: 'incidents-form',
|
||
idea: 'ideas-form',
|
||
}
|
||
|
||
const typeColor: Record<string, string> = {
|
||
process: '#3b82f6',
|
||
order: '#8b5cf6',
|
||
incident: '#ef4444',
|
||
idea: '#10b981',
|
||
delegation: '#f59e0b',
|
||
}
|
||
|
||
const typeGradient: Record<string, string> = {
|
||
process: 'linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%)',
|
||
order: 'linear-gradient(135deg, #8b5cf6 0%, #6d28d9 100%)',
|
||
incident: 'linear-gradient(135deg, #ef4444 0%, #b91c1c 100%)',
|
||
idea: 'linear-gradient(135deg, #10b981 0%, #047857 100%)',
|
||
delegation: 'linear-gradient(135deg, #f59e0b 0%, #b45309 100%)',
|
||
}
|
||
|
||
const typeIcon: Record<string, string> = {
|
||
process: '⚙️',
|
||
order: '📦',
|
||
incident: '🚨',
|
||
idea: '💡',
|
||
delegation: '🤝',
|
||
}
|
||
|
||
export default function InboxPage({ onNavigate, onRefresh }: InboxPageProps) {
|
||
const [items, setItems] = useState<InboxItem[]>([])
|
||
const [loading, setLoading] = useState(true)
|
||
const [busyName, setBusyName] = useState('')
|
||
const [errorName, setErrorName] = useState('')
|
||
const [search, setSearch] = useState('')
|
||
const [filterType, setFilterType] = useState<string>('all')
|
||
const [page, setPage] = useState(1)
|
||
|
||
const load = () => {
|
||
setLoading(true)
|
||
getInboxItems().then(data => {
|
||
setItems(data || [])
|
||
setLoading(false)
|
||
}).catch(() => setLoading(false))
|
||
}
|
||
|
||
useEffect(() => { load() }, [])
|
||
|
||
const counts = useMemo(() => {
|
||
const res: Record<string, number> = { all: items.length, action: 0, info: 0 }
|
||
for (const item of items) {
|
||
if (item.requires_action) res.action = (res.action || 0) + 1
|
||
else res.info = (res.info || 0) + 1
|
||
res[item.type] = (res[item.type] || 0) + 1
|
||
}
|
||
return res
|
||
}, [items])
|
||
|
||
const filteredItems = useMemo(() => {
|
||
return items.filter(item => {
|
||
if (filterType === 'action' && !item.requires_action) return false
|
||
if (filterType === 'info' && item.requires_action) return false
|
||
if (filterType !== 'all' && filterType !== 'action' && filterType !== 'info' && item.type !== filterType) return false
|
||
if (search.trim()) {
|
||
const q = search.toLowerCase()
|
||
const match = [item.number, item.title, item.action, item.organization, item.type].filter(Boolean).join(' ').toLowerCase()
|
||
if (!match.includes(q)) return false
|
||
}
|
||
return true
|
||
})
|
||
}, [items, filterType, search])
|
||
|
||
useEffect(() => { setPage(1) }, [filterType, search])
|
||
const pageItems = filteredItems.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE)
|
||
|
||
const respond = async (name: string, response: 'Accepted' | 'Rejected') => {
|
||
setBusyName(name)
|
||
setErrorName('')
|
||
try {
|
||
await respondToDelegation(name, response)
|
||
await load()
|
||
onRefresh?.()
|
||
} catch {
|
||
setErrorName(name)
|
||
} finally {
|
||
setBusyName('')
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div style={{ maxWidth: 1100, margin: '0 auto', display: 'flex', flexDirection: 'column', gap: 20 }}>
|
||
{/* Top Banner & Stats */}
|
||
<div style={{
|
||
background: 'linear-gradient(135deg, #1e1b4b 0%, #312e81 50%, #1e293b 100%)',
|
||
borderRadius: 16,
|
||
padding: '24px 28px',
|
||
color: '#ffffff',
|
||
boxShadow: '0 10px 25px -5px rgba(30, 27, 75, 0.25), 0 8px 10px -6px rgba(30, 27, 75, 0.2)',
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
gap: 18,
|
||
position: 'relative',
|
||
overflow: 'hidden'
|
||
}}>
|
||
{/* Glow decoration */}
|
||
<div style={{
|
||
position: 'absolute',
|
||
top: -40,
|
||
right: -40,
|
||
width: 180,
|
||
height: 180,
|
||
borderRadius: '50%',
|
||
background: 'radial-gradient(circle, rgba(99, 102, 241, 0.35) 0%, transparent 70%)',
|
||
pointerEvents: 'none'
|
||
}} />
|
||
|
||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 14 }}>
|
||
<div>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||
<span style={{ fontSize: 24 }}>📬</span>
|
||
<h2 style={{ fontSize: 20, fontWeight: 800, color: '#ffffff', margin: 0, letterSpacing: '-0.3px' }}>
|
||
Unified Activity Inbox
|
||
</h2>
|
||
</div>
|
||
<p style={{ margin: '4px 0 0', fontSize: 13, color: '#c7d2fe', opacity: 0.9 }}>
|
||
Real-time tasks, approvals, delegations, and workflow actions requiring your attention.
|
||
</p>
|
||
</div>
|
||
|
||
<button
|
||
type="button"
|
||
onClick={load}
|
||
disabled={loading}
|
||
style={{
|
||
background: 'rgba(255, 255, 255, 0.15)',
|
||
backdropFilter: 'blur(8px)',
|
||
border: '1px solid rgba(255, 255, 255, 0.25)',
|
||
color: 'white',
|
||
borderRadius: 10,
|
||
padding: '8px 16px',
|
||
fontSize: 13,
|
||
fontWeight: 600,
|
||
cursor: loading ? 'wait' : 'pointer',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: 8,
|
||
transition: 'all 0.15s ease'
|
||
}}
|
||
onMouseEnter={e => { (e.currentTarget as HTMLElement).style.background = 'rgba(255, 255, 255, 0.25)' }}
|
||
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.background = 'rgba(255, 255, 255, 0.15)' }}
|
||
>
|
||
<span style={{ transform: loading ? 'rotate(180deg)' : 'none', transition: 'transform 0.5s', display: 'inline-block' }}>🔄</span>
|
||
{loading ? 'Refreshing…' : 'Refresh'}
|
||
</button>
|
||
</div>
|
||
|
||
{/* Quick stat counters */}
|
||
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
|
||
<div style={{
|
||
background: 'rgba(255, 255, 255, 0.1)',
|
||
backdropFilter: 'blur(6px)',
|
||
borderRadius: 10,
|
||
padding: '8px 14px',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: 10,
|
||
border: '1px solid rgba(255, 255, 255, 0.15)'
|
||
}}>
|
||
<span style={{ fontSize: 12, color: '#e0e7ff', fontWeight: 500 }}>Total Items</span>
|
||
<span style={{ background: 'white', color: '#1e1b4b', fontWeight: 800, fontSize: 12, padding: '2px 8px', borderRadius: 999 }}>
|
||
{counts.all || 0}
|
||
</span>
|
||
</div>
|
||
|
||
<div style={{
|
||
background: 'rgba(239, 68, 68, 0.2)',
|
||
backdropFilter: 'blur(6px)',
|
||
borderRadius: 10,
|
||
padding: '8px 14px',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: 10,
|
||
border: '1px solid rgba(239, 68, 68, 0.35)'
|
||
}}>
|
||
<span style={{ fontSize: 12, color: '#fecaca', fontWeight: 600 }}>Action Required</span>
|
||
<span style={{ background: '#ef4444', color: 'white', fontWeight: 800, fontSize: 12, padding: '2px 8px', borderRadius: 999 }}>
|
||
{counts.action || 0}
|
||
</span>
|
||
</div>
|
||
|
||
<div style={{
|
||
background: 'rgba(59, 130, 246, 0.2)',
|
||
backdropFilter: 'blur(6px)',
|
||
borderRadius: 10,
|
||
padding: '8px 14px',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: 10,
|
||
border: '1px solid rgba(59, 130, 246, 0.35)'
|
||
}}>
|
||
<span style={{ fontSize: 12, color: '#bfdbfe', fontWeight: 500 }}>Informational</span>
|
||
<span style={{ background: '#3b82f6', color: 'white', fontWeight: 800, fontSize: 12, padding: '2px 8px', borderRadius: 999 }}>
|
||
{counts.info || 0}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Filter Tabs and Search Bar */}
|
||
<div style={{
|
||
display: 'flex',
|
||
flexWrap: 'wrap',
|
||
alignItems: 'center',
|
||
justifyContent: 'space-between',
|
||
gap: 14,
|
||
background: 'white',
|
||
padding: '12px 16px',
|
||
borderRadius: 12,
|
||
border: '1px solid #e2e8f0',
|
||
boxShadow: '0 1px 3px rgba(0,0,0,0.04)'
|
||
}} className="form-card">
|
||
{/* Pills */}
|
||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', alignItems: 'center' }}>
|
||
{[
|
||
{ id: 'all', label: 'All', icon: '📋', count: counts.all },
|
||
{ id: 'action', label: 'Needs Action', icon: '🚨', count: counts.action, badgeColor: '#ef4444' },
|
||
{ id: 'info', label: 'Info', icon: 'ℹ️', count: counts.info },
|
||
{ id: 'incident', label: 'Incidents', icon: '⚠️', count: counts.incident },
|
||
{ id: 'idea', label: 'Ideas', icon: '💡', count: counts.idea },
|
||
{ id: 'order', label: 'Orders', icon: '📦', count: counts.order },
|
||
{ id: 'process', label: 'Processes', icon: '⚙️', count: counts.process },
|
||
{ id: 'delegation', label: 'Delegations', icon: '🤝', count: counts.delegation },
|
||
].map(tab => {
|
||
const active = filterType === tab.id
|
||
if (tab.id !== 'all' && tab.id !== 'action' && tab.id !== 'info' && (!tab.count || tab.count === 0)) return null
|
||
return (
|
||
<button
|
||
key={tab.id}
|
||
type="button"
|
||
onClick={() => setFilterType(tab.id)}
|
||
style={{
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: 6,
|
||
padding: '6px 12px',
|
||
borderRadius: 999,
|
||
border: active ? '1px solid #4f46e5' : '1px solid #e2e8f0',
|
||
background: active ? '#4f46e5' : '#f8fafc',
|
||
color: active ? '#ffffff' : '#475569',
|
||
fontSize: 12,
|
||
fontWeight: 600,
|
||
cursor: 'pointer',
|
||
transition: 'all 0.15s ease'
|
||
}}
|
||
>
|
||
<span>{tab.icon}</span>
|
||
<span>{tab.label}</span>
|
||
{tab.count != null && tab.count > 0 && (
|
||
<span style={{
|
||
fontSize: 10,
|
||
fontWeight: 700,
|
||
padding: '1px 6px',
|
||
borderRadius: 999,
|
||
background: active ? 'rgba(255,255,255,0.25)' : (tab.badgeColor || '#e2e8f0'),
|
||
color: active ? '#ffffff' : (tab.badgeColor ? 'white' : '#475569')
|
||
}}>
|
||
{tab.count}
|
||
</span>
|
||
)}
|
||
</button>
|
||
)
|
||
})}
|
||
</div>
|
||
|
||
{/* Search */}
|
||
<div style={{ minWidth: 200, flex: '1 1 200px', maxWidth: 300 }}>
|
||
<input
|
||
type="text"
|
||
placeholder="Search items, titles, IDs…"
|
||
value={search}
|
||
onChange={e => setSearch(e.target.value)}
|
||
style={{
|
||
width: '100%',
|
||
padding: '7px 12px',
|
||
borderRadius: 8,
|
||
border: '1px solid #cbd5e1',
|
||
fontSize: 12,
|
||
boxSizing: 'border-box',
|
||
background: '#f8fafc',
|
||
color: '#334155'
|
||
}}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Cards List */}
|
||
{loading ? (
|
||
<div style={{ textAlign: 'center', padding: '60px 20px', color: '#94a3b8', fontSize: 14 }}>
|
||
<div style={{ fontSize: 28, marginBottom: 8, animation: 'spin 1s linear infinite' }}>⏳</div>
|
||
Loading inbox updates…
|
||
</div>
|
||
) : filteredItems.length === 0 ? (
|
||
<div style={{
|
||
background: 'white',
|
||
borderRadius: 16,
|
||
padding: '60px 24px',
|
||
textAlign: 'center',
|
||
border: '1px solid #e2e8f0',
|
||
boxShadow: '0 1px 3px rgba(0,0,0,0.03)'
|
||
}} className="form-card">
|
||
<div style={{ fontSize: 44, marginBottom: 12 }}>✨</div>
|
||
<h3 style={{ fontSize: 16, fontWeight: 700, color: '#1e293b', margin: 0 }}>
|
||
{search || filterType !== 'all' ? 'No matching inbox items' : 'You are all caught up!'}
|
||
</h3>
|
||
<p style={{ fontSize: 13, color: '#64748b', marginTop: 6, maxWidth: 420, margin: '6px auto 0' }}>
|
||
{search || filterType !== 'all'
|
||
? 'Try changing your filter pills or searching for a different keyword.'
|
||
: 'There are no active pending approvals or tasks requiring your action right now.'}
|
||
</p>
|
||
{(search || filterType !== 'all') && (
|
||
<button
|
||
type="button"
|
||
onClick={() => { setSearch(''); setFilterType('all') }}
|
||
style={{
|
||
marginTop: 16,
|
||
padding: '7px 16px',
|
||
borderRadius: 8,
|
||
background: '#4f46e5',
|
||
color: 'white',
|
||
border: 'none',
|
||
fontWeight: 600,
|
||
fontSize: 12,
|
||
cursor: 'pointer'
|
||
}}
|
||
>
|
||
Reset Filters
|
||
</button>
|
||
)}
|
||
</div>
|
||
) : (
|
||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(330px, 1fr))', gap: 14 }}>
|
||
{pageItems.map((item, idx) => {
|
||
const tint = typeColor[item.type] || colors.gray
|
||
const gradient = typeGradient[item.type] || 'linear-gradient(135deg, #64748b 0%, #475569 100%)'
|
||
const clickable = item.type !== 'delegation'
|
||
|
||
return (
|
||
<div
|
||
key={item.name || idx}
|
||
className="inbox-card form-card"
|
||
onClick={clickable ? () => onNavigate(typeToPage[item.type] as Page, item.name) : undefined}
|
||
style={{
|
||
background: 'white',
|
||
borderRadius: 14,
|
||
padding: '16px 18px',
|
||
border: '1px solid #e2e8f0',
|
||
boxShadow: '0 1px 3px rgba(0,0,0,0.04)',
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
justifyContent: 'space-between',
|
||
gap: 14,
|
||
cursor: clickable ? 'pointer' : 'default',
|
||
position: 'relative',
|
||
overflow: 'hidden',
|
||
transition: 'transform 0.15s ease, box-shadow 0.15s ease'
|
||
}}
|
||
onMouseEnter={e => {
|
||
if (clickable) {
|
||
(e.currentTarget as HTMLElement).style.transform = 'translateY(-2px)'
|
||
;(e.currentTarget as HTMLElement).style.boxShadow = '0 10px 20px -5px rgba(0,0,0,0.08)'
|
||
}
|
||
}}
|
||
onMouseLeave={e => {
|
||
if (clickable) {
|
||
(e.currentTarget as HTMLElement).style.transform = 'none'
|
||
;(e.currentTarget as HTMLElement).style.boxShadow = '0 1px 3px rgba(0,0,0,0.04)'
|
||
}
|
||
}}
|
||
>
|
||
{/* Accent line */}
|
||
<div style={{ position: 'absolute', top: 0, left: 0, right: 0, height: 4, background: gradient }} />
|
||
|
||
<div>
|
||
{/* Card Header row */}
|
||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, marginBottom: 10 }}>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||
<div style={{
|
||
width: 32,
|
||
height: 32,
|
||
borderRadius: 8,
|
||
background: gradient,
|
||
color: 'white',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
fontSize: 15,
|
||
flexShrink: 0,
|
||
boxShadow: `0 3px 8px -1px ${tint}66`
|
||
}}>
|
||
{typeIcon[item.type] || '📌'}
|
||
</div>
|
||
<span style={{ fontSize: 12, fontWeight: 700, color: tint, letterSpacing: '0.3px' }}>
|
||
{item.number || item.name}
|
||
</span>
|
||
</div>
|
||
|
||
{item.requires_action ? (
|
||
<span style={{
|
||
fontSize: 10,
|
||
fontWeight: 800,
|
||
color: '#ef4444',
|
||
background: '#fef2f2',
|
||
border: '1px solid #fecaca',
|
||
padding: '2px 8px',
|
||
borderRadius: 999,
|
||
textTransform: 'uppercase',
|
||
letterSpacing: '0.4px',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: 4
|
||
}}>
|
||
<span style={{ width: 6, height: 6, borderRadius: '50%', background: '#ef4444' }} />
|
||
Action
|
||
</span>
|
||
) : (
|
||
<span style={{
|
||
fontSize: 10,
|
||
fontWeight: 700,
|
||
color: '#64748b',
|
||
background: '#f1f5f9',
|
||
padding: '2px 8px',
|
||
borderRadius: 999,
|
||
textTransform: 'uppercase',
|
||
letterSpacing: '0.3px'
|
||
}}>
|
||
{item.type}
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
{/* Card Title */}
|
||
<div className="inbox-card-title" style={{
|
||
fontSize: 14,
|
||
fontWeight: 700,
|
||
color: '#1e293b',
|
||
lineHeight: 1.35,
|
||
marginBottom: 6,
|
||
overflow: 'hidden',
|
||
textOverflow: 'ellipsis',
|
||
display: '-webkit-box',
|
||
WebkitLineClamp: 2,
|
||
WebkitBoxOrient: 'vertical'
|
||
}}>
|
||
{item.title || item.action || 'Untitled Item'}
|
||
</div>
|
||
|
||
{/* Action description / remarks */}
|
||
<div style={{ fontSize: 12, color: '#475569', lineHeight: 1.4, marginBottom: 8 }}>
|
||
{item.action}
|
||
</div>
|
||
|
||
{/* Meta tag chips */}
|
||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', alignItems: 'center' }}>
|
||
{item.organization && (
|
||
<span style={{
|
||
fontSize: 11,
|
||
color: '#64748b',
|
||
background: '#f8fafc',
|
||
border: '1px solid #e2e8f0',
|
||
padding: '2px 7px',
|
||
borderRadius: 6,
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: 4
|
||
}}>
|
||
🏢 {item.organization}
|
||
</span>
|
||
)}
|
||
{item.date && (
|
||
<span style={{
|
||
fontSize: 11,
|
||
color: '#64748b',
|
||
background: '#f8fafc',
|
||
border: '1px solid #e2e8f0',
|
||
padding: '2px 7px',
|
||
borderRadius: 6
|
||
}}>
|
||
📅 {item.date}
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
{errorName === item.name && (
|
||
<div style={{ marginTop: 8, fontSize: 11, color: '#ef4444', fontWeight: 600 }}>
|
||
⚠️ Failed to process response. Please try again.
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Footer Action */}
|
||
<div>
|
||
{item.type === 'delegation' ? (
|
||
<div style={{ display: 'flex', gap: 8, marginTop: 4 }}>
|
||
<button
|
||
type="button"
|
||
onClick={() => respond(item.name, 'Accepted')}
|
||
disabled={busyName === item.name}
|
||
style={{
|
||
flex: 1,
|
||
padding: '7px 0',
|
||
borderRadius: 8,
|
||
border: 'none',
|
||
background: '#16a34a',
|
||
color: 'white',
|
||
fontWeight: 700,
|
||
fontSize: 12,
|
||
cursor: busyName === item.name ? 'wait' : 'pointer',
|
||
opacity: busyName === item.name ? 0.6 : 1,
|
||
transition: 'background 0.15s ease'
|
||
}}
|
||
>
|
||
✓ Accept
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => respond(item.name, 'Rejected')}
|
||
disabled={busyName === item.name}
|
||
style={{
|
||
flex: 1,
|
||
padding: '7px 0',
|
||
borderRadius: 8,
|
||
border: 'none',
|
||
background: '#ef4444',
|
||
color: 'white',
|
||
fontWeight: 700,
|
||
fontSize: 12,
|
||
cursor: busyName === item.name ? 'wait' : 'pointer',
|
||
opacity: busyName === item.name ? 0.6 : 1,
|
||
transition: 'background 0.15s ease'
|
||
}}
|
||
>
|
||
✕ Reject
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<button
|
||
type="button"
|
||
onClick={e => {
|
||
e.stopPropagation()
|
||
onNavigate(typeToPage[item.type] as Page, item.name)
|
||
}}
|
||
style={{
|
||
width: '100%',
|
||
padding: '7px 12px',
|
||
borderRadius: 8,
|
||
border: item.requires_action ? 'none' : '1px solid #cbd5e1',
|
||
background: item.requires_action ? gradient : '#f8fafc',
|
||
color: item.requires_action ? '#ffffff' : '#334155',
|
||
fontWeight: 700,
|
||
fontSize: 12,
|
||
cursor: 'pointer',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
gap: 6,
|
||
transition: 'all 0.15s ease'
|
||
}}
|
||
>
|
||
<span>{item.requires_action ? 'Review & Act Now' : 'View Details'}</span>
|
||
<span style={{ fontSize: 13 }}>→</span>
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
)}
|
||
<Pagination page={page} total={filteredItems.length} onPageChange={setPage} />
|
||
</div>
|
||
)
|
||
}
|