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:
@@ -43,7 +43,7 @@ const Home = () => {
|
||||
<span className="animate-pulse">_</span>
|
||||
</div>
|
||||
|
||||
<h1 className="text-5xl md:text-7xl font-bold bg-gradient-to-r from-blue-600 via-purple-600 to-indigo-600 bg-clip-text text-transparent mb-6">
|
||||
<h1 className="text-5xl hidden md:text-7xl font-bold bg-gradient-to-r from-blue-600 via-purple-600 to-indigo-600 bg-clip-text text-transparent mb-6">
|
||||
{SITE_CONFIG.title}
|
||||
</h1>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -20,6 +20,30 @@ const InvoicePreview = () => {
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [selectedTemplate] = useState('minimal');
|
||||
|
||||
// Calculate totals (same logic as InvoiceEditor)
|
||||
const calculateTotals = (items, discount = 0, fees = [], discounts = []) => {
|
||||
const subtotal = items.reduce((sum, item) => sum + (item.amount || 0), 0);
|
||||
|
||||
// Calculate total fees
|
||||
const totalFees = fees.reduce((sum, fee) => {
|
||||
const feeAmount = fee.type === 'percentage'
|
||||
? (subtotal * fee.value) / 100
|
||||
: fee.value;
|
||||
return sum + feeAmount;
|
||||
}, 0);
|
||||
|
||||
// Calculate total discounts
|
||||
const totalDiscounts = discounts.reduce((sum, discountItem) => {
|
||||
const discountAmount = discountItem.type === 'percentage'
|
||||
? (subtotal * discountItem.value) / 100
|
||||
: discountItem.value;
|
||||
return sum + discountAmount;
|
||||
}, 0);
|
||||
|
||||
const total = subtotal + totalFees - discount - totalDiscounts;
|
||||
return { subtotal, total };
|
||||
};
|
||||
|
||||
// Load invoice data from localStorage
|
||||
useEffect(() => {
|
||||
try {
|
||||
@@ -28,11 +52,41 @@ const InvoicePreview = () => {
|
||||
|
||||
if (savedInvoice) {
|
||||
const parsedInvoice = JSON.parse(savedInvoice);
|
||||
|
||||
// Recalculate totals and installments to ensure accuracy
|
||||
const { subtotal, total } = calculateTotals(
|
||||
parsedInvoice.items || [],
|
||||
parsedInvoice.discount || 0,
|
||||
parsedInvoice.fees || [],
|
||||
parsedInvoice.discounts || []
|
||||
);
|
||||
|
||||
// Update totals
|
||||
parsedInvoice.subtotal = subtotal;
|
||||
parsedInvoice.total = total;
|
||||
|
||||
// Recalculate installments if they exist
|
||||
if (parsedInvoice.paymentTerms?.installments?.length > 0) {
|
||||
const updatedInstallments = parsedInvoice.paymentTerms.installments.map(installment => {
|
||||
if (installment.type === 'percentage') {
|
||||
const baseAmount = parsedInvoice.paymentTerms?.type === 'downpayment'
|
||||
? total - (parsedInvoice.paymentTerms?.downPayment?.amount || 0)
|
||||
: total;
|
||||
const amount = (baseAmount * (installment.percentage || 0)) / 100;
|
||||
return { ...installment, amount };
|
||||
}
|
||||
return installment;
|
||||
});
|
||||
|
||||
parsedInvoice.paymentTerms = {
|
||||
...parsedInvoice.paymentTerms,
|
||||
installments: updatedInstallments
|
||||
};
|
||||
}
|
||||
|
||||
setInvoiceData(parsedInvoice);
|
||||
// Set page title with invoice number
|
||||
document.title = `Invoice Preview - ${parsedInvoice.invoiceNumber || 'Draft'} | DevTools`;
|
||||
} else {
|
||||
// No invoice data, redirect back to editor
|
||||
// No invoice data found, redirect to editor
|
||||
navigate('/invoice-editor');
|
||||
}
|
||||
|
||||
@@ -41,11 +95,10 @@ const InvoicePreview = () => {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load invoice data:', error);
|
||||
navigate('/invoice-editor', { replace: true });
|
||||
navigate('/invoice-editor');
|
||||
}
|
||||
}, [navigate]);
|
||||
|
||||
|
||||
// Format number with thousand separator
|
||||
const formatNumber = (num) => {
|
||||
if (!invoiceData?.settings?.thousandSeparator) return num.toString();
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Calendar, Sparkles, Bug, Zap, Shield, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import ToolLayout from '../components/ToolLayout';
|
||||
import { getReleases } from '../utils/releaseNotesAPI';
|
||||
|
||||
const ReleaseNotes = () => {
|
||||
const [releases, setReleases] = useState([]);
|
||||
@@ -9,6 +8,7 @@ const ReleaseNotes = () => {
|
||||
const [expandedReleases, setExpandedReleases] = useState(new Set());
|
||||
|
||||
// Parse commit messages into user-friendly release notes (keeping local version for now)
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const parseCommitMessage = (message) => {
|
||||
// Skip non-user-informative commits
|
||||
const skipPatterns = [
|
||||
@@ -186,54 +186,31 @@ const ReleaseNotes = () => {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// Fetch dynamic release data from Gitea API
|
||||
// Load release data from commits.json
|
||||
const fetchReleases = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// Gitea API configuration using your environment variables
|
||||
const config = {
|
||||
source: 'gitea',
|
||||
owner: process.env.REACT_APP_GITEA_OWNER || 'dwindown',
|
||||
repo: process.env.REACT_APP_GITEA_REPO || 'dewedev',
|
||||
token: process.env.REACT_APP_GITEA_TOKEN,
|
||||
baseUrl: process.env.REACT_APP_GITEA_BASE_URL || 'https://git.backoffice.biz.id'
|
||||
};
|
||||
|
||||
const fetchedReleases = await getReleases(config);
|
||||
setReleases(fetchedReleases);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch releases from Gitea:', error);
|
||||
// Fallback to static data if API fails
|
||||
const fallbackData = [
|
||||
{
|
||||
id: 'fallback-1',
|
||||
date: '2025-01-28T00:19:28+07:00',
|
||||
message: 'feat: Invoice Editor improvements and code cleanup',
|
||||
author: 'Developer'
|
||||
},
|
||||
{
|
||||
id: 'fallback-2',
|
||||
date: '2025-01-27T23:45:00+07:00',
|
||||
message: 'feat: Enhanced What\'s New feature with NON_TOOLS category and global footer',
|
||||
author: 'Developer'
|
||||
}
|
||||
];
|
||||
const response = await fetch('/data/commits.json');
|
||||
const data = await response.json();
|
||||
|
||||
const parsedReleases = fallbackData
|
||||
.map(commit => {
|
||||
const parsed = parseCommitMessage(commit.message);
|
||||
if (!parsed) return null;
|
||||
|
||||
return {
|
||||
...parsed,
|
||||
date: commit.date,
|
||||
id: commit.id,
|
||||
author: commit.author
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
setReleases(parsedReleases);
|
||||
// Transform changelog data to release format
|
||||
const releases = [];
|
||||
data.changelog.forEach(dateEntry => {
|
||||
dateEntry.changes.forEach(change => {
|
||||
releases.push({
|
||||
id: `${dateEntry.date}-${change.type}-${change.title.replace(/\s+/g, '-')}`,
|
||||
date: change.datetime || dateEntry.date, // Use datetime if available, fallback to date
|
||||
type: change.type,
|
||||
title: change.title,
|
||||
description: change.description
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
setReleases(releases);
|
||||
} catch (error) {
|
||||
console.error('Failed to load commits.json:', error);
|
||||
setReleases([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -351,7 +328,6 @@ const ReleaseNotes = () => {
|
||||
minute: '2-digit'
|
||||
})}
|
||||
</span>
|
||||
<span>#{release.hash}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user