addnew payments table
This commit is contained in:
@@ -12,7 +12,7 @@ import {
|
|||||||
ArrowLeft, CheckCircle, Clock, AlertTriangle, PauseCircle, ShieldAlert,
|
ArrowLeft, CheckCircle, Clock, AlertTriangle, PauseCircle, ShieldAlert,
|
||||||
FileText, DollarSign, GitPullRequest, AlertCircle, Activity, Milestone,
|
FileText, DollarSign, GitPullRequest, AlertCircle, Activity, Milestone,
|
||||||
Shield, ChevronRight, Phone, MessageSquare, Mail, AlertOctagon, TrendingUp, Key, Zap, Camera, Plus, Map, User, Crosshair, Image as ImageIcon, MapPin,
|
Shield, ChevronRight, Phone, MessageSquare, Mail, AlertOctagon, TrendingUp, Key, Zap, Camera, Plus, Map, User, Crosshair, Image as ImageIcon, MapPin,
|
||||||
Upload, Trash2, Eye, Pencil, X as XIcon, FolderOpen, Download, File, StickyNote
|
Upload, Trash2, Eye, Pencil, X as XIcon, FolderOpen, Download, File, StickyNote, Banknote, ChevronLeft, ChevronDown as ChevronDownIcon
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import ChangeOrderDrawer from '../../components/owner/ChangeOrderDrawer';
|
import ChangeOrderDrawer from '../../components/owner/ChangeOrderDrawer';
|
||||||
import InvoiceDetailModal from '../../components/owner/InvoiceDetailModal';
|
import InvoiceDetailModal from '../../components/owner/InvoiceDetailModal';
|
||||||
@@ -67,6 +67,7 @@ const tabs = [
|
|||||||
{ id: 'rfis', label: 'RFIs', icon: FileText },
|
{ id: 'rfis', label: 'RFIs', icon: FileText },
|
||||||
{ id: 'milestones', label: 'Milestones', icon: Milestone },
|
{ id: 'milestones', label: 'Milestones', icon: Milestone },
|
||||||
{ id: 'invoices', label: 'Invoices', icon: DollarSign },
|
{ id: 'invoices', label: 'Invoices', icon: DollarSign },
|
||||||
|
{ id: 'payments', label: 'Payments', icon: Banknote },
|
||||||
{ id: 'risks', label: 'Risk & Issues', icon: AlertCircle },
|
{ id: 'risks', label: 'Risk & Issues', icon: AlertCircle },
|
||||||
{ id: 'activity', label: 'Activity', icon: Clock },
|
{ id: 'activity', label: 'Activity', icon: Clock },
|
||||||
{ id: 'docs', label: 'Docs', icon: FolderOpen },
|
{ id: 'docs', label: 'Docs', icon: FolderOpen },
|
||||||
@@ -105,6 +106,21 @@ function seedDocsMock(projectId) {
|
|||||||
return all.slice(0, 4 + seed);
|
return all.slice(0, 4 + seed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function seedPaymentsMock(projectId) {
|
||||||
|
const all = [
|
||||||
|
{ id: 'pay_001', from: 'State Farm Insurance', method: 'ACH', amount: 18500, date: '2026-01-28', refNumber: 'ACH-2026-4411', memo: 'Initial claim payout — roof replacement' },
|
||||||
|
{ id: 'pay_002', from: 'Justin Johnson', method: 'Check', amount: 2500, date: '2026-02-05', refNumber: 'CHK-8832', memo: 'Homeowner deductible payment' },
|
||||||
|
{ id: 'pay_003', from: 'State Farm Insurance', method: 'ACH', amount: 6200, date: '2026-02-18', refNumber: 'ACH-2026-4499', memo: 'Supplement approval — additional scope' },
|
||||||
|
{ id: 'pay_004', from: 'Justin Johnson', method: 'Credit Card', amount: 1800.50, date: '2026-03-01', refNumber: 'CC-9901', memo: 'Upgrade — premium underlayment' },
|
||||||
|
{ id: 'pay_005', from: 'State Farm Insurance', method: 'Check', amount: 4100, date: '2026-03-12', refNumber: 'CHK-7761', memo: 'Final supplement payout' },
|
||||||
|
{ id: 'pay_006', from: 'Justin Johnson', method: 'Cash', amount: 500, date: '2026-03-20', refNumber: '', memo: 'Gutter guard add-on' },
|
||||||
|
];
|
||||||
|
const seed = projectId.charCodeAt(projectId.length - 1) % 3;
|
||||||
|
return all.slice(0, 3 + seed);
|
||||||
|
}
|
||||||
|
|
||||||
|
const PAYMENTS_PER_PAGE = 5;
|
||||||
|
|
||||||
const OwnerProjectDetail = () => {
|
const OwnerProjectDetail = () => {
|
||||||
const { projectId } = useParams();
|
const { projectId } = useParams();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
@@ -115,6 +131,11 @@ const OwnerProjectDetail = () => {
|
|||||||
const [selectedInvoice, setSelectedInvoice] = useState(null);
|
const [selectedInvoice, setSelectedInvoice] = useState(null);
|
||||||
const [createModalConfig, setCreateModalConfig] = useState(null);
|
const [createModalConfig, setCreateModalConfig] = useState(null);
|
||||||
|
|
||||||
|
// ── Payments state ─────────────────────────────────────────────────────────
|
||||||
|
const [payments, setPayments] = useState(() => seedPaymentsMock(projectId));
|
||||||
|
const [paymentSort, setPaymentSort] = useState({ field: 'date', dir: 'desc' });
|
||||||
|
const [paymentPage, setPaymentPage] = useState(0);
|
||||||
|
|
||||||
// ── Docs state ────────────────────────────────────────────────────────────
|
// ── Docs state ────────────────────────────────────────────────────────────
|
||||||
const [docs, setDocs] = useState(() => seedDocsMock(projectId));
|
const [docs, setDocs] = useState(() => seedDocsMock(projectId));
|
||||||
const [viewDoc, setViewDoc] = useState(null); // doc object being viewed
|
const [viewDoc, setViewDoc] = useState(null); // doc object being viewed
|
||||||
@@ -160,6 +181,41 @@ const OwnerProjectDetail = () => {
|
|||||||
alert("Action successful! (Mock interface)");
|
alert("Action successful! (Mock interface)");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ── Payment handlers ────────────────────────────────────────────────────
|
||||||
|
const handleAddPayment = (data) => {
|
||||||
|
const newPayment = {
|
||||||
|
id: `pay_${Date.now()}`,
|
||||||
|
from: data.from || '',
|
||||||
|
method: data.method || 'Cash',
|
||||||
|
amount: parseFloat(data.amount) || 0,
|
||||||
|
date: data.date || new Date().toISOString().split('T')[0],
|
||||||
|
refNumber: data.refNumber || '',
|
||||||
|
memo: data.memo || '',
|
||||||
|
};
|
||||||
|
setPayments(prev => [newPayment, ...prev]);
|
||||||
|
setPaymentPage(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const togglePaymentSort = (field) => {
|
||||||
|
setPaymentSort(prev => prev.field === field ? { field, dir: prev.dir === 'asc' ? 'desc' : 'asc' } : { field, dir: 'desc' });
|
||||||
|
setPaymentPage(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const sortedPayments = useMemo(() => {
|
||||||
|
return [...payments].sort((a, b) => {
|
||||||
|
if (paymentSort.field === 'amount') return paymentSort.dir === 'asc' ? a.amount - b.amount : b.amount - a.amount;
|
||||||
|
if (paymentSort.field === 'date') return paymentSort.dir === 'asc' ? a.date.localeCompare(b.date) : b.date.localeCompare(a.date);
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
}, [payments, paymentSort]);
|
||||||
|
|
||||||
|
// Pagination logic for payments
|
||||||
|
const paginatedPayments = sortedPayments.slice(paymentPage * PAYMENTS_PER_PAGE, (paymentPage + 1) * PAYMENTS_PER_PAGE);
|
||||||
|
// Calculate total number of pages for pagination
|
||||||
|
const totalPaymentPages = Math.ceil(sortedPayments.length / PAYMENTS_PER_PAGE);
|
||||||
|
// Calculate total received payments
|
||||||
|
const totalReceived = payments.reduce((sum, p) => sum + p.amount, 0);
|
||||||
|
|
||||||
const project = useMemo(() =>
|
const project = useMemo(() =>
|
||||||
projects.find(p => p.id === projectId && p.ownerId === user?.id)
|
projects.find(p => p.id === projectId && p.ownerId === user?.id)
|
||||||
, [projects, projectId, user]);
|
, [projects, projectId, user]);
|
||||||
@@ -846,6 +902,180 @@ const OwnerProjectDetail = () => {
|
|||||||
</NeoCard>
|
</NeoCard>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* PAYMENTS TAB */}
|
||||||
|
{activeTab === 'payments' && (
|
||||||
|
<div className="space-y-6 max-w-7xl mx-auto">
|
||||||
|
{/* Summary Row */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||||
|
<SpotlightCard className="p-4">
|
||||||
|
<div className={`text-xl font-extrabold ${NEON_GREEN}`}>{formatCurrency(totalReceived)}</div>
|
||||||
|
<div className="text-[10px] uppercase font-bold tracking-wider text-zinc-500 mt-1">Total Received</div>
|
||||||
|
</SpotlightCard>
|
||||||
|
<SpotlightCard className="p-4">
|
||||||
|
<div className="text-xl font-extrabold text-zinc-900 dark:text-white">{payments.length}</div>
|
||||||
|
<div className="text-[10px] uppercase font-bold tracking-wider text-zinc-500 mt-1">Payments</div>
|
||||||
|
</SpotlightCard>
|
||||||
|
<SpotlightCard className="p-4">
|
||||||
|
<div className="text-xl font-extrabold text-zinc-900 dark:text-white font-mono">
|
||||||
|
{payments.length > 0 ? [...payments].sort((a, b) => b.date.localeCompare(a.date))[0].date : '—'}
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] uppercase font-bold tracking-wider text-zinc-500 mt-1">Latest Payment</div>
|
||||||
|
</SpotlightCard>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Payments Table */}
|
||||||
|
<NeoCard spotlightColor="rgba(57, 255, 20, 0.1)" innerClassName="p-0 lg:p-0">
|
||||||
|
<div className="p-5 border-b border-zinc-200 dark:border-white/5 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Banknote className={NEON_GREEN} size={20} />
|
||||||
|
<h3 className="text-lg font-bold text-zinc-900 dark:text-white tracking-widest uppercase">Payments Received</h3>
|
||||||
|
<span className="text-xs font-bold bg-zinc-100 dark:bg-white/5 border border-zinc-200 dark:border-white/5 px-2 py-0.5 rounded-full text-zinc-500 dark:text-zinc-400">
|
||||||
|
{payments.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setCreateModalConfig({
|
||||||
|
title: 'Record Payment', icon: Banknote, iconColor: 'text-[#39ff14]', iconBg: 'bg-[#39ff14]/10',
|
||||||
|
submitLabel: 'Save Payment', submitColor: 'bg-[#39ff14]/20 text-[#39ff14] border-[#39ff14]/30',
|
||||||
|
fields: [
|
||||||
|
{ name: 'from', label: 'From / Payer Name', type: 'text', required: true, placeholder: 'e.g. State Farm Insurance' },
|
||||||
|
{ name: 'method', label: 'Payment Method', type: 'select', options: [{ label: 'Cash', value: 'Cash' }, { label: 'Check', value: 'Check' }, { label: 'ACH', value: 'ACH' }, { label: 'Credit Card', value: 'Credit Card' }, { label: 'Wire Transfer', value: 'Wire Transfer' }], required: true },
|
||||||
|
{ name: 'amount', label: 'Amount ($)', type: 'number', required: true },
|
||||||
|
{ name: 'date', label: 'Payment Date', type: 'date', required: true },
|
||||||
|
{ name: 'refNumber', label: 'Check No. / Reference', type: 'text', placeholder: 'e.g. CHK-1234' },
|
||||||
|
{ name: 'memo', label: 'Memo / Notes', type: 'textarea' },
|
||||||
|
],
|
||||||
|
onSubmit: handleAddPayment
|
||||||
|
})}
|
||||||
|
className="bg-[#39ff14]/10 hover:bg-[#39ff14]/20 text-green-600 dark:text-[#39ff14] border border-[#39ff14]/30 px-4 py-2 rounded-lg text-xs font-bold transition flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Plus size={14} /> Record Payment
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{payments.length > 0 ? (
|
||||||
|
<>
|
||||||
|
{/* Desktop Table */}
|
||||||
|
<div className="overflow-x-auto w-full hidden md:block">
|
||||||
|
<table className="w-full text-left border-collapse min-w-[800px]">
|
||||||
|
<thead className="bg-zinc-100 dark:bg-[#18181b]/80 border-b border-zinc-200 dark:border-white/10">
|
||||||
|
<tr>
|
||||||
|
<th className="px-5 py-4 text-xs font-bold uppercase tracking-wider text-zinc-500 dark:text-zinc-400">From / Method</th>
|
||||||
|
<th
|
||||||
|
className="px-5 py-4 text-xs font-bold uppercase tracking-wider text-zinc-500 dark:text-zinc-400 text-right cursor-pointer hover:text-zinc-700 dark:hover:text-zinc-200 transition-colors select-none"
|
||||||
|
onClick={() => togglePaymentSort('amount')}
|
||||||
|
>
|
||||||
|
Amount {paymentSort.field === 'amount' ? (paymentSort.dir === 'asc' ? '↑' : '↓') : ''}
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
className="px-5 py-4 text-xs font-bold uppercase tracking-wider text-zinc-500 dark:text-zinc-400 cursor-pointer hover:text-zinc-700 dark:hover:text-zinc-200 transition-colors select-none"
|
||||||
|
onClick={() => togglePaymentSort('date')}
|
||||||
|
>
|
||||||
|
Date {paymentSort.field === 'date' ? (paymentSort.dir === 'asc' ? '↑' : '↓') : ''}
|
||||||
|
</th>
|
||||||
|
<th className="px-5 py-4 text-xs font-bold uppercase tracking-wider text-zinc-500 dark:text-zinc-400">Check No. / Ref</th>
|
||||||
|
<th className="px-5 py-4 text-xs font-bold uppercase tracking-wider text-zinc-500 dark:text-zinc-400">Memo</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-zinc-100 dark:divide-white/5">
|
||||||
|
{paginatedPayments.map((pay) => (
|
||||||
|
<tr key={pay.id} className="hover:bg-zinc-50 dark:hover:bg-white/5 transition-colors">
|
||||||
|
<td className="px-5 py-4">
|
||||||
|
<div className="font-bold text-sm text-zinc-900 dark:text-white">{pay.from}</div>
|
||||||
|
<span className={`inline-block mt-1 px-2.5 py-0.5 rounded-full text-[10px] font-bold uppercase tracking-widest ${
|
||||||
|
pay.method === 'ACH' ? 'text-sky-600 dark:text-[#00f0ff] bg-sky-50 dark:bg-blue-500/10 border border-sky-200 dark:border-blue-500/20' :
|
||||||
|
pay.method === 'Check' ? 'text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-500/10 border border-amber-200 dark:border-amber-500/20' :
|
||||||
|
pay.method === 'Credit Card' ? 'text-purple-600 dark:text-purple-400 bg-purple-50 dark:bg-purple-500/10 border border-purple-200 dark:border-purple-500/20' :
|
||||||
|
pay.method === 'Wire Transfer' ? 'text-emerald-600 dark:text-emerald-400 bg-emerald-50 dark:bg-emerald-500/10 border border-emerald-200 dark:border-emerald-500/20' :
|
||||||
|
'text-zinc-600 dark:text-zinc-400 bg-zinc-100 dark:bg-white/5 border border-zinc-200 dark:border-white/10'
|
||||||
|
}`}>
|
||||||
|
{pay.method}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-4 text-right font-mono text-sm font-bold text-green-600 dark:text-[#39ff14]">
|
||||||
|
{formatCurrency(pay.amount)}
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-4 text-sm text-zinc-500 dark:text-zinc-400 font-mono">{pay.date}</td>
|
||||||
|
<td className="px-5 py-4 font-mono text-xs text-zinc-500 dark:text-zinc-400">{pay.refNumber || '—'}</td>
|
||||||
|
<td className="px-5 py-4 text-xs text-zinc-500 dark:text-zinc-400 max-w-[200px] truncate">{pay.memo || '—'}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile Card View */}
|
||||||
|
<div className="md:hidden p-4 space-y-3">
|
||||||
|
{paginatedPayments.map((pay) => (
|
||||||
|
<div key={pay.id} className="bg-zinc-50 dark:bg-white/[0.03] border border-zinc-100 dark:border-white/[0.06] rounded-2xl p-4 space-y-3">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="font-bold text-sm text-zinc-900 dark:text-white">{pay.from}</p>
|
||||||
|
<span className={`inline-block mt-1 px-2.5 py-0.5 rounded-full text-[10px] font-bold uppercase tracking-widest ${
|
||||||
|
pay.method === 'ACH' ? 'text-sky-600 dark:text-[#00f0ff] bg-sky-50 dark:bg-blue-500/10 border border-sky-200 dark:border-blue-500/20' :
|
||||||
|
pay.method === 'Check' ? 'text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-500/10 border border-amber-200 dark:border-amber-500/20' :
|
||||||
|
pay.method === 'Credit Card' ? 'text-purple-600 dark:text-purple-400 bg-purple-50 dark:bg-purple-500/10 border border-purple-200 dark:border-purple-500/20' :
|
||||||
|
pay.method === 'Wire Transfer' ? 'text-emerald-600 dark:text-emerald-400 bg-emerald-50 dark:bg-emerald-500/10 border border-emerald-200 dark:border-emerald-500/20' :
|
||||||
|
'text-zinc-600 dark:text-zinc-400 bg-zinc-100 dark:bg-white/5 border border-zinc-200 dark:border-white/10'
|
||||||
|
}`}>
|
||||||
|
{pay.method}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="font-mono text-lg font-black text-green-600 dark:text-[#39ff14]">{formatCurrency(pay.amount)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||||
|
<div>
|
||||||
|
<span className="text-zinc-500 dark:text-zinc-500">Date</span>
|
||||||
|
<p className="font-mono text-zinc-700 dark:text-zinc-300">{pay.date}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-zinc-500 dark:text-zinc-500">Ref #</span>
|
||||||
|
<p className="font-mono text-zinc-700 dark:text-zinc-300">{pay.refNumber || '—'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{pay.memo && (
|
||||||
|
<p className="text-xs text-zinc-500 dark:text-zinc-400 border-t border-zinc-100 dark:border-white/5 pt-2">{pay.memo}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{totalPaymentPages > 1 && (
|
||||||
|
<div className="px-5 py-4 border-t border-zinc-200 dark:border-white/5 flex items-center justify-between">
|
||||||
|
<p className="text-xs text-zinc-500 dark:text-zinc-400 font-mono">
|
||||||
|
{paymentPage * PAYMENTS_PER_PAGE + 1}–{Math.min((paymentPage + 1) * PAYMENTS_PER_PAGE, sortedPayments.length)} of {sortedPayments.length}
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setPaymentPage(p => Math.max(0, p - 1))}
|
||||||
|
disabled={paymentPage === 0}
|
||||||
|
className="p-2 rounded-lg bg-zinc-100 dark:bg-white/5 border border-zinc-200 dark:border-white/10 text-zinc-500 dark:text-zinc-400 hover:bg-zinc-200 dark:hover:bg-white/10 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
<ChevronLeft size={14} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setPaymentPage(p => Math.min(totalPaymentPages - 1, p + 1))}
|
||||||
|
disabled={paymentPage >= totalPaymentPages - 1}
|
||||||
|
className="p-2 rounded-lg bg-zinc-100 dark:bg-white/5 border border-zinc-200 dark:border-white/10 text-zinc-500 dark:text-zinc-400 hover:bg-zinc-200 dark:hover:bg-white/10 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
<ChevronRight size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="p-16 text-center text-zinc-500">
|
||||||
|
<Banknote size={48} className="mx-auto mb-4 text-zinc-400 dark:text-zinc-600" />
|
||||||
|
<p className="font-bold text-lg text-zinc-900 dark:text-white">No payments recorded yet.</p>
|
||||||
|
<p className="text-sm mt-2 text-zinc-400">Record your first payment to start tracking.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</NeoCard>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* RISK & ISSUES TAB */}
|
{/* RISK & ISSUES TAB */}
|
||||||
{activeTab === 'risks' && (
|
{activeTab === 'risks' && (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
|||||||
Reference in New Issue
Block a user