refactor: Simplify Portfolio page - remove unreliable data
SIMPLIFIED STATS: - Removed: Total Invested, Est. Value, Profit/Loss - Added: Expiring Soon (domains expiring in 30 days) - Added: Need Attention (domains with health issues) - Kept: Total Domains, Listed for Sale SIMPLIFIED TABLE: - Domain column: name + registrar - Added column: simple date added - Expires column: with color-coded expiry warnings (30d, expired) - Health column: quick health status - Actions: Three-dot dropdown menu THREE-DOT MENU: ├── Health Check ├── Edit Details ├── ───────────── ├── List for Sale (accent color) ├── Visit Website ├── ───────────── ├── Record Sale └── Remove (danger) SIMPLIFIED SUBTITLE: - Shows domain count + expiring soon count - No more profit/loss display This focuses on reliable, actionable data: ✅ Domain names (100% accurate) ✅ Expiry dates (user input) ✅ Health status (real-time check) ❌ Valuations (unreliable estimates) ❌ Profit/Loss (depends on estimates)
This commit is contained in:
@ -4,7 +4,7 @@ import { useEffect, useState } from 'react'
|
|||||||
import { useStore } from '@/lib/store'
|
import { useStore } from '@/lib/store'
|
||||||
import { api, PortfolioDomain, PortfolioSummary, DomainValuation, DomainHealthReport, HealthStatus } from '@/lib/api'
|
import { api, PortfolioDomain, PortfolioSummary, DomainValuation, DomainHealthReport, HealthStatus } from '@/lib/api'
|
||||||
import { CommandCenterLayout } from '@/components/CommandCenterLayout'
|
import { CommandCenterLayout } from '@/components/CommandCenterLayout'
|
||||||
import { PremiumTable, StatCard, PageContainer, TableActionButton } from '@/components/PremiumTable'
|
import { PremiumTable, StatCard, PageContainer } from '@/components/PremiumTable'
|
||||||
import { Toast, useToast } from '@/components/Toast'
|
import { Toast, useToast } from '@/components/Toast'
|
||||||
import {
|
import {
|
||||||
Plus,
|
Plus,
|
||||||
@ -13,19 +13,17 @@ import {
|
|||||||
DollarSign,
|
DollarSign,
|
||||||
Calendar,
|
Calendar,
|
||||||
Building,
|
Building,
|
||||||
RefreshCw,
|
|
||||||
Loader2,
|
Loader2,
|
||||||
TrendingUp,
|
|
||||||
Sparkles,
|
|
||||||
ArrowUpRight,
|
ArrowUpRight,
|
||||||
X,
|
X,
|
||||||
Briefcase,
|
Briefcase,
|
||||||
PiggyBank,
|
|
||||||
ShoppingCart,
|
ShoppingCart,
|
||||||
Activity,
|
Activity,
|
||||||
Shield,
|
Shield,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
Tag,
|
Tag,
|
||||||
|
MoreVertical,
|
||||||
|
ExternalLink,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
// Health status configuration
|
// Health status configuration
|
||||||
@ -67,6 +65,9 @@ export default function PortfolioPage() {
|
|||||||
const [healthReports, setHealthReports] = useState<Record<string, DomainHealthReport>>({})
|
const [healthReports, setHealthReports] = useState<Record<string, DomainHealthReport>>({})
|
||||||
const [loadingHealth, setLoadingHealth] = useState<Record<string, boolean>>({})
|
const [loadingHealth, setLoadingHealth] = useState<Record<string, boolean>>({})
|
||||||
const [selectedHealthDomain, setSelectedHealthDomain] = useState<string | null>(null)
|
const [selectedHealthDomain, setSelectedHealthDomain] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Dropdown menu state
|
||||||
|
const [openMenuId, setOpenMenuId] = useState<number | null>(null)
|
||||||
|
|
||||||
const [addForm, setAddForm] = useState({
|
const [addForm, setAddForm] = useState({
|
||||||
domain: '',
|
domain: '',
|
||||||
@ -261,10 +262,15 @@ export default function PortfolioPage() {
|
|||||||
// Dynamic subtitle
|
// Dynamic subtitle
|
||||||
const getSubtitle = () => {
|
const getSubtitle = () => {
|
||||||
if (loading) return 'Loading your portfolio...'
|
if (loading) return 'Loading your portfolio...'
|
||||||
if (portfolio.length === 0) return 'Start tracking your domain investments'
|
if (portfolio.length === 0) return 'Start tracking your domains'
|
||||||
const profit = summary?.total_profit || 0
|
const expiringSoon = portfolio.filter(d => {
|
||||||
if (profit > 0) return `${portfolio.length} domains • +$${profit.toLocaleString()} profit`
|
if (!d.renewal_date) return false
|
||||||
if (profit < 0) return `${portfolio.length} domains • -$${Math.abs(profit).toLocaleString()} loss`
|
const days = Math.ceil((new Date(d.renewal_date).getTime() - Date.now()) / (1000 * 60 * 60 * 24))
|
||||||
|
return days <= 30 && days > 0
|
||||||
|
}).length
|
||||||
|
if (expiringSoon > 0) {
|
||||||
|
return `${portfolio.length} domain${portfolio.length !== 1 ? 's' : ''} • ${expiringSoon} expiring soon`
|
||||||
|
}
|
||||||
return `Managing ${portfolio.length} domain${portfolio.length !== 1 ? 's' : ''}`
|
return `Managing ${portfolio.length} domain${portfolio.length !== 1 ? 's' : ''}`
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -288,18 +294,25 @@ export default function PortfolioPage() {
|
|||||||
{toast && <Toast message={toast.message} type={toast.type} onClose={hideToast} />}
|
{toast && <Toast message={toast.message} type={toast.type} onClose={hideToast} />}
|
||||||
|
|
||||||
<PageContainer>
|
<PageContainer>
|
||||||
{/* Summary Stats */}
|
{/* Summary Stats - Only reliable data */}
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-5 gap-4">
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
<StatCard title="Total Domains" value={summary?.total_domains || 0} icon={Briefcase} />
|
<StatCard title="Total Domains" value={summary?.total_domains || 0} icon={Briefcase} />
|
||||||
<StatCard title="Total Invested" value={`$${(summary?.total_invested || 0).toLocaleString()}`} icon={DollarSign} />
|
|
||||||
<StatCard title="Est. Value" value={`$${(summary?.total_value || 0).toLocaleString()}`} icon={TrendingUp} />
|
|
||||||
<StatCard
|
<StatCard
|
||||||
title="Profit/Loss"
|
title="Expiring Soon"
|
||||||
value={`${(summary?.total_profit || 0) >= 0 ? '+' : ''}$${(summary?.total_profit || 0).toLocaleString()}`}
|
value={portfolio.filter(d => {
|
||||||
icon={PiggyBank}
|
if (!d.renewal_date) return false
|
||||||
accent={(summary?.total_profit || 0) >= 0}
|
const days = Math.ceil((new Date(d.renewal_date).getTime() - Date.now()) / (1000 * 60 * 60 * 24))
|
||||||
|
return days <= 30 && days > 0
|
||||||
|
}).length}
|
||||||
|
icon={Calendar}
|
||||||
|
accent
|
||||||
/>
|
/>
|
||||||
<StatCard title="Sold" value={summary?.sold_domains || 0} icon={ShoppingCart} />
|
<StatCard
|
||||||
|
title="Need Attention"
|
||||||
|
value={Object.values(healthReports).filter(r => r.status !== 'healthy').length}
|
||||||
|
icon={AlertTriangle}
|
||||||
|
/>
|
||||||
|
<StatCard title="Listed for Sale" value={summary?.sold_domains || 0} icon={Tag} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!canAddMore && (
|
{!canAddMore && (
|
||||||
@ -340,48 +353,53 @@ export default function PortfolioPage() {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'purchase',
|
key: 'added',
|
||||||
header: 'Purchase',
|
header: 'Added',
|
||||||
hideOnMobile: true,
|
hideOnMobile: true,
|
||||||
|
hideOnTablet: true,
|
||||||
render: (domain) => (
|
render: (domain) => (
|
||||||
<div>
|
<span className="text-sm text-foreground-muted">
|
||||||
{domain.purchase_price && (
|
{domain.purchase_date
|
||||||
<span className="font-medium text-foreground">${domain.purchase_price.toLocaleString()}</span>
|
? new Date(domain.purchase_date).toLocaleDateString()
|
||||||
)}
|
: new Date(domain.created_at).toLocaleDateString()
|
||||||
{domain.purchase_date && (
|
}
|
||||||
<p className="text-xs text-foreground-muted flex items-center gap-1 mt-0.5">
|
</span>
|
||||||
<Calendar className="w-3 h-3" /> {new Date(domain.purchase_date).toLocaleDateString()}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'valuation',
|
|
||||||
header: 'Est. Value',
|
|
||||||
align: 'right',
|
|
||||||
render: (domain) => (
|
|
||||||
domain.current_valuation ? (
|
|
||||||
<span className="font-semibold text-foreground">${domain.current_valuation.toLocaleString()}</span>
|
|
||||||
) : (
|
|
||||||
<span className="text-foreground-muted">—</span>
|
|
||||||
)
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'renewal',
|
key: 'renewal',
|
||||||
header: 'Renewal',
|
header: 'Expires',
|
||||||
hideOnMobile: true,
|
hideOnMobile: true,
|
||||||
hideOnTablet: true,
|
render: (domain) => {
|
||||||
render: (domain) => (
|
if (!domain.renewal_date) {
|
||||||
domain.renewal_date ? (
|
return <span className="text-foreground-subtle">—</span>
|
||||||
<span className="text-sm text-foreground-muted">
|
}
|
||||||
{new Date(domain.renewal_date).toLocaleDateString()}
|
const days = Math.ceil((new Date(domain.renewal_date).getTime() - Date.now()) / (1000 * 60 * 60 * 24))
|
||||||
</span>
|
const isExpiringSoon = days <= 30 && days > 0
|
||||||
) : (
|
const isExpired = days <= 0
|
||||||
<span className="text-foreground-subtle">—</span>
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={clsx(
|
||||||
|
"text-sm",
|
||||||
|
isExpired && "text-red-400",
|
||||||
|
isExpiringSoon && "text-amber-400",
|
||||||
|
!isExpired && !isExpiringSoon && "text-foreground-muted"
|
||||||
|
)}>
|
||||||
|
{new Date(domain.renewal_date).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
{isExpiringSoon && (
|
||||||
|
<span className="px-1.5 py-0.5 text-[10px] font-medium bg-amber-400/10 text-amber-400 rounded">
|
||||||
|
{days}d
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{isExpired && (
|
||||||
|
<span className="px-1.5 py-0.5 text-[10px] font-medium bg-red-400/10 text-red-400 rounded">
|
||||||
|
EXPIRED
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
),
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'health',
|
key: 'health',
|
||||||
@ -424,51 +442,77 @@ export default function PortfolioPage() {
|
|||||||
header: '',
|
header: '',
|
||||||
align: 'right',
|
align: 'right',
|
||||||
render: (domain) => (
|
render: (domain) => (
|
||||||
<div className="flex items-center gap-1 justify-end">
|
<div className="relative">
|
||||||
<TableActionButton
|
|
||||||
icon={Shield}
|
|
||||||
onClick={() => handleHealthCheck(domain.domain)}
|
|
||||||
loading={loadingHealth[domain.domain]}
|
|
||||||
title="Health check (SSL, DNS, HTTP)"
|
|
||||||
variant={healthReports[domain.domain] ? 'accent' : 'default'}
|
|
||||||
/>
|
|
||||||
<TableActionButton
|
|
||||||
icon={Sparkles}
|
|
||||||
onClick={() => handleValuate(domain)}
|
|
||||||
title="Get valuation"
|
|
||||||
/>
|
|
||||||
<TableActionButton
|
|
||||||
icon={RefreshCw}
|
|
||||||
onClick={() => handleRefresh(domain)}
|
|
||||||
loading={refreshingId === domain.id}
|
|
||||||
title="Refresh valuation"
|
|
||||||
/>
|
|
||||||
<TableActionButton
|
|
||||||
icon={Edit2}
|
|
||||||
onClick={() => openEditModal(domain)}
|
|
||||||
title="Edit"
|
|
||||||
/>
|
|
||||||
<Link
|
|
||||||
href={`/command/listings?domain=${encodeURIComponent(domain.domain)}`}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
className="px-3 py-2 text-xs font-medium text-accent hover:bg-accent/10 rounded-lg transition-colors flex items-center gap-1"
|
|
||||||
>
|
|
||||||
<Tag className="w-3 h-3" />
|
|
||||||
List
|
|
||||||
</Link>
|
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); openSellModal(domain) }}
|
onClick={(e) => {
|
||||||
className="px-3 py-2 text-xs font-medium text-foreground-muted hover:bg-foreground/5 rounded-lg transition-colors"
|
e.stopPropagation()
|
||||||
title="Record a completed sale (for P&L tracking)"
|
setOpenMenuId(openMenuId === domain.id ? null : domain.id)
|
||||||
|
}}
|
||||||
|
className="p-2 text-foreground-muted hover:text-foreground hover:bg-foreground/5 rounded-lg transition-colors"
|
||||||
>
|
>
|
||||||
Sold?
|
<MoreVertical className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
<TableActionButton
|
|
||||||
icon={Trash2}
|
{openMenuId === domain.id && (
|
||||||
onClick={() => handleDelete(domain)}
|
<>
|
||||||
variant="danger"
|
{/* Backdrop */}
|
||||||
title="Remove"
|
<div
|
||||||
/>
|
className="fixed inset-0 z-40"
|
||||||
|
onClick={() => setOpenMenuId(null)}
|
||||||
|
/>
|
||||||
|
{/* Menu */}
|
||||||
|
<div className="absolute right-0 top-full mt-1 z-50 w-48 py-1 bg-background-secondary border border-border/50 rounded-xl shadow-xl">
|
||||||
|
<button
|
||||||
|
onClick={() => { handleHealthCheck(domain.domain); setOpenMenuId(null) }}
|
||||||
|
className="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-foreground-muted hover:text-foreground hover:bg-foreground/5 transition-colors"
|
||||||
|
>
|
||||||
|
<Shield className="w-4 h-4" />
|
||||||
|
Health Check
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { openEditModal(domain); setOpenMenuId(null) }}
|
||||||
|
className="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-foreground-muted hover:text-foreground hover:bg-foreground/5 transition-colors"
|
||||||
|
>
|
||||||
|
<Edit2 className="w-4 h-4" />
|
||||||
|
Edit Details
|
||||||
|
</button>
|
||||||
|
<div className="my-1 border-t border-border/30" />
|
||||||
|
<Link
|
||||||
|
href={`/command/listings?domain=${encodeURIComponent(domain.domain)}`}
|
||||||
|
onClick={() => setOpenMenuId(null)}
|
||||||
|
className="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-accent hover:bg-accent/5 transition-colors"
|
||||||
|
>
|
||||||
|
<Tag className="w-4 h-4" />
|
||||||
|
List for Sale
|
||||||
|
</Link>
|
||||||
|
<a
|
||||||
|
href={`https://${domain.domain}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
onClick={() => setOpenMenuId(null)}
|
||||||
|
className="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-foreground-muted hover:text-foreground hover:bg-foreground/5 transition-colors"
|
||||||
|
>
|
||||||
|
<ExternalLink className="w-4 h-4" />
|
||||||
|
Visit Website
|
||||||
|
</a>
|
||||||
|
<div className="my-1 border-t border-border/30" />
|
||||||
|
<button
|
||||||
|
onClick={() => { openSellModal(domain); setOpenMenuId(null) }}
|
||||||
|
className="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-foreground-muted hover:text-foreground hover:bg-foreground/5 transition-colors"
|
||||||
|
>
|
||||||
|
<DollarSign className="w-4 h-4" />
|
||||||
|
Record Sale
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { handleDelete(domain); setOpenMenuId(null) }}
|
||||||
|
className="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-red-400 hover:bg-red-400/5 transition-colors"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user