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:
dwindown
2025-09-28 17:14:54 +07:00
parent 9993614073
commit 78570f04f0
20 changed files with 712 additions and 395 deletions

View File

@@ -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>