50 .tsx components and 6 .ts hook files use React client APIs
(createContext, useState, useEffect, useRef, useMemo, useCallback,
forwardRef, etc.) but lacked the "use client" directive at the top
of the source file. tsc-emitted dist files therefore also lacked it.
Older Next.js / Turbopack versions traversed the import graph and
implicitly treated the imports as client when their consumer was a
client component. Next.js 16's stricter Turbopack rejects this: any
file that touches client-only React APIs must declare "use client"
explicitly, regardless of where it is imported from.
Symptom in consumers (gscSupport build, 2026-05-17):
./templates/limitless-ui/dist/theme/ThemeProvider.js:2:10
You're importing a module that depends on `createContext` into a
React Server Component module. This API is only available in
Client Components. To fix, mark the file (or its parent) with
the `"use client"` directive.
Adding the directive at source-file level cascades through tsc into
the emitted dist/ — verified gscSupport + gscCRM build cleanly after
this change.
Affected files (50 total):
src/theme/ThemeProvider.tsx
src/hooks/useDisclosure.ts
src/components/{Accordion,Carousel,DualListBox,Form,Wizard,…}.tsx
src/validation/hooks/{useValidation,useFieldValidation,…}.ts
src/genui/hooks/{useGenUI,useWebMCP}.ts
... and more — every component that touches createContext / a
React hook now self-declares as client.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1013 lines
32 KiB
TypeScript
1013 lines
32 KiB
TypeScript
"use client";
|
|
|
|
import React, { useState } from 'react';
|
|
|
|
// Types
|
|
export interface InvoiceItem {
|
|
id: string;
|
|
description: string;
|
|
quantity: number;
|
|
unitPrice: number;
|
|
total: number;
|
|
tax?: number;
|
|
}
|
|
|
|
export interface InvoiceData {
|
|
id: string;
|
|
invoiceNumber: string;
|
|
status: 'draft' | 'pending' | 'paid' | 'overdue' | 'cancelled';
|
|
issueDate: Date;
|
|
dueDate: Date;
|
|
paidDate?: Date;
|
|
from: {
|
|
name: string;
|
|
address?: string;
|
|
email?: string;
|
|
phone?: string;
|
|
logo?: string;
|
|
taxId?: string;
|
|
};
|
|
to: {
|
|
name: string;
|
|
address?: string;
|
|
email?: string;
|
|
phone?: string;
|
|
taxId?: string;
|
|
};
|
|
items: InvoiceItem[];
|
|
subtotal: number;
|
|
tax?: number;
|
|
taxRate?: number;
|
|
discount?: number;
|
|
discountType?: 'fixed' | 'percentage';
|
|
total: number;
|
|
currency?: string;
|
|
notes?: string;
|
|
terms?: string;
|
|
paymentMethod?: string;
|
|
}
|
|
|
|
// Invoice Template
|
|
export interface InvoiceTemplateProps {
|
|
/** Invoice data */
|
|
invoice: InvoiceData;
|
|
/** Print handler */
|
|
onPrint?: () => void;
|
|
/** Download handler */
|
|
onDownload?: () => void;
|
|
/** Send handler */
|
|
onSend?: () => void;
|
|
/** Edit handler */
|
|
onEdit?: () => void;
|
|
/** Additional CSS classes */
|
|
className?: string;
|
|
}
|
|
|
|
export const InvoiceTemplate: React.FC<InvoiceTemplateProps> = ({
|
|
invoice,
|
|
onPrint,
|
|
onDownload,
|
|
onSend,
|
|
onEdit,
|
|
className = '',
|
|
}) => {
|
|
const currency = invoice.currency || '$';
|
|
|
|
const formatCurrency = (amount: number) => {
|
|
return `${currency}${amount.toFixed(2)}`;
|
|
};
|
|
|
|
const formatDate = (date: Date) => {
|
|
return date.toLocaleDateString([], {
|
|
year: 'numeric',
|
|
month: 'long',
|
|
day: 'numeric',
|
|
});
|
|
};
|
|
|
|
const getStatusColor = (status: InvoiceData['status']) => {
|
|
switch (status) {
|
|
case 'paid':
|
|
return '#10b981';
|
|
case 'pending':
|
|
return '#f59e0b';
|
|
case 'overdue':
|
|
return '#ef4444';
|
|
case 'draft':
|
|
return '#6b7280';
|
|
case 'cancelled':
|
|
return '#9ca3af';
|
|
default:
|
|
return '#6b7280';
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className={`ll-invoice-template ${className}`}>
|
|
<div className="ll-invoice-actions">
|
|
{onEdit && (
|
|
<button className="ll-invoice-action-btn" onClick={onEdit}>
|
|
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
|
|
<path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z" />
|
|
</svg>
|
|
Edit
|
|
</button>
|
|
)}
|
|
{onPrint && (
|
|
<button className="ll-invoice-action-btn" onClick={onPrint}>
|
|
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
|
|
<path d="M19 8H5c-1.66 0-3 1.34-3 3v6h4v4h12v-4h4v-6c0-1.66-1.34-3-3-3zm-3 11H8v-5h8v5zm3-7c-.55 0-1-.45-1-1s.45-1 1-1 1 .45 1 1-.45 1-1 1zm-1-9H6v4h12V3z" />
|
|
</svg>
|
|
Print
|
|
</button>
|
|
)}
|
|
{onDownload && (
|
|
<button className="ll-invoice-action-btn" onClick={onDownload}>
|
|
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
|
|
<path d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z" />
|
|
</svg>
|
|
Download
|
|
</button>
|
|
)}
|
|
{onSend && (
|
|
<button className="ll-invoice-action-btn ll-invoice-action-btn-primary" onClick={onSend}>
|
|
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
|
|
<path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z" />
|
|
</svg>
|
|
Send
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
<div className="ll-invoice-document">
|
|
<div className="ll-invoice-header">
|
|
<div className="ll-invoice-brand">
|
|
{invoice.from.logo ? (
|
|
<img src={invoice.from.logo} alt={invoice.from.name} className="ll-invoice-logo" />
|
|
) : (
|
|
<h1 className="ll-invoice-company">{invoice.from.name}</h1>
|
|
)}
|
|
</div>
|
|
|
|
<div className="ll-invoice-meta">
|
|
<h2 className="ll-invoice-title">INVOICE</h2>
|
|
<div className="ll-invoice-number">#{invoice.invoiceNumber}</div>
|
|
<div
|
|
className="ll-invoice-status"
|
|
style={{ backgroundColor: getStatusColor(invoice.status) }}
|
|
>
|
|
{invoice.status.charAt(0).toUpperCase() + invoice.status.slice(1)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="ll-invoice-parties">
|
|
<div className="ll-invoice-from">
|
|
<h3>From</h3>
|
|
<p className="ll-invoice-party-name">{invoice.from.name}</p>
|
|
{invoice.from.address && <p>{invoice.from.address}</p>}
|
|
{invoice.from.email && <p>{invoice.from.email}</p>}
|
|
{invoice.from.phone && <p>{invoice.from.phone}</p>}
|
|
{invoice.from.taxId && <p>Tax ID: {invoice.from.taxId}</p>}
|
|
</div>
|
|
|
|
<div className="ll-invoice-to">
|
|
<h3>Bill To</h3>
|
|
<p className="ll-invoice-party-name">{invoice.to.name}</p>
|
|
{invoice.to.address && <p>{invoice.to.address}</p>}
|
|
{invoice.to.email && <p>{invoice.to.email}</p>}
|
|
{invoice.to.phone && <p>{invoice.to.phone}</p>}
|
|
{invoice.to.taxId && <p>Tax ID: {invoice.to.taxId}</p>}
|
|
</div>
|
|
|
|
<div className="ll-invoice-dates">
|
|
<div className="ll-invoice-date">
|
|
<span className="ll-invoice-date-label">Issue Date</span>
|
|
<span className="ll-invoice-date-value">{formatDate(invoice.issueDate)}</span>
|
|
</div>
|
|
<div className="ll-invoice-date">
|
|
<span className="ll-invoice-date-label">Due Date</span>
|
|
<span className="ll-invoice-date-value">{formatDate(invoice.dueDate)}</span>
|
|
</div>
|
|
{invoice.paidDate && (
|
|
<div className="ll-invoice-date">
|
|
<span className="ll-invoice-date-label">Paid Date</span>
|
|
<span className="ll-invoice-date-value">{formatDate(invoice.paidDate)}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<table className="ll-invoice-items">
|
|
<thead>
|
|
<tr>
|
|
<th>Description</th>
|
|
<th>Qty</th>
|
|
<th>Unit Price</th>
|
|
{invoice.items.some((item) => item.tax !== undefined) && <th>Tax</th>}
|
|
<th>Total</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{invoice.items.map((item) => (
|
|
<tr key={item.id}>
|
|
<td>{item.description}</td>
|
|
<td>{item.quantity}</td>
|
|
<td>{formatCurrency(item.unitPrice)}</td>
|
|
{invoice.items.some((i) => i.tax !== undefined) && (
|
|
<td>{item.tax !== undefined ? `${item.tax}%` : '-'}</td>
|
|
)}
|
|
<td>{formatCurrency(item.total)}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
|
|
<div className="ll-invoice-summary">
|
|
<div className="ll-invoice-notes">
|
|
{invoice.notes && (
|
|
<div className="ll-invoice-note">
|
|
<h4>Notes</h4>
|
|
<p>{invoice.notes}</p>
|
|
</div>
|
|
)}
|
|
{invoice.terms && (
|
|
<div className="ll-invoice-terms">
|
|
<h4>Terms & Conditions</h4>
|
|
<p>{invoice.terms}</p>
|
|
</div>
|
|
)}
|
|
{invoice.paymentMethod && (
|
|
<div className="ll-invoice-payment">
|
|
<h4>Payment Method</h4>
|
|
<p>{invoice.paymentMethod}</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="ll-invoice-totals">
|
|
<div className="ll-invoice-total-row">
|
|
<span>Subtotal</span>
|
|
<span>{formatCurrency(invoice.subtotal)}</span>
|
|
</div>
|
|
{invoice.discount !== undefined && invoice.discount > 0 && (
|
|
<div className="ll-invoice-total-row">
|
|
<span>
|
|
Discount
|
|
{invoice.discountType === 'percentage' && ` (${invoice.discount}%)`}
|
|
</span>
|
|
<span>
|
|
-{formatCurrency(
|
|
invoice.discountType === 'percentage'
|
|
? invoice.subtotal * (invoice.discount / 100)
|
|
: invoice.discount
|
|
)}
|
|
</span>
|
|
</div>
|
|
)}
|
|
{invoice.tax !== undefined && (
|
|
<div className="ll-invoice-total-row">
|
|
<span>Tax{invoice.taxRate && ` (${invoice.taxRate}%)`}</span>
|
|
<span>{formatCurrency(invoice.tax)}</span>
|
|
</div>
|
|
)}
|
|
<div className="ll-invoice-total-row ll-invoice-grand-total">
|
|
<span>Total</span>
|
|
<span>{formatCurrency(invoice.total)}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// Invoice List
|
|
export interface InvoiceListProps {
|
|
/** Invoices to display */
|
|
invoices: InvoiceData[];
|
|
/** Invoice click handler */
|
|
onInvoiceClick?: (invoice: InvoiceData) => void;
|
|
/** Selection mode */
|
|
selectable?: boolean;
|
|
/** Selected IDs */
|
|
selectedIds?: string[];
|
|
/** Selection change handler */
|
|
onSelectionChange?: (ids: string[]) => void;
|
|
/** Sort field */
|
|
sortBy?: 'date' | 'number' | 'client' | 'amount' | 'status';
|
|
/** Sort direction */
|
|
sortDirection?: 'asc' | 'desc';
|
|
/** Sort change handler */
|
|
onSortChange?: (field: string, direction: 'asc' | 'desc') => void;
|
|
/** Loading state */
|
|
loading?: boolean;
|
|
/** Additional CSS classes */
|
|
className?: string;
|
|
}
|
|
|
|
export const InvoiceList: React.FC<InvoiceListProps> = ({
|
|
invoices,
|
|
onInvoiceClick,
|
|
selectable = false,
|
|
selectedIds = [],
|
|
onSelectionChange,
|
|
sortBy = 'date',
|
|
sortDirection = 'desc',
|
|
onSortChange,
|
|
loading = false,
|
|
className = '',
|
|
}) => {
|
|
const currency = '$';
|
|
|
|
const formatCurrency = (amount: number) => {
|
|
return `${currency}${amount.toFixed(2)}`;
|
|
};
|
|
|
|
const formatDate = (date: Date) => {
|
|
return date.toLocaleDateString([], { month: 'short', day: 'numeric', year: 'numeric' });
|
|
};
|
|
|
|
const getStatusClass = (status: InvoiceData['status']) => {
|
|
return `ll-invoice-list-status-${status}`;
|
|
};
|
|
|
|
const handleSelectAll = () => {
|
|
if (selectedIds.length === invoices.length) {
|
|
onSelectionChange?.([]);
|
|
} else {
|
|
onSelectionChange?.(invoices.map((i) => i.id));
|
|
}
|
|
};
|
|
|
|
const handleSelect = (id: string) => {
|
|
if (selectedIds.includes(id)) {
|
|
onSelectionChange?.(selectedIds.filter((sid) => sid !== id));
|
|
} else {
|
|
onSelectionChange?.([...selectedIds, id]);
|
|
}
|
|
};
|
|
|
|
const handleSort = (field: string) => {
|
|
const newDirection = sortBy === field && sortDirection === 'asc' ? 'desc' : 'asc';
|
|
onSortChange?.(field, newDirection);
|
|
};
|
|
|
|
const SortIcon = ({ field }: { field: string }) => {
|
|
if (sortBy !== field) return null;
|
|
return (
|
|
<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor" className="ll-invoice-sort-icon">
|
|
<path d={sortDirection === 'asc' ? 'M7 14l5-5 5 5z' : 'M7 10l5 5 5-5z'} />
|
|
</svg>
|
|
);
|
|
};
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className={`ll-invoice-list ll-invoice-list-loading ${className}`}>
|
|
<div className="ll-invoice-list-spinner" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className={`ll-invoice-list ${className}`}>
|
|
<table className="ll-invoice-list-table">
|
|
<thead>
|
|
<tr>
|
|
{selectable && (
|
|
<th className="ll-invoice-list-checkbox">
|
|
<input
|
|
type="checkbox"
|
|
checked={selectedIds.length === invoices.length && invoices.length > 0}
|
|
onChange={handleSelectAll}
|
|
/>
|
|
</th>
|
|
)}
|
|
<th onClick={() => handleSort('number')} className="ll-invoice-list-sortable">
|
|
Invoice <SortIcon field="number" />
|
|
</th>
|
|
<th onClick={() => handleSort('client')} className="ll-invoice-list-sortable">
|
|
Client <SortIcon field="client" />
|
|
</th>
|
|
<th onClick={() => handleSort('date')} className="ll-invoice-list-sortable">
|
|
Date <SortIcon field="date" />
|
|
</th>
|
|
<th>Due Date</th>
|
|
<th onClick={() => handleSort('amount')} className="ll-invoice-list-sortable">
|
|
Amount <SortIcon field="amount" />
|
|
</th>
|
|
<th onClick={() => handleSort('status')} className="ll-invoice-list-sortable">
|
|
Status <SortIcon field="status" />
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{invoices.map((invoice) => (
|
|
<tr
|
|
key={invoice.id}
|
|
className={selectedIds.includes(invoice.id) ? 'll-invoice-list-selected' : ''}
|
|
onClick={() => onInvoiceClick?.(invoice)}
|
|
>
|
|
{selectable && (
|
|
<td className="ll-invoice-list-checkbox" onClick={(e) => e.stopPropagation()}>
|
|
<input
|
|
type="checkbox"
|
|
checked={selectedIds.includes(invoice.id)}
|
|
onChange={() => handleSelect(invoice.id)}
|
|
/>
|
|
</td>
|
|
)}
|
|
<td className="ll-invoice-list-number">#{invoice.invoiceNumber}</td>
|
|
<td className="ll-invoice-list-client">{invoice.to.name}</td>
|
|
<td className="ll-invoice-list-date">{formatDate(invoice.issueDate)}</td>
|
|
<td className="ll-invoice-list-due">{formatDate(invoice.dueDate)}</td>
|
|
<td className="ll-invoice-list-amount">{formatCurrency(invoice.total)}</td>
|
|
<td>
|
|
<span className={`ll-invoice-list-status ${getStatusClass(invoice.status)}`}>
|
|
{invoice.status.charAt(0).toUpperCase() + invoice.status.slice(1)}
|
|
</span>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
|
|
{invoices.length === 0 && (
|
|
<div className="ll-invoice-list-empty">
|
|
<svg viewBox="0 0 24 24" width="48" height="48" fill="currentColor">
|
|
<path d="M14 2H6c-1.1 0-1.99.9-1.99 2L4 20c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8l-6-6zm2 16H8v-2h8v2zm0-4H8v-2h8v2zm-3-5V3.5L18.5 9H13z" />
|
|
</svg>
|
|
<p>No invoices found</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// Invoice Grid
|
|
export interface InvoiceGridProps {
|
|
/** Invoices to display */
|
|
invoices: InvoiceData[];
|
|
/** Invoice click handler */
|
|
onInvoiceClick?: (invoice: InvoiceData) => void;
|
|
/** Loading state */
|
|
loading?: boolean;
|
|
/** Additional CSS classes */
|
|
className?: string;
|
|
}
|
|
|
|
export const InvoiceGrid: React.FC<InvoiceGridProps> = ({
|
|
invoices,
|
|
onInvoiceClick,
|
|
loading = false,
|
|
className = '',
|
|
}) => {
|
|
const currency = '$';
|
|
|
|
const formatCurrency = (amount: number) => {
|
|
return `${currency}${amount.toFixed(2)}`;
|
|
};
|
|
|
|
const formatDate = (date: Date) => {
|
|
return date.toLocaleDateString([], { month: 'short', day: 'numeric' });
|
|
};
|
|
|
|
const getStatusColor = (status: InvoiceData['status']) => {
|
|
switch (status) {
|
|
case 'paid': return '#10b981';
|
|
case 'pending': return '#f59e0b';
|
|
case 'overdue': return '#ef4444';
|
|
case 'draft': return '#6b7280';
|
|
case 'cancelled': return '#9ca3af';
|
|
default: return '#6b7280';
|
|
}
|
|
};
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className={`ll-invoice-grid ll-invoice-grid-loading ${className}`}>
|
|
<div className="ll-invoice-grid-spinner" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className={`ll-invoice-grid ${className}`}>
|
|
{invoices.map((invoice) => (
|
|
<div
|
|
key={invoice.id}
|
|
className="ll-invoice-card"
|
|
onClick={() => onInvoiceClick?.(invoice)}
|
|
>
|
|
<div className="ll-invoice-card-header">
|
|
<span className="ll-invoice-card-number">#{invoice.invoiceNumber}</span>
|
|
<span
|
|
className="ll-invoice-card-status"
|
|
style={{ backgroundColor: getStatusColor(invoice.status) }}
|
|
>
|
|
{invoice.status}
|
|
</span>
|
|
</div>
|
|
|
|
<div className="ll-invoice-card-client">
|
|
<h4>{invoice.to.name}</h4>
|
|
{invoice.to.email && <p>{invoice.to.email}</p>}
|
|
</div>
|
|
|
|
<div className="ll-invoice-card-details">
|
|
<div className="ll-invoice-card-date">
|
|
<span>Issue Date</span>
|
|
<span>{formatDate(invoice.issueDate)}</span>
|
|
</div>
|
|
<div className="ll-invoice-card-date">
|
|
<span>Due Date</span>
|
|
<span>{formatDate(invoice.dueDate)}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="ll-invoice-card-amount">
|
|
<span>Total</span>
|
|
<span>{formatCurrency(invoice.total)}</span>
|
|
</div>
|
|
</div>
|
|
))}
|
|
|
|
{invoices.length === 0 && (
|
|
<div className="ll-invoice-grid-empty">
|
|
<p>No invoices found</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// Invoice Form
|
|
export interface InvoiceFormProps {
|
|
/** Initial invoice data */
|
|
initialData?: Partial<InvoiceData>;
|
|
/** Save handler */
|
|
onSave?: (invoice: InvoiceData) => void;
|
|
/** Cancel handler */
|
|
onCancel?: () => void;
|
|
/** Clients for autocomplete */
|
|
clients?: { id: string; name: string; email?: string; address?: string }[];
|
|
/** Additional CSS classes */
|
|
className?: string;
|
|
}
|
|
|
|
export const InvoiceForm: React.FC<InvoiceFormProps> = ({
|
|
initialData,
|
|
onSave,
|
|
onCancel,
|
|
clients = [],
|
|
className = '',
|
|
}) => {
|
|
const [invoice, setInvoice] = useState<Partial<InvoiceData>>({
|
|
invoiceNumber: '',
|
|
status: 'draft',
|
|
issueDate: new Date(),
|
|
dueDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
|
|
from: { name: '' },
|
|
to: { name: '' },
|
|
items: [],
|
|
subtotal: 0,
|
|
total: 0,
|
|
currency: '$',
|
|
...initialData,
|
|
});
|
|
|
|
const handleChange = (field: string, value: unknown) => {
|
|
setInvoice((prev) => ({ ...prev, [field]: value }));
|
|
};
|
|
|
|
const handleFromChange = (field: string, value: string) => {
|
|
setInvoice((prev) => ({
|
|
...prev,
|
|
from: { ...prev.from!, [field]: value },
|
|
}));
|
|
};
|
|
|
|
const handleToChange = (field: string, value: string) => {
|
|
setInvoice((prev) => ({
|
|
...prev,
|
|
to: { ...prev.to!, [field]: value },
|
|
}));
|
|
};
|
|
|
|
const addItem = () => {
|
|
const newItem: InvoiceItem = {
|
|
id: Date.now().toString(),
|
|
description: '',
|
|
quantity: 1,
|
|
unitPrice: 0,
|
|
total: 0,
|
|
};
|
|
setInvoice((prev) => ({
|
|
...prev,
|
|
items: [...(prev.items || []), newItem],
|
|
}));
|
|
};
|
|
|
|
const updateItem = (id: string, field: keyof InvoiceItem, value: unknown) => {
|
|
setInvoice((prev) => {
|
|
const items = prev.items?.map((item) => {
|
|
if (item.id !== id) return item;
|
|
const updated = { ...item, [field]: value };
|
|
if (field === 'quantity' || field === 'unitPrice') {
|
|
updated.total = updated.quantity * updated.unitPrice;
|
|
}
|
|
return updated;
|
|
}) || [];
|
|
|
|
const subtotal = items.reduce((sum, item) => sum + item.total, 0);
|
|
const tax = prev.taxRate ? subtotal * (prev.taxRate / 100) : prev.tax || 0;
|
|
let discount = 0;
|
|
if (prev.discount) {
|
|
discount = prev.discountType === 'percentage'
|
|
? subtotal * (prev.discount / 100)
|
|
: prev.discount;
|
|
}
|
|
const total = subtotal + tax - discount;
|
|
|
|
return { ...prev, items, subtotal, tax, total };
|
|
});
|
|
};
|
|
|
|
const removeItem = (id: string) => {
|
|
setInvoice((prev) => {
|
|
const items = prev.items?.filter((item) => item.id !== id) || [];
|
|
const subtotal = items.reduce((sum, item) => sum + item.total, 0);
|
|
const tax = prev.taxRate ? subtotal * (prev.taxRate / 100) : prev.tax || 0;
|
|
let discount = 0;
|
|
if (prev.discount) {
|
|
discount = prev.discountType === 'percentage'
|
|
? subtotal * (prev.discount / 100)
|
|
: prev.discount;
|
|
}
|
|
const total = subtotal + tax - discount;
|
|
|
|
return { ...prev, items, subtotal, tax, total };
|
|
});
|
|
};
|
|
|
|
const handleSubmit = (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (invoice.invoiceNumber && invoice.from?.name && invoice.to?.name) {
|
|
onSave?.(invoice as InvoiceData);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<form className={`ll-invoice-form ${className}`} onSubmit={handleSubmit}>
|
|
<div className="ll-invoice-form-header">
|
|
<h2>{initialData?.id ? 'Edit Invoice' : 'New Invoice'}</h2>
|
|
</div>
|
|
|
|
<div className="ll-invoice-form-section">
|
|
<h3>Invoice Details</h3>
|
|
<div className="ll-invoice-form-row">
|
|
<div className="ll-invoice-form-field">
|
|
<label>Invoice Number</label>
|
|
<input
|
|
type="text"
|
|
value={invoice.invoiceNumber || ''}
|
|
onChange={(e) => handleChange('invoiceNumber', e.target.value)}
|
|
required
|
|
/>
|
|
</div>
|
|
<div className="ll-invoice-form-field">
|
|
<label>Status</label>
|
|
<select
|
|
value={invoice.status}
|
|
onChange={(e) => handleChange('status', e.target.value)}
|
|
>
|
|
<option value="draft">Draft</option>
|
|
<option value="pending">Pending</option>
|
|
<option value="paid">Paid</option>
|
|
<option value="overdue">Overdue</option>
|
|
<option value="cancelled">Cancelled</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="ll-invoice-form-row">
|
|
<div className="ll-invoice-form-field">
|
|
<label>Issue Date</label>
|
|
<input
|
|
type="date"
|
|
value={invoice.issueDate?.toISOString().split('T')[0] || ''}
|
|
onChange={(e) => handleChange('issueDate', new Date(e.target.value))}
|
|
required
|
|
/>
|
|
</div>
|
|
<div className="ll-invoice-form-field">
|
|
<label>Due Date</label>
|
|
<input
|
|
type="date"
|
|
value={invoice.dueDate?.toISOString().split('T')[0] || ''}
|
|
onChange={(e) => handleChange('dueDate', new Date(e.target.value))}
|
|
required
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="ll-invoice-form-section">
|
|
<h3>From</h3>
|
|
<div className="ll-invoice-form-field">
|
|
<label>Company Name</label>
|
|
<input
|
|
type="text"
|
|
value={invoice.from?.name || ''}
|
|
onChange={(e) => handleFromChange('name', e.target.value)}
|
|
required
|
|
/>
|
|
</div>
|
|
<div className="ll-invoice-form-field">
|
|
<label>Address</label>
|
|
<textarea
|
|
value={invoice.from?.address || ''}
|
|
onChange={(e) => handleFromChange('address', e.target.value)}
|
|
rows={2}
|
|
/>
|
|
</div>
|
|
<div className="ll-invoice-form-row">
|
|
<div className="ll-invoice-form-field">
|
|
<label>Email</label>
|
|
<input
|
|
type="email"
|
|
value={invoice.from?.email || ''}
|
|
onChange={(e) => handleFromChange('email', e.target.value)}
|
|
/>
|
|
</div>
|
|
<div className="ll-invoice-form-field">
|
|
<label>Phone</label>
|
|
<input
|
|
type="tel"
|
|
value={invoice.from?.phone || ''}
|
|
onChange={(e) => handleFromChange('phone', e.target.value)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="ll-invoice-form-section">
|
|
<h3>Bill To</h3>
|
|
{clients.length > 0 && (
|
|
<div className="ll-invoice-form-field">
|
|
<label>Select Client</label>
|
|
<select
|
|
onChange={(e) => {
|
|
const client = clients.find((c) => c.id === e.target.value);
|
|
if (client) {
|
|
setInvoice((prev) => ({
|
|
...prev,
|
|
to: {
|
|
name: client.name,
|
|
email: client.email,
|
|
address: client.address,
|
|
},
|
|
}));
|
|
}
|
|
}}
|
|
>
|
|
<option value="">Select a client...</option>
|
|
{clients.map((client) => (
|
|
<option key={client.id} value={client.id}>{client.name}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
)}
|
|
<div className="ll-invoice-form-field">
|
|
<label>Client Name</label>
|
|
<input
|
|
type="text"
|
|
value={invoice.to?.name || ''}
|
|
onChange={(e) => handleToChange('name', e.target.value)}
|
|
required
|
|
/>
|
|
</div>
|
|
<div className="ll-invoice-form-field">
|
|
<label>Address</label>
|
|
<textarea
|
|
value={invoice.to?.address || ''}
|
|
onChange={(e) => handleToChange('address', e.target.value)}
|
|
rows={2}
|
|
/>
|
|
</div>
|
|
<div className="ll-invoice-form-row">
|
|
<div className="ll-invoice-form-field">
|
|
<label>Email</label>
|
|
<input
|
|
type="email"
|
|
value={invoice.to?.email || ''}
|
|
onChange={(e) => handleToChange('email', e.target.value)}
|
|
/>
|
|
</div>
|
|
<div className="ll-invoice-form-field">
|
|
<label>Phone</label>
|
|
<input
|
|
type="tel"
|
|
value={invoice.to?.phone || ''}
|
|
onChange={(e) => handleToChange('phone', e.target.value)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="ll-invoice-form-section">
|
|
<h3>Items</h3>
|
|
<table className="ll-invoice-form-items">
|
|
<thead>
|
|
<tr>
|
|
<th>Description</th>
|
|
<th>Qty</th>
|
|
<th>Unit Price</th>
|
|
<th>Total</th>
|
|
<th></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{invoice.items?.map((item) => (
|
|
<tr key={item.id}>
|
|
<td>
|
|
<input
|
|
type="text"
|
|
value={item.description}
|
|
onChange={(e) => updateItem(item.id, 'description', e.target.value)}
|
|
placeholder="Item description"
|
|
/>
|
|
</td>
|
|
<td>
|
|
<input
|
|
type="number"
|
|
min="1"
|
|
value={item.quantity}
|
|
onChange={(e) => updateItem(item.id, 'quantity', parseInt(e.target.value) || 1)}
|
|
/>
|
|
</td>
|
|
<td>
|
|
<input
|
|
type="number"
|
|
min="0"
|
|
step="0.01"
|
|
value={item.unitPrice}
|
|
onChange={(e) => updateItem(item.id, 'unitPrice', parseFloat(e.target.value) || 0)}
|
|
/>
|
|
</td>
|
|
<td className="ll-invoice-form-item-total">
|
|
{invoice.currency}{item.total.toFixed(2)}
|
|
</td>
|
|
<td>
|
|
<button
|
|
type="button"
|
|
className="ll-invoice-form-remove-item"
|
|
onClick={() => removeItem(item.id)}
|
|
>
|
|
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
|
|
<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z" />
|
|
</svg>
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
|
|
<button type="button" className="ll-invoice-form-add-item" onClick={addItem}>
|
|
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
|
|
<path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z" />
|
|
</svg>
|
|
Add Item
|
|
</button>
|
|
</div>
|
|
|
|
<div className="ll-invoice-form-section">
|
|
<h3>Additional</h3>
|
|
<div className="ll-invoice-form-field">
|
|
<label>Notes</label>
|
|
<textarea
|
|
value={invoice.notes || ''}
|
|
onChange={(e) => handleChange('notes', e.target.value)}
|
|
placeholder="Additional notes..."
|
|
rows={3}
|
|
/>
|
|
</div>
|
|
<div className="ll-invoice-form-field">
|
|
<label>Terms & Conditions</label>
|
|
<textarea
|
|
value={invoice.terms || ''}
|
|
onChange={(e) => handleChange('terms', e.target.value)}
|
|
placeholder="Payment terms..."
|
|
rows={3}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="ll-invoice-form-totals">
|
|
<div className="ll-invoice-form-total-row">
|
|
<span>Subtotal</span>
|
|
<span>{invoice.currency}{(invoice.subtotal || 0).toFixed(2)}</span>
|
|
</div>
|
|
<div className="ll-invoice-form-total-row ll-invoice-form-grand-total">
|
|
<span>Total</span>
|
|
<span>{invoice.currency}{(invoice.total || 0).toFixed(2)}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="ll-invoice-form-actions">
|
|
{onCancel && (
|
|
<button type="button" className="ll-invoice-form-btn-secondary" onClick={onCancel}>
|
|
Cancel
|
|
</button>
|
|
)}
|
|
<button type="submit" className="ll-invoice-form-btn-primary">
|
|
{initialData?.id ? 'Update Invoice' : 'Create Invoice'}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
);
|
|
};
|
|
|
|
// Invoice Stats
|
|
export interface InvoiceStatsProps {
|
|
/** Total invoices */
|
|
total: number;
|
|
/** Paid amount */
|
|
paid: number;
|
|
/** Pending amount */
|
|
pending: number;
|
|
/** Overdue amount */
|
|
overdue: number;
|
|
/** Currency symbol */
|
|
currency?: string;
|
|
/** Additional CSS classes */
|
|
className?: string;
|
|
}
|
|
|
|
export const InvoiceStats: React.FC<InvoiceStatsProps> = ({
|
|
total,
|
|
paid,
|
|
pending,
|
|
overdue,
|
|
currency = '$',
|
|
className = '',
|
|
}) => {
|
|
const formatCurrency = (amount: number) => {
|
|
return `${currency}${amount.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
|
};
|
|
|
|
return (
|
|
<div className={`ll-invoice-stats ${className}`}>
|
|
<div className="ll-invoice-stat">
|
|
<div className="ll-invoice-stat-icon ll-invoice-stat-total">
|
|
<svg viewBox="0 0 24 24" width="24" height="24" fill="currentColor">
|
|
<path d="M14 2H6c-1.1 0-1.99.9-1.99 2L4 20c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8l-6-6zm2 16H8v-2h8v2zm0-4H8v-2h8v2zm-3-5V3.5L18.5 9H13z" />
|
|
</svg>
|
|
</div>
|
|
<div className="ll-invoice-stat-content">
|
|
<span className="ll-invoice-stat-value">{formatCurrency(total)}</span>
|
|
<span className="ll-invoice-stat-label">Total</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="ll-invoice-stat">
|
|
<div className="ll-invoice-stat-icon ll-invoice-stat-paid">
|
|
<svg viewBox="0 0 24 24" width="24" height="24" fill="currentColor">
|
|
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
|
|
</svg>
|
|
</div>
|
|
<div className="ll-invoice-stat-content">
|
|
<span className="ll-invoice-stat-value">{formatCurrency(paid)}</span>
|
|
<span className="ll-invoice-stat-label">Paid</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="ll-invoice-stat">
|
|
<div className="ll-invoice-stat-icon ll-invoice-stat-pending">
|
|
<svg viewBox="0 0 24 24" width="24" height="24" fill="currentColor">
|
|
<path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm.5-13H11v6l5.25 3.15.75-1.23-4.5-2.67z" />
|
|
</svg>
|
|
</div>
|
|
<div className="ll-invoice-stat-content">
|
|
<span className="ll-invoice-stat-value">{formatCurrency(pending)}</span>
|
|
<span className="ll-invoice-stat-label">Pending</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="ll-invoice-stat">
|
|
<div className="ll-invoice-stat-icon ll-invoice-stat-overdue">
|
|
<svg viewBox="0 0 24 24" width="24" height="24" fill="currentColor">
|
|
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z" />
|
|
</svg>
|
|
</div>
|
|
<div className="ll-invoice-stat-content">
|
|
<span className="ll-invoice-stat-value">{formatCurrency(overdue)}</span>
|
|
<span className="ll-invoice-stat-label">Overdue</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|