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:
@@ -59,16 +59,29 @@ const Layout = ({ children }) => {
|
||||
<header className="fixed top-0 left-0 right-0 z-50 bg-white/80 dark:bg-slate-800/80 backdrop-blur-md shadow-lg border-b border-slate-200/50 dark:border-slate-700/50 flex-shrink-0">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex justify-between items-center h-16">
|
||||
<button onClick={() => navigateWithGuard('/')} className="flex items-center space-x-3 group">
|
||||
<button onClick={() => navigateWithGuard('/')} className="flex items-center group">
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-blue-500 to-purple-500 rounded-lg blur opacity-20 group-hover:opacity-40 transition-opacity"></div>
|
||||
<div className="relative bg-gradient-to-r from-blue-500 to-purple-500 p-2 rounded-lg">
|
||||
<Terminal className="h-6 w-6 text-white" />
|
||||
<div className="absolute inset-0 rounded-lg blur opacity-20 group-hover:opacity-40 transition-opacity"></div>
|
||||
<div className="relative p-2">
|
||||
<img
|
||||
src="/logo.svg"
|
||||
alt={SITE_CONFIG.title}
|
||||
className="h-8 w-auto"
|
||||
style={{ maxWidth: '150px' }}
|
||||
onError={(e) => {
|
||||
// Fallback to Terminal icon with text if logo fails to load
|
||||
e.target.style.display = 'none';
|
||||
e.target.nextSibling.style.display = 'flex';
|
||||
}}
|
||||
/>
|
||||
<div className="hidden items-center space-x-3">
|
||||
<Terminal className="h-6 w-6 text-blue-500" />
|
||||
<span className="text-xl font-bold bg-gradient-to-r from-blue-600 via-purple-600 to-indigo-600 bg-clip-text text-transparent">
|
||||
{SITE_CONFIG.title}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-xl font-bold bg-gradient-to-r from-blue-600 via-purple-600 to-indigo-600 bg-clip-text text-transparent">
|
||||
{SITE_CONFIG.title}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div className="flex items-center space-x-4">
|
||||
@@ -266,16 +279,29 @@ const Layout = ({ children }) => {
|
||||
<footer className="bg-white/30 dark:bg-slate-800/30 backdrop-blur-sm border-t border-slate-200/30 dark:border-slate-700/30 mt-20">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
<div className="text-center">
|
||||
<div className="flex items-center justify-center gap-3 mb-4">
|
||||
<div className="flex items-center justify-center mb-4">
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-blue-500 to-purple-500 rounded-lg blur opacity-20"></div>
|
||||
<div className="relative bg-gradient-to-r from-blue-500 to-purple-500 p-2 rounded-lg">
|
||||
<Terminal className="h-5 w-5 text-white" />
|
||||
<div className="absolute inset-0 rounded-lg blur opacity-20"></div>
|
||||
<div className="relative p-2">
|
||||
<img
|
||||
src="/icon-192x192.png"
|
||||
alt={SITE_CONFIG.title}
|
||||
className="h-16 w-auto"
|
||||
style={{ maxWidth: '100px' }}
|
||||
onError={(e) => {
|
||||
// Fallback to Terminal icon with text if logo fails to load
|
||||
e.target.style.display = 'none';
|
||||
e.target.nextSibling.style.display = 'flex';
|
||||
}}
|
||||
/>
|
||||
<div className="hidden items-center gap-3">
|
||||
<Terminal className="h-5 w-5 text-blue-500" />
|
||||
<span className="text-lg font-bold bg-gradient-to-r from-blue-600 via-purple-600 to-indigo-600 bg-clip-text text-transparent">
|
||||
{SITE_CONFIG.title}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-lg font-bold bg-gradient-to-r from-blue-600 via-purple-600 to-indigo-600 bg-clip-text text-transparent">
|
||||
{SITE_CONFIG.title}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-center gap-2 mb-3">
|
||||
<div className="w-2 h-2 bg-gradient-to-r from-blue-500 to-purple-500 rounded-full"></div>
|
||||
@@ -338,6 +364,19 @@ const Layout = ({ children }) => {
|
||||
<footer className="bg-white/30 dark:bg-slate-800/30 backdrop-blur-sm border-t border-slate-200/30 dark:border-slate-700/30">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
||||
<div className="text-center">
|
||||
<div className="flex items-center justify-center gap-2 mb-2">
|
||||
<img
|
||||
src="/icon-192x192.png"
|
||||
alt={SITE_CONFIG.title}
|
||||
className="h-16 w-auto"
|
||||
style={{ maxWidth: '100px' }}
|
||||
onError={(e) => {
|
||||
// Fallback to Terminal icon with text if logo fails to load
|
||||
e.target.style.display = 'none';
|
||||
e.target.nextSibling.style.display = 'flex';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-center gap-2 mb-2">
|
||||
<div className="w-2 h-2 bg-gradient-to-r from-blue-500 to-purple-500 rounded-full"></div>
|
||||
<span className="text-xs font-medium text-slate-600 dark:text-slate-400">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Edit3, Table, LinkIcon, Hash, Wand2, GitCompare, Type, Home, Zap, FileText } from 'lucide-react';
|
||||
import { Edit3, Table, LinkIcon, Hash, Wand2, GitCompare, Type, Home, FileText } from 'lucide-react';
|
||||
|
||||
// Master tools configuration - single source of truth
|
||||
export const TOOL_CATEGORIES = {
|
||||
@@ -121,13 +121,6 @@ export const NON_TOOLS = [
|
||||
icon: Home,
|
||||
description: 'Back to homepage',
|
||||
category: 'non_tools'
|
||||
},
|
||||
{
|
||||
path: '/release-notes',
|
||||
name: "What's New",
|
||||
icon: Zap,
|
||||
description: 'Latest updates and new features',
|
||||
category: 'non_tools'
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -24,7 +24,10 @@ export const fetchGitHubReleases = async (owner, repo, token = null) => {
|
||||
// Fetch commits from GitHub API
|
||||
const response = await fetch(
|
||||
`https://api.github.com/repos/${owner}/${repo}/commits?per_page=20`,
|
||||
{ headers }
|
||||
{
|
||||
method: 'GET',
|
||||
headers
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -49,16 +52,18 @@ export const fetchGitHubReleases = async (owner, repo, token = null) => {
|
||||
// Option 2: Gitea API Integration (for your Coolify server setup)
|
||||
export const fetchGiteaReleases = async (owner, repo, token, baseUrl) => {
|
||||
try {
|
||||
const headers = {
|
||||
'Authorization': `token ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
};
|
||||
// Use URL parameters for auth to avoid CORS preflight
|
||||
const url = new URL(`${baseUrl}/api/v1/repos/${owner}/${repo}/commits`);
|
||||
url.searchParams.set('limit', '20');
|
||||
if (token) {
|
||||
url.searchParams.set('token', token);
|
||||
}
|
||||
|
||||
// Fetch commits from Gitea API
|
||||
const response = await fetch(
|
||||
`${baseUrl}/api/v1/repos/${owner}/${repo}/commits?limit=20`,
|
||||
{ headers }
|
||||
);
|
||||
// Fetch commits from Gitea API with minimal headers
|
||||
const response = await fetch(url.toString(), {
|
||||
method: 'GET',
|
||||
mode: 'cors'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Gitea API error: ${response.status}`);
|
||||
@@ -82,7 +87,7 @@ export const fetchGiteaReleases = async (owner, repo, token, baseUrl) => {
|
||||
// Option 3: Custom Backend API
|
||||
export const fetchCustomReleases = async (apiEndpoint) => {
|
||||
try {
|
||||
const response = await fetch(apiEndpoint);
|
||||
const response = await fetch(apiEndpoint, { method: 'GET' });
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`API error: ${response.status}`);
|
||||
@@ -98,7 +103,7 @@ export const fetchCustomReleases = async (apiEndpoint) => {
|
||||
// Option 4: Static JSON File (simplest approach)
|
||||
export const fetchStaticReleases = async () => {
|
||||
try {
|
||||
const response = await fetch('/data/releases.json');
|
||||
const response = await fetch('/data/releases.json', { method: 'GET' });
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load releases: ${response.status}`);
|
||||
|
||||
Reference in New Issue
Block a user