feat: Enhanced release notes system, fixed invoice installments, and improved logo integration

- Updated release notes to use new JSON structure with individual commit timestamps
- Removed hash display from release notes for cleaner UI
- Fixed automatic recalculation of percentage-based installments in Invoice Editor and Preview
- Integrated custom logo.svg in header and footer with cleaner styling
- Moved all data files to /public/data/ for better organization
- Cleaned up unused release data files and improved file structure
This commit is contained in:
dwindown
2025-09-28 17:14:54 +07:00
parent 9993614073
commit 78570f04f0
20 changed files with 712 additions and 395 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

BIN
public/apple-touch-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

130
public/data/commits.json Normal file
View File

@@ -0,0 +1,130 @@
{
"changelog": [
{
"date": "2025-09-28",
"changes": [
{
"datetime": "2025-09-28T17:11:19+07:00",
"type": "enhancement",
"title": "Release Notes System Improvements",
"description": "Updated release notes to use new JSON structure with individual commit timestamps, removed hash display, and moved data to /public/data/ for better organization."
},
{
"datetime": "2025-09-28T17:10:30+07:00",
"type": "fix",
"title": "Invoice Installment Calculation Fix",
"description": "Fixed automatic recalculation of percentage-based installments in both Invoice Editor and Preview when invoice totals change due to item, fee, or discount modifications."
},
{
"datetime": "2025-09-28T17:09:45+07:00",
"type": "enhancement",
"title": "Logo Integration & Layout Improvements",
"description": "Integrated custom logo.svg in header and footer, removed background styling for cleaner logo display, and cleaned up unused release data files."
},
{
"datetime": "2025-09-28T00:41:48+07:00",
"type": "feature",
"title": "SEO & Privacy Compliance",
"description": "Comprehensive SEO optimization with GDPR-compliant analytics and consent management."
}
]
},
{
"date": "2025-09-27",
"changes": [
{
"datetime": "2025-09-27T23:54:19+07:00",
"type": "feature",
"title": "Mobile UI Improvements",
"description": "Optimized interface for mobile devices with better analytics integration."
},
{
"datetime": "2025-09-27T23:14:26+07:00",
"type": "enhancement",
"title": "Enhanced Object Editor & Table View",
"description": "Improved user interface and experience with better JSON parsing, HTML rendering, and copy functionality."
},
{
"datetime": "2025-09-27T22:20:13+07:00",
"type": "feature",
"title": "What's New Feature & Navigation Improvements",
"description": "Added attractive 'What's New' button to homepage, created NON_TOOLS category for better navigation organization, and implemented unified global footer across all pages."
},
{
"datetime": "2025-09-27T21:28:43+07:00",
"type": "feature",
"title": "Invoice Editor Major Update",
"description": "Complete overhaul of Invoice Editor with currency system, PDF generation fixes, improved UI/UX, and streamlined preview toolbar."
},
{
"datetime": "2025-09-27T20:25:56+07:00",
"type": "feature",
"title": "Enhanced Workflow for All Tools",
"description": "Added convenient 'Clear', 'Copy', 'Sample', and 'Download' buttons across all tools to help streamline your workflow."
},
{
"datetime": "2025-09-27T19:54:41+07:00",
"type": "fix",
"title": "General Bug Fixes",
"description": "Addressed various minor bugs and improved overall site performance for a faster, smoother experience."
}
]
},
{
"date": "2025-09-21",
"changes": [
{
"datetime": "2025-09-21T17:29:46+07:00",
"type": "enhancement",
"title": "Improved Tool Pages",
"description": "Every tool now features a clear header, breadcrumb navigation, and a helpful description to make finding and using them easier than ever."
},
{
"datetime": "2025-09-21T16:51:17+07:00",
"type": "feature",
"title": "New 'What's New' Page",
"description": "Launched this 'What's New' page to keep you updated on the latest changes."
}
]
},
{
"date": "2025-09-19",
"changes": [
{
"datetime": "2025-09-19T00:09:05+07:00",
"type": "fix",
"title": "UI Fixes",
"description": "Corrected a UI bug in the Text Extractor tool and improved the copy button functionality."
}
]
},
{
"date": "2025-09-18",
"changes": [
{
"datetime": "2025-09-18T23:44:39+07:00",
"type": "enhancement",
"title": "Sidebar and Menu Enhancements",
"description": "Improved the tool sidebar and mobile menu for better navigation."
}
]
},
{
"date": "2025-08-21",
"changes": [
{
"datetime": "2025-08-21T23:45:46+07:00",
"type": "feature",
"title": "New Tools Added",
"description": "A comprehensive suite of developer tools has been added to the site, ready for you to use."
},
{
"datetime": "2025-08-21T23:17:54+07:00",
"type": "feature",
"title": "Website Launch",
"description": "Welcome to our new site! We're excited to launch with a full set of developer tools."
}
]
}
]
}

View File

