import { useState, useMemo, useCallback } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext';
import { AppLayout } from '../../components/layout/AppLayout';
import { LoadingSpinner } from '../../components/ui/LoadingSpinner';
import {
estimatesApi,
type Estimate,
type EstimateStage,
type EstimateSource,
} from '../../api/estimates';
import {
Search,
Filter,
X,
ChevronDown,
ChevronUp,
ChevronsUpDown,
ArrowRight,
FileText,
MoreVertical,
RotateCcw,
} from 'lucide-react';
// βββ Helpers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
function formatCurrency(value: number): string {
if (value >= 1_000_000) return `$${(value / 1_000_000).toFixed(1)}M`;
if (value >= 1_000) return `$${(value / 1_000).toFixed(1)}K`;
return `$${value.toFixed(0)}`;
}
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
});
}
function capitalize(str: string): string {
return str.charAt(0).toUpperCase() + str.slice(1);
}
function capitalizeSource(src: EstimateSource): string {
const map: Record<EstimateSource, string> = {
manual: 'Manual',
angi: 'Angi',
servicetitan: 'ServiceTitan',
jobber: 'Jobber',
hubspot: 'HubSpot',
email: 'Email',
referral: 'Referral',
website: 'Website',
};
return map[src] ?? capitalize(src);
}
// βββ Stage badge colors βββββββββββββββββββββββββββββββββββββββββββββββββββ
const STAGE_BADGES: Record<EstimateStage, { bg: string; text: string; label: string }> = {
draft: { bg: 'bg-slate-100', text: 'text-slate-700', label: 'Draft' },
delivered: { bg: 'bg-blue-100', text: 'text-blue-700', label: 'Delivered' },
accepted: { bg: 'bg-emerald-100', text: 'text-emerald-700', label: 'Accepted' },
scheduled: { bg: 'bg-amber-100', text: 'text-amber-700', label: 'Scheduled' },
closed: { bg: 'bg-green-100', text: 'text-green-700', label: 'Closed' },
rejected: { bg: 'bg-red-100', text: 'text-red-700', label: 'Rejected' },
expired: { bg: 'bg-gray-100', text: 'text-gray-700', label: 'Expired' },
};
// βββ Valid stage transitions βββββββββββββββββββββββββββββββββββββββββββββ
const VALID_TRANSITIONS: Record<EstimateStage, EstimateStage[]> = {
draft: ['delivered'],
delivered: ['accepted', 'rejected', 'expired'],
accepted: ['scheduled', 'rejected'],
scheduled: ['closed', 'rejected'],
rejected: [],
expired: [],
closed: [],
};
// βββ Sort config ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
type SortField = 'estimate_number' | 'customer_name' | 'project_type' | 'total_value' | 'stage' | 'source' | 'created_at';
type SortOrder = 'asc' | 'desc';
interface SortState {
field: SortField;
order: SortOrder;
}
const DEFAULT_SORT: SortState = { field: 'created_at', order: 'desc' };
const SORTABLE_COLUMNS: { key: SortField; label: string }[] = [
{ key: 'estimate_number', label: 'Estimate #' },
{ key: 'customer_name', label: 'Customer' },
{ key: 'project_type', label: 'Project Type' },
{ key: 'total_value', label: 'Value' },
{ key: 'stage', label: 'Stage' },
{ key: 'source', label: 'Source' },
{ key: 'created_at', label: 'Created' },
];
// βββ Filter types βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
interface Filters {
stage: EstimateStage | '';
source: EstimateSource | '';
minValue: string;
maxValue: string;
search: string;
}
const DEFAULT_FILTERS: Filters = {
stage: '',
source: '',
minValue: '',
maxValue: '',
search: '',
};
const ALL_STAGES: { value: EstimateStage; label: string }[] = [
{ value: 'draft', label: 'Draft' },
{ value: 'delivered', label: 'Delivered' },
{ value: 'accepted', label: 'Accepted' },
{ value: 'scheduled', label: 'Scheduled' },
{ value: 'closed', label: 'Closed' },
{ value: 'rejected', label: 'Rejected' },
{ value: 'expired', label: 'Expired' },
];
const ALL_SOURCES: { value: EstimateSource; label: string }[] = [
{ value: 'manual', label: 'Manual' },
{ value: 'angi', label: 'Angi' },
{ value: 'servicetitan', label: 'ServiceTitan' },
{ value: 'jobber', label: 'Jobber' },
{ value: 'hubspot', label: 'HubSpot' },
{ value: 'email', label: 'Email' },
{ value: 'referral', label: 'Referral' },
{ value: 'website', label: 'Website' },
];
// βββ Stage Badge with inline transition βββββββββββββββββββββββββββββββββββ
function StageTransitionBadge({
estimate,
onTransition,
transitioning,
}: {
estimate: Estimate;
onTransition: (_id: string, _stage: EstimateStage) => void;
transitioning: boolean;
}) {
const [open, setOpen] = useState(false);
const badge = STAGE_BADGES[estimate.stage];
const options = VALID_TRANSITIONS[estimate.stage];
if (options.length === 0) {
return (
<span
className={`inline-flex rounded-full px-2.5 py-1 text-xs font-semibold ${badge.bg} ${badge.text} cursor-default`}
title={`${badge.label} (terminal)`}
>
{badge.label}
</span>
);
}
return (
<div className="relative inline-block">
<button
onClick={() => setOpen(!open)}
className={`inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-xs font-semibold ${badge.bg} ${badge.text} hover:opacity-80 cursor-pointer min-h-[28px]`}
title="Click to transition stage"
>
{badge.label}
<ChevronDown className="h-3 w-3 opacity-60" />
</button>
{open && (
<>
<div className="fixed inset-0 z-40" onClick={() => setOpen(false)} />
<div className="absolute left-0 top-full mt-1 z-50 w-48 rounded-lg border border-surface-200 bg-white shadow-lg py-1">
<p className="px-3 py-1.5 text-[10px] font-semibold uppercase tracking-wider text-surface-400">
Move toβ¦
</p>
{options.map((nextStage) => {
const next = STAGE_BADGES[nextStage];
return (
<button
key={nextStage}
disabled={transitioning}
onClick={() => {
onTransition(estimate.id, nextStage);
setOpen(false);
}}
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-left hover:bg-surface-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
<span className={`inline-flex rounded-full px-2 py-0.5 text-[10px] font-semibold ${next.bg} ${next.text}`}>
{next.label}
</span>
<ArrowRight className="h-3 w-3 text-surface-400" />
</button>
);
})}
</div>
</>
)}
</div>
);
}
// βββ Pagination βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
function Pagination({
page,
perPage,
total,
totalPages,
onPageChange,
}: {
page: number;
perPage: number;
total: number;
totalPages: number;
onPageChange: (page: number) => void;
}) {
const totalPagesClamped = Math.max(totalPages, 1);
const getPageNumbers = (): (number | string)[] => {
const pages: (number | string)[] = [];
if (totalPagesClamped <= 7) {
for (let i = 1; i <= totalPagesClamped; i++) pages.push(i);
} else {
pages.push(1);
if (page > 3) pages.push('β¦');
for (let i = Math.max(2, page - 1); i <= Math.min(totalPagesClamped - 1, page + 1); i++) {
pages.push(i);
}
if (page < totalPagesClamped - 2) pages.push('β¦');
pages.push(totalPagesClamped);
}
return pages;
};
const startItem = (page - 1) * perPage + 1;
const endItem = Math.min(page * perPage, total);
return (
<div className="flex flex-col sm:flex-row items-center justify-between gap-4 px-4 py-3 border-t border-surface-200">
<p className="text-sm text-surface-500">
Showing <span className="font-medium text-surface-700">{startItem}</span> to{' '}
<span className="font-medium text-surface-700">{total > 0 ? endItem : 0}</span> of{' '}
<span className="font-medium text-surface-700">{total}</span> estimates
</p>
{totalPagesClamped > 1 && (
<div className="flex items-center gap-1">
<button
onClick={() => onPageChange(page - 1)}
disabled={page <= 1}
className="inline-flex items-center justify-center w-8 h-8 rounded-md border border-surface-300 text-sm text-surface-600 hover:bg-surface-50 disabled:opacity-40 disabled:cursor-not-allowed"
title="Previous page"
>
βΉ
</button>
{getPageNumbers().map((p, i) =>
typeof p === 'string' ? (
<span key={`ellipsis-${i}`} className="w-8 h-8 flex items-center justify-center text-surface-400 text-sm">
{p}
</span>
) : (
<button
key={p}
onClick={() => onPageChange(p)}
className={`inline-flex items-center justify-center w-8 h-8 rounded-md text-sm font-medium ${
p === page
? 'bg-brand-500 text-white'
: 'border border-surface-300 text-surface-600 hover:bg-surface-50'
}`}
>
{p}
</button>
)
)}
<button
onClick={() => onPageChange(page + 1)}
disabled={page >= totalPagesClamped}
className="inline-flex items-center justify-center w-8 h-8 rounded-md border border-surface-300 text-sm text-surface-600 hover:bg-surface-50 disabled:opacity-40 disabled:cursor-not-allowed"
title="Next page"
>
βΊ
</button>
</div>
)}
</div>
);
}
// βββ Page βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
export function EstimatesListPage() {
const { user } = useAuth();
const navigate = useNavigate();
const queryClient = useQueryClient();
const companyId = user?.tenantId as string;
const [filters, setFilters] = useState<Filters>(DEFAULT_FILTERS);
const [sort, setSort] = useState<SortState>(DEFAULT_SORT);
const [page, setPage] = useState(1);
const perPage = 20;
const [transitioningIds, setTransitioningIds] = useState<Set<string>>(new Set());
const [activeActionMenu, setActiveActionMenu] = useState<string | null>(null);
// Reset to page 1 when filters change
const handleFilterChange = useCallback((updated: Partial<Filters>) => {
setFilters((prev) => ({ ...prev, ...updated }));
setPage(1);
}, []);
// Build API params
const apiParams = useMemo(() => {
const params: Parameters<typeof estimatesApi.getEstimates>[1] = {
page,
per_page: perPage,
sort_by: sort.field,
sort_order: sort.order,
...(filters.stage ? { stage: filters.stage as EstimateStage } : {}),
...(filters.source ? { source: filters.source as EstimateSource } : {}),
...(filters.minValue ? { min_value: Number(filters.minValue) } : {}),
...(filters.maxValue ? { max_value: Number(filters.maxValue) } : {}),
...(filters.search ? { project_type: '' } : {}), // search handled via query key below
};
return params;
}, [page, perPage, sort, filters]);
// Fetch estimates
const { data, isLoading, error } = useQuery({
queryKey: ['estimates-list', companyId, apiParams, filters.search],
queryFn: () => estimatesApi.getEstimates(companyId, apiParams),
keepPreviousData: true,
});
// Transition mutation
const transitionMutation = useMutation({
mutationFn: ({ companyId, estimateId, stage }: { companyId: string; estimateId: string; stage: EstimateStage }) =>
estimatesApi.transitionEstimate(companyId, estimateId, { stage }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['estimates-list'] });
queryClient.invalidateQueries({ queryKey: ['estimates-funnel'] });
},
onSettled: (_data, _error, variables) => {
setTransitioningIds((prev) => {
const next = new Set(prev);
next.delete(variables.estimateId);
return next;
});
},
});
const handleTransition = useCallback(
(estimateId: string, toStage: EstimateStage) => {
if (transitioningIds.has(estimateId)) return;
setTransitioningIds((prev) => new Set(prev).add(estimateId));
transitionMutation.mutate({ companyId, estimateId, stage: toStage });
},
[companyId, transitioningIds, transitionMutation],
);
const handleSort = useCallback((field: SortField) => {
setSort((prev) => ({
field,
order: prev.field === field && prev.order === 'asc' ? 'desc' : 'asc',
}));
}, []);
const clearFilters = useCallback(() => {
setFilters(DEFAULT_FILTERS);
setPage(1);
}, []);
const hasActiveFilters = filters.stage || filters.source || filters.minValue || filters.maxValue || filters.search;
const SortIcon = ({ field }: { field: SortField }) => {
if (sort.field !== field) {
return <ChevronsUpDown className="h-3.5 w-3.5 opacity-40" />;
}
return sort.order === 'asc' ? (
<ChevronUp className="h-3.5 w-3.5" />
) : (
<ChevronDown className="h-3.5 w-3.5" />
);
};
// βββ Loading βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
if (isLoading) {
return (
<AppLayout>
<div className="flex items-center justify-center h-96">
<LoadingSpinner />
</div>
</AppLayout>
);
}
// βββ Error βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
if (error) {
return (
<AppLayout>
<div className="rounded-xl border border-red-200 bg-red-50 p-8 text-center">
<p className="text-red-700 font-semibold">Failed to load estimates</p>
<p className="text-red-600 text-sm mt-1">{error.message}</p>
<button
onClick={() => queryClient.invalidateQueries({ queryKey: ['estimates-list'] })}
className="mt-4 inline-flex items-center gap-1.5 rounded-lg bg-red-600 px-4 py-2 text-sm text-white hover:bg-red-700"
>
<RotateCcw className="h-4 w-4" />
Retry
</button>
</div>
</AppLayout>
);
}
const estimates = data?.estimates ?? [];
const totalPages = Math.max(data?.total_pages ?? data?.pages ?? 1, 1);
const total = data?.total ?? 0;
return (
<AppLayout>
<div className="space-y-6">
{/* βββ Header βββββββββββββββββββββββββββββββββββββββββββββββ */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-surface-900">Estimates</h1>
<p className="text-sm text-surface-500 mt-1">
{total} total estimate{total !== 1 ? 's' : ''}
</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => navigate('/app/estimates')}
className="inline-flex items-center gap-1.5 rounded-lg border border-surface-300 px-3 py-2 text-sm text-surface-600 hover:bg-surface-50 min-h-[44px]"
>
<FileText className="h-4 w-4" />
Funnel
</button>
</div>
</div>
{/* βββ Filter Bar βββββββββββββββββββββββββββββββββββββββββββ */}
<div className="rounded-xl border border-surface-200 bg-white p-4">
<div className="flex items-center gap-2 mb-3">
<Filter className="h-4 w-4 text-surface-400" />
<span className="text-xs font-semibold uppercase tracking-wider text-surface-500">Filters</span>
{hasActiveFilters && (
<button
onClick={clearFilters}
className="ml-auto inline-flex items-center gap-1 text-xs text-brand-600 hover:text-brand-700 font-medium"
>
<X className="h-3 w-3" />
Clear all
</button>
)}
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-3">
{/* Search */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-surface-400" />
<input
type="text"
placeholder="Search customerβ¦"
value={filters.search}
onChange={(e) => handleFilterChange({ search: e.target.value })}
className="w-full rounded-lg border border-surface-300 pl-9 pr-3 py-2 text-sm outline-none focus:border-brand-500 focus:ring-1 focus:ring-brand-500"
/>
</div>
{/* Stage */}
<select
value={filters.stage}
onChange={(e) => handleFilterChange({ stage: e.target.value as EstimateStage | '' })}
className="rounded-lg border border-surface-300 px-3 py-2 text-sm outline-none focus:border-brand-500 focus:ring-1 focus:ring-brand-500 bg-white"
>
<option value="">All Stages</option>
{ALL_STAGES.map((s) => (
<option key={s.value} value={s.value}>{s.label}</option>
))}
</select>
{/* Source */}
<select
value={filters.source}
onChange={(e) => handleFilterChange({ source: e.target.value as EstimateSource | '' })}
className="rounded-lg border border-surface-300 px-3 py-2 text-sm outline-none focus:border-brand-500 focus:ring-1 focus:ring-brand-500 bg-white"
>
<option value="">All Sources</option>
{ALL_SOURCES.map((s) => (
<option key={s.value} value={s.value}>{s.label}</option>
))}
</select>
{/* Min value */}
<input
type="number"
placeholder="Min value"
value={filters.minValue}
onChange={(e) => handleFilterChange({ minValue: e.target.value })}
className="rounded-lg border border-surface-300 px-3 py-2 text-sm outline-none focus:border-brand-500 focus:ring-1 focus:ring-brand-500"
/>
{/* Max value */}
<input
type="number"
placeholder="Max value"
value={filters.maxValue}
onChange={(e) => handleFilterChange({ maxValue: e.target.value })}
className="rounded-lg border border-surface-300 px-3 py-2 text-sm outline-none focus:border-brand-500 focus:ring-1 focus:ring-brand-500"
/>
</div>
</div>
{/* βββ Data Table βββββββββββββββββββββββββββββββββββββββββββ */}
<div className="rounded-xl border border-surface-200 bg-white overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-surface-200 bg-surface-50">
{SORTABLE_COLUMNS.map((col) => (
<th
key={col.key}
onClick={() => handleSort(col.key)}
className="text-left py-3 px-4 text-xs font-semibold uppercase tracking-wider text-surface-500 cursor-pointer hover:text-surface-700 select-none whitespace-nowrap"
>
<div className="flex items-center gap-1">
{col.label}
<SortIcon field={col.key} />
</div>
</th>
))}
<th className="text-right py-3 px-4 text-xs font-semibold uppercase tracking-wider text-surface-500">
Actions
</th>
</tr>
</thead>
<tbody>
{estimates.length === 0 ? (
<tr>
<td colSpan={8} className="text-center py-12 text-surface-400">
<FileText className="h-10 w-10 mx-auto mb-3 opacity-40" />
<p className="font-medium">No estimates found</p>
<p className="text-xs mt-1">Try adjusting your filters or create a new estimate</p>
</td>
</tr>
) : (
estimates.map((estimate) => {
const badge = STAGE_BADGES[estimate.stage];
return (
<tr
key={estimate.id}
onClick={() => navigate(`/app/estimates/${estimate.id}`)}
className="border-b border-surface-100 last:border-0 hover:bg-surface-50 cursor-pointer transition-colors"
>
{/* Estimate # */}
<td className="py-3 px-4 font-mono text-xs font-semibold text-surface-700 whitespace-nowrap">
{estimate.estimate_number}
</td>
{/* Customer */}
<td className="py-3 px-4">
<div>
<p className="font-medium text-surface-900">{estimate.customer_name}</p>
<p className="text-xs text-surface-400">{estimate.customer_email}</p>
</div>
</td>
{/* Project Type */}
<td className="py-3 px-4 text-surface-600 whitespace-nowrap">
{capitalize(estimate.project_type)}
</td>
{/* Value */}
<td className="py-3 px-4 font-semibold text-surface-900 whitespace-nowrap">
{formatCurrency(estimate.total_value)}
</td>
{/* Stage */}
<td className="py-3 px-4 whitespace-nowrap">
<div
onClick={(e) => e.stopPropagation()}
>
<StageTransitionBadge
estimate={estimate}
onTransition={handleTransition}
transitioning={transitioningIds.has(estimate.id)}
/>
</div>
</td>
{/* Source */}
<td className="py-3 px-4 text-surface-500 whitespace-nowrap capitalize">
{capitalizeSource(estimate.source)}
</td>
{/* Created */}
<td className="py-3 px-4 text-surface-500 whitespace-nowrap">
{formatDate(estimate.created_at)}
</td>
{/* Actions */}
<td className="py-3 px-4 text-right whitespace-nowrap">
<div
className="relative inline-block"
onClick={(e) => e.stopPropagation()}
>
<button
onClick={() =>
setActiveActionMenu(
activeActionMenu === estimate.id ? null : estimate.id
)
}
className="inline-flex items-center justify-center w-8 h-8 rounded-md hover:bg-surface-200 text-surface-400 hover:text-surface-600 min-h-[32px]"
title="Actions"
>
<MoreVertical className="h-4 w-4" />
</button>
{activeActionMenu === estimate.id && (
<>
<div className="fixed inset-0 z-40" onClick={() => setActiveActionMenu(null)} />
<div className="absolute right-0 top-full mt-1 z-50 w-44 rounded-lg border border-surface-200 bg-white shadow-lg py-1">
<button
onClick={() => {
navigate(`/app/estimates/${estimate.id}`);
setActiveActionMenu(null);
}}
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-left hover:bg-surface-50"
>
<ArrowRight className="h-3.5 w-3.5 text-surface-400" />
View Details
</button>
</div>
</>
)}
</div>
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
{/* βββ Pagination βββββββββββββββββββββββββββββββββββββββββ */}
<Pagination
page={page}
perPage={perPage}
total={total}
totalPages={totalPages}
onPageChange={setPage}
/>
</div>
</div>
</AppLayout>
);
}