feat: Enhanced release notes system, fixed invoice installments, and improved logo integration
- Updated release notes to use new JSON structure with individual commit timestamps - Removed hash display from release notes for cleaner UI - Fixed automatic recalculation of percentage-based installments in Invoice Editor and Preview - Integrated custom logo.svg in header and footer with cleaner styling - Moved all data files to /public/data/ for better organization - Cleaned up unused release data files and improved file structure
This commit is contained in:
@@ -100,7 +100,7 @@ const InvoiceEditor = () => {
|
||||
useEffect(() => {
|
||||
const loadCurrencies = async () => {
|
||||
try {
|
||||
const response = await fetch('/utils/currencies.json');
|
||||
const response = await fetch('/data/currencies.json');
|
||||
const currencyData = await response.json();
|
||||
setCurrencies(currencyData);
|
||||
} catch (error) {
|
||||
@@ -174,6 +174,47 @@ const InvoiceEditor = () => {
|
||||
}
|
||||
}, [invoiceData, createNewCompleted]);
|
||||
|
||||
// Safety recalculation - ensure totals are always correct
|
||||
useEffect(() => {
|
||||
const { subtotal, total } = calculateTotals(
|
||||
invoiceData.items,
|
||||
invoiceData.discount,
|
||||
invoiceData.fees,
|
||||
invoiceData.discounts
|
||||
);
|
||||
|
||||
if (invoiceData.subtotal !== subtotal || invoiceData.total !== total) {
|
||||
setInvoiceData(prev => {
|
||||
const updated = {
|
||||
...prev,
|
||||
subtotal,
|
||||
total
|
||||
};
|
||||
|
||||
// Recalculate installments when total changes
|
||||
if (prev.paymentTerms?.installments?.length > 0) {
|
||||
const updatedInstallments = prev.paymentTerms.installments.map(installment => {
|
||||
if (installment.type === 'percentage') {
|
||||
const baseAmount = prev.paymentTerms?.type === 'downpayment'
|
||||
? total - (prev.paymentTerms?.downPayment?.amount || 0)
|
||||
: total;
|
||||
const amount = (baseAmount * (installment.percentage || 0)) / 100;
|
||||
return { ...installment, amount };
|
||||
}
|
||||
return installment;
|
||||
});
|
||||
|
||||
updated.paymentTerms = {
|
||||
...prev.paymentTerms,
|
||||
installments: updatedInstallments
|
||||
};
|
||||
}
|
||||
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
}, [invoiceData.items, invoiceData.discount, invoiceData.fees, invoiceData.discounts, invoiceData.subtotal, invoiceData.total]);
|
||||
|
||||
// Save PDF page size to localStorage
|
||||
useEffect(() => {
|
||||
try {
|
||||
@@ -1254,53 +1295,64 @@ const InvoiceEditor = () => {
|
||||
</button>
|
||||
</div>
|
||||
{(invoiceData.fees || []).map((fee, index) => (
|
||||
<div key={fee.id} className="flex gap-2 mb-2 p-2 bg-gray-50 dark:bg-gray-700 rounded-md">
|
||||
<input
|
||||
type="text"
|
||||
value={fee.label}
|
||||
onChange={(e) => updateFee(fee.id, 'label', e.target.value)}
|
||||
placeholder="Fee name"
|
||||
className="flex-1 px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-600 dark:text-white"
|
||||
/>
|
||||
<select
|
||||
value={fee.type}
|
||||
onChange={(e) => updateFee(fee.id, 'type', e.target.value)}
|
||||
className="px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-600 dark:text-white"
|
||||
>
|
||||
<option value="fixed">Fixed</option>
|
||||
<option value="percentage">%</option>
|
||||
</select>
|
||||
<input
|
||||
type="number"
|
||||
value={fee.value}
|
||||
onChange={(e) => updateFee(fee.id, 'value', parseFloat(e.target.value) || 0)}
|
||||
placeholder="0"
|
||||
className="w-20 px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-600 dark:text-white"
|
||||
min="0"
|
||||
step="0.01"
|
||||
/>
|
||||
<div className="flex items-center gap-1">
|
||||
<div key={fee.id} className="flex gap-3 mb-2 p-3 bg-gray-50 dark:bg-gray-700 rounded-md">
|
||||
{/* Main Content Area */}
|
||||
<div className="flex-1 flex flex-col xl:flex-row gap-2">
|
||||
{/* Fee Name */}
|
||||
<input
|
||||
type="text"
|
||||
value={fee.label}
|
||||
onChange={(e) => updateFee(fee.id, 'label', e.target.value)}
|
||||
placeholder="Fee name"
|
||||
className="flex-1 px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-600 dark:text-white font-medium"
|
||||
/>
|
||||
|
||||
{/* Controls Row */}
|
||||
<div className="flex flex-wrap gap-2 flex-col xl:flex-row">
|
||||
<input
|
||||
type="number"
|
||||
value={fee.value}
|
||||
onChange={(e) => updateFee(fee.id, 'value', parseFloat(e.target.value) || 0)}
|
||||
placeholder="0"
|
||||
className="flex-1 xl:flex-0 xl:w-20 px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-600 dark:text-white"
|
||||
min="0"
|
||||
step="0.01"
|
||||
/>
|
||||
<select
|
||||
value={fee.type}
|
||||
onChange={(e) => updateFee(fee.id, 'type', e.target.value)}
|
||||
className="px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-600 dark:text-white"
|
||||
>
|
||||
<option value="fixed">Fixed</option>
|
||||
<option value="percentage">%</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons - Always on Right */}
|
||||
<div className="flex flex-col xl:flex-row gap-1 items-center justify-center">
|
||||
<button
|
||||
onClick={() => moveItem('fees', fee.id, 'up')}
|
||||
disabled={index === 0}
|
||||
className="p-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 rounded transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 rounded transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
title="Move up"
|
||||
>
|
||||
<ChevronUp className="h-3 w-3" />
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => moveItem('fees', fee.id, 'down')}
|
||||
disabled={index === (invoiceData.fees || []).length - 1}
|
||||
className="p-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 rounded transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 rounded transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
title="Move down"
|
||||
>
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => removeFee(fee.id)}
|
||||
className="p-1 text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300"
|
||||
className="p-2 text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300 hover:bg-red-50 dark:hover:bg-red-900/20 rounded transition-colors"
|
||||
title="Delete fee"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1320,53 +1372,64 @@ const InvoiceEditor = () => {
|
||||
</button>
|
||||
</div>
|
||||
{(invoiceData.discounts || []).map((discount, index) => (
|
||||
<div key={discount.id} className="flex gap-2 mb-2 p-2 bg-gray-50 dark:bg-gray-700 rounded-md">
|
||||
<input
|
||||
type="text"
|
||||
value={discount.label}
|
||||
onChange={(e) => updateDiscount(discount.id, 'label', e.target.value)}
|
||||
placeholder="Discount name"
|
||||
className="flex-1 px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-600 dark:text-white"
|
||||
/>
|
||||
<select
|
||||
value={discount.type}
|
||||
onChange={(e) => updateDiscount(discount.id, 'type', e.target.value)}
|
||||
className="px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-600 dark:text-white"
|
||||
>
|
||||
<option value="fixed">Fixed</option>
|
||||
<option value="percentage">%</option>
|
||||
</select>
|
||||
<input
|
||||
type="number"
|
||||
value={discount.value}
|
||||
onChange={(e) => updateDiscount(discount.id, 'value', parseFloat(e.target.value) || 0)}
|
||||
placeholder="0"
|
||||
className="w-20 px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-600 dark:text-white"
|
||||
min="0"
|
||||
step="0.01"
|
||||
/>
|
||||
<div className="flex items-center gap-1">
|
||||
<div key={discount.id} className="flex gap-3 mb-2 p-3 bg-gray-50 dark:bg-gray-700 rounded-md">
|
||||
{/* Main Content Area */}
|
||||
<div className="flex-1 flex flex-col xl:flex-row gap-2">
|
||||
{/* Discount Name */}
|
||||
<input
|
||||
type="text"
|
||||
value={discount.label}
|
||||
onChange={(e) => updateDiscount(discount.id, 'label', e.target.value)}
|
||||
placeholder="Discount name"
|
||||
className="flex-1 px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-600 dark:text-white font-medium"
|
||||
/>
|
||||
|
||||
{/* Controls Row */}
|
||||
<div className="flex flex-wrap gap-2 flex-col xl:flex-row">
|
||||
<input
|
||||
type="number"
|
||||
value={discount.value}
|
||||
onChange={(e) => updateDiscount(discount.id, 'value', parseFloat(e.target.value) || 0)}
|
||||
placeholder="0"
|
||||
className="flex-1 xl:flex-0 xl:w-20 px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-600 dark:text-white"
|
||||
min="0"
|
||||
step="0.01"
|
||||
/>
|
||||
<select
|
||||
value={discount.type}
|
||||
onChange={(e) => updateDiscount(discount.id, 'type', e.target.value)}
|
||||
className="px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-600 dark:text-white"
|
||||
>
|
||||
<option value="fixed">Fixed</option>
|
||||
<option value="percentage">%</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons - Always on Right */}
|
||||
<div className="flex flex-col xl:flex-row gap-1 items-center justify-center">
|
||||
<button
|
||||
onClick={() => moveItem('discounts', discount.id, 'up')}
|
||||
disabled={index === 0}
|
||||
className="p-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 rounded transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 rounded transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
title="Move up"
|
||||
>
|
||||
<ChevronUp className="h-3 w-3" />
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => moveItem('discounts', discount.id, 'down')}
|
||||
disabled={index === (invoiceData.discounts || []).length - 1}
|
||||
className="p-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 rounded transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 rounded transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
title="Move down"
|
||||
>
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => removeDiscount(discount.id)}
|
||||
className="p-1 text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300"
|
||||
className="p-2 text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300 hover:bg-red-50 dark:hover:bg-red-900/20 rounded transition-colors"
|
||||
title="Delete discount"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1384,7 +1447,7 @@ const InvoiceEditor = () => {
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between items-center py-2 border-b border-emerald-200 dark:border-emerald-700">
|
||||
<span className="text-gray-600 dark:text-gray-400">Subtotal:</span>
|
||||
<span className="font-medium text-gray-900 dark:text-white">{formatCurrency(invoiceData.subtotal, true)}</span>
|
||||
<span className="font-medium text-gray-900 dark:text-white flex-shrink-0">{formatCurrency(invoiceData.subtotal, true)}</span>
|
||||
</div>
|
||||
|
||||
{/* Dynamic Fees */}
|
||||
@@ -1393,7 +1456,7 @@ const InvoiceEditor = () => {
|
||||
<span className="text-gray-600 dark:text-gray-400">
|
||||
{fee.label || 'Fee'} {fee.type === 'percentage' ? `(${fee.value}%)` : ''}:
|
||||
</span>
|
||||
<span className="font-medium text-blue-600 dark:text-blue-400">
|
||||
<span className="font-medium text-blue-600 dark:text-blue-400 flex-shrink-0">
|
||||
+{formatCurrency(fee.type === 'percentage' ? (invoiceData.subtotal * fee.value) / 100 : fee.value, true)}
|
||||
</span>
|
||||
</div>
|
||||
@@ -1405,7 +1468,7 @@ const InvoiceEditor = () => {
|
||||
<span className="text-gray-600 dark:text-gray-400">
|
||||
{discount.label || 'Discount'} {discount.type === 'percentage' ? `(${discount.value}%)` : ''}:
|
||||
</span>
|
||||
<span className="font-medium text-red-600 dark:text-red-400">
|
||||
<span className="font-medium text-red-600 dark:text-red-400 flex-shrink-0">
|
||||
-{formatCurrency(discount.type === 'percentage' ? (invoiceData.subtotal * discount.value) / 100 : discount.value, true)}
|
||||
</span>
|
||||
</div>
|
||||
@@ -1415,13 +1478,13 @@ const InvoiceEditor = () => {
|
||||
{invoiceData.discount > 0 && (
|
||||
<div className="flex justify-between items-center py-2 border-b border-emerald-200 dark:border-emerald-700">
|
||||
<span className="text-gray-600 dark:text-gray-400">Discount:</span>
|
||||
<span className="font-medium text-red-600 dark:text-red-400">-{formatCurrency(invoiceData.discount, true)}</span>
|
||||
<span className="font-medium text-red-600 dark:text-red-400 flex-shrink-0">-{formatCurrency(invoiceData.discount, true)}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="bg-emerald-100 dark:bg-emerald-900/30 rounded-lg p-4 mt-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-lg font-semibold text-emerald-800 dark:text-emerald-200">Total:</span>
|
||||
<span className="text-2xl font-bold text-emerald-800 dark:text-emerald-200">{formatCurrency(invoiceData.total, true)}</span>
|
||||
<span className="text-2xl font-bold text-emerald-800 dark:text-emerald-200 flex-shrink-0">{formatCurrency(invoiceData.total, true)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1463,98 +1526,106 @@ const InvoiceEditor = () => {
|
||||
<h4 className="text-md font-medium text-gray-900 dark:text-white mb-3">Down Payment</h4>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex gap-2 mb-2 p-2 bg-gray-50 dark:bg-gray-700 rounded-md">
|
||||
<input
|
||||
type="text"
|
||||
value="Down Payment"
|
||||
readOnly
|
||||
className="flex-1 px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-600 dark:text-white bg-gray-100 dark:bg-gray-600"
|
||||
/>
|
||||
<select
|
||||
value={invoiceData.paymentTerms?.downPayment?.type || 'percentage'}
|
||||
onChange={(e) => {
|
||||
const type = e.target.value;
|
||||
updateInvoiceData('paymentTerms', {
|
||||
...invoiceData.paymentTerms,
|
||||
downPayment: {
|
||||
...invoiceData.paymentTerms.downPayment,
|
||||
type
|
||||
<div className="flex gap-3 mb-2 p-3 bg-gray-50 dark:bg-gray-700 rounded-md">
|
||||
{/* Main Content Area */}
|
||||
<div className="flex-1 flex flex-col lg:flex-row gap-2">
|
||||
{/* Down Payment Label */}
|
||||
<input
|
||||
type="text"
|
||||
value="Down Payment"
|
||||
readOnly
|
||||
className="flex-1 px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-600 dark:text-white bg-gray-100 dark:bg-gray-600 font-medium"
|
||||
/>
|
||||
|
||||
{/* Controls Row */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<input
|
||||
type="number"
|
||||
value={
|
||||
invoiceData.paymentTerms?.downPayment?.type === 'fixed'
|
||||
? (invoiceData.paymentTerms?.downPayment?.amount || 0)
|
||||
: (invoiceData.paymentTerms?.downPayment?.percentage || 0)
|
||||
}
|
||||
});
|
||||
}}
|
||||
className="px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-600 dark:text-white"
|
||||
>
|
||||
<option value="percentage">%</option>
|
||||
<option value="fixed">Fixed</option>
|
||||
</select>
|
||||
<input
|
||||
type="number"
|
||||
value={
|
||||
invoiceData.paymentTerms?.downPayment?.type === 'fixed'
|
||||
? (invoiceData.paymentTerms?.downPayment?.amount || 0)
|
||||
: (invoiceData.paymentTerms?.downPayment?.percentage || 0)
|
||||
}
|
||||
onChange={(e) => {
|
||||
const value = parseFloat(e.target.value) || 0;
|
||||
const isFixed = invoiceData.paymentTerms?.downPayment?.type === 'fixed';
|
||||
|
||||
let amount, percentage;
|
||||
if (isFixed) {
|
||||
amount = value;
|
||||
percentage = invoiceData.total > 0 ? (amount / invoiceData.total) * 100 : 0;
|
||||
} else {
|
||||
percentage = value;
|
||||
amount = (invoiceData.total * percentage) / 100;
|
||||
}
|
||||
|
||||
updateInvoiceData('paymentTerms', {
|
||||
...invoiceData.paymentTerms,
|
||||
downPayment: {
|
||||
...invoiceData.paymentTerms.downPayment,
|
||||
percentage,
|
||||
amount
|
||||
}
|
||||
});
|
||||
}}
|
||||
placeholder="0"
|
||||
className="w-20 px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-600 dark:text-white"
|
||||
min="0"
|
||||
max="100"
|
||||
step="0.01"
|
||||
/>
|
||||
<input
|
||||
type="date"
|
||||
value={invoiceData.paymentTerms?.downPayment?.dueDate || ''}
|
||||
onChange={(e) => updateInvoiceData('paymentTerms', {
|
||||
...invoiceData.paymentTerms,
|
||||
downPayment: {
|
||||
...invoiceData.paymentTerms.downPayment,
|
||||
dueDate: e.target.value
|
||||
}
|
||||
})}
|
||||
className="px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-600 dark:text-white"
|
||||
/>
|
||||
<select
|
||||
value={invoiceData.paymentTerms?.downPayment?.status || 'pending'}
|
||||
onChange={(e) => updateInvoiceData('paymentTerms', {
|
||||
...invoiceData.paymentTerms,
|
||||
downPayment: {
|
||||
...invoiceData.paymentTerms.downPayment,
|
||||
status: e.target.value
|
||||
}
|
||||
})}
|
||||
className={`px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-600 dark:text-white ${
|
||||
invoiceData.paymentTerms?.downPayment?.status === 'paid' ? 'bg-green-50 text-green-800 dark:bg-green-900/20 dark:text-green-300' :
|
||||
invoiceData.paymentTerms?.downPayment?.status === 'current' ? 'bg-yellow-50 text-yellow-800 dark:bg-yellow-900/20 dark:text-yellow-300' :
|
||||
invoiceData.paymentTerms?.downPayment?.status === 'overdue' ? 'bg-red-50 text-red-800 dark:bg-red-900/20 dark:text-red-300' :
|
||||
'bg-gray-50 text-gray-800 dark:bg-gray-700 dark:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="current">Current</option>
|
||||
<option value="paid">Paid</option>
|
||||
<option value="overdue">Overdue</option>
|
||||
</select>
|
||||
onChange={(e) => {
|
||||
const value = parseFloat(e.target.value) || 0;
|
||||
const isFixed = invoiceData.paymentTerms?.downPayment?.type === 'fixed';
|
||||
|
||||
let amount, percentage;
|
||||
if (isFixed) {
|
||||
amount = value;
|
||||
percentage = invoiceData.total > 0 ? (amount / invoiceData.total) * 100 : 0;
|
||||
} else {
|
||||
percentage = value;
|
||||
amount = (invoiceData.total * percentage) / 100;
|
||||
}
|
||||
|
||||
updateInvoiceData('paymentTerms', {
|
||||
...invoiceData.paymentTerms,
|
||||
downPayment: {
|
||||
...invoiceData.paymentTerms.downPayment,
|
||||
percentage,
|
||||
amount
|
||||
}
|
||||
});
|
||||
}}
|
||||
placeholder="0"
|
||||
className="flex-1 lg:flex-0 w-20 px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-600 dark:text-white"
|
||||
min="0"
|
||||
max="100"
|
||||
step="0.01"
|
||||
/>
|
||||
<select
|
||||
value={invoiceData.paymentTerms?.downPayment?.type || 'percentage'}
|
||||
onChange={(e) => {
|
||||
const type = e.target.value;
|
||||
updateInvoiceData('paymentTerms', {
|
||||
...invoiceData.paymentTerms,
|
||||
downPayment: {
|
||||
...invoiceData.paymentTerms.downPayment,
|
||||
type
|
||||
}
|
||||
});
|
||||
}}
|
||||
className="px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-600 dark:text-white"
|
||||
>
|
||||
<option value="percentage">%</option>
|
||||
<option value="fixed">Fixed</option>
|
||||
</select>
|
||||
<input
|
||||
type="date"
|
||||
value={invoiceData.paymentTerms?.downPayment?.dueDate || ''}
|
||||
onChange={(e) => updateInvoiceData('paymentTerms', {
|
||||
...invoiceData.paymentTerms,
|
||||
downPayment: {
|
||||
...invoiceData.paymentTerms.downPayment,
|
||||
dueDate: e.target.value
|
||||
}
|
||||
})}
|
||||
className="flex-1 lg:flex-0 px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-600 dark:text-white"
|
||||
/>
|
||||
<select
|
||||
value={invoiceData.paymentTerms?.downPayment?.status || 'pending'}
|
||||
onChange={(e) => updateInvoiceData('paymentTerms', {
|
||||
...invoiceData.paymentTerms,
|
||||
downPayment: {
|
||||
...invoiceData.paymentTerms.downPayment,
|
||||
status: e.target.value
|
||||
}
|
||||
})}
|
||||
className={`flex-1 lg:flex-0 px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-600 dark:text-white ${
|
||||
invoiceData.paymentTerms?.downPayment?.status === 'paid' ? 'bg-green-50 text-green-800 dark:bg-green-900/20 dark:text-green-300' :
|
||||
invoiceData.paymentTerms?.downPayment?.status === 'current' ? 'bg-yellow-50 text-yellow-800 dark:bg-yellow-900/20 dark:text-yellow-300' :
|
||||
invoiceData.paymentTerms?.downPayment?.status === 'overdue' ? 'bg-red-50 text-red-800 dark:bg-red-900/20 dark:text-red-300' :
|
||||
'bg-gray-50 text-gray-800 dark:bg-gray-700 dark:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="current">Current</option>
|
||||
<option value="paid">Paid</option>
|
||||
<option value="overdue">Overdue</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1587,131 +1658,141 @@ const InvoiceEditor = () => {
|
||||
installments: [...(invoiceData.paymentTerms?.installments || []), newInstallment]
|
||||
});
|
||||
}}
|
||||
className="flex items-center gap-1 px-3 py-1 text-sm bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors"
|
||||
className="flex items-center gap-1 px-2 py-1 text-xs bg-indigo-100 hover:bg-indigo-200 dark:bg-indigo-900 dark:hover:bg-indigo-800 text-indigo-700 dark:text-indigo-300 rounded-md transition-colors whitespace-nowrap"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
Add Installment
|
||||
Add Term
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{(invoiceData.paymentTerms?.installments || []).map((installment, index) => (
|
||||
<div key={installment.id} className="flex gap-2 mb-2 p-2 bg-gray-50 dark:bg-gray-700 rounded-md">
|
||||
<input
|
||||
type="text"
|
||||
value={installment.description}
|
||||
onChange={(e) => {
|
||||
const updatedInstallments = invoiceData.paymentTerms.installments.map(inst =>
|
||||
inst.id === installment.id ? { ...inst, description: e.target.value } : inst
|
||||
);
|
||||
updateInvoiceData('paymentTerms', {
|
||||
...invoiceData.paymentTerms,
|
||||
installments: updatedInstallments
|
||||
});
|
||||
}}
|
||||
placeholder="Installment name"
|
||||
className="flex-1 px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-600 dark:text-white"
|
||||
/>
|
||||
<select
|
||||
value={installment.type || 'fixed'}
|
||||
onChange={(e) => {
|
||||
const updatedInstallments = invoiceData.paymentTerms.installments.map(inst =>
|
||||
inst.id === installment.id ? { ...inst, type: e.target.value } : inst
|
||||
);
|
||||
updateInvoiceData('paymentTerms', {
|
||||
...invoiceData.paymentTerms,
|
||||
installments: updatedInstallments
|
||||
});
|
||||
}}
|
||||
className="px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-600 dark:text-white"
|
||||
>
|
||||
<option value="fixed">Fixed</option>
|
||||
<option value="percentage">%</option>
|
||||
</select>
|
||||
<input
|
||||
type="number"
|
||||
value={installment.type === 'percentage' ? (installment.percentage || 0) : (installment.amount || 0)}
|
||||
onChange={(e) => {
|
||||
const value = parseFloat(e.target.value) || 0;
|
||||
const baseAmount = invoiceData.paymentTerms?.type === 'downpayment'
|
||||
? invoiceData.total - (invoiceData.paymentTerms?.downPayment?.amount || 0)
|
||||
: invoiceData.total;
|
||||
|
||||
let amount, percentage;
|
||||
if (installment.type === 'percentage') {
|
||||
percentage = value;
|
||||
amount = (baseAmount * percentage) / 100;
|
||||
} else {
|
||||
amount = value;
|
||||
percentage = baseAmount > 0 ? (amount / baseAmount) * 100 : 0;
|
||||
}
|
||||
|
||||
const updatedInstallments = invoiceData.paymentTerms.installments.map(inst =>
|
||||
inst.id === installment.id ? { ...inst, amount, percentage } : inst
|
||||
);
|
||||
updateInvoiceData('paymentTerms', {
|
||||
...invoiceData.paymentTerms,
|
||||
installments: updatedInstallments
|
||||
});
|
||||
}}
|
||||
placeholder="0"
|
||||
className="w-20 px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-600 dark:text-white"
|
||||
min="0"
|
||||
step="0.01"
|
||||
/>
|
||||
<input
|
||||
type="date"
|
||||
value={installment.dueDate}
|
||||
onChange={(e) => {
|
||||
const updatedInstallments = invoiceData.paymentTerms.installments.map(inst =>
|
||||
inst.id === installment.id ? { ...inst, dueDate: e.target.value } : inst
|
||||
);
|
||||
updateInvoiceData('paymentTerms', {
|
||||
...invoiceData.paymentTerms,
|
||||
installments: updatedInstallments
|
||||
});
|
||||
}}
|
||||
className="px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-600 dark:text-white"
|
||||
/>
|
||||
<select
|
||||
value={installment.status || 'pending'}
|
||||
onChange={(e) => {
|
||||
const updatedInstallments = invoiceData.paymentTerms.installments.map(inst =>
|
||||
inst.id === installment.id ? { ...inst, status: e.target.value } : inst
|
||||
);
|
||||
updateInvoiceData('paymentTerms', {
|
||||
...invoiceData.paymentTerms,
|
||||
installments: updatedInstallments
|
||||
});
|
||||
}}
|
||||
className={`px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-600 dark:text-white ${
|
||||
installment.status === 'paid' ? 'bg-green-50 text-green-800 dark:bg-green-900/20 dark:text-green-300' :
|
||||
installment.status === 'current' ? 'bg-yellow-50 text-yellow-800 dark:bg-yellow-900/20 dark:text-yellow-300' :
|
||||
installment.status === 'overdue' ? 'bg-red-50 text-red-800 dark:bg-red-900/20 dark:text-red-300' :
|
||||
'bg-gray-50 text-gray-800 dark:bg-gray-700 dark:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="current">Current</option>
|
||||
<option value="paid">Paid</option>
|
||||
<option value="overdue">Overdue</option>
|
||||
</select>
|
||||
<div className="flex items-center gap-1">
|
||||
<div key={installment.id} className="flex gap-3 mb-2 p-3 bg-gray-50 dark:bg-gray-700 rounded-md">
|
||||
{/* Main Content Area */}
|
||||
<div className="flex-1 flex flex-col lg:flex-row gap-2">
|
||||
{/* Installment Name - Full Width */}
|
||||
<input
|
||||
type="text"
|
||||
value={installment.description}
|
||||
onChange={(e) => {
|
||||
const updatedInstallments = invoiceData.paymentTerms.installments.map(inst =>
|
||||
inst.id === installment.id ? { ...inst, description: e.target.value } : inst
|
||||
);
|
||||
updateInvoiceData('paymentTerms', {
|
||||
...invoiceData.paymentTerms,
|
||||
installments: updatedInstallments
|
||||
});
|
||||
}}
|
||||
placeholder="Installment name"
|
||||
className="flex-1 px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-600 dark:text-white font-medium"
|
||||
/>
|
||||
|
||||
{/* Controls Row */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<input
|
||||
type="number"
|
||||
value={installment.type === 'percentage' ? (installment.percentage || 0) : (installment.amount || 0)}
|
||||
onChange={(e) => {
|
||||
const value = parseFloat(e.target.value) || 0;
|
||||
const baseAmount = invoiceData.paymentTerms?.type === 'downpayment'
|
||||
? invoiceData.total - (invoiceData.paymentTerms?.downPayment?.amount || 0)
|
||||
: invoiceData.total;
|
||||
|
||||
let amount, percentage;
|
||||
if (installment.type === 'percentage') {
|
||||
percentage = value;
|
||||
amount = (baseAmount * percentage) / 100;
|
||||
} else {
|
||||
amount = value;
|
||||
percentage = baseAmount > 0 ? (amount / baseAmount) * 100 : 0;
|
||||
}
|
||||
|
||||
const updatedInstallments = invoiceData.paymentTerms.installments.map(inst =>
|
||||
inst.id === installment.id ? { ...inst, amount, percentage } : inst
|
||||
);
|
||||
updateInvoiceData('paymentTerms', {
|
||||
...invoiceData.paymentTerms,
|
||||
installments: updatedInstallments
|
||||
});
|
||||
}}
|
||||
placeholder="0"
|
||||
className="flex-1 lg:flex-0 w-20 px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-600 dark:text-white"
|
||||
min="0"
|
||||
step="0.01"
|
||||
/>
|
||||
<select
|
||||
value={installment.type || 'fixed'}
|
||||
onChange={(e) => {
|
||||
const updatedInstallments = invoiceData.paymentTerms.installments.map(inst =>
|
||||
inst.id === installment.id ? { ...inst, type: e.target.value } : inst
|
||||
);
|
||||
updateInvoiceData('paymentTerms', {
|
||||
...invoiceData.paymentTerms,
|
||||
installments: updatedInstallments
|
||||
});
|
||||
}}
|
||||
className="px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-600 dark:text-white"
|
||||
>
|
||||
<option value="fixed">Fixed</option>
|
||||
<option value="percentage">%</option>
|
||||
</select>
|
||||
<input
|
||||
type="date"
|
||||
value={installment.dueDate}
|
||||
onChange={(e) => {
|
||||
const updatedInstallments = invoiceData.paymentTerms.installments.map(inst =>
|
||||
inst.id === installment.id ? { ...inst, dueDate: e.target.value } : inst
|
||||
);
|
||||
updateInvoiceData('paymentTerms', {
|
||||
...invoiceData.paymentTerms,
|
||||
installments: updatedInstallments
|
||||
});
|
||||
}}
|
||||
className="flex-1 lg:flex-0 px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-600 dark:text-white"
|
||||
/>
|
||||
<select
|
||||
value={installment.status || 'pending'}
|
||||
onChange={(e) => {
|
||||
const updatedInstallments = invoiceData.paymentTerms.installments.map(inst =>
|
||||
inst.id === installment.id ? { ...inst, status: e.target.value } : inst
|
||||
);
|
||||
updateInvoiceData('paymentTerms', {
|
||||
...invoiceData.paymentTerms,
|
||||
installments: updatedInstallments
|
||||
});
|
||||
}}
|
||||
className={`flex-1 lg:flex-0 px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-600 dark:text-white ${
|
||||
installment.status === 'paid' ? 'bg-green-50 text-green-800 dark:bg-green-900/20 dark:text-green-300' :
|
||||
installment.status === 'current' ? 'bg-yellow-50 text-yellow-800 dark:bg-yellow-900/20 dark:text-yellow-300' :
|
||||
installment.status === 'overdue' ? 'bg-red-50 text-red-800 dark:bg-red-900/20 dark:text-red-300' :
|
||||
'bg-gray-50 text-gray-800 dark:bg-gray-700 dark:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="current">Current</option>
|
||||
<option value="paid">Paid</option>
|
||||
<option value="overdue">Overdue</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons - Always on Right */}
|
||||
<div className="flex flex-col lg:flex-row gap-1 items-center justify-center">
|
||||
<button
|
||||
onClick={() => moveInstallment(installment.id, 'up')}
|
||||
disabled={index === 0}
|
||||
className="p-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 rounded transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 rounded transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
title="Move up"
|
||||
>
|
||||
<ChevronUp className="h-3 w-3" />
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => moveInstallment(installment.id, 'down')}
|
||||
disabled={index === (invoiceData.paymentTerms?.installments || []).length - 1}
|
||||
className="p-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 rounded transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 rounded transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
title="Move down"
|
||||
>
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
@@ -1721,9 +1802,10 @@ const InvoiceEditor = () => {
|
||||
installments: updatedInstallments
|
||||
});
|
||||
}}
|
||||
className="p-1 text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300"
|
||||
className="p-2 text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300 hover:bg-red-50 dark:hover:bg-red-900/20 rounded transition-colors mt-12 lg:mt-0"
|
||||
title="Delete installment"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user