@@ -1,37 +0,0 @@
[
{
"id": "04db088f",
"message": "feat: Invoice Editor improvements and code cleanup",
"date": "2025-01-28T00:19:28+07:00",
"author": "Developer",
"url": "https://git.backoffice.biz.id/dwindown/dewedev/-/commit/04db088f"
},
{
"id": "b2850ea1",
"message": "fix: Remove unused variables to resolve ESLint errors",
"date": "2025-01-27T23:45:00+07:00",
"author": "Developer",
"url": "https://git.backoffice.biz.id/dwindown/dewedev/-/commit/b2850ea1"
},
{
"id": "7792190e",
"message": "feat: Enhanced What's New feature with NON_TOOLS category and global footer",
"date": "2025-01-27T22:30:00+07:00",
"author": "Developer",
"url": "https://git.backoffice.biz.id/dwindown/dewedev/-/commit/7792190e"
},
{
"id": "21d0406e",
"message": "Improve ObjectEditor and PostmanTable UI/UX",
"date": "2025-01-27T21:15:00+07:00",
"author": "Developer",
"url": "https://git.backoffice.biz.id/dwindown/dewedev/-/commit/21d0406e"
},
{
"id": "57655410",
"message": "feat: optimize analytics and mobile UI improvements",
"date": "2025-01-27T20:00:00+07:00",
"author": "Developer",
"url": "https://git.backoffice.biz.id/dwindown/dewedev/-/commit/57655410"
}
]

BIN
public/favicon-16x16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 962 B

BIN
public/favicon-32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

BIN
public/icon-192x192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

BIN
public/icon-512x512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

View File

@@ -3,10 +3,14 @@
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" /> <link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<link rel="icon" type="image/png" sizes="16x16" href="%PUBLIC_URL%/favicon-16x16.png" />
<link rel="icon" type="image/png" sizes="32x32" href="%PUBLIC_URL%/favicon-32x32.png" />
<link rel="apple-touch-icon" sizes="180x180" href="%PUBLIC_URL%/apple-touch-icon.png" />
<link rel="icon" type="image/png" sizes="192x192" href="%PUBLIC_URL%/android-chrome-192x192.png" />
<link rel="icon" type="image/png" sizes="512x512" href="%PUBLIC_URL%/android-chrome-512x512.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#0ea5e9" /> <meta name="theme-color" content="#0ea5e9" />
<meta name="description" content="Developer Tools MVP - Essential utilities for web developers" /> <meta name="description" content="Developer Tools MVP - Essential utilities for web developers" />
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" /> <link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<title>Developer Tools - Web Developer Utilities</title> <title>Developer Tools - Web Developer Utilities</title>
</head> </head>
@@ -14,4 +18,4 @@
<noscript>You need to enable JavaScript to run this app.</noscript> <noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div> <div id="root"></div>
</body> </body>
</html> </html>

72
public/logo.svg Normal file
View File

