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
BIN
public/android-chrome-192x192.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
public/android-chrome-512x512.png
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
public/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
130
public/data/commits.json
Normal 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."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
After Width: | Height: | Size: 962 B |
BIN
public/favicon-32x32.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
public/favicon.ico
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
public/icon-192x192.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
public/icon-512x512.png
Normal file
|
After Width: | Height: | Size: 36 KiB |
@@ -3,10 +3,14 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<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="theme-color" content="#0ea5e9" />
|
||||
<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" />
|
||||
<title>Developer Tools - Web Developer Utilities</title>
|
||||
</head>
|
||||
|
||||
72
public/logo.svg
Normal 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 |
@@ -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>
|
||||
</div>
|
||||
<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>
|
||||
</button>
|
||||
|
||||
<div className="flex items-center space-x-4">
|
||||
@@ -266,17 +279,30 @@ 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>
|
||||
</div>
|
||||
<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>
|
||||
</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>
|
||||
<span className="text-sm font-medium text-slate-600 dark:text-slate-400">
|
||||
@@ -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,13 +1295,28 @@ 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">
|
||||
<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-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"
|
||||
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}
|
||||
@@ -1270,37 +1326,33 @@ const InvoiceEditor = () => {
|
||||
<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>
|
||||
</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,13 +1372,28 @@ 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">
|
||||
<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-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"
|
||||
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}
|
||||
@@ -1336,37 +1403,33 @@ const InvoiceEditor = () => {
|
||||
<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>
|
||||
</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,30 +1526,19 @@ 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">
|
||||
<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-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"
|
||||
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"
|
||||
/>
|
||||
<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>
|
||||
|
||||
{/* Controls Row */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<input
|
||||
type="number"
|
||||
value={
|
||||
@@ -1517,11 +1569,28 @@ const InvoiceEditor = () => {
|
||||
});
|
||||
}}
|
||||
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"
|
||||
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 || ''}
|
||||
@@ -1532,7 +1601,7 @@ const InvoiceEditor = () => {
|
||||
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"
|
||||
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'}
|
||||
@@ -1543,7 +1612,7 @@ const InvoiceEditor = () => {
|
||||
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 ${
|
||||
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' :
|
||||
@@ -1557,6 +1626,8 @@ const InvoiceEditor = () => {
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{invoiceData.paymentTerms?.downPayment?.amount > 0 && (
|
||||
<div className="mt-3 p-2 bg-blue-50 dark:bg-blue-900/20 rounded text-sm text-blue-800 dark:text-blue-200">
|
||||
@@ -1587,16 +1658,19 @@ 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">
|
||||
<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}
|
||||
@@ -1610,24 +1684,11 @@ const InvoiceEditor = () => {
|
||||
});
|
||||
}}
|
||||
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"
|
||||
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"
|
||||
/>
|
||||
<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>
|
||||
|
||||
{/* Controls Row */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<input
|
||||
type="number"
|
||||
value={installment.type === 'percentage' ? (installment.percentage || 0) : (installment.amount || 0)}
|
||||
@@ -1655,10 +1716,26 @@ const InvoiceEditor = () => {
|
||||
});
|
||||
}}
|
||||
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"
|
||||
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}
|
||||
@@ -1671,7 +1748,7 @@ const InvoiceEditor = () => {
|
||||
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"
|
||||
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'}
|
||||
@@ -1684,7 +1761,7 @@ const InvoiceEditor = () => {
|
||||
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 ${
|
||||
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' :
|
||||
@@ -1696,22 +1773,26 @@ const InvoiceEditor = () => {
|
||||
<option value="paid">Paid</option>
|
||||
<option value="overdue">Overdue</option>
|
||||
</select>
|
||||
<div className="flex items-center gap-1">
|
||||
</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 response = await fetch('/data/commits.json');
|
||||
const data = await response.json();
|
||||
|
||||
const fetchedReleases = await getReleases(config);
|
||||
setReleases(fetchedReleases);
|
||||
// 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 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
|
||||
.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);
|
||||
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}`);
|
||||
|
||||