Updated project changes
This commit is contained in:
@@ -32,6 +32,7 @@ import VendorDashboard from './pages/vendor/VendorDashboard';
|
||||
import VendorOrders from './pages/vendor/VendorOrders';
|
||||
import ContractorDashboard from './pages/contractor/ContractorDashboard';
|
||||
import SubContractorDashboard from './pages/subcontractor/SubContractorDashboard';
|
||||
import PaymentManagement from './pages/owner/PaymentManagement';
|
||||
import SubcontractorTaskDetailPage from './pages/subcontractor/SubcontractorTaskDetailPage';
|
||||
import SubcontractorProjectsPage from './pages/subcontractor/SubcontractorProjectsPage';
|
||||
import SubcontractorProjectDetailPage from './pages/subcontractor/SubcontractorProjectDetailPage';
|
||||
@@ -100,6 +101,13 @@ function App() {
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
|
||||
{/* Payment Management (owner/admin side of the Payments module) */}
|
||||
<Route path="/owner/payments" element={
|
||||
<ProtectedRoute allowedRoles={['OWNER', 'ADMIN']}>
|
||||
<PaymentManagement />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
|
||||
{/* Self-service profile for internal roles */}
|
||||
<Route path="/profile" element={
|
||||
<ProtectedRoute allowedRoles={['OWNER', 'ADMIN', 'FIELD_AGENT', 'CONTRACTOR', 'SUBCONTRACTOR']}>
|
||||
|
||||
@@ -153,6 +153,7 @@ const Layout = () => {
|
||||
{ to: "/admin/schedule", icon: Calendar, label: "Team Schedule" },
|
||||
{ to: "/admin/leaderboard", icon: Trophy, label: "Leaderboard" },
|
||||
{ to: "/owner/subcontractor-tasks", icon: HardHat, label: "Subcontractor Tasks" },
|
||||
{ to: "/owner/payments", icon: CreditCard, label: "Payment Management" },
|
||||
{ to: "/owner/people", icon: Users, label: "People" },
|
||||
{ to: "/owner/settings", icon: Settings, label: "Org Settings" },
|
||||
...commonItems,
|
||||
@@ -175,6 +176,7 @@ const Layout = () => {
|
||||
},
|
||||
{ to: "/admin/estimates", icon: Calculator, label: "Estimates" },
|
||||
{ to: "/admin/subcontractor-tasks", icon: HardHat, label: "Subcontractor Tasks" },
|
||||
{ to: "/owner/payments", icon: CreditCard, label: "Payment Management" },
|
||||
{ to: "/admin/settings", icon: Settings, label: "Org Settings" },
|
||||
...commonItems,
|
||||
];
|
||||
|
||||
@@ -652,9 +652,9 @@ function generateProperties() {
|
||||
|
||||
const MOCK_USERS = [
|
||||
// Customers
|
||||
{ id: 'c1', type: 'customer', username: 'alice', email: 'alice@example.com', password: 'password', name: 'Alice Customer', propertyId: 'P-2600' },
|
||||
{ id: 'c2', type: 'customer', username: 'bob', email: 'bob@example.com', password: 'password', name: 'Bob Buyer' },
|
||||
{ id: 'c3', type: 'customer', username: 'charlie', email: 'charlie@example.com', password: 'password', name: 'Charlie Client' },
|
||||
{ id: 'c1', type: 'customer', username: 'alice', email: 'alice@example.com', password: 'password', name: 'Alice Customer', propertyId: 'P-2600', phone: '214-555-2600' },
|
||||
{ id: 'c2', type: 'customer', username: 'bob', email: 'bob@example.com', password: 'password', name: 'Bob Buyer', phone: '214-555-2601' },
|
||||
{ id: 'c3', type: 'customer', username: 'charlie', email: 'charlie@example.com', password: 'password', name: 'Charlie Client', phone: '214-555-2602' },
|
||||
|
||||
// Field Agents (5 Agents)
|
||||
{ id: 'e1', type: 'employee', empId: 'LUP-1040', email: 'agent1@plano.com', password: 'password', role: 'FIELD_AGENT', name: 'Cody Tatum', xp: 12450, doorsKnocked: 342, leadsGained: 45, appointmentsSet: 18, streakDays: 5, achievements: ['Hot Spot Hunter', 'Storm Chaser'] },
|
||||
|
||||
@@ -32,7 +32,11 @@ const InvoiceList = ({ invoices, paidIds = [], customerEmail }) => {
|
||||
|
||||
try {
|
||||
// On success this navigates away to Stripe; control won't return.
|
||||
await startCheckout({ invoiceId: invoice.id, customerEmail });
|
||||
await startCheckout({
|
||||
invoiceId: invoice.id,
|
||||
customerEmail,
|
||||
fallback: { title: invoice.title, amount: invoice.amount, currency: invoice.currency },
|
||||
});
|
||||
} catch (err) {
|
||||
toast.error('Unable to start checkout', { description: err.message });
|
||||
setRedirectingId(null);
|
||||
|
||||
@@ -46,7 +46,11 @@ const OutstandingPanel = ({ invoices, paidIds = [], customerEmail }) => {
|
||||
}
|
||||
|
||||
try {
|
||||
await startCheckout({ invoiceId: invoice.id, customerEmail });
|
||||
await startCheckout({
|
||||
invoiceId: invoice.id,
|
||||
customerEmail,
|
||||
fallback: { title: invoice.title, amount: invoice.amount, currency: invoice.currency },
|
||||
});
|
||||
} catch (err) {
|
||||
toast.error('Unable to start checkout', { description: err.message });
|
||||
setRedirectingId(null);
|
||||
|
||||
@@ -1,36 +1,20 @@
|
||||
/**
|
||||
* PaymentProvider — module-local state, fully independent from the app's global
|
||||
* mockStore so the Payments module can be added/removed in isolation.
|
||||
* PaymentProvider — module-local state for the customer Payments portal.
|
||||
*
|
||||
* Transaction history is sourced from two places and merged by id:
|
||||
* 1. The server (api/payments/transactions → Vercel KV, written by the
|
||||
* webhook) — the production source of truth.
|
||||
* 2. A local record (localStorage) written when a customer returns to the
|
||||
* success page — gives instant feedback and works without KV in the demo.
|
||||
* Invoices and transactions are sourced from the shared paymentStore (localStorage)
|
||||
* so the customer sees payment requests created on the owner side, and payments
|
||||
* made here flow back to the owner's Payment Management page. The server-side
|
||||
* (webhook-written) transaction history is merged in on top when available.
|
||||
*/
|
||||
|
||||
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
|
||||
import { INVOICES } from '../data/invoices';
|
||||
import { stripeConfig } from '../config/stripeConfig';
|
||||
|
||||
const STORAGE_KEY = 'lynkeduppro.payments.transactions';
|
||||
|
||||
const loadLocal = () => {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
return raw ? JSON.parse(raw) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const persistLocal = (txns) => {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(txns));
|
||||
} catch {
|
||||
/* localStorage unavailable (private mode) — keep in-memory only. */
|
||||
}
|
||||
};
|
||||
import {
|
||||
loadInvoices,
|
||||
loadTransactions,
|
||||
saveTransactions,
|
||||
subscribe,
|
||||
} from '../data/paymentStore';
|
||||
|
||||
const mergeById = (a, b) => {
|
||||
const map = new Map();
|
||||
@@ -45,8 +29,19 @@ const mergeById = (a, b) => {
|
||||
const PaymentContext = createContext(null);
|
||||
|
||||
export const PaymentProvider = ({ children }) => {
|
||||
const [invoices] = useState(INVOICES);
|
||||
const [transactions, setTransactions] = useState(loadLocal);
|
||||
const [invoices, setInvoices] = useState(loadInvoices);
|
||||
const [transactions, setTransactions] = useState(loadTransactions);
|
||||
|
||||
// Re-read from the shared store whenever the owner adds an invoice, marks
|
||||
// one paid, or a payment is recorded (same-tab and cross-tab).
|
||||
useEffect(
|
||||
() =>
|
||||
subscribe(() => {
|
||||
setInvoices(loadInvoices());
|
||||
setTransactions((prev) => mergeById(prev, loadTransactions()));
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
// Pull the server-side (webhook-written) history and merge it in.
|
||||
useEffect(() => {
|
||||
@@ -72,7 +67,7 @@ export const PaymentProvider = ({ children }) => {
|
||||
if (!txn?.id) return;
|
||||
setTransactions((prev) => {
|
||||
const next = mergeById(prev, [txn]);
|
||||
persistLocal(next);
|
||||
saveTransactions(next); // persists to the shared store + notifies the owner side
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* Shared payment store — the single source of truth that lets the OWNER/ADMIN
|
||||
* side and the CUSTOMER side talk to each other in this demo (no backend).
|
||||
*
|
||||
* Both invoices and transactions live in localStorage so a payment request the
|
||||
* owner creates appears in the customer's Outstanding list, and a payment the
|
||||
* customer makes (or the owner marks manually) shows back on the owner's
|
||||
* Payment Management page. In production these reads/writes would hit the CRM
|
||||
* billing API instead — the component surface would stay the same.
|
||||
*/
|
||||
|
||||
import { INVOICES } from './invoices';
|
||||
|
||||
const INVOICES_KEY = 'lynkeduppro.payments.invoices';
|
||||
const TXNS_KEY = 'lynkeduppro.payments.transactions';
|
||||
const UPDATE_EVENT = 'payments:updated';
|
||||
|
||||
/**
|
||||
* Default customer mobile number for the demo. The owner/admin "New Payment
|
||||
* Request" form prefills this so a request lands with the current demo
|
||||
* customer. In production this comes from a customer lookup instead.
|
||||
*/
|
||||
export const DEMO_CUSTOMER_PHONE = '214-555-2600';
|
||||
|
||||
/** Compare two phone numbers by digits only (ignores spaces, dashes, etc.). */
|
||||
export const normalizePhone = (phone) => String(phone || '').replace(/\D/g, '');
|
||||
|
||||
/**
|
||||
* Does this invoice belong to the given customer phone? Invoices without a
|
||||
* `customerPhone` are treated as unassigned and visible to any customer (keeps
|
||||
* the original seed invoices working).
|
||||
*/
|
||||
export const invoiceMatchesCustomer = (invoice, phone) =>
|
||||
!invoice.customerPhone ||
|
||||
normalizePhone(invoice.customerPhone) === normalizePhone(phone);
|
||||
|
||||
const read = (key, fallback) => {
|
||||
try {
|
||||
const raw = localStorage.getItem(key);
|
||||
return raw ? JSON.parse(raw) : fallback;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
};
|
||||
|
||||
const write = (key, value) => {
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify(value));
|
||||
} catch {
|
||||
/* localStorage unavailable (private mode) — best effort only. */
|
||||
}
|
||||
};
|
||||
|
||||
/** Notify same-tab listeners (the native `storage` event only fires cross-tab). */
|
||||
const emit = () => {
|
||||
try {
|
||||
window.dispatchEvent(new Event(UPDATE_EVENT));
|
||||
} catch {
|
||||
/* non-browser / SSR — nothing to notify. */
|
||||
}
|
||||
};
|
||||
|
||||
/** Subscribe to any invoice/transaction change. Returns an unsubscribe fn. */
|
||||
export const subscribe = (callback) => {
|
||||
window.addEventListener(UPDATE_EVENT, callback);
|
||||
window.addEventListener('storage', callback);
|
||||
return () => {
|
||||
window.removeEventListener(UPDATE_EVENT, callback);
|
||||
window.removeEventListener('storage', callback);
|
||||
};
|
||||
};
|
||||
|
||||
/* ------------------------------- Invoices -------------------------------- */
|
||||
|
||||
/** Load invoices, seeding from the static sample list on first run. */
|
||||
export const loadInvoices = () => {
|
||||
const stored = read(INVOICES_KEY, null);
|
||||
if (Array.isArray(stored)) return stored;
|
||||
write(INVOICES_KEY, INVOICES);
|
||||
return INVOICES;
|
||||
};
|
||||
|
||||
export const saveInvoices = (list) => {
|
||||
write(INVOICES_KEY, list);
|
||||
emit();
|
||||
};
|
||||
|
||||
export const getInvoice = (id) => loadInvoices().find((i) => i.id === id) || null;
|
||||
|
||||
/** Append a new invoice (payment request) and return it. */
|
||||
export const addInvoice = (invoice) => {
|
||||
const next = [...loadInvoices(), invoice];
|
||||
saveInvoices(next);
|
||||
return invoice;
|
||||
};
|
||||
|
||||
/** Next sequential invoice id, e.g. "INV-2068". */
|
||||
export const nextInvoiceId = () => {
|
||||
const max = loadInvoices().reduce((m, inv) => {
|
||||
const n = parseInt(String(inv.id).replace(/\D/g, ''), 10);
|
||||
return Number.isFinite(n) && n > m ? n : m;
|
||||
}, 2000);
|
||||
return `INV-${max + 1}`;
|
||||
};
|
||||
|
||||
/* ----------------------------- Transactions ------------------------------ */
|
||||
|
||||
export const loadTransactions = () => read(TXNS_KEY, []);
|
||||
|
||||
export const saveTransactions = (list) => {
|
||||
write(TXNS_KEY, list);
|
||||
emit();
|
||||
};
|
||||
|
||||
/** Has this invoice been paid (by the customer or marked by the owner)? */
|
||||
export const isInvoicePaid = (invoiceId) =>
|
||||
loadTransactions().some((t) => t.invoiceId === invoiceId && t.status === 'paid');
|
||||
|
||||
/**
|
||||
* Mark an invoice paid by writing a `paid` transaction — used both by the
|
||||
* customer success flow and by the owner's manual "Mark as Paid" action.
|
||||
* No-ops if a paid transaction for this invoice already exists.
|
||||
*/
|
||||
export const markInvoicePaid = (invoiceId, { method = 'manual' } = {}) => {
|
||||
const inv = getInvoice(invoiceId);
|
||||
if (!inv || isInvoicePaid(invoiceId)) return null;
|
||||
|
||||
const txn = {
|
||||
id: `${method}-${invoiceId}-${Date.now()}`,
|
||||
invoiceId,
|
||||
amount: inv.amount,
|
||||
currency: inv.currency,
|
||||
customerEmail: null,
|
||||
status: 'paid',
|
||||
createdAt: new Date().toISOString(),
|
||||
method,
|
||||
};
|
||||
saveTransactions([txn, ...loadTransactions()]);
|
||||
return txn;
|
||||
};
|
||||
@@ -1,7 +1,8 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useSearchParams, useNavigate } from 'react-router-dom';
|
||||
import { Lock, Loader2, ArrowLeft, FlaskConical, CheckCircle2 } from 'lucide-react';
|
||||
import { INVOICES, formatAmount } from '../data/invoices';
|
||||
import { Lock, Loader2, ArrowLeft, CheckCircle2 } from 'lucide-react';
|
||||
import { formatAmount } from '../data/invoices';
|
||||
import { getInvoice } from '../data/paymentStore';
|
||||
import { stripeConfig } from '../config/stripeConfig';
|
||||
|
||||
/**
|
||||
@@ -17,7 +18,7 @@ const DemoCheckout = () => {
|
||||
const [params] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const invoiceId = params.get('invoice');
|
||||
const invoice = INVOICES.find((i) => i.id === invoiceId);
|
||||
const invoice = getInvoice(invoiceId);
|
||||
|
||||
const [paying, setPaying] = useState(false);
|
||||
|
||||
@@ -46,12 +47,6 @@ const DemoCheckout = () => {
|
||||
|
||||
return (
|
||||
<Shell>
|
||||
{/* Demo banner */}
|
||||
<div className="mb-6 flex items-center gap-2 rounded-xl border border-amber-300/60 dark:border-amber-500/30 bg-amber-50 dark:bg-amber-500/10 px-4 py-2.5 text-xs font-medium text-amber-800 dark:text-amber-200">
|
||||
<FlaskConical size={15} />
|
||||
Demo mode — simulated Stripe Checkout. No real card or charge. Add Stripe keys to go live.
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-8">
|
||||
{/* Left: order summary (Stripe-style) */}
|
||||
<div className="md:pr-8 md:border-r border-zinc-200 dark:border-white/10">
|
||||
|
||||
@@ -2,11 +2,10 @@ import React, { useState } from 'react';
|
||||
import { CreditCard } from 'lucide-react';
|
||||
import { useAuth } from '../../../context/AuthContext';
|
||||
import { usePayments } from '../context/PaymentProvider';
|
||||
import { stripeConfig } from '../config/stripeConfig';
|
||||
import { invoiceMatchesCustomer } from '../data/paymentStore';
|
||||
import InvoiceList from '../components/InvoiceList';
|
||||
import OutstandingPanel from '../components/OutstandingPanel';
|
||||
import TransactionHistory from '../components/TransactionHistory';
|
||||
import StripeConfigNotice from '../components/StripeConfigNotice';
|
||||
|
||||
const TabButton = ({ id, label, activeTab, onSelect }) => (
|
||||
<button
|
||||
@@ -31,6 +30,10 @@ const PaymentsPage = () => {
|
||||
const { invoices, transactions } = usePayments();
|
||||
const [activeTab, setActiveTab] = useState('pay');
|
||||
|
||||
// Only show invoices addressed to this customer's mobile number (plus any
|
||||
// unassigned seed invoices). Keeps one customer from seeing another's bills.
|
||||
const myInvoices = invoices.filter((inv) => invoiceMatchesCustomer(inv, user?.phone));
|
||||
|
||||
// Invoices marked paid by the (server) transaction history.
|
||||
const paidIds = transactions
|
||||
.filter((t) => t.status === 'paid' && t.invoiceId)
|
||||
@@ -54,8 +57,6 @@ const PaymentsPage = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!stripeConfig.isConfigured && <StripeConfigNotice />}
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex space-x-1 border-b border-zinc-200 dark:border-zinc-800 mb-6 overflow-x-auto scrollbar-hide">
|
||||
<TabButton id="pay" label="Make a Payment" activeTab={activeTab} onSelect={setActiveTab} />
|
||||
@@ -67,14 +68,14 @@ const PaymentsPage = () => {
|
||||
<div className="animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||
{activeTab === 'pay' && (
|
||||
<InvoiceList
|
||||
invoices={invoices}
|
||||
invoices={myInvoices}
|
||||
paidIds={paidIds}
|
||||
customerEmail={user?.email || user?.username}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'outstanding' && (
|
||||
<OutstandingPanel
|
||||
invoices={invoices}
|
||||
invoices={myInvoices}
|
||||
paidIds={paidIds}
|
||||
customerEmail={user?.email || user?.username}
|
||||
/>
|
||||
|
||||
@@ -25,7 +25,7 @@ const getStripe = () => {
|
||||
* Create a session for an invoice and redirect to Stripe's hosted page.
|
||||
* Throws (with a friendly message) if the backend isn't configured/reachable.
|
||||
*/
|
||||
export async function startCheckout({ invoiceId, customerEmail } = {}) {
|
||||
export async function startCheckout({ invoiceId, customerEmail, fallback } = {}) {
|
||||
if (!stripeConfig.isConfigured) {
|
||||
const err = new Error(
|
||||
'Stripe is not configured yet. Add your publishable key to enable payments.'
|
||||
@@ -37,7 +37,9 @@ export async function startCheckout({ invoiceId, customerEmail } = {}) {
|
||||
const res = await fetch(`${stripeConfig.apiBase}/create-checkout-session`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ invoiceId, customerEmail }),
|
||||
// `fallback` carries the invoice's amount/title for demo invoices the
|
||||
// owner created in localStorage (the server's static map won't have them).
|
||||
body: JSON.stringify({ invoiceId, customerEmail, fallback }),
|
||||
});
|
||||
|
||||
const data = await res.json().catch(() => ({}));
|
||||
|
||||
@@ -0,0 +1,340 @@
|
||||
import React, { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
CreditCard, Plus, X, FileText, CheckCircle2, Clock, AlertCircle,
|
||||
DollarSign, TrendingUp, Receipt, Phone,
|
||||
} from 'lucide-react';
|
||||
import { SpotlightCard } from '../../components/SpotlightCard';
|
||||
import { formatAmount } from '../../modules/payments/data/invoices';
|
||||
import {
|
||||
loadInvoices, loadTransactions, addInvoice, markInvoicePaid,
|
||||
nextInvoiceId, subscribe, DEMO_CUSTOMER_PHONE, normalizePhone,
|
||||
} from '../../modules/payments/data/paymentStore';
|
||||
|
||||
const DEFAULT_PROPERTY = '2612 Dunwick Dr, Plano, TX 75023';
|
||||
|
||||
const EMPTY_FORM = {
|
||||
title: '',
|
||||
description: '',
|
||||
amount: '',
|
||||
dueDate: '',
|
||||
property: DEFAULT_PROPERTY,
|
||||
customerPhone: DEMO_CUSTOMER_PHONE,
|
||||
};
|
||||
|
||||
/**
|
||||
* Owner/Admin Payment Management — create payment requests that land in the
|
||||
* customer's Outstanding list, then track which have been paid. A request is
|
||||
* "Paid" once a paid transaction references it (the customer pays via the
|
||||
* portal, or the owner marks it paid manually here for offline/cash payments).
|
||||
*/
|
||||
const PaymentManagement = () => {
|
||||
const [invoices, setInvoices] = useState(loadInvoices);
|
||||
const [transactions, setTransactions] = useState(loadTransactions);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [form, setForm] = useState(EMPTY_FORM);
|
||||
|
||||
// Stay in sync with payments made on the customer side.
|
||||
useEffect(
|
||||
() =>
|
||||
subscribe(() => {
|
||||
setInvoices(loadInvoices());
|
||||
setTransactions(loadTransactions());
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
const paidIds = useMemo(
|
||||
() =>
|
||||
new Set(
|
||||
transactions
|
||||
.filter((t) => t.status === 'paid' && t.invoiceId)
|
||||
.map((t) => t.invoiceId)
|
||||
),
|
||||
[transactions]
|
||||
);
|
||||
|
||||
const isPaid = useCallback(
|
||||
(inv) => inv.status === 'paid' || paidIds.has(inv.id),
|
||||
[paidIds]
|
||||
);
|
||||
|
||||
const today = useMemo(() => {
|
||||
const d = new Date();
|
||||
d.setHours(0, 0, 0, 0);
|
||||
return d;
|
||||
}, []);
|
||||
|
||||
const rows = useMemo(
|
||||
() =>
|
||||
[...invoices].sort((a, b) => {
|
||||
// Outstanding first, then by due date.
|
||||
const ap = isPaid(a) ? 1 : 0;
|
||||
const bp = isPaid(b) ? 1 : 0;
|
||||
if (ap !== bp) return ap - bp;
|
||||
return new Date(a.dueDate) - new Date(b.dueDate);
|
||||
}),
|
||||
[invoices, isPaid]
|
||||
);
|
||||
|
||||
const totals = useMemo(() => {
|
||||
let outstanding = 0;
|
||||
let collected = 0;
|
||||
invoices.forEach((inv) => {
|
||||
if (isPaid(inv)) collected += inv.amount || 0;
|
||||
else outstanding += inv.amount || 0;
|
||||
});
|
||||
return { outstanding, collected };
|
||||
}, [invoices, isPaid]);
|
||||
|
||||
const handleField = (e) =>
|
||||
setForm((f) => ({ ...f, [e.target.name]: e.target.value }));
|
||||
|
||||
const handleCreate = (e) => {
|
||||
e.preventDefault();
|
||||
const amountNum = parseFloat(form.amount);
|
||||
if (!form.title.trim()) return toast.error('Title is required.');
|
||||
if (!Number.isFinite(amountNum) || amountNum <= 0)
|
||||
return toast.error('Enter a valid amount greater than 0.');
|
||||
if (!form.dueDate) return toast.error('Due date is required.');
|
||||
if (normalizePhone(form.customerPhone).length < 7)
|
||||
return toast.error('Enter a valid customer mobile number.');
|
||||
|
||||
const invoice = {
|
||||
id: nextInvoiceId(),
|
||||
title: form.title.trim(),
|
||||
description: form.description.trim(),
|
||||
property: form.property.trim() || DEFAULT_PROPERTY,
|
||||
customerPhone: form.customerPhone.trim(),
|
||||
dueDate: form.dueDate,
|
||||
amount: Math.round(amountNum * 100), // dollars → cents
|
||||
currency: 'usd',
|
||||
};
|
||||
addInvoice(invoice);
|
||||
toast.success(`Payment request ${invoice.id} sent`, {
|
||||
description: `${formatAmount(invoice.amount, 'usd')} sent to ${invoice.customerPhone}.`,
|
||||
});
|
||||
setForm(EMPTY_FORM);
|
||||
setShowForm(false);
|
||||
};
|
||||
|
||||
const handleMarkPaid = (inv) => {
|
||||
const txn = markInvoicePaid(inv.id, { method: 'manual' });
|
||||
if (txn) toast.success(`${inv.id} marked as paid`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-full bg-zinc-50 dark:bg-[#09090b] text-zinc-900 dark:text-white relative pb-20 transition-colors duration-300">
|
||||
<div className="fixed top-0 left-0 w-full h-full overflow-hidden pointer-events-none z-0">
|
||||
<div className="absolute top-[-10%] left-[-10%] w-[50%] h-[50%] bg-blue-500/5 dark:bg-blue-900/10 rounded-full blur-[120px]" />
|
||||
<div className="absolute bottom-[-10%] right-[-10%] w-[50%] h-[50%] bg-indigo-500/5 dark:bg-indigo-900/10 rounded-full blur-[120px]" />
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 p-4 sm:p-8 max-w-7xl mx-auto space-y-8">
|
||||
{/* Header */}
|
||||
<header className="flex flex-col md:flex-row justify-between items-start md:items-center border-b border-zinc-200 dark:border-white/5 pb-6 gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-14 h-14 rounded-2xl bg-gradient-to-br from-blue-500 to-indigo-600 flex items-center justify-center text-white shadow-lg shrink-0">
|
||||
<CreditCard size={26} />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-3xl sm:text-4xl font-extrabold text-transparent bg-clip-text bg-gradient-to-r from-zinc-900 to-zinc-600 dark:from-white dark:to-white/60 tracking-tight">
|
||||
Payment Management
|
||||
</h1>
|
||||
<p className="text-zinc-500 dark:text-zinc-400 mt-1 font-light">
|
||||
Request payments and track what customers owe.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowForm((s) => !s)}
|
||||
className="px-4 py-2.5 bg-blue-600 hover:bg-blue-700 text-white font-bold rounded-xl text-sm transition-colors shadow-lg shadow-blue-500/20 flex items-center gap-2 shrink-0"
|
||||
>
|
||||
{showForm ? <><X size={16} /> Close</> : <><Plus size={16} /> New Payment Request</>}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{/* New request form */}
|
||||
{showForm && (
|
||||
<SpotlightCard className="p-6 animate-in fade-in slide-in-from-top-2 duration-300">
|
||||
<h2 className="text-lg font-bold mb-4 flex items-center gap-2">
|
||||
<Receipt size={18} className="text-blue-500" /> New Payment Request
|
||||
</h2>
|
||||
<form onSubmit={handleCreate} className="grid md:grid-cols-2 gap-4">
|
||||
<Field label="Title" className="md:col-span-2">
|
||||
<input
|
||||
name="title" value={form.title} onChange={handleField}
|
||||
placeholder="e.g. Roof Repair — Shingle Replacement"
|
||||
className={inputCls}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Description" className="md:col-span-2">
|
||||
<input
|
||||
name="description" value={form.description} onChange={handleField}
|
||||
placeholder="Short description of the work / charge"
|
||||
className={inputCls}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Amount (USD)">
|
||||
<div className="relative">
|
||||
<DollarSign size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-400" />
|
||||
<input
|
||||
name="amount" value={form.amount} onChange={handleField}
|
||||
type="number" min="0" step="0.01" placeholder="950.00"
|
||||
className={`${inputCls} pl-9`}
|
||||
/>
|
||||
</div>
|
||||
</Field>
|
||||
<Field label="Due date">
|
||||
<input
|
||||
name="dueDate" value={form.dueDate} onChange={handleField}
|
||||
type="date" className={inputCls}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Customer Mobile Number" className="md:col-span-2">
|
||||
<div className="relative">
|
||||
<Phone size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-400" />
|
||||
<input
|
||||
name="customerPhone" value={form.customerPhone} onChange={handleField}
|
||||
type="tel" placeholder="214-555-2600"
|
||||
className={`${inputCls} pl-9`}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-[11px] text-zinc-400 mt-1 block">
|
||||
Request goes to the customer with this mobile number.
|
||||
</span>
|
||||
</Field>
|
||||
<Field label="Property" className="md:col-span-2">
|
||||
<input
|
||||
name="property" value={form.property} onChange={handleField}
|
||||
className={inputCls}
|
||||
/>
|
||||
</Field>
|
||||
<div className="md:col-span-2 flex justify-end gap-3 pt-2">
|
||||
<button
|
||||
type="button" onClick={() => { setForm(EMPTY_FORM); setShowForm(false); }}
|
||||
className="px-4 py-2 rounded-lg text-sm font-bold text-zinc-600 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-white/5 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-5 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm font-bold shadow-lg shadow-blue-500/20 transition-colors flex items-center gap-1.5"
|
||||
>
|
||||
<Plus size={15} /> Send Request
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</SpotlightCard>
|
||||
)}
|
||||
|
||||
{/* KPI cards */}
|
||||
<section className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<Kpi icon={<Clock size={20} />} color="text-amber-500" label="Total Outstanding"
|
||||
value={formatAmount(totals.outstanding, 'usd')} />
|
||||
<Kpi icon={<TrendingUp size={20} />} color="text-emerald-500" label="Total Collected"
|
||||
value={formatAmount(totals.collected, 'usd')} />
|
||||
<Kpi icon={<FileText size={20} />} color="text-blue-500" label="Payment Requests"
|
||||
value={invoices.length} />
|
||||
</section>
|
||||
|
||||
{/* Requests list */}
|
||||
<section>
|
||||
<h2 className="text-lg font-bold mb-4">All Payment Requests</h2>
|
||||
<div className="grid gap-3">
|
||||
{rows.map((inv) => {
|
||||
const paid = isPaid(inv);
|
||||
const overdue = !paid && new Date(inv.dueDate) < today;
|
||||
return (
|
||||
<SpotlightCard key={inv.id} className="p-4 md:p-5">
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div className="flex items-start gap-4 min-w-0">
|
||||
<div className={`w-11 h-11 rounded-xl flex items-center justify-center shrink-0 border ${
|
||||
paid
|
||||
? 'bg-emerald-50 dark:bg-emerald-900/20 text-emerald-600 dark:text-emerald-400 border-emerald-100 dark:border-emerald-500/20'
|
||||
: 'bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400 border-blue-100 dark:border-blue-500/20'
|
||||
}`}>
|
||||
<FileText size={20} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h4 className="font-bold truncate">{inv.title}</h4>
|
||||
<span className="text-[10px] font-mono text-zinc-400 shrink-0">{inv.id}</span>
|
||||
</div>
|
||||
<p className="text-xs text-zinc-400 mt-1 truncate flex items-center gap-2">
|
||||
{inv.customerPhone && (
|
||||
<span className="inline-flex items-center gap-1 text-zinc-500 dark:text-zinc-400">
|
||||
<Phone size={11} /> {inv.customerPhone}
|
||||
</span>
|
||||
)}
|
||||
<span className="truncate">{inv.property}</span>
|
||||
</p>
|
||||
<p className={`text-xs mt-1 flex items-center gap-1 ${
|
||||
overdue ? 'text-red-500 dark:text-red-400 font-semibold' : 'text-zinc-400'
|
||||
}`}>
|
||||
{overdue && <AlertCircle size={12} />}
|
||||
{overdue ? 'Overdue · ' : 'Due '}{new Date(inv.dueDate).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between md:justify-end gap-4 shrink-0">
|
||||
<span className="text-lg font-black">
|
||||
{formatAmount(inv.amount, inv.currency)}
|
||||
</span>
|
||||
{paid ? (
|
||||
<span className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-emerald-50 dark:bg-emerald-900/20 text-emerald-600 dark:text-emerald-400 text-xs font-bold border border-emerald-100 dark:border-emerald-500/20">
|
||||
<CheckCircle2 size={14} /> Paid
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => handleMarkPaid(inv)}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-zinc-300 dark:border-white/10 text-zinc-700 dark:text-zinc-200 text-xs font-bold hover:bg-zinc-100 dark:hover:bg-white/5 transition-colors"
|
||||
>
|
||||
<CheckCircle2 size={14} /> Mark as Paid
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</SpotlightCard>
|
||||
);
|
||||
})}
|
||||
{rows.length === 0 && (
|
||||
<div className="p-10 text-center border border-dashed border-zinc-300 dark:border-zinc-700 rounded-xl text-zinc-500">
|
||||
No payment requests yet — create one to get started.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const inputCls =
|
||||
'w-full px-3 py-2 rounded-lg bg-white dark:bg-zinc-900/60 border border-zinc-300 dark:border-white/10 text-sm text-zinc-900 dark:text-white placeholder-zinc-400 focus:outline-none focus:ring-2 focus:ring-blue-500/40 focus:border-blue-500 transition';
|
||||
|
||||
const Field = ({ label, className = '', children }) => (
|
||||
<label className={`block ${className}`}>
|
||||
<span className="text-xs font-bold uppercase tracking-wider text-zinc-500 dark:text-zinc-400 mb-1.5 block">
|
||||
{label}
|
||||
</span>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
|
||||
const Kpi = ({ icon, color, label, value }) => (
|
||||
<SpotlightCard className="p-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`p-2.5 rounded-xl bg-zinc-100 dark:bg-zinc-800 ${color}`}>
|
||||
{icon}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-bold uppercase tracking-wider text-zinc-500 dark:text-zinc-400">{label}</p>
|
||||
<p className="text-2xl font-black mt-0.5">{value}</p>
|
||||
</div>
|
||||
</div>
|
||||
</SpotlightCard>
|
||||
);
|
||||
|
||||
export default PaymentManagement;
|
||||
Reference in New Issue
Block a user