@@ -0,0 +1,72 @@
<svg version="1.2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 300 75" width="300" height="75">
<defs>
<radialGradient id="g1" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="matrix(44.318,10.028,-10.028,44.318,53.669,12.735)">
<stop offset="0" stop-color="#d056a5"/>
<stop offset="1" stop-color="#6665e9"/>
</radialGradient>
<clipPath clipPathUnits="userSpaceOnUse" id="cp1">
<path d="m32.56 20.66c0.59-2.02 2.46-3.4 4.57-3.41 2.07 0.01 4.25 0.01 4.25 0.01 0 0-8.18 27.43-11.08 37.2-0.6 2.03-2.47 3.41-4.59 3.42-2.05 0-4.25 0-4.25 0 0 0 8.18-27.43 11.1-37.22z"/>
</clipPath>
<linearGradient id="g2" x2="1" gradientUnits="userSpaceOnUse" gradientTransform="matrix(-8.104,25.657,-4.604,-1.454,34.912,29.585)">
<stop offset="0" stop-color="#d056a5" stop-opacity=".21"/>
<stop offset="1" stop-color="#d056a5" stop-opacity="0"/>
</linearGradient>
</defs>
<style>
.s0 { fill: #4a5567 }
.s1 { fill: #808080 }
.s2 { fill: url(#g1) }
.s3 { fill: #ffffff }
.s4 { fill: url(#g2) }
</style>
<g>
<g id="d">
<path class="s0" d="m105.66 24.68c-3.24-3.13-7.33-4.81-11.77-4.81-10.34 0-18.51 8.05-18.51 19.47 0 11.18 8.17 19.35 18.51 19.35 4.44 0 8.65-1.68 11.77-4.93l0.72 4.33h8.54v-49.87h-9.26zm0 14.66c0 6.37-4.8 10.45-10.81 10.45-5.77 0-10.46-4.08-10.46-10.45 0-6.49 4.69-10.58 10.46-10.58 6.01 0 10.81 4.09 10.81 10.58z"/>
</g>
<g id="e">
<path class="s0" d="m146.16 44.51c-1.56 3.24-5.17 5.17-9.01 5.17-5.05 0-9.25-3.13-10.1-8.18h29.33v-3.48c-0.36-11.18-7.93-18.15-19.23-18.15-10.82 0-19.59 8.05-19.59 19.47 0 11.18 8.77 19.35 19.59 19.35 8.89 0 17.54-5.77 18.87-14.18zm-18.27-10.34c1.8-3.24 5.17-5.28 9.25-5.28 3.97 0 7.09 2.04 8.66 5.28z"/>
</g>
<g id="w">
<path class="s0" d="m200.48 58.09l13.7-37.86h-9.61l-7.93 23.2-8.06-23.2h-8.05l-7.93 23.2-7.93-23.2h-9.74l13.82 37.86h7.7l8.05-22.47 8.17 22.47z"/>
</g>
<g id="e.2">
<path class="s0" d="m240.86 44.51c-1.68 3.24-5.17 5.17-9.01 5.17-5.05 0-9.26-3.13-10.22-8.18h29.44v-3.48c-0.36-11.18-8.05-18.15-19.22-18.15-10.82 0-19.59 8.05-19.59 19.47 0 11.18 8.77 19.35 19.59 19.35 8.89 0 17.54-5.77 18.86-14.18zm-18.26-10.34c1.68-3.24 5.16-5.28 9.25-5.28 3.85 0 7.09 2.04 8.65 5.28z"/>
</g>
<g id=".">
<path class="s1" d="m252.55 58.21q-0.65 0.01-1.1-0.44-0.46-0.46-0.46-1.1 0-0.66 0.46-1.11 0.45-0.46 1.1-0.46 0.63 0 1.07 0.46 0.44 0.45 0.44 1.11 0 0.64-0.44 1.1-0.44 0.45-1.07 0.44z"/>
</g>
<g id="d">
<path class="s1" d="m255.74 51.14q0-2.11 0.85-3.71 0.84-1.59 2.35-2.47 1.49-0.87 3.35-0.86 1.6 0 2.99 0.72 1.35 0.75 2.11 1.95v-7.27h2.31v18.57h-2.31v-2.59q-0.69 1.23-2.01 2.03-1.35 0.78-3.11 0.78-1.83 0-3.33-0.9-1.51-0.91-2.35-2.53-0.85-1.63-0.85-3.72zm11.65 0.03q-0.01-1.57-0.65-2.71-0.63-1.16-1.68-1.77-1.06-0.63-2.35-0.62-1.27 0-2.33 0.6-1.05 0.6-1.68 1.77-0.63 1.14-0.63 2.7 0 1.58 0.63 2.76 0.63 1.15 1.68 1.76 1.06 0.63 2.33 0.62 1.29 0.01 2.35-0.62 1.05-0.61 1.68-1.76 0.64-1.17 0.65-2.73z"/>
</g>
<g id="e">
<path class="s1" d="m285.59 50.66q0.01 0.67-0.08 1.39h-10.98q0.12 2.02 1.39 3.17 1.26 1.14 3.07 1.14c0.98 0 1.83-0.24 2.47-0.7q0.99-0.69 1.4-1.83h2.45q-0.54 1.97-2.21 3.22-1.65 1.24-4.11 1.24-1.96 0-3.49-0.88-1.55-0.87-2.43-2.49-0.87-1.62-0.87-3.75 0-2.14 0.85-3.74 0.85-1.62 2.39-2.47 1.56-0.87 3.55-0.86 1.96 0 3.47 0.84 1.5 0.85 2.31 2.35c0.56 0.98 0.82 2.13 0.82 3.37zm-2.34-0.48q-0.01-1.29-0.59-2.25-0.57-0.94-1.56-1.42-1-0.48-2.21-0.48-1.72-0.01-2.95 1.1-1.21 1.09-1.39 3.05z"/>
</g>
<g id="v">
<path class="s1" d="m293.22 55.96l4.28-11.64h2.43l-5.4 13.75h-2.65l-5.4-13.75h2.45z"/>
</g>
<g id="Folder 1">
<g>
<g>
<path fill-rule="evenodd" class="s2" d="m62.85 21.84v31.43c0 8.66-7.04 15.71-15.71 15.71h-31.43c-8.68 0-15.72-7.05-15.72-15.71v-31.43c0-8.68 7.04-15.72 15.72-15.72h31.43c8.67 0 15.71 7.04 15.71 15.72z"/>
</g>
</g>
<g>
<g>
<path fill-rule="evenodd" class="s3" d="m21.02 52.24l-7.88-6.43c-2.51-2.05-3.96-5.13-3.95-8.37 0-3.25 1.49-6.31 4-8.33l6.67-5.37c1.92-1.54 4.73-1.23 6.28 0.68l1.9 2.35-10.14 8.16c-0.77 0.62-1.23 1.55-1.23 2.54 0 0.99 0.44 1.91 1.19 2.53l5.46 4.47zm20.43-28.1l8.2 6.59c2.53 2.04 4 5.1 4.02 8.34 0.01 3.25-1.44 6.32-3.96 8.36l-6.72 5.5c-1.92 1.56-4.72 1.27-6.29-0.65l-1.91-2.33 10.19-8.31c0.77-0.62 1.21-1.56 1.21-2.54-0.02-0.99-0.46-1.91-1.23-2.53l-5.81-4.68zm-14.7 23.11l1.31 1.07-2.55 3.12zm8.95-18.14l-0.88-0.71 1.74-2.2z"/>
</g>
</g>
<g>
<g>
<path fill-rule="evenodd" class="s3" d="m32.56 20.66c0.59-2.02 2.46-3.4 4.57-3.41 2.07 0.01 4.25 0.01 4.25 0.01 0 0-8.18 27.43-11.08 37.2-0.6 2.03-2.47 3.41-4.59 3.42-2.05 0-4.25 0-4.25 0 0 0 8.18-27.43 11.1-37.22z"/>
<g id="Clip-Path" clip-path="url(#cp1)">
<g>
<g>
<path fill-rule="evenodd" class="s4" d="m36.04 20.66c0.59-2.02 2.46-3.4 4.57-3.41 2.07 0.01 4.25 0.01 4.25 0.01 0 0-8.18 27.43-11.08 37.2-0.6 2.03-2.47 3.41-4.58 3.42-2.06 0-4.25 0-4.25 0 0 0 8.18-27.43 11.09-37.22z"/>
</g>
</g>
</g>
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

View File

@@ -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"> <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="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between items-center h-16"> <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="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="absolute inset-0 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"> <div className="relative p-2">
<Terminal className="h-6 w-6 text-white" /> <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>
</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> </button>
<div className="flex items-center space-x-4"> <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"> <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="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div className="text-center"> <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="relative">
<div className="absolute inset-0 bg-gradient-to-r from-blue-500 to-purple-500 rounded-lg blur opacity-20"></div> <div className="absolute inset-0 rounded-lg blur opacity-20"></div>
<div className="relative bg-gradient-to-r from-blue-500 to-purple-500 p-2 rounded-lg"> <div className="relative p-2">
<Terminal className="h-5 w-5 text-white" /> <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>
</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>
<div className="flex items-center justify-center gap-2 mb-3"> <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> <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"> <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="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
<div className="text-center"> <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="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> <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"> <span className="text-xs font-medium text-slate-600 dark:text-slate-400">

View File

@@ -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 // Master tools configuration - single source of truth
export const TOOL_CATEGORIES = { export const TOOL_CATEGORIES = {
@@ -121,13 +121,6 @@ export const NON_TOOLS = [
icon: Home, icon: Home,
description: 'Back to homepage', description: 'Back to homepage',
category: 'non_tools' category: 'non_tools'
},
{
path: '/release-notes',
name: "What's New",
icon: Zap,
description: 'Latest updates and new features',
category: 'non_tools'
} }
]; ];

View File

@@ -43,7 +43,7 @@ const Home = () => {
<span className="animate-pulse">_</span> <span className="animate-pulse">_</span>
</div> </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} {SITE_CONFIG.title}
</h1> </h1>

View File

@@ -100,7 +100,7 @@ const InvoiceEditor = () => {
useEffect(() => { useEffect(() => {
const loadCurrencies = async () => { const loadCurrencies = async () => {
try { try {
const response = await fetch('/utils/currencies.json'); const response = await fetch('/data/currencies.json');
const currencyData = await response.json(); const currencyData = await response.json();
setCurrencies(currencyData); setCurrencies(currencyData);
} catch (error) { } catch (error) {
@@ -174,6 +174,47 @@ const InvoiceEditor = () => {
} }
}, [invoiceData, createNewCompleted]); }, [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 // Save PDF page size to localStorage
useEffect(() => { useEffect(() => {
try { try {
@@ -1254,53 +1295,64 @@ const InvoiceEditor = () => {
</button> </button>
</div> </div>
{(invoiceData.fees || []).map((fee, index) => ( {(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"> <div key={fee.id} className="flex gap-3 mb-2 p-3 bg-gray-50 dark:bg-gray-700 rounded-md">
<input {/* Main Content Area */}
type="text" <div className="flex-1 flex flex-col xl:flex-row gap-2">
value={fee.label} {/* Fee Name */}
onChange={(e) => updateFee(fee.id, 'label', e.target.value)} <input
placeholder="Fee name" type="text"
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" value={fee.label}
/> onChange={(e) => updateFee(fee.id, 'label', e.target.value)}
<select placeholder="Fee name"
value={fee.type} 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"
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"
> {/* Controls Row */}
<option value="fixed">Fixed</option> <div className="flex flex-wrap gap-2 flex-col xl:flex-row">
<option value="percentage">%</option> <input
</select> type="number"
<input value={fee.value}
type="number" onChange={(e) => updateFee(fee.id, 'value', parseFloat(e.target.value) || 0)}
value={fee.value} placeholder="0"
onChange={(e) => updateFee(fee.id, 'value', parseFloat(e.target.value) || 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"
placeholder="0" min="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" step="0.01"
min="0" />
step="0.01" <select
/> value={fee.type}
<div className="flex items-center gap-1"> 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 <button
onClick={() => moveItem('fees', fee.id, 'up')} onClick={() => moveItem('fees', fee.id, 'up')}
disabled={index === 0} 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" title="Move up"
> >
<ChevronUp className="h-3 w-3" /> <ChevronUp className="h-4 w-4" />
</button> </button>
<button <button
onClick={() => moveItem('fees', fee.id, 'down')} onClick={() => moveItem('fees', fee.id, 'down')}
disabled={index === (invoiceData.fees || []).length - 1} 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" title="Move down"
> >
<ChevronDown className="h-3 w-3" /> <ChevronDown className="h-4 w-4" />
</button> </button>
<button <button
onClick={() => removeFee(fee.id)} 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> </button>
</div> </div>
</div> </div>
@@ -1320,53 +1372,64 @@ const InvoiceEditor = () => {
</button> </button>
</div> </div>
{(invoiceData.discounts || []).map((discount, index) => ( {(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"> <div key={discount.id} className="flex gap-3 mb-2 p-3 bg-gray-50 dark:bg-gray-700 rounded-md">
<input {/* Main Content Area */}
type="text" <div className="flex-1 flex flex-col xl:flex-row gap-2">
value={discount.label} {/* Discount Name */}
onChange={(e) => updateDiscount(discount.id, 'label', e.target.value)} <input
placeholder="Discount name" type="text"
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" value={discount.label}
/> onChange={(e) => updateDiscount(discount.id, 'label', e.target.value)}
<select placeholder="Discount name"
value={discount.type} 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"
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"
> {/* Controls Row */}
<option value="fixed">Fixed</option> <div className="flex flex-wrap gap-2 flex-col xl:flex-row">
<option value="percentage">%</option> <input
</select> type="number"
<input value={discount.value}
type="number" onChange={(e) => updateDiscount(discount.id, 'value', parseFloat(e.target.value) || 0)}
value={discount.value} placeholder="0"
onChange={(e) => updateDiscount(discount.id, 'value', parseFloat(e.target.value) || 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"
placeholder="0" min="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" step="0.01"
min="0" />
step="0.01" <select
/> value={discount.type}
<div className="flex items-center gap-1"> 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 <button
onClick={() => moveItem('discounts', discount.id, 'up')} onClick={() => moveItem('discounts', discount.id, 'up')}
disabled={index === 0} 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" title="Move up"
> >
<ChevronUp className="h-3 w-3" /> <ChevronUp className="h-4 w-4" />
</button> </button>
<button <button
onClick={() => moveItem('discounts', discount.id, 'down')} onClick={() => moveItem('discounts', discount.id, 'down')}
disabled={index === (invoiceData.discounts || []).length - 1} 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" title="Move down"
> >
<ChevronDown className="h-3 w-3" /> <ChevronDown className="h-4 w-4" />
</button> </button>
<button <button
onClick={() => removeDiscount(discount.id)} 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> </button>
</div> </div>
</div> </div>
@@ -1384,7 +1447,7 @@ const InvoiceEditor = () => {
<div className="space-y-3"> <div className="space-y-3">
<div className="flex justify-between items-center py-2 border-b border-emerald-200 dark:border-emerald-700"> <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="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> </div>
{/* Dynamic Fees */} {/* Dynamic Fees */}
@@ -1393,7 +1456,7 @@ const InvoiceEditor = () => {
<span className="text-gray-600 dark:text-gray-400"> <span className="text-gray-600 dark:text-gray-400">
{fee.label || 'Fee'} {fee.type === 'percentage' ? `(${fee.value}%)` : ''}: {fee.label || 'Fee'} {fee.type === 'percentage' ? `(${fee.value}%)` : ''}:
</span> </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)} +{formatCurrency(fee.type === 'percentage' ? (invoiceData.subtotal * fee.value) / 100 : fee.value, true)}
</span> </span>
</div> </div>
@@ -1405,7 +1468,7 @@ const InvoiceEditor = () => {
<span className="text-gray-600 dark:text-gray-400"> <span className="text-gray-600 dark:text-gray-400">
{discount.label || 'Discount'} {discount.type === 'percentage' ? `(${discount.value}%)` : ''}: {discount.label || 'Discount'} {discount.type === 'percentage' ? `(${discount.value}%)` : ''}:
</span> </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)} -{formatCurrency(discount.type === 'percentage' ? (invoiceData.subtotal * discount.value) / 100 : discount.value, true)}
</span> </span>
</div> </div>
@@ -1415,13 +1478,13 @@ const InvoiceEditor = () => {
{invoiceData.discount > 0 && ( {invoiceData.discount > 0 && (
<div className="flex justify-between items-center py-2 border-b border-emerald-200 dark:border-emerald-700"> <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="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>
)} )}
<div className="bg-emerald-100 dark:bg-emerald-900/30 rounded-lg p-4 mt-4"> <div className="bg-emerald-100 dark:bg-emerald-900/30 rounded-lg p-4 mt-4">
<div className="flex justify-between items-center"> <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-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> </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> <h4 className="text-md font-medium text-gray-900 dark:text-white mb-3">Down Payment</h4>
<div className="space-y-2"> <div className="space-y-2">
<div className="flex gap-2 mb-2 p-2 bg-gray-50 dark:bg-gray-700 rounded-md"> <div className="flex gap-3 mb-2 p-3 bg-gray-50 dark:bg-gray-700 rounded-md">
<input {/* Main Content Area */}
type="text" <div className="flex-1 flex flex-col lg:flex-row gap-2">
value="Down Payment" {/* Down Payment Label */}
readOnly <input
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" type="text"
/> value="Down Payment"
<select readOnly
value={invoiceData.paymentTerms?.downPayment?.type || 'percentage'} 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"
onChange={(e) => { />
const type = e.target.value;
updateInvoiceData('paymentTerms', { {/* Controls Row */}
...invoiceData.paymentTerms, <div className="flex flex-wrap gap-2">
downPayment: { <input
...invoiceData.paymentTerms.downPayment, type="number"
type 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;
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" const isFixed = invoiceData.paymentTerms?.downPayment?.type === 'fixed';
>
<option value="percentage">%</option> let amount, percentage;
<option value="fixed">Fixed</option> if (isFixed) {
</select> amount = value;
<input percentage = invoiceData.total > 0 ? (amount / invoiceData.total) * 100 : 0;
type="number" } else {
value={ percentage = value;
invoiceData.paymentTerms?.downPayment?.type === 'fixed' amount = (invoiceData.total * percentage) / 100;
? (invoiceData.paymentTerms?.downPayment?.amount || 0) }
: (invoiceData.paymentTerms?.downPayment?.percentage || 0)
} updateInvoiceData('paymentTerms', {
onChange={(e) => { ...invoiceData.paymentTerms,
const value = parseFloat(e.target.value) || 0; downPayment: {
const isFixed = invoiceData.paymentTerms?.downPayment?.type === 'fixed'; ...invoiceData.paymentTerms.downPayment,
percentage,
let amount, percentage; amount
if (isFixed) { }
amount = value; });
percentage = invoiceData.total > 0 ? (amount / invoiceData.total) * 100 : 0; }}
} else { placeholder="0"
percentage = value; 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"
amount = (invoiceData.total * percentage) / 100; min="0"
} max="100"
step="0.01"
updateInvoiceData('paymentTerms', { />
...invoiceData.paymentTerms, <select
downPayment: { value={invoiceData.paymentTerms?.downPayment?.type || 'percentage'}
...invoiceData.paymentTerms.downPayment, onChange={(e) => {
percentage, const type = e.target.value;
amount updateInvoiceData('paymentTerms', {
} ...invoiceData.paymentTerms,
}); downPayment: {
}} ...invoiceData.paymentTerms.downPayment,
placeholder="0" type
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" 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"
/> >
<input <option value="percentage">%</option>
type="date" <option value="fixed">Fixed</option>
value={invoiceData.paymentTerms?.downPayment?.dueDate || ''} </select>
onChange={(e) => updateInvoiceData('paymentTerms', { <input
...invoiceData.paymentTerms, type="date"
downPayment: { value={invoiceData.paymentTerms?.downPayment?.dueDate || ''}
...invoiceData.paymentTerms.downPayment, onChange={(e) => updateInvoiceData('paymentTerms', {
dueDate: e.target.value ...invoiceData.paymentTerms,
} downPayment: {
})} ...invoiceData.paymentTerms.downPayment,
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" dueDate: e.target.value
/> }
<select })}
value={invoiceData.paymentTerms?.downPayment?.status || 'pending'} 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"
onChange={(e) => updateInvoiceData('paymentTerms', { />
...invoiceData.paymentTerms, <select
downPayment: { value={invoiceData.paymentTerms?.downPayment?.status || 'pending'}
...invoiceData.paymentTerms.downPayment, onChange={(e) => updateInvoiceData('paymentTerms', {
status: e.target.value ...invoiceData.paymentTerms,
} downPayment: {
})} ...invoiceData.paymentTerms.downPayment,
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 ${ status: e.target.value
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' : 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 ${
'bg-gray-50 text-gray-800 dark:bg-gray-700 dark:text-gray-300' 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' :
<option value="pending">Pending</option> 'bg-gray-50 text-gray-800 dark:bg-gray-700 dark:text-gray-300'
<option value="current">Current</option> }`}
<option value="paid">Paid</option> >
<option value="overdue">Overdue</option> <option value="pending">Pending</option>
</select> <option value="current">Current</option>
<option value="paid">Paid</option>
<option value="overdue">Overdue</option>
</select>
</div>
</div>
</div> </div>
</div> </div>
@@ -1587,131 +1658,141 @@ const InvoiceEditor = () => {
installments: [...(invoiceData.paymentTerms?.installments || []), newInstallment] 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" /> <Plus className="h-3 w-3" />
Add Installment Add Term
</button> </button>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
{(invoiceData.paymentTerms?.installments || []).map((installment, index) => ( {(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"> <div key={installment.id} className="flex gap-3 mb-2 p-3 bg-gray-50 dark:bg-gray-700 rounded-md">
<input {/* Main Content Area */}
type="text" <div className="flex-1 flex flex-col lg:flex-row gap-2">
value={installment.description} {/* Installment Name - Full Width */}
onChange={(e) => { <input
const updatedInstallments = invoiceData.paymentTerms.installments.map(inst => type="text"
inst.id === installment.id ? { ...inst, description: e.target.value } : inst value={installment.description}
); onChange={(e) => {
updateInvoiceData('paymentTerms', { const updatedInstallments = invoiceData.paymentTerms.installments.map(inst =>
...invoiceData.paymentTerms, inst.id === installment.id ? { ...inst, description: e.target.value } : inst
installments: updatedInstallments );
}); updateInvoiceData('paymentTerms', {
}} ...invoiceData.paymentTerms,
placeholder="Installment name" installments: updatedInstallments
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 placeholder="Installment name"
value={installment.type || 'fixed'} 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"
onChange={(e) => { />
const updatedInstallments = invoiceData.paymentTerms.installments.map(inst =>
inst.id === installment.id ? { ...inst, type: e.target.value } : inst {/* Controls Row */}
); <div className="flex flex-wrap gap-2">
updateInvoiceData('paymentTerms', { <input
...invoiceData.paymentTerms, type="number"
installments: updatedInstallments value={installment.type === 'percentage' ? (installment.percentage || 0) : (installment.amount || 0)}
}); onChange={(e) => {
}} const value = parseFloat(e.target.value) || 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" const baseAmount = invoiceData.paymentTerms?.type === 'downpayment'
> ? invoiceData.total - (invoiceData.paymentTerms?.downPayment?.amount || 0)
<option value="fixed">Fixed</option> : invoiceData.total;
<option value="percentage">%</option>
</select> let amount, percentage;
<input if (installment.type === 'percentage') {
type="number" percentage = value;
value={installment.type === 'percentage' ? (installment.percentage || 0) : (installment.amount || 0)} amount = (baseAmount * percentage) / 100;
onChange={(e) => { } else {
const value = parseFloat(e.target.value) || 0; amount = value;
const baseAmount = invoiceData.paymentTerms?.type === 'downpayment' percentage = baseAmount > 0 ? (amount / baseAmount) * 100 : 0;
? invoiceData.total - (invoiceData.paymentTerms?.downPayment?.amount || 0) }
: invoiceData.total;
const updatedInstallments = invoiceData.paymentTerms.installments.map(inst =>
let amount, percentage; inst.id === installment.id ? { ...inst, amount, percentage } : inst
if (installment.type === 'percentage') { );
percentage = value; updateInvoiceData('paymentTerms', {
amount = (baseAmount * percentage) / 100; ...invoiceData.paymentTerms,
} else { installments: updatedInstallments
amount = value; });
percentage = baseAmount > 0 ? (amount / baseAmount) * 100 : 0; }}
} 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"
const updatedInstallments = invoiceData.paymentTerms.installments.map(inst => min="0"
inst.id === installment.id ? { ...inst, amount, percentage } : inst step="0.01"
); />
updateInvoiceData('paymentTerms', { <select
...invoiceData.paymentTerms, value={installment.type || 'fixed'}
installments: updatedInstallments onChange={(e) => {
}); const updatedInstallments = invoiceData.paymentTerms.installments.map(inst =>
}} inst.id === installment.id ? { ...inst, type: e.target.value } : inst
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" updateInvoiceData('paymentTerms', {
min="0" ...invoiceData.paymentTerms,
step="0.01" installments: updatedInstallments
/> });
<input }}
type="date" 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"
value={installment.dueDate} >
onChange={(e) => { <option value="fixed">Fixed</option>
const updatedInstallments = invoiceData.paymentTerms.installments.map(inst => <option value="percentage">%</option>
inst.id === installment.id ? { ...inst, dueDate: e.target.value } : inst </select>
); <input
updateInvoiceData('paymentTerms', { type="date"
...invoiceData.paymentTerms, value={installment.dueDate}
installments: updatedInstallments onChange={(e) => {
}); const updatedInstallments = invoiceData.paymentTerms.installments.map(inst =>
}} inst.id === installment.id ? { ...inst, dueDate: e.target.value } : inst
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" );
/> updateInvoiceData('paymentTerms', {
<select ...invoiceData.paymentTerms,
value={installment.status || 'pending'} installments: updatedInstallments
onChange={(e) => { });
const updatedInstallments = invoiceData.paymentTerms.installments.map(inst => }}
inst.id === installment.id ? { ...inst, status: e.target.value } : inst 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"
); />
updateInvoiceData('paymentTerms', { <select
...invoiceData.paymentTerms, value={installment.status || 'pending'}
installments: updatedInstallments onChange={(e) => {
}); const updatedInstallments = invoiceData.paymentTerms.installments.map(inst =>
}} inst.id === installment.id ? { ...inst, status: e.target.value } : inst
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' : updateInvoiceData('paymentTerms', {
installment.status === 'current' ? 'bg-yellow-50 text-yellow-800 dark:bg-yellow-900/20 dark:text-yellow-300' : ...invoiceData.paymentTerms,
installment.status === 'overdue' ? 'bg-red-50 text-red-800 dark:bg-red-900/20 dark:text-red-300' : installments: updatedInstallments
'bg-gray-50 text-gray-800 dark:bg-gray-700 dark:text-gray-300' });
}`} }}
> 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 ${
<option value="pending">Pending</option> installment.status === 'paid' ? 'bg-green-50 text-green-800 dark:bg-green-900/20 dark:text-green-300' :
<option value="current">Current</option> installment.status === 'current' ? 'bg-yellow-50 text-yellow-800 dark:bg-yellow-900/20 dark:text-yellow-300' :
<option value="paid">Paid</option> installment.status === 'overdue' ? 'bg-red-50 text-red-800 dark:bg-red-900/20 dark:text-red-300' :
<option value="overdue">Overdue</option> 'bg-gray-50 text-gray-800 dark:bg-gray-700 dark:text-gray-300'
</select> }`}
<div className="flex items-center gap-1"> >
<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 <button
onClick={() => moveInstallment(installment.id, 'up')} onClick={() => moveInstallment(installment.id, 'up')}
disabled={index === 0} 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" title="Move up"
> >
<ChevronUp className="h-3 w-3" /> <ChevronUp className="h-4 w-4" />
</button> </button>
<button <button
onClick={() => moveInstallment(installment.id, 'down')} onClick={() => moveInstallment(installment.id, 'down')}
disabled={index === (invoiceData.paymentTerms?.installments || []).length - 1} 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" title="Move down"
> >
<ChevronDown className="h-3 w-3" /> <ChevronDown className="h-4 w-4" />
</button> </button>
<button <button
onClick={() => { onClick={() => {
@@ -1721,9 +1802,10 @@ const InvoiceEditor = () => {
installments: updatedInstallments 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> </button>
</div> </div>
</div> </div>

View File

@@ -20,6 +20,30 @@ const InvoicePreview = () => {
const [isGenerating, setIsGenerating] = useState(false); const [isGenerating, setIsGenerating] = useState(false);
const [selectedTemplate] = useState('minimal'); 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 // Load invoice data from localStorage
useEffect(() => { useEffect(() => {
try { try {
@@ -28,11 +52,41 @@ const InvoicePreview = () => {
if (savedInvoice) { if (savedInvoice) {
const parsedInvoice = JSON.parse(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); setInvoiceData(parsedInvoice);
// Set page title with invoice number
document.title = `Invoice Preview - ${parsedInvoice.invoiceNumber || 'Draft'} | DevTools`;
} else { } else {
// No invoice data, redirect back to editor // No invoice data found, redirect to editor
navigate('/invoice-editor'); navigate('/invoice-editor');
} }
@@ -41,11 +95,10 @@ const InvoicePreview = () => {
} }
} catch (error) { } catch (error) {
console.error('Failed to load invoice data:', error); console.error('Failed to load invoice data:', error);
navigate('/invoice-editor', { replace: true }); navigate('/invoice-editor');
} }
}, [navigate]); }, [navigate]);
// Format number with thousand separator // Format number with thousand separator
const formatNumber = (num) => { const formatNumber = (num) => {
if (!invoiceData?.settings?.thousandSeparator) return num.toString(); if (!invoiceData?.settings?.thousandSeparator) return num.toString();

View File

@@ -1,7 +1,6 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { Calendar, Sparkles, Bug, Zap, Shield, ChevronDown, ChevronUp } from 'lucide-react'; import { Calendar, Sparkles, Bug, Zap, Shield, ChevronDown, ChevronUp } from 'lucide-react';
import ToolLayout from '../components/ToolLayout'; import ToolLayout from '../components/ToolLayout';
import { getReleases } from '../utils/releaseNotesAPI';
const ReleaseNotes = () => { const ReleaseNotes = () => {
const [releases, setReleases] = useState([]); const [releases, setReleases] = useState([]);
@@ -9,6 +8,7 @@ const ReleaseNotes = () => {
const [expandedReleases, setExpandedReleases] = useState(new Set()); const [expandedReleases, setExpandedReleases] = useState(new Set());
// Parse commit messages into user-friendly release notes (keeping local version for now) // Parse commit messages into user-friendly release notes (keeping local version for now)
// eslint-disable-next-line no-unused-vars
const parseCommitMessage = (message) => { const parseCommitMessage = (message) => {
// Skip non-user-informative commits // Skip non-user-informative commits
const skipPatterns = [ const skipPatterns = [
@@ -186,54 +186,31 @@ const ReleaseNotes = () => {
}; };
useEffect(() => { useEffect(() => {
// Fetch dynamic release data from Gitea API // Load release data from commits.json
const fetchReleases = async () => { const fetchReleases = async () => {
setLoading(true); setLoading(true);
try { try {
// Gitea API configuration using your environment variables const response = await fetch('/data/commits.json');
const config = { const data = await response.json();
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 parsedReleases = fallbackData // Transform changelog data to release format
.map(commit => { const releases = [];
const parsed = parseCommitMessage(commit.message); data.changelog.forEach(dateEntry => {
if (!parsed) return null; dateEntry.changes.forEach(change => {
releases.push({
return { id: `${dateEntry.date}-${change.type}-${change.title.replace(/\s+/g, '-')}`,
...parsed, date: change.datetime || dateEntry.date, // Use datetime if available, fallback to date
date: commit.date, type: change.type,
id: commit.id, title: change.title,
author: commit.author description: change.description
}; });
}) });
.filter(Boolean); });
setReleases(parsedReleases); setReleases(releases);
} catch (error) {
console.error('Failed to load commits.json:', error);
setReleases([]);
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -351,7 +328,6 @@ const ReleaseNotes = () => {
minute: '2-digit' minute: '2-digit'
})} })}
</span> </span>
<span>#{release.hash}</span>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -24,7 +24,10 @@ export const fetchGitHubReleases = async (owner, repo, token = null) => {
// Fetch commits from GitHub API // Fetch commits from GitHub API
const response = await fetch( const response = await fetch(
`https://api.github.com/repos/${owner}/${repo}/commits?per_page=20`, `https://api.github.com/repos/${owner}/${repo}/commits?per_page=20`,
{ headers } {
method: 'GET',
headers
}
); );
if (!response.ok) { 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) // Option 2: Gitea API Integration (for your Coolify server setup)
export const fetchGiteaReleases = async (owner, repo, token, baseUrl) => { export const fetchGiteaReleases = async (owner, repo, token, baseUrl) => {
try { try {
const headers = { // Use URL parameters for auth to avoid CORS preflight
'Authorization': `token ${token}`, const url = new URL(`${baseUrl}/api/v1/repos/${owner}/${repo}/commits`);
'Content-Type': 'application/json' url.searchParams.set('limit', '20');
}; if (token) {
url.searchParams.set('token', token);
}
// Fetch commits from Gitea API // Fetch commits from Gitea API with minimal headers
const response = await fetch( const response = await fetch(url.toString(), {
`${baseUrl}/api/v1/repos/${owner}/${repo}/commits?limit=20`, method: 'GET',
{ headers } mode: 'cors'
); });
if (!response.ok) { if (!response.ok) {
throw new Error(`Gitea API error: ${response.status}`); 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 // Option 3: Custom Backend API
export const fetchCustomReleases = async (apiEndpoint) => { export const fetchCustomReleases = async (apiEndpoint) => {
try { try {
const response = await fetch(apiEndpoint); const response = await fetch(apiEndpoint, { method: 'GET' });
if (!response.ok) { if (!response.ok) {
throw new Error(`API error: ${response.status}`); throw new Error(`API error: ${response.status}`);
@@ -98,7 +103,7 @@ export const fetchCustomReleases = async (apiEndpoint) => {
// Option 4: Static JSON File (simplest approach) // Option 4: Static JSON File (simplest approach)
export const fetchStaticReleases = async () => { export const fetchStaticReleases = async () => {
try { try {
const response = await fetch('/data/releases.json'); const response = await fetch('/data/releases.json', { method: 'GET' });
if (!response.ok) { if (!response.ok) {
throw new Error(`Failed to load releases: ${response.status}`); throw new Error(`Failed to load releases: ${response.status}`);