mirror of
https://github.com/soconnor0919/beenvoice.git
synced 2025-12-13 01:24:44 -05:00
feat: polish invoice editor and viewer UI with custom NumberInput
component - Create custom NumberInput component with increment/decrement buttons - Add 0.25 step increments for hours and rates in invoice forms - Implement emerald-themed styling with hover states and accessibility - Add keyboard navigation (arrow keys) and proper ARIA support - Condense invoice editor tax/totals section into efficient grid layout - Update client dropdown to single-line format (name + email) - Add fixed footer with floating action bar pattern matching business forms - Redesign invoice viewer with better space utilization and visual hierarchy - Maintain professional appearance and consistent design system - Fix Next.js 15 params Promise handling across all invoice pages - Resolve TypeScript compilation errors and type-only imports
This commit is contained in:
87
bun.lock
87
bun.lock
@@ -11,6 +11,7 @@
|
|||||||
"@libsql/client": "^0.14.0",
|
"@libsql/client": "^0.14.0",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.14",
|
"@radix-ui/react-alert-dialog": "^1.1.14",
|
||||||
"@radix-ui/react-checkbox": "^1.3.2",
|
"@radix-ui/react-checkbox": "^1.3.2",
|
||||||
|
"@radix-ui/react-collapsible": "^1.1.11",
|
||||||
"@radix-ui/react-dialog": "^1.1.14",
|
"@radix-ui/react-dialog": "^1.1.14",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
||||||
"@radix-ui/react-label": "^2.1.7",
|
"@radix-ui/react-label": "^2.1.7",
|
||||||
@@ -20,10 +21,12 @@
|
|||||||
"@radix-ui/react-select": "^2.2.5",
|
"@radix-ui/react-select": "^2.2.5",
|
||||||
"@radix-ui/react-separator": "^1.1.7",
|
"@radix-ui/react-separator": "^1.1.7",
|
||||||
"@radix-ui/react-slot": "^1.2.3",
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
|
"@radix-ui/react-switch": "^1.2.5",
|
||||||
"@radix-ui/react-tabs": "^1.1.12",
|
"@radix-ui/react-tabs": "^1.1.12",
|
||||||
"@react-pdf/renderer": "^4.3.0",
|
"@react-pdf/renderer": "^4.3.0",
|
||||||
"@t3-oss/env-nextjs": "^0.12.0",
|
"@t3-oss/env-nextjs": "^0.12.0",
|
||||||
"@tanstack/react-query": "^5.69.0",
|
"@tanstack/react-query": "^5.69.0",
|
||||||
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"@trpc/client": "^11.0.0",
|
"@trpc/client": "^11.0.0",
|
||||||
"@trpc/react-query": "^11.0.0",
|
"@trpc/react-query": "^11.0.0",
|
||||||
"@trpc/server": "^11.0.0",
|
"@trpc/server": "^11.0.0",
|
||||||
@@ -37,7 +40,7 @@
|
|||||||
"file-saver": "^2.0.5",
|
"file-saver": "^2.0.5",
|
||||||
"lucide": "^0.525.0",
|
"lucide": "^0.525.0",
|
||||||
"lucide-react": "^0.525.0",
|
"lucide-react": "^0.525.0",
|
||||||
"next": "^15.2.3",
|
"next": "^15.4.1",
|
||||||
"next-auth": "5.0.0-beta.25",
|
"next-auth": "5.0.0-beta.25",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-day-picker": "^9.8.0",
|
"react-day-picker": "^9.8.0",
|
||||||
@@ -77,6 +80,8 @@
|
|||||||
"trustedDependencies": [
|
"trustedDependencies": [
|
||||||
"@tailwindcss/oxide",
|
"@tailwindcss/oxide",
|
||||||
"better-sqlite3",
|
"better-sqlite3",
|
||||||
|
"esbuild",
|
||||||
|
"sharp",
|
||||||
"unrs-resolver",
|
"unrs-resolver",
|
||||||
],
|
],
|
||||||
"packages": {
|
"packages": {
|
||||||
@@ -192,47 +197,49 @@
|
|||||||
|
|
||||||
"@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="],
|
"@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="],
|
||||||
|
|
||||||
"@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.2", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.1.0" }, "os": "darwin", "cpu": "arm64" }, "sha512-OfXHZPppddivUJnqyKoi5YVeHRkkNE2zUFT2gbpKxp/JZCFYEYubnMg+gOp6lWfasPrTS+KPosKqdI+ELYVDtg=="],
|
"@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.3", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.0" }, "os": "darwin", "cpu": "arm64" }, "sha512-ryFMfvxxpQRsgZJqBd4wsttYQbCxsJksrv9Lw/v798JcQ8+w84mBWuXwl+TT0WJ/WrYOLaYpwQXi3sA9nTIaIg=="],
|
||||||
|
|
||||||
"@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.2", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.1.0" }, "os": "darwin", "cpu": "x64" }, "sha512-dYvWqmjU9VxqXmjEtjmvHnGqF8GrVjM2Epj9rJ6BUIXvk8slvNDJbhGFvIoXzkDhrJC2jUxNLz/GUjjvSzfw+g=="],
|
"@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.3", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.0" }, "os": "darwin", "cpu": "x64" }, "sha512-yHpJYynROAj12TA6qil58hmPmAwxKKC7reUqtGLzsOHfP7/rniNGTL8tjWX6L3CTV4+5P4ypcS7Pp+7OB+8ihA=="],
|
||||||
|
|
||||||
"@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.1.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-HZ/JUmPwrJSoM4DIQPv/BfNh9yrOA8tlBbqbLz4JZ5uew2+o22Ik+tHQJcih7QJuSa0zo5coHTfD5J8inqj9DA=="],
|
"@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-sBZmpwmxqwlqG9ueWFXtockhsxefaV6O84BMOrhtg/YqbTaRdqDE7hxraVE3y6gVM4eExmfzW4a8el9ArLeEiQ=="],
|
||||||
|
|
||||||
"@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.1.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-Xzc2ToEmHN+hfvsl9wja0RlnXEgpKNmftriQp6XzY/RaSfwD9th+MSh0WQKzUreLKKINb3afirxW7A0fz2YWuQ=="],
|
"@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-M64XVuL94OgiNHa5/m2YvEQI5q2cl9d/wk0qFTDVXcYzi43lxuiFTftMR1tOnFQovVXNZJ5TURSDK2pNe9Yzqg=="],
|
||||||
|
|
||||||
"@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.1.0", "", { "os": "linux", "cpu": "arm" }, "sha512-s8BAd0lwUIvYCJyRdFqvsj+BJIpDBSxs6ivrOPm/R7piTs5UIwY5OjXrP2bqXC9/moGsyRa37eYWYCOGVXxVrA=="],
|
"@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.0", "", { "os": "linux", "cpu": "arm" }, "sha512-mWd2uWvDtL/nvIzThLq3fr2nnGfyr/XMXlq8ZJ9WMR6PXijHlC3ksp0IpuhK6bougvQrchUAfzRLnbsen0Cqvw=="],
|
||||||
|
|
||||||
"@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.1.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-IVfGJa7gjChDET1dK9SekxFFdflarnUB8PwW8aGwEoF3oAsSDuNUTYS+SKDOyOJxQyDC1aPFMuRYLoDInyV9Ew=="],
|
"@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-RXwd0CgG+uPRX5YYrkzKyalt2OJYRiJQ8ED/fi1tq9WQW2jsQIn0tqrlR5l5dr/rjqq6AHAxURhj2DVjyQWSOA=="],
|
||||||
|
|
||||||
"@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.1.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-tiXxFZFbhnkWE2LA8oQj7KYR+bWBkiV2nilRldT7bqoEZ4HiDOcePr9wVDAZPi/Id5fT1oY9iGnDq20cwUz8lQ=="],
|
"@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.2.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-Xod/7KaDDHkYu2phxxfeEPXfVXFKx70EAFZ0qyUdOjCcxbjqyJOEUpDe6RIyaunGxT34Anf9ue/wuWOqBW2WcQ=="],
|
||||||
|
|
||||||
"@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.1.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-xukSwvhguw7COyzvmjydRb3x/09+21HykyapcZchiCUkTThEQEOMtBj9UhkaBRLuBrgLFzQ2wbxdeCCJW/jgJA=="],
|
"@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.2.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-eMKfzDxLGT8mnmPJTNMcjfO33fLiTDsrMlUVcp6b96ETbnJmd4uvZxVJSKPQfS+odwfVaGifhsB07J1LynFehw=="],
|
||||||
|
|
||||||
"@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.1.0", "", { "os": "linux", "cpu": "x64" }, "sha512-yRj2+reB8iMg9W5sULM3S74jVS7zqSzHG3Ol/twnAAkAhnGQnpjj6e4ayUz7V+FpKypwgs82xbRdYtchTTUB+Q=="],
|
"@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.0", "", { "os": "linux", "cpu": "x64" }, "sha512-ZW3FPWIc7K1sH9E3nxIGB3y3dZkpJlMnkk7z5tu1nSkBoCgw2nSRTFHI5pB/3CQaJM0pdzMF3paf9ckKMSE9Tg=="],
|
||||||
|
|
||||||
"@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.1.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-jYZdG+whg0MDK+q2COKbYidaqW/WTz0cc1E+tMAusiDygrM4ypmSCjOJPmFTvHHJ8j/6cAGyeDWZOsK06tP33w=="],
|
"@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UG+LqQJbf5VJ8NWJ5Z3tdIe/HXjuIdo4JeVNADXBFuG7z9zjoegpzzGIyV5zQKi4zaJjnAd2+g2nna8TZvuW9Q=="],
|
||||||
|
|
||||||
"@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.1.0", "", { "os": "linux", "cpu": "x64" }, "sha512-wK7SBdwrAiycjXdkPnGCPLjYb9lD4l6Ze2gSdAGVZrEL05AOUJESWU2lhlC+Ffn5/G+VKuSm6zzbQSzFX/P65A=="],
|
"@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.0", "", { "os": "linux", "cpu": "x64" }, "sha512-SRYOLR7CXPgNze8akZwjoGBoN1ThNZoqpOgfnOxmWsklTGVfJiGJoC/Lod7aNMGA1jSsKWM1+HRX43OP6p9+6Q=="],
|
||||||
|
|
||||||
"@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.2", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.1.0" }, "os": "linux", "cpu": "arm" }, "sha512-0DZzkvuEOqQUP9mo2kjjKNok5AmnOr1jB2XYjkaoNRwpAYMDzRmAqUIa1nRi58S2WswqSfPOWLNOr0FDT3H5RQ=="],
|
"@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.0" }, "os": "linux", "cpu": "arm" }, "sha512-oBK9l+h6KBN0i3dC8rYntLiVfW8D8wH+NPNT3O/WBHeW0OQWCjfWksLUaPidsrDKpJgXp3G3/hkmhptAW0I3+A=="],
|
||||||
|
|
||||||
"@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.2", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.1.0" }, "os": "linux", "cpu": "arm64" }, "sha512-D8n8wgWmPDakc83LORcfJepdOSN6MvWNzzz2ux0MnIbOqdieRZwVYY32zxVx+IFUT8er5KPcyU3XXsn+GzG/0Q=="],
|
"@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.0" }, "os": "linux", "cpu": "arm64" }, "sha512-QdrKe3EvQrqwkDrtuTIjI0bu6YEJHTgEeqdzI3uWJOH6G1O8Nl1iEeVYRGdj1h5I21CqxSvQp1Yv7xeU3ZewbA=="],
|
||||||
|
|
||||||
"@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.2", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.1.0" }, "os": "linux", "cpu": "s390x" }, "sha512-EGZ1xwhBI7dNISwxjChqBGELCWMGDvmxZXKjQRuqMrakhO8QoMgqCrdjnAqJq/CScxfRn+Bb7suXBElKQpPDiw=="],
|
"@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.34.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.2.0" }, "os": "linux", "cpu": "ppc64" }, "sha512-GLtbLQMCNC5nxuImPR2+RgrviwKwVql28FWZIW1zWruy6zLgA5/x2ZXk3mxj58X/tszVF69KK0Is83V8YgWhLA=="],
|
||||||
|
|
||||||
"@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.2", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.1.0" }, "os": "linux", "cpu": "x64" }, "sha512-sD7J+h5nFLMMmOXYH4DD9UtSNBD05tWSSdWAcEyzqW8Cn5UxXvsHAxmxSesYUsTOBmUnjtxghKDl15EvfqLFbQ=="],
|
"@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.0" }, "os": "linux", "cpu": "s390x" }, "sha512-3gahT+A6c4cdc2edhsLHmIOXMb17ltffJlxR0aC2VPZfwKoTGZec6u5GrFgdR7ciJSsHT27BD3TIuGcuRT0KmQ=="],
|
||||||
|
|
||||||
"@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.2", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.1.0" }, "os": "linux", "cpu": "arm64" }, "sha512-NEE2vQ6wcxYav1/A22OOxoSOGiKnNmDzCYFOZ949xFmrWZOVII1Bp3NqVVpvj+3UeHMFyN5eP/V5hzViQ5CZNA=="],
|
"@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.0" }, "os": "linux", "cpu": "x64" }, "sha512-8kYso8d806ypnSq3/Ly0QEw90V5ZoHh10yH0HnrzOCr6DKAPI6QVHvwleqMkVQ0m+fc7EH8ah0BB0QPuWY6zJQ=="],
|
||||||
|
|
||||||
"@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.2", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.1.0" }, "os": "linux", "cpu": "x64" }, "sha512-DOYMrDm5E6/8bm/yQLCWyuDJwUnlevR8xtF8bs+gjZ7cyUNYXiSf/E8Kp0Ss5xasIaXSHzb888V1BE4i1hFhAA=="],
|
"@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.3", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.0" }, "os": "linux", "cpu": "arm64" }, "sha512-vAjbHDlr4izEiXM1OTggpCcPg9tn4YriK5vAjowJsHwdBIdx0fYRsURkxLG2RLm9gyBq66gwtWI8Gx0/ov+JKQ=="],
|
||||||
|
|
||||||
"@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.2", "", { "dependencies": { "@emnapi/runtime": "^1.4.3" }, "cpu": "none" }, "sha512-/VI4mdlJ9zkaq53MbIG6rZY+QRN3MLbR6usYlgITEzi4Rpx5S6LFKsycOQjkOGmqTNmkIdLjEvooFKwww6OpdQ=="],
|
"@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.3", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.0" }, "os": "linux", "cpu": "x64" }, "sha512-gCWUn9547K5bwvOn9l5XGAEjVTTRji4aPTqLzGXHvIr6bIDZKNTA34seMPgM0WmSf+RYBH411VavCejp3PkOeQ=="],
|
||||||
|
|
||||||
"@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-cfP/r9FdS63VA5k0xiqaNaEoGxBg9k7uE+RQGzuK9fHt7jib4zAVVseR9LsE4gJcNWgT6APKMNnCcnyOtmSEUQ=="],
|
"@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.3", "", { "dependencies": { "@emnapi/runtime": "^1.4.4" }, "cpu": "none" }, "sha512-+CyRcpagHMGteySaWos8IbnXcHgfDn7pO2fiC2slJxvNq9gDipYBN42/RagzctVRKgxATmfqOSulgZv5e1RdMg=="],
|
||||||
|
|
||||||
"@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-QLjGGvAbj0X/FXl8n1WbtQ6iVBpWU7JO94u/P2M4a8CFYsvQi4GW2mRy/JqkRx0qpBzaOdKJKw8uc930EX2AHw=="],
|
"@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-MjnHPnbqMXNC2UgeLJtX4XqoVHHlZNd+nPt1kRPmj63wURegwBhZlApELdtxM2OIZDRv/DFtLcNhVbd1z8GYXQ=="],
|
||||||
|
|
||||||
"@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.2", "", { "os": "win32", "cpu": "x64" }, "sha512-aUdT6zEYtDKCaxkofmmJDJYGCf0+pJg3eU9/oBuqvEeoB9dKI6ZLc/1iLJCTuJQDO4ptntAlkUmHgGjyuobZbw=="],
|
"@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-xuCdhH44WxuXgOM714hn4amodJMZl3OEvf0GVTm0BEyMeA2to+8HEdRPShH0SLYptJY1uBw+SCFP9WVQi1Q/cw=="],
|
||||||
|
|
||||||
|
"@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.3", "", { "os": "win32", "cpu": "x64" }, "sha512-OWwz05d++TxzLEv4VnsTz5CmZ6mI6S05sfQGEMrNrQcOEERbX46332IvE7pO/EUiw7jUrrS40z/M7kPyjfl04g=="],
|
||||||
|
|
||||||
"@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "", { "dependencies": { "minipass": "^7.0.4" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="],
|
"@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "", { "dependencies": { "minipass": "^7.0.4" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="],
|
||||||
|
|
||||||
@@ -272,25 +279,25 @@
|
|||||||
|
|
||||||
"@neon-rs/load": ["@neon-rs/load@0.0.4", "", {}, "sha512-kTPhdZyTQxB+2wpiRcFWrDcejc4JI6tkPuS7UZCG4l6Zvc5kU/gGQ/ozvHTh1XR5tS+UlfAfGuPajjzQjCiHCw=="],
|
"@neon-rs/load": ["@neon-rs/load@0.0.4", "", {}, "sha512-kTPhdZyTQxB+2wpiRcFWrDcejc4JI6tkPuS7UZCG4l6Zvc5kU/gGQ/ozvHTh1XR5tS+UlfAfGuPajjzQjCiHCw=="],
|
||||||
|
|
||||||
"@next/env": ["@next/env@15.3.5", "", {}, "sha512-7g06v8BUVtN2njAX/r8gheoVffhiKFVt4nx74Tt6G4Hqw9HCLYQVx/GkH2qHvPtAHZaUNZ0VXAa0pQP6v1wk7g=="],
|
"@next/env": ["@next/env@15.4.1", "", {}, "sha512-DXQwFGAE2VH+f2TJsKepRXpODPU+scf5fDbKOME8MMyeyswe4XwgRdiiIYmBfkXU+2ssliLYznajTrOQdnLR5A=="],
|
||||||
|
|
||||||
"@next/eslint-plugin-next": ["@next/eslint-plugin-next@15.3.5", "", { "dependencies": { "fast-glob": "3.3.1" } }, "sha512-BZwWPGfp9po/rAnJcwUBaM+yT/+yTWIkWdyDwc74G9jcfTrNrmsHe+hXHljV066YNdVs8cxROxX5IgMQGX190w=="],
|
"@next/eslint-plugin-next": ["@next/eslint-plugin-next@15.3.5", "", { "dependencies": { "fast-glob": "3.3.1" } }, "sha512-BZwWPGfp9po/rAnJcwUBaM+yT/+yTWIkWdyDwc74G9jcfTrNrmsHe+hXHljV066YNdVs8cxROxX5IgMQGX190w=="],
|
||||||
|
|
||||||
"@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@15.3.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-lM/8tilIsqBq+2nq9kbTW19vfwFve0NR7MxfkuSUbRSgXlMQoJYg+31+++XwKVSXk4uT23G2eF/7BRIKdn8t8w=="],
|
"@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@15.4.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-L+81yMsiHq82VRXS2RVq6OgDwjvA4kDksGU8hfiDHEXP+ncKIUhUsadAVB+MRIp2FErs/5hpXR0u2eluWPAhig=="],
|
||||||
|
|
||||||
"@next/swc-darwin-x64": ["@next/swc-darwin-x64@15.3.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-WhwegPQJ5IfoUNZUVsI9TRAlKpjGVK0tpJTL6KeiC4cux9774NYE9Wu/iCfIkL/5J8rPAkqZpG7n+EfiAfidXA=="],
|
"@next/swc-darwin-x64": ["@next/swc-darwin-x64@15.4.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-jfz1RXu6SzL14lFl05/MNkcN35lTLMJWPbqt7Xaj35+ZWAX342aePIJrN6xBdGeKl6jPXJm0Yqo3Xvh3Gpo3Uw=="],
|
||||||
|
|
||||||
"@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@15.3.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-LVD6uMOZ7XePg3KWYdGuzuvVboxujGjbcuP2jsPAN3MnLdLoZUXKRc6ixxfs03RH7qBdEHCZjyLP/jBdCJVRJQ=="],
|
"@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@15.4.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-k0tOFn3dsnkaGfs6iQz8Ms6f1CyQe4GacXF979sL8PNQxjYS1swx9VsOyUQYaPoGV8nAZ7OX8cYaeiXGq9ahPQ=="],
|
||||||
|
|
||||||
"@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@15.3.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-k8aVScYZ++BnS2P69ClK7v4nOu702jcF9AIHKu6llhHEtBSmM2zkPGl9yoqbSU/657IIIb0QHpdxEr0iW9z53A=="],
|
"@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@15.4.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-4ogGQ/3qDzbbK3IwV88ltihHFbQVq6Qr+uEapzXHXBH1KsVBZOB50sn6BWHPcFjwSoMX2Tj9eH/fZvQnSIgc3g=="],
|
||||||
|
|
||||||
"@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@15.3.5", "", { "os": "linux", "cpu": "x64" }, "sha512-2xYU0DI9DGN/bAHzVwADid22ba5d/xrbrQlr2U+/Q5WkFUzeL0TDR963BdrtLS/4bMmKZGptLeg6282H/S2i8A=="],
|
"@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@15.4.1", "", { "os": "linux", "cpu": "x64" }, "sha512-Jj0Rfw3wIgp+eahMz/tOGwlcYYEFjlBPKU7NqoOkTX0LY45i5W0WcDpgiDWSLrN8KFQq/LW7fZq46gxGCiOYlQ=="],
|
||||||
|
|
||||||
"@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@15.3.5", "", { "os": "linux", "cpu": "x64" }, "sha512-TRYIqAGf1KCbuAB0gjhdn5Ytd8fV+wJSM2Nh2is/xEqR8PZHxfQuaiNhoF50XfY90sNpaRMaGhF6E+qjV1b9Tg=="],
|
"@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@15.4.1", "", { "os": "linux", "cpu": "x64" }, "sha512-9WlEZfnw1vFqkWsTMzZDgNL7AUI1aiBHi0S2m8jvycPyCq/fbZjtE/nDkhJRYbSjXbtRHYLDBlmP95kpjEmJbw=="],
|
||||||
|
|
||||||
"@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@15.3.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-h04/7iMEUSMY6fDGCvdanKqlO1qYvzNxntZlCzfE8i5P0uqzVQWQquU1TIhlz0VqGQGXLrFDuTJVONpqGqjGKQ=="],
|
"@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@15.4.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-WodRbZ9g6CQLRZsG3gtrA9w7Qfa9BwDzhFVdlI6sV0OCPq9JrOrJSp9/ioLsezbV8w9RCJ8v55uzJuJ5RgWLZg=="],
|
||||||
|
|
||||||
"@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@15.3.5", "", { "os": "win32", "cpu": "x64" }, "sha512-5fhH6fccXxnX2KhllnGhkYMndhOiLOLEiVGYjP2nizqeGWkN10sA9taATlXwake2E2XMvYZjjz0Uj7T0y+z1yw=="],
|
"@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@15.4.1", "", { "os": "win32", "cpu": "x64" }, "sha512-y+wTBxelk2xiNofmDOVU7O5WxTHcvOoL3srOM0kxTzKDjQ57kPU0tpnPJ/BWrRnsOwXEv0+3QSbGR7hY4n9LkQ=="],
|
||||||
|
|
||||||
"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
|
"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
|
||||||
|
|
||||||
@@ -314,6 +321,8 @@
|
|||||||
|
|
||||||
"@radix-ui/react-checkbox": ["@radix-ui/react-checkbox@1.3.2", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-yd+dI56KZqawxKZrJ31eENUwqc1QSqg4OZ15rybGjF2ZNwMO+wCyHzAVLRp9qoYJf7kYy0YpZ2b0JCzJ42HZpA=="],
|
"@radix-ui/react-checkbox": ["@radix-ui/react-checkbox@1.3.2", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-yd+dI56KZqawxKZrJ31eENUwqc1QSqg4OZ15rybGjF2ZNwMO+wCyHzAVLRp9qoYJf7kYy0YpZ2b0JCzJ42HZpA=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-collapsible": ["@radix-ui/react-collapsible@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-2qrRsVGSCYasSz1RFOorXwl0H7g7J1frQtgpQgYrt+MOidtPAINHn9CPovQXb83r8ahapdx3Tu0fa/pdFFSdPg=="],
|
||||||
|
|
||||||
"@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw=="],
|
"@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw=="],
|
||||||
|
|
||||||
"@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="],
|
"@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="],
|
||||||
@@ -360,6 +369,8 @@
|
|||||||
|
|
||||||
"@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
"@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-switch": ["@radix-ui/react-switch@1.2.5", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-5ijLkak6ZMylXsaImpZ8u4Rlf5grRmoc0p0QeX9VJtlrM4f5m3nCTX8tWga/zOA8PZYIR/t0p2Mnvd7InrJ6yQ=="],
|
||||||
|
|
||||||
"@radix-ui/react-tabs": ["@radix-ui/react-tabs@1.1.12", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.10", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-GTVAlRVrQrSw3cEARM0nAx73ixrWDPNZAruETn3oHCNP6SbZ/hNxdxp+u7VkIEv3/sFoLq1PfcHrl7Pnp0CDpw=="],
|
"@radix-ui/react-tabs": ["@radix-ui/react-tabs@1.1.12", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.10", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-GTVAlRVrQrSw3cEARM0nAx73ixrWDPNZAruETn3oHCNP6SbZ/hNxdxp+u7VkIEv3/sFoLq1PfcHrl7Pnp0CDpw=="],
|
||||||
|
|
||||||
"@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg=="],
|
"@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg=="],
|
||||||
@@ -412,8 +423,6 @@
|
|||||||
|
|
||||||
"@rushstack/eslint-patch": ["@rushstack/eslint-patch@1.12.0", "", {}, "sha512-5EwMtOqvJMMa3HbmxLlF74e+3/HhwBTMcvt3nqVJgGCozO6hzIPOBlwm8mGVNR9SN2IJpxSnlxczyDjcn7qIyw=="],
|
"@rushstack/eslint-patch": ["@rushstack/eslint-patch@1.12.0", "", {}, "sha512-5EwMtOqvJMMa3HbmxLlF74e+3/HhwBTMcvt3nqVJgGCozO6hzIPOBlwm8mGVNR9SN2IJpxSnlxczyDjcn7qIyw=="],
|
||||||
|
|
||||||
"@swc/counter": ["@swc/counter@0.1.3", "", {}, "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ=="],
|
|
||||||
|
|
||||||
"@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="],
|
"@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="],
|
||||||
|
|
||||||
"@t3-oss/env-core": ["@t3-oss/env-core@0.12.0", "", { "peerDependencies": { "typescript": ">=5.0.0", "valibot": "^1.0.0-beta.7 || ^1.0.0", "zod": "^3.24.0" }, "optionalPeers": ["typescript", "valibot", "zod"] }, "sha512-lOPj8d9nJJTt81mMuN9GMk8x5veOt7q9m11OSnCBJhwp1QrL/qR+M8Y467ULBSm9SunosryWNbmQQbgoiMgcdw=="],
|
"@t3-oss/env-core": ["@t3-oss/env-core@0.12.0", "", { "peerDependencies": { "typescript": ">=5.0.0", "valibot": "^1.0.0-beta.7 || ^1.0.0", "zod": "^3.24.0" }, "optionalPeers": ["typescript", "valibot", "zod"] }, "sha512-lOPj8d9nJJTt81mMuN9GMk8x5veOt7q9m11OSnCBJhwp1QrL/qR+M8Y467ULBSm9SunosryWNbmQQbgoiMgcdw=="],
|
||||||
@@ -454,6 +463,10 @@
|
|||||||
|
|
||||||
"@tanstack/react-query": ["@tanstack/react-query@5.82.0", "", { "dependencies": { "@tanstack/query-core": "5.82.0" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-mnk8/ofKEthFeMdhV1dV8YXRf+9HqvXAcciXkoo755d/ocfWq7N/Y9jGOzS3h7ZW9dDGwSIhs3/HANWUBsyqYg=="],
|
"@tanstack/react-query": ["@tanstack/react-query@5.82.0", "", { "dependencies": { "@tanstack/query-core": "5.82.0" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-mnk8/ofKEthFeMdhV1dV8YXRf+9HqvXAcciXkoo755d/ocfWq7N/Y9jGOzS3h7ZW9dDGwSIhs3/HANWUBsyqYg=="],
|
||||||
|
|
||||||
|
"@tanstack/react-table": ["@tanstack/react-table@8.21.3", "", { "dependencies": { "@tanstack/table-core": "8.21.3" }, "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww=="],
|
||||||
|
|
||||||
|
"@tanstack/table-core": ["@tanstack/table-core@8.21.3", "", {}, "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg=="],
|
||||||
|
|
||||||
"@trpc/client": ["@trpc/client@11.4.3", "", { "peerDependencies": { "@trpc/server": "11.4.3", "typescript": ">=5.7.2" } }, "sha512-i2suttUCfColktXT8bqex5kHW5jpT15nwUh0hGSDiW1keN621kSUQKcLJ095blqQAUgB+lsmgSqSMmB4L9shQQ=="],
|
"@trpc/client": ["@trpc/client@11.4.3", "", { "peerDependencies": { "@trpc/server": "11.4.3", "typescript": ">=5.7.2" } }, "sha512-i2suttUCfColktXT8bqex5kHW5jpT15nwUh0hGSDiW1keN621kSUQKcLJ095blqQAUgB+lsmgSqSMmB4L9shQQ=="],
|
||||||
|
|
||||||
"@trpc/react-query": ["@trpc/react-query@11.4.3", "", { "peerDependencies": { "@tanstack/react-query": "^5.80.3", "@trpc/client": "11.4.3", "@trpc/server": "11.4.3", "react": ">=18.2.0", "react-dom": ">=18.2.0", "typescript": ">=5.7.2" } }, "sha512-z+jhAiOBD22NNhHtvF0iFp9hO36YFA7M8AiUu/XtNmMxyLd3Y9/d1SMjMwlTdnGqxEGPo41VEWBrdhDUGtUuHg=="],
|
"@trpc/react-query": ["@trpc/react-query@11.4.3", "", { "peerDependencies": { "@tanstack/react-query": "^5.80.3", "@trpc/client": "11.4.3", "@trpc/server": "11.4.3", "react": ">=18.2.0", "react-dom": ">=18.2.0", "typescript": ">=5.7.2" } }, "sha512-z+jhAiOBD22NNhHtvF0iFp9hO36YFA7M8AiUu/XtNmMxyLd3Y9/d1SMjMwlTdnGqxEGPo41VEWBrdhDUGtUuHg=="],
|
||||||
@@ -614,8 +627,6 @@
|
|||||||
|
|
||||||
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
|
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
|
||||||
|
|
||||||
"busboy": ["busboy@1.6.0", "", { "dependencies": { "streamsearch": "^1.1.0" } }, "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA=="],
|
|
||||||
|
|
||||||
"call-bind": ["call-bind@1.0.8", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.0", "es-define-property": "^1.0.0", "get-intrinsic": "^1.2.4", "set-function-length": "^1.2.2" } }, "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww=="],
|
"call-bind": ["call-bind@1.0.8", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.0", "es-define-property": "^1.0.0", "get-intrinsic": "^1.2.4", "set-function-length": "^1.2.2" } }, "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww=="],
|
||||||
|
|
||||||
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
|
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
|
||||||
@@ -1034,7 +1045,7 @@
|
|||||||
|
|
||||||
"natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
|
"natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
|
||||||
|
|
||||||
"next": ["next@15.3.5", "", { "dependencies": { "@next/env": "15.3.5", "@swc/counter": "0.1.3", "@swc/helpers": "0.5.15", "busboy": "1.6.0", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "15.3.5", "@next/swc-darwin-x64": "15.3.5", "@next/swc-linux-arm64-gnu": "15.3.5", "@next/swc-linux-arm64-musl": "15.3.5", "@next/swc-linux-x64-gnu": "15.3.5", "@next/swc-linux-x64-musl": "15.3.5", "@next/swc-win32-arm64-msvc": "15.3.5", "@next/swc-win32-x64-msvc": "15.3.5", "sharp": "^0.34.1" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.41.2", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-RkazLBMMDJSJ4XZQ81kolSpwiCt907l0xcgcpF4xC2Vml6QVcPNXW0NQRwQ80FFtSn7UM52XN0anaw8TEJXaiw=="],
|
"next": ["next@15.4.1", "", { "dependencies": { "@next/env": "15.4.1", "@swc/helpers": "0.5.15", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "15.4.1", "@next/swc-darwin-x64": "15.4.1", "@next/swc-linux-arm64-gnu": "15.4.1", "@next/swc-linux-arm64-musl": "15.4.1", "@next/swc-linux-x64-gnu": "15.4.1", "@next/swc-linux-x64-musl": "15.4.1", "@next/swc-win32-arm64-msvc": "15.4.1", "@next/swc-win32-x64-msvc": "15.4.1", "sharp": "^0.34.3" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-eNKB1q8C7o9zXF8+jgJs2CzSLIU3T6bQtX6DcTnCq1sIR1CJ0GlSyRs1BubQi3/JgCnr9Vr+rS5mOMI38FFyQw=="],
|
||||||
|
|
||||||
"next-auth": ["next-auth@5.0.0-beta.25", "", { "dependencies": { "@auth/core": "0.37.2" }, "peerDependencies": { "@simplewebauthn/browser": "^9.0.1", "@simplewebauthn/server": "^9.0.2", "next": "^14.0.0-0 || ^15.0.0-0", "nodemailer": "^6.6.5", "react": "^18.2.0 || ^19.0.0-0" }, "optionalPeers": ["@simplewebauthn/browser", "@simplewebauthn/server", "nodemailer"] }, "sha512-2dJJw1sHQl2qxCrRk+KTQbeH+izFbGFPuJj5eGgBZFYyiYYtvlrBeUw1E/OJJxTRjuxbSYGnCTkUIRsIIW0bog=="],
|
"next-auth": ["next-auth@5.0.0-beta.25", "", { "dependencies": { "@auth/core": "0.37.2" }, "peerDependencies": { "@simplewebauthn/browser": "^9.0.1", "@simplewebauthn/server": "^9.0.2", "next": "^14.0.0-0 || ^15.0.0-0", "nodemailer": "^6.6.5", "react": "^18.2.0 || ^19.0.0-0" }, "optionalPeers": ["@simplewebauthn/browser", "@simplewebauthn/server", "nodemailer"] }, "sha512-2dJJw1sHQl2qxCrRk+KTQbeH+izFbGFPuJj5eGgBZFYyiYYtvlrBeUw1E/OJJxTRjuxbSYGnCTkUIRsIIW0bog=="],
|
||||||
|
|
||||||
@@ -1180,7 +1191,7 @@
|
|||||||
|
|
||||||
"set-proto": ["set-proto@1.0.0", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0" } }, "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw=="],
|
"set-proto": ["set-proto@1.0.0", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0" } }, "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw=="],
|
||||||
|
|
||||||
"sharp": ["sharp@0.34.2", "", { "dependencies": { "color": "^4.2.3", "detect-libc": "^2.0.4", "semver": "^7.7.2" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.2", "@img/sharp-darwin-x64": "0.34.2", "@img/sharp-libvips-darwin-arm64": "1.1.0", "@img/sharp-libvips-darwin-x64": "1.1.0", "@img/sharp-libvips-linux-arm": "1.1.0", "@img/sharp-libvips-linux-arm64": "1.1.0", "@img/sharp-libvips-linux-ppc64": "1.1.0", "@img/sharp-libvips-linux-s390x": "1.1.0", "@img/sharp-libvips-linux-x64": "1.1.0", "@img/sharp-libvips-linuxmusl-arm64": "1.1.0", "@img/sharp-libvips-linuxmusl-x64": "1.1.0", "@img/sharp-linux-arm": "0.34.2", "@img/sharp-linux-arm64": "0.34.2", "@img/sharp-linux-s390x": "0.34.2", "@img/sharp-linux-x64": "0.34.2", "@img/sharp-linuxmusl-arm64": "0.34.2", "@img/sharp-linuxmusl-x64": "0.34.2", "@img/sharp-wasm32": "0.34.2", "@img/sharp-win32-arm64": "0.34.2", "@img/sharp-win32-ia32": "0.34.2", "@img/sharp-win32-x64": "0.34.2" } }, "sha512-lszvBmB9QURERtyKT2bNmsgxXK0ShJrL/fvqlonCo7e6xBF8nT8xU6pW+PMIbLsz0RxQk3rgH9kd8UmvOzlMJg=="],
|
"sharp": ["sharp@0.34.3", "", { "dependencies": { "color": "^4.2.3", "detect-libc": "^2.0.4", "semver": "^7.7.2" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.3", "@img/sharp-darwin-x64": "0.34.3", "@img/sharp-libvips-darwin-arm64": "1.2.0", "@img/sharp-libvips-darwin-x64": "1.2.0", "@img/sharp-libvips-linux-arm": "1.2.0", "@img/sharp-libvips-linux-arm64": "1.2.0", "@img/sharp-libvips-linux-ppc64": "1.2.0", "@img/sharp-libvips-linux-s390x": "1.2.0", "@img/sharp-libvips-linux-x64": "1.2.0", "@img/sharp-libvips-linuxmusl-arm64": "1.2.0", "@img/sharp-libvips-linuxmusl-x64": "1.2.0", "@img/sharp-linux-arm": "0.34.3", "@img/sharp-linux-arm64": "0.34.3", "@img/sharp-linux-ppc64": "0.34.3", "@img/sharp-linux-s390x": "0.34.3", "@img/sharp-linux-x64": "0.34.3", "@img/sharp-linuxmusl-arm64": "0.34.3", "@img/sharp-linuxmusl-x64": "0.34.3", "@img/sharp-wasm32": "0.34.3", "@img/sharp-win32-arm64": "0.34.3", "@img/sharp-win32-ia32": "0.34.3", "@img/sharp-win32-x64": "0.34.3" } }, "sha512-eX2IQ6nFohW4DbvHIOLRB3MHFpYqaqvXd3Tp5e/T/dSH83fxaNJQRvDMhASmkNTsNTVF2/OOopzRCt7xokgPfg=="],
|
||||||
|
|
||||||
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
|
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
|
||||||
|
|
||||||
@@ -1214,8 +1225,6 @@
|
|||||||
|
|
||||||
"stop-iteration-iterator": ["stop-iteration-iterator@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "internal-slot": "^1.1.0" } }, "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ=="],
|
"stop-iteration-iterator": ["stop-iteration-iterator@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "internal-slot": "^1.1.0" } }, "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ=="],
|
||||||
|
|
||||||
"streamsearch": ["streamsearch@1.1.0", "", {}, "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg=="],
|
|
||||||
|
|
||||||
"string.prototype.includes": ["string.prototype.includes@2.0.1", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.3" } }, "sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg=="],
|
"string.prototype.includes": ["string.prototype.includes@2.0.1", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.3" } }, "sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg=="],
|
||||||
|
|
||||||
"string.prototype.matchall": ["string.prototype.matchall@4.0.12", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-abstract": "^1.23.6", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.6", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "internal-slot": "^1.1.0", "regexp.prototype.flags": "^1.5.3", "set-function-name": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA=="],
|
"string.prototype.matchall": ["string.prototype.matchall@4.0.12", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-abstract": "^1.23.6", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.6", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "internal-slot": "^1.1.0", "regexp.prototype.flags": "^1.5.3", "set-function-name": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA=="],
|
||||||
|
|||||||
138
docs/RESPONSIVE_TABLE_EXAMPLES.md
Normal file
138
docs/RESPONSIVE_TABLE_EXAMPLES.md
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
# Responsive Table Examples
|
||||||
|
|
||||||
|
This document shows how tables adapt across different screen sizes in the beenvoice application.
|
||||||
|
|
||||||
|
## Mobile View (< 640px)
|
||||||
|
|
||||||
|
### Invoices Table
|
||||||
|
- **Visible**: Invoice number, client name, amount, status, actions
|
||||||
|
- **Hidden**: Issue date, due date (shown on detail view)
|
||||||
|
- **Features**: Compact spacing, smaller buttons, simplified pagination
|
||||||
|
|
||||||
|
### Clients Table
|
||||||
|
- **Visible**: Name with email, actions
|
||||||
|
- **Hidden**: Phone, address, created date
|
||||||
|
- **Icon**: Hidden on mobile to save space
|
||||||
|
|
||||||
|
### Businesses Table
|
||||||
|
- **Visible**: Name with email, actions
|
||||||
|
- **Hidden**: Phone, address, tax ID, website
|
||||||
|
- **Icon**: Hidden on mobile to save space
|
||||||
|
|
||||||
|
## Tablet View (640px - 1024px)
|
||||||
|
|
||||||
|
### Invoices Table
|
||||||
|
- **Added**: Issue date column
|
||||||
|
- **Still Hidden**: Due date (less critical than issue date)
|
||||||
|
- **Features**: Search bar expands, column visibility toggle appears
|
||||||
|
|
||||||
|
### Clients Table
|
||||||
|
- **Added**: Phone column, client icon
|
||||||
|
- **Still Hidden**: Address, created date
|
||||||
|
- **Features**: Better spacing, full search functionality
|
||||||
|
|
||||||
|
### Businesses Table
|
||||||
|
- **Added**: Phone column, business icon
|
||||||
|
- **Still Hidden**: Address, tax ID
|
||||||
|
- **Features**: Website links become visible
|
||||||
|
|
||||||
|
## Desktop View (> 1024px)
|
||||||
|
|
||||||
|
### All Tables
|
||||||
|
- **Full Features**: All columns visible
|
||||||
|
- **Enhanced**:
|
||||||
|
- Full pagination controls with page size selector
|
||||||
|
- Column visibility toggle
|
||||||
|
- Advanced filters
|
||||||
|
- Comfortable spacing
|
||||||
|
- All metadata visible
|
||||||
|
|
||||||
|
## Code Examples
|
||||||
|
|
||||||
|
### Responsive Column Definition
|
||||||
|
```tsx
|
||||||
|
// Hide on mobile, show on tablet and up
|
||||||
|
{
|
||||||
|
accessorKey: "phone",
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataTableColumnHeader column={column} title="Phone" />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="hidden md:inline">{row.original.phone || "—"}</span>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide on mobile and tablet, show on desktop
|
||||||
|
{
|
||||||
|
id: "address",
|
||||||
|
header: "Address",
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="hidden lg:inline">{formatAddress(row.original)}</span>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Responsive Cell Content
|
||||||
|
```tsx
|
||||||
|
// Icon hidden on mobile
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="hidden rounded-lg bg-status-info-muted p-2 sm:flex">
|
||||||
|
<UserPlus className="h-4 w-4 text-status-info" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate font-medium">{client.name}</p>
|
||||||
|
<p className="truncate text-sm text-muted-foreground">
|
||||||
|
{client.email || "—"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Responsive Actions
|
||||||
|
```tsx
|
||||||
|
// Compact action buttons that work on all screen sizes
|
||||||
|
<div className="flex items-center justify-end gap-1">
|
||||||
|
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
|
||||||
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Filter Bar Behavior
|
||||||
|
|
||||||
|
### Mobile
|
||||||
|
- Search input takes full width
|
||||||
|
- Filter dropdowns stack vertically
|
||||||
|
- Column visibility hidden
|
||||||
|
- Clear filters button visible when filters active
|
||||||
|
|
||||||
|
### Tablet+
|
||||||
|
- Search input limited to max-width
|
||||||
|
- Filter dropdowns in horizontal row
|
||||||
|
- Column visibility toggle appears
|
||||||
|
- All controls in single row
|
||||||
|
|
||||||
|
## Pagination Behavior
|
||||||
|
|
||||||
|
### Mobile
|
||||||
|
- Simplified page indicator (1/5 format)
|
||||||
|
- Compact button spacing
|
||||||
|
- Page size selector with smaller text
|
||||||
|
|
||||||
|
### Desktop
|
||||||
|
- Full "Page 1 of 5" text
|
||||||
|
- Comfortable button spacing
|
||||||
|
- First/Last page buttons visible
|
||||||
|
- Entries count with detailed information
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
1. **Priority Content**: Always show the most important data on mobile
|
||||||
|
2. **Progressive Enhancement**: Add columns as screen size increases
|
||||||
|
3. **Touch Targets**: Maintain 44px minimum touch targets on mobile
|
||||||
|
4. **Text Truncation**: Use `truncate` class for long text in narrow columns
|
||||||
|
5. **Icon Usage**: Hide decorative icons on mobile, keep functional ones
|
||||||
|
6. **Testing**: Always test at 375px (iPhone SE), 768px (iPad), and 1440px (Desktop)
|
||||||
324
docs/UI_UNIFORMITY_GUIDE.md
Normal file
324
docs/UI_UNIFORMITY_GUIDE.md
Normal file
@@ -0,0 +1,324 @@
|
|||||||
|
# UI Uniformity Guide for beenvoice
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This guide documents the unified component system implemented across the beenvoice application to ensure consistent UI/UX patterns. The system follows a hierarchical approach where:
|
||||||
|
|
||||||
|
1. **CSS Variables** (in `globals.css`) define the design tokens
|
||||||
|
2. **UI Components** (in `components/ui`) consume these variables
|
||||||
|
3. **Pages** use components with minimal additional styling
|
||||||
|
|
||||||
|
## Design System Principles
|
||||||
|
|
||||||
|
### 1. Variable-Based Theming
|
||||||
|
All colors, spacing, and other design tokens are defined as CSS variables in `globals.css`:
|
||||||
|
- Brand colors: `--brand-primary`, `--brand-secondary`
|
||||||
|
- Status colors: `--status-success`, `--status-warning`, `--status-error`, `--status-info`
|
||||||
|
- Semantic colors: `--background`, `--foreground`, `--muted`, etc.
|
||||||
|
|
||||||
|
### 2. Component Composition
|
||||||
|
Complex UI patterns are built from smaller, reusable components rather than duplicating code.
|
||||||
|
|
||||||
|
### 3. Minimal Page-Level Styling
|
||||||
|
Pages should primarily compose pre-built components and avoid custom Tailwind classes where possible.
|
||||||
|
|
||||||
|
## Core Unified Components
|
||||||
|
|
||||||
|
### Page Layout Components
|
||||||
|
|
||||||
|
#### `PageContent`
|
||||||
|
Wraps page content with consistent spacing:
|
||||||
|
```tsx
|
||||||
|
<PageContent spacing="default">
|
||||||
|
{/* Page sections */}
|
||||||
|
</PageContent>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `PageSection`
|
||||||
|
Groups related content with optional title and actions:
|
||||||
|
```tsx
|
||||||
|
<PageSection
|
||||||
|
title="Section Title"
|
||||||
|
description="Optional description"
|
||||||
|
actions={<Button>Action</Button>}
|
||||||
|
>
|
||||||
|
{/* Section content */}
|
||||||
|
</PageSection>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `PageGrid`
|
||||||
|
Responsive grid layout with preset column options:
|
||||||
|
```tsx
|
||||||
|
<PageGrid columns={3} gap="default">
|
||||||
|
{/* Grid items */}
|
||||||
|
</PageGrid>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Data Display Components
|
||||||
|
|
||||||
|
#### `DataTable`
|
||||||
|
Unified table component using @tanstack/react-table with floating card design:
|
||||||
|
```tsx
|
||||||
|
import { ColumnDef } from "@tanstack/react-table";
|
||||||
|
import { DataTable, DataTableColumnHeader } from "~/components/ui/data-table";
|
||||||
|
import { PageSection } from "~/components/ui/page-layout";
|
||||||
|
|
||||||
|
const columns: ColumnDef<DataType>[] = [
|
||||||
|
{
|
||||||
|
accessorKey: "name",
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataTableColumnHeader column={column} title="Name" />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const name = row.getValue("name") as string;
|
||||||
|
return <div className="font-medium">{name}</div>;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "actions",
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const item = row.original;
|
||||||
|
return (
|
||||||
|
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
|
||||||
|
<Edit className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const filterableColumns = [
|
||||||
|
{
|
||||||
|
id: "status",
|
||||||
|
title: "Status",
|
||||||
|
options: [
|
||||||
|
{ label: "Active", value: "active" },
|
||||||
|
{ label: "Inactive", value: "inactive" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
// Wrap in PageSection for title/description
|
||||||
|
<PageSection
|
||||||
|
title="Table Title"
|
||||||
|
description="Optional description"
|
||||||
|
>
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
data={data}
|
||||||
|
searchPlaceholder="Search by name..."
|
||||||
|
filterableColumns={filterableColumns}
|
||||||
|
/>
|
||||||
|
</PageSection>
|
||||||
|
```
|
||||||
|
|
||||||
|
Features:
|
||||||
|
- **Floating Card Design**: Three separate cards for filter bar, table content, and pagination
|
||||||
|
- **Filter Bar Card**: Minimal padding (p-3) with global search and column filters
|
||||||
|
- **Table Content Card**: Clean borders with overflow handling
|
||||||
|
- **Pagination Card**: Compact controls with page size selector
|
||||||
|
- **Responsive Design**: Mobile-optimized with hidden columns on smaller screens
|
||||||
|
- **Tight Appearance**: Compact spacing with smaller action buttons
|
||||||
|
- **Sorting**: Visual indicators with proper arrow directions
|
||||||
|
- **Column Visibility**: Toggle columns (hidden on mobile)
|
||||||
|
- **Dark Mode**: Consistent styling across light/dark themes
|
||||||
|
- **Loading States**: DataTableSkeleton component with matching card structure
|
||||||
|
|
||||||
|
#### `StatsCard`
|
||||||
|
Displays statistics with consistent styling:
|
||||||
|
```tsx
|
||||||
|
<StatsCard
|
||||||
|
title="Total Revenue"
|
||||||
|
value="$10,000"
|
||||||
|
icon={DollarSign}
|
||||||
|
description="From 50 invoices"
|
||||||
|
variant="success"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `QuickActionCard`
|
||||||
|
Interactive cards for navigation or actions:
|
||||||
|
```tsx
|
||||||
|
<QuickActionCard
|
||||||
|
title="Create Invoice"
|
||||||
|
description="Start a new invoice"
|
||||||
|
icon={Plus}
|
||||||
|
variant="success"
|
||||||
|
>
|
||||||
|
<Link href="/invoices/new">
|
||||||
|
<div className="h-full w-full" />
|
||||||
|
</Link>
|
||||||
|
</QuickActionCard>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Feedback Components
|
||||||
|
|
||||||
|
#### `EmptyState`
|
||||||
|
Consistent empty state displays:
|
||||||
|
```tsx
|
||||||
|
<EmptyState
|
||||||
|
icon={<FileText className="h-8 w-8" />}
|
||||||
|
title="No invoices yet"
|
||||||
|
description="Create your first invoice to get started"
|
||||||
|
action={<Button>Create Invoice</Button>}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Component Variants
|
||||||
|
|
||||||
|
### Color Variants
|
||||||
|
Most components support these variants:
|
||||||
|
- `default` - Uses default theme colors
|
||||||
|
- `success` - Green color scheme for positive states
|
||||||
|
- `warning` - Orange/amber for warnings
|
||||||
|
- `error` - Red for errors or destructive actions
|
||||||
|
- `info` - Blue for informational content
|
||||||
|
|
||||||
|
### Size Variants
|
||||||
|
- `sm` - Small size
|
||||||
|
- `default` - Normal size
|
||||||
|
- `lg` - Large size
|
||||||
|
|
||||||
|
## Usage Examples
|
||||||
|
|
||||||
|
### Standard Page Structure
|
||||||
|
```tsx
|
||||||
|
export default function ExamplePage() {
|
||||||
|
return (
|
||||||
|
<PageContent>
|
||||||
|
<PageHeader
|
||||||
|
title="Page Title"
|
||||||
|
description="Page description"
|
||||||
|
variant="gradient"
|
||||||
|
>
|
||||||
|
<Button variant="brand">
|
||||||
|
Primary Action
|
||||||
|
</Button>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<PageSection>
|
||||||
|
<PageGrid columns={4}>
|
||||||
|
<StatsCard {...statsProps} />
|
||||||
|
</PageGrid>
|
||||||
|
</PageSection>
|
||||||
|
|
||||||
|
<PageSection
|
||||||
|
title="Data Table Title"
|
||||||
|
description="Table description"
|
||||||
|
>
|
||||||
|
<DataTable {...tableProps} />
|
||||||
|
</PageSection>
|
||||||
|
</PageContent>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Consistent Button Usage
|
||||||
|
```tsx
|
||||||
|
// Primary actions
|
||||||
|
<Button variant="brand">Create New</Button>
|
||||||
|
|
||||||
|
// Secondary actions
|
||||||
|
<Button variant="outline">Cancel</Button>
|
||||||
|
|
||||||
|
// Destructive actions
|
||||||
|
<Button variant="destructive">Delete</Button>
|
||||||
|
|
||||||
|
// Icon-only actions
|
||||||
|
<Button variant="ghost" size="icon">
|
||||||
|
<Edit className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Styling Guidelines
|
||||||
|
|
||||||
|
### Do's
|
||||||
|
- ✅ Use predefined color variables from globals.css
|
||||||
|
- ✅ Compose existing UI components
|
||||||
|
- ✅ Use semantic variant names (success, error, etc.)
|
||||||
|
- ✅ Follow the established spacing patterns
|
||||||
|
- ✅ Use the PageLayout components for structure
|
||||||
|
|
||||||
|
### Don'ts
|
||||||
|
- ❌ Add custom colors directly in components
|
||||||
|
- ❌ Create one-off table or card implementations
|
||||||
|
- ❌ Override component styles with important flags
|
||||||
|
- ❌ Use arbitrary spacing values
|
||||||
|
- ❌ Mix different UI patterns on the same page
|
||||||
|
|
||||||
|
## Migration Checklist
|
||||||
|
|
||||||
|
When updating a page to use the unified system:
|
||||||
|
|
||||||
|
1. Replace custom tables with `DataTable` using @tanstack/react-table ColumnDef
|
||||||
|
2. Replace statistics displays with `StatsCard`
|
||||||
|
3. Replace action cards with `QuickActionCard`
|
||||||
|
4. Wrap content in `PageContent` and `PageSection`
|
||||||
|
5. Use `PageGrid` for responsive layouts
|
||||||
|
6. Replace custom empty states with `EmptyState`
|
||||||
|
7. Update buttons to use the `brand` variant for primary actions
|
||||||
|
8. Remove page-specific color classes
|
||||||
|
9. Use `DataTableColumnHeader` for sortable column headers
|
||||||
|
10. Use `DataTableSkeleton` for loading states
|
||||||
|
|
||||||
|
## Color System Reference
|
||||||
|
|
||||||
|
### Brand Colors
|
||||||
|
- Primary: Green (`#16a34a` / `oklch(0.646 0.222 164.25)`)
|
||||||
|
- Secondary: Teal/cyan shades
|
||||||
|
- Gradients: Use `bg-brand-gradient` class
|
||||||
|
|
||||||
|
### Status Colors
|
||||||
|
- Success: Green shades
|
||||||
|
- Warning: Amber/orange shades
|
||||||
|
- Error: Red shades
|
||||||
|
- Info: Blue shades
|
||||||
|
|
||||||
|
### Semantic Colors
|
||||||
|
- Background: White/dark gray
|
||||||
|
- Foreground: Black/white text
|
||||||
|
- Muted: Gray shades for secondary content
|
||||||
|
- Border: Light gray borders
|
||||||
|
|
||||||
|
## Component Documentation
|
||||||
|
|
||||||
|
For detailed component APIs and props, refer to:
|
||||||
|
- `/src/components/ui/data-table.tsx` - TanStack Table-based data table with sorting, filtering, and pagination
|
||||||
|
- `/src/components/ui/stats-card.tsx` - Statistics display cards
|
||||||
|
- `/src/components/ui/quick-action-card.tsx` - Interactive action cards
|
||||||
|
- `/src/components/ui/page-layout.tsx` - Page structure components
|
||||||
|
|
||||||
|
### DataTable Props
|
||||||
|
- `columns`: ColumnDef array from @tanstack/react-table
|
||||||
|
- `data`: Array of data to display
|
||||||
|
- `searchPlaceholder?`: Placeholder text for search input
|
||||||
|
- `showColumnVisibility?`: Show/hide column visibility toggle (default: true)
|
||||||
|
- `showPagination?`: Show/hide pagination controls (default: true)
|
||||||
|
- `showSearch?`: Show/hide search input (default: true)
|
||||||
|
- `pageSize?`: Number of items per page (default: 10)
|
||||||
|
- `filterableColumns?`: Array of column filters with options
|
||||||
|
|
||||||
|
Note: `title` and `description` should be provided via the wrapping `PageSection` component for consistent spacing and typography.
|
||||||
|
|
||||||
|
### Responsive Table Guidelines
|
||||||
|
- Use `hidden sm:flex` classes for icons in table cells
|
||||||
|
- Use `hidden md:inline` for less important columns on mobile
|
||||||
|
- Use `min-w-0` and `truncate` for text that might overflow
|
||||||
|
- Keep action buttons small with `h-8 w-8 p-0` sizing
|
||||||
|
- Test tables at all breakpoints (mobile, tablet, desktop)
|
||||||
|
|
||||||
|
## Future Considerations
|
||||||
|
|
||||||
|
1. **Form Components**: Create unified form field components
|
||||||
|
2. **Modal Patterns**: Standardize modal and dialog usage
|
||||||
|
3. **Loading States**: Create consistent skeleton loaders
|
||||||
|
4. **Animation**: Define standard transition patterns
|
||||||
|
5. **Icons**: Establish icon usage guidelines
|
||||||
|
|
||||||
|
## Maintenance
|
||||||
|
|
||||||
|
To maintain UI consistency:
|
||||||
|
1. Always check for existing components before creating new ones
|
||||||
|
2. Update this guide when adding new unified components
|
||||||
|
3. Review PRs for adherence to these patterns
|
||||||
|
4. Refactor pages that deviate from the system
|
||||||
198
docs/breadcrumbs-guide.md
Normal file
198
docs/breadcrumbs-guide.md
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
# Dynamic Breadcrumbs Guide
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The breadcrumb system in beenvoice automatically generates navigation trails based on the current URL path. It features intelligent pluralization, proper capitalization, and dynamic resource name fetching.
|
||||||
|
|
||||||
|
## Key Features
|
||||||
|
|
||||||
|
### 1. Automatic Pluralization
|
||||||
|
|
||||||
|
The breadcrumb system intelligently handles singular and plural forms:
|
||||||
|
|
||||||
|
- **List pages** (e.g., `/dashboard/businesses`) → "Businesses"
|
||||||
|
- **Detail pages** (e.g., `/dashboard/businesses/[id]`) → "Business"
|
||||||
|
- **New pages** (e.g., `/dashboard/businesses/new`) → "Business" (singular context)
|
||||||
|
|
||||||
|
### 2. Smart Capitalization
|
||||||
|
|
||||||
|
All route segments are automatically capitalized:
|
||||||
|
- `businesses` → "Businesses"
|
||||||
|
- `clients` → "Clients"
|
||||||
|
- `invoices` → "Invoices"
|
||||||
|
|
||||||
|
### 3. Dynamic Resource Names
|
||||||
|
|
||||||
|
Instead of showing UUIDs, breadcrumbs fetch and display actual resource names:
|
||||||
|
- `/dashboard/clients/123e4567-e89b-12d3-a456-426614174000` → "Dashboard / Clients / John Doe"
|
||||||
|
- `/dashboard/invoices/987fcdeb-51a2-43f1-b321-123456789abc` → "Dashboard / Invoices / INV-2024-001"
|
||||||
|
|
||||||
|
### 4. Context-Aware Labels
|
||||||
|
|
||||||
|
Special pages are handled intelligently:
|
||||||
|
- **Edit pages**: Show the resource name instead of "Edit" as the last breadcrumb
|
||||||
|
- **New pages**: Show "New" as the last breadcrumb
|
||||||
|
- **Import/Export pages**: Show appropriate action labels
|
||||||
|
|
||||||
|
## Implementation Details
|
||||||
|
|
||||||
|
### Pluralization Rules
|
||||||
|
|
||||||
|
The system uses a comprehensive pluralization utility (`src/lib/pluralize.ts`) that handles:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Common business terms
|
||||||
|
business → businesses
|
||||||
|
client → clients
|
||||||
|
invoice → invoices
|
||||||
|
category → categories
|
||||||
|
company → companies
|
||||||
|
|
||||||
|
// General rules
|
||||||
|
- Words ending in 's', 'ss', 'sh', 'ch', 'x', 'z' → add 'es'
|
||||||
|
- Words ending in consonant + 'y' → change to 'ies'
|
||||||
|
- Words ending in 'f' or 'fe' → change to 'ves'
|
||||||
|
- Default → add 's'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Resource Fetching
|
||||||
|
|
||||||
|
The breadcrumbs automatically detect resource IDs and fetch the appropriate data:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Detects UUID patterns in the URL
|
||||||
|
const isUUID = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/
|
||||||
|
|
||||||
|
// Fetches data based on resource type
|
||||||
|
- Clients: Shows client name
|
||||||
|
- Invoices: Shows invoice number or formatted date
|
||||||
|
- Businesses: Shows business name
|
||||||
|
```
|
||||||
|
|
||||||
|
### Loading States
|
||||||
|
|
||||||
|
While fetching resource data, breadcrumbs show loading skeletons:
|
||||||
|
```tsx
|
||||||
|
<Skeleton className="inline-block h-5 w-24 align-middle" />
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage Examples
|
||||||
|
|
||||||
|
### Basic List Page
|
||||||
|
**URL**: `/dashboard/clients`
|
||||||
|
**Breadcrumbs**: Dashboard / Clients
|
||||||
|
|
||||||
|
### Resource Detail Page
|
||||||
|
**URL**: `/dashboard/clients/550e8400-e29b-41d4-a716-446655440000`
|
||||||
|
**Breadcrumbs**: Dashboard / Clients / Jane Smith
|
||||||
|
|
||||||
|
### Resource Edit Page
|
||||||
|
**URL**: `/dashboard/businesses/550e8400-e29b-41d4-a716-446655440000/edit`
|
||||||
|
**Breadcrumbs**: Dashboard / Businesses / Acme Corp
|
||||||
|
*(Note: "Edit" is hidden when showing the resource name)*
|
||||||
|
|
||||||
|
### New Resource Page
|
||||||
|
**URL**: `/dashboard/invoices/new`
|
||||||
|
**Breadcrumbs**: Dashboard / Invoices / New
|
||||||
|
|
||||||
|
### Nested Resources
|
||||||
|
**URL**: `/dashboard/clients/550e8400-e29b-41d4-a716-446655440000/invoices`
|
||||||
|
**Breadcrumbs**: Dashboard / Clients / John Doe / Invoices
|
||||||
|
|
||||||
|
## Customization
|
||||||
|
|
||||||
|
### Adding New Resource Types
|
||||||
|
|
||||||
|
To add a new resource type, update the pluralization rules:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// In src/lib/pluralize.ts
|
||||||
|
const PLURALIZATION_RULES = {
|
||||||
|
// ... existing rules
|
||||||
|
product: { singular: "Product", plural: "Products" },
|
||||||
|
service: { singular: "Service", plural: "Services" },
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Custom Resource Labels
|
||||||
|
|
||||||
|
For resources that need custom display logic, add to the breadcrumb component:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// For invoices, show invoice number instead of ID
|
||||||
|
if (prevSegment === "invoices") {
|
||||||
|
label = invoice.invoiceNumber || format(new Date(invoice.issueDate), "MMM dd, yyyy");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Special Segments
|
||||||
|
|
||||||
|
Add new special segments to the `SPECIAL_SEGMENTS` object:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const SPECIAL_SEGMENTS = {
|
||||||
|
new: "New",
|
||||||
|
edit: "Edit",
|
||||||
|
import: "Import",
|
||||||
|
export: "Export",
|
||||||
|
duplicate: "Duplicate",
|
||||||
|
archive: "Archive",
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
1. **Consistent Naming**: Use consistent URL patterns across your app
|
||||||
|
- List pages: `/dashboard/[resource]`
|
||||||
|
- Detail pages: `/dashboard/[resource]/[id]`
|
||||||
|
- Actions: `/dashboard/[resource]/[id]/[action]`
|
||||||
|
|
||||||
|
2. **Resource Fetching**: Only fetch data when needed
|
||||||
|
- Check resource type before enabling queries
|
||||||
|
- Use proper loading states
|
||||||
|
|
||||||
|
3. **Error Handling**: Handle cases where resources don't exist
|
||||||
|
- Show fallback text or maintain UUID display
|
||||||
|
- Don't break the breadcrumb trail
|
||||||
|
|
||||||
|
4. **Performance**: Breadcrumb queries are lightweight
|
||||||
|
- Only fetch minimal data (id, name)
|
||||||
|
- Use React Query caching effectively
|
||||||
|
|
||||||
|
## API Integration
|
||||||
|
|
||||||
|
The breadcrumb component integrates with tRPC routers:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Each resource router should have a getById method
|
||||||
|
getById: protectedProcedure
|
||||||
|
.input(z.object({ id: z.string() }))
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
// Return resource with at least id and name/title
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Accessibility
|
||||||
|
|
||||||
|
- Breadcrumbs use semantic HTML with proper ARIA labels
|
||||||
|
- Each segment is a link except the current page
|
||||||
|
- Proper keyboard navigation support
|
||||||
|
- Screen reader friendly with role="navigation"
|
||||||
|
|
||||||
|
## Responsive Design
|
||||||
|
|
||||||
|
- Breadcrumbs wrap on smaller screens
|
||||||
|
- Font sizes adjust: `text-sm sm:text-base`
|
||||||
|
- Separators scale appropriately
|
||||||
|
- Loading skeletons match text size
|
||||||
|
|
||||||
|
## Migration from Static Breadcrumbs
|
||||||
|
|
||||||
|
If migrating from hardcoded breadcrumbs:
|
||||||
|
|
||||||
|
1. Remove static breadcrumb definitions
|
||||||
|
2. Ensure URLs follow consistent patterns
|
||||||
|
3. Add getById methods to resource routers
|
||||||
|
4. Update imports to use `DashboardBreadcrumbs`
|
||||||
|
|
||||||
|
The dynamic system will automatically generate appropriate breadcrumbs based on the URL structure.
|
||||||
154
docs/data-table-improvements.md
Normal file
154
docs/data-table-improvements.md
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
# Data Table Improvements Summary
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The data table component has been significantly improved to address padding, scaling, and responsiveness issues. The tables now provide a cleaner, more compact appearance while maintaining excellent usability across all device sizes.
|
||||||
|
|
||||||
|
## Key Improvements Made
|
||||||
|
|
||||||
|
### 1. Tighter, More Consistent Padding
|
||||||
|
|
||||||
|
**Before:**
|
||||||
|
- Inconsistent padding across different table sections
|
||||||
|
- Excessive vertical padding making tables feel loose
|
||||||
|
- Cards had default py-6 padding that was too spacious
|
||||||
|
|
||||||
|
**After:**
|
||||||
|
- Table cells: `py-1.5` (mobile) / `py-2` (desktop) - reduced from `py-2.5` / `py-3`
|
||||||
|
- Table headers: `h-9` (mobile) / `h-10` (desktop) - reduced from `h-10` / `h-12`
|
||||||
|
- Filter/pagination cards: `py-2` with `px-3` horizontal padding
|
||||||
|
- Table card: `p-0` to wrap content tightly
|
||||||
|
|
||||||
|
### 2. Improved Responsive Column Handling
|
||||||
|
|
||||||
|
**Before:**
|
||||||
|
```tsx
|
||||||
|
// Cells would hide but headers remained visible
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="hidden md:inline">{row.original.phone}</span>
|
||||||
|
),
|
||||||
|
```
|
||||||
|
|
||||||
|
**After:**
|
||||||
|
```tsx
|
||||||
|
// Both header and cell hide together
|
||||||
|
cell: ({ row }) => row.original.phone || "—",
|
||||||
|
meta: {
|
||||||
|
headerClassName: "hidden md:table-cell",
|
||||||
|
cellClassName: "hidden md:table-cell",
|
||||||
|
},
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Better Small Card Appearance
|
||||||
|
|
||||||
|
- Filter card: Compact `py-2` padding with proper horizontal spacing
|
||||||
|
- Pagination card: Matching `py-2` padding for consistency
|
||||||
|
- Content aligned properly within smaller card boundaries
|
||||||
|
- Removed excessive gaps between elements
|
||||||
|
- Search box now has consistent padding without extra bottom spacing on mobile
|
||||||
|
|
||||||
|
### 4. Responsive Font Sizing
|
||||||
|
|
||||||
|
- Base text: `text-xs` on mobile, `text-sm` on desktop
|
||||||
|
- Consistent scaling across all table elements
|
||||||
|
- Better readability on small screens without wasting space
|
||||||
|
|
||||||
|
## Visual Comparison
|
||||||
|
|
||||||
|
### Table Density
|
||||||
|
- **Before**: ~60px per row with excessive padding
|
||||||
|
- **After**: ~40px per row with comfortable but efficient spacing
|
||||||
|
|
||||||
|
### Card Heights
|
||||||
|
- **Filter Card**: Reduced from ~80px to ~56px
|
||||||
|
- **Pagination Card**: Reduced from ~72px to ~48px
|
||||||
|
- **Table Card**: Now wraps content exactly with no extra space
|
||||||
|
- **Pagination Layout**: Entry count and pagination controls now stay on the same line on mobile
|
||||||
|
|
||||||
|
## Implementation Examples
|
||||||
|
|
||||||
|
### Responsive Column Definition
|
||||||
|
```tsx
|
||||||
|
const columns: ColumnDef<DataType>[] = [
|
||||||
|
{
|
||||||
|
accessorKey: "name",
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataTableColumnHeader column={column} title="Name" />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => row.original.name,
|
||||||
|
// Always visible
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "email",
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataTableColumnHeader column={column} title="Email" />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => row.original.email,
|
||||||
|
meta: {
|
||||||
|
// Hidden on mobile, visible on tablets and up
|
||||||
|
headerClassName: "hidden md:table-cell",
|
||||||
|
cellClassName: "hidden md:table-cell",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "createdAt",
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataTableColumnHeader column={column} title="Created" />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => formatDate(row.getValue("createdAt")),
|
||||||
|
meta: {
|
||||||
|
// Only visible on large screens
|
||||||
|
headerClassName: "hidden lg:table-cell",
|
||||||
|
cellClassName: "hidden lg:table-cell",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
### Page Header Actions
|
||||||
|
Page headers now properly position action buttons to the right on all screen sizes:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<PageHeader
|
||||||
|
title="Invoices"
|
||||||
|
description="Manage your invoices and track payments"
|
||||||
|
variant="gradient"
|
||||||
|
>
|
||||||
|
<Button asChild variant="brand" size="lg">
|
||||||
|
<Link href="/dashboard/invoices/new">
|
||||||
|
<Plus className="mr-2 h-5 w-5" /> New Invoice
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</PageHeader>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Breakpoint Reference
|
||||||
|
- `sm`: 640px and up
|
||||||
|
- `md`: 768px and up
|
||||||
|
- `lg`: 1024px and up
|
||||||
|
- `xl`: 1280px and up
|
||||||
|
|
||||||
|
## Benefits
|
||||||
|
|
||||||
|
1. **More Data Visible**: Tighter spacing allows more rows to be visible without scrolling
|
||||||
|
2. **Professional Appearance**: Clean, compact design suitable for business applications
|
||||||
|
3. **Better Mobile UX**: Properly hidden columns prevent layout breaking
|
||||||
|
4. **Consistent Styling**: All table instances now follow the same spacing rules
|
||||||
|
5. **Performance**: CSS-only solution with no JavaScript overhead
|
||||||
|
6. **Improved Mobile Layout**: Pagination controls stay inline with entry count on mobile
|
||||||
|
7. **Consistent Header Actions**: Action buttons properly positioned to the right
|
||||||
|
|
||||||
|
## Migration Checklist
|
||||||
|
|
||||||
|
- [x] Update column definitions to use `meta` properties
|
||||||
|
- [x] Remove inline responsive classes from cell content
|
||||||
|
- [x] Test on actual mobile devices
|
||||||
|
- [x] Verify touch targets remain accessible (min 44x44px)
|
||||||
|
- [x] Check that critical data remains visible on small screens
|
||||||
|
|
||||||
|
## Best Practices Going Forward
|
||||||
|
|
||||||
|
1. **Column Priority**: Always keep the most important 2-3 columns visible on mobile
|
||||||
|
2. **Content Density**: Use the tighter spacing for data tables, looser spacing for content lists
|
||||||
|
3. **Responsive Testing**: Test at 320px, 768px, and 1024px minimum
|
||||||
|
4. **Accessibility**: Ensure interactive elements maintain proper touch targets despite tighter spacing
|
||||||
246
docs/data-table-responsive-guide.md
Normal file
246
docs/data-table-responsive-guide.md
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
# Data Table Responsive Design Guide
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The data table component has been updated to provide better responsive behavior, consistent padding, and proper scaling across different screen sizes.
|
||||||
|
|
||||||
|
## Key Improvements
|
||||||
|
|
||||||
|
### 1. Consistent Padding
|
||||||
|
- Uniform padding across all table elements
|
||||||
|
- Responsive padding that scales with screen size
|
||||||
|
- Cards now have consistent spacing (p-3 on mobile, p-4 on desktop)
|
||||||
|
|
||||||
|
### 2. Proper Responsive Column Hiding
|
||||||
|
- Columns now properly hide both headers and cells on smaller screens
|
||||||
|
- Uses `meta` properties for clean column visibility control
|
||||||
|
- No more orphaned headers on mobile devices
|
||||||
|
|
||||||
|
### 3. Better Scaling
|
||||||
|
- Font sizes adapt to screen size (text-xs on mobile, text-sm on desktop)
|
||||||
|
- Button sizes and spacing adjust appropriately
|
||||||
|
- Pagination controls are optimized for touch devices
|
||||||
|
|
||||||
|
## Using Responsive Columns
|
||||||
|
|
||||||
|
### Basic Column Definition
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const columns: ColumnDef<YourDataType>[] = [
|
||||||
|
{
|
||||||
|
accessorKey: "name",
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataTableColumnHeader column={column} title="Name" />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => row.original.name,
|
||||||
|
// Always visible on all screen sizes
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "phone",
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataTableColumnHeader column={column} title="Phone" />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => row.original.phone || "—",
|
||||||
|
meta: {
|
||||||
|
// Hidden on mobile, visible on md screens and up
|
||||||
|
headerClassName: "hidden md:table-cell",
|
||||||
|
cellClassName: "hidden md:table-cell",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "address",
|
||||||
|
header: "Address",
|
||||||
|
cell: ({ row }) => formatAddress(row.original),
|
||||||
|
meta: {
|
||||||
|
// Hidden on mobile and tablet, visible on lg screens and up
|
||||||
|
headerClassName: "hidden lg:table-cell",
|
||||||
|
cellClassName: "hidden lg:table-cell",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "createdAt",
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataTableColumnHeader column={column} title="Created" />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => formatDate(row.getValue("createdAt")),
|
||||||
|
meta: {
|
||||||
|
// Only visible on xl screens and up
|
||||||
|
headerClassName: "hidden xl:table-cell",
|
||||||
|
cellClassName: "hidden xl:table-cell",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
### Responsive Breakpoints
|
||||||
|
|
||||||
|
- **Always visible**: No meta properties needed
|
||||||
|
- **md and up** (768px+): `hidden md:table-cell`
|
||||||
|
- **lg and up** (1024px+): `hidden lg:table-cell`
|
||||||
|
- **xl and up** (1280px+): `hidden xl:table-cell`
|
||||||
|
|
||||||
|
## Complex Cell Content
|
||||||
|
|
||||||
|
For cells with complex content that should partially hide on mobile:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
{
|
||||||
|
accessorKey: "client",
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataTableColumnHeader column={column} title="Client" />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const client = row.original;
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{/* Icon hidden on mobile, shown on sm screens */}
|
||||||
|
<div className="bg-status-info-muted hidden rounded-lg p-2 sm:flex">
|
||||||
|
<UserIcon className="text-status-info h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate font-medium">{client.name}</p>
|
||||||
|
{/* Secondary info can be hidden on very small screens if needed */}
|
||||||
|
<p className="text-muted-foreground truncate text-sm">
|
||||||
|
{client.email || "—"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
### 1. Priority-Based Column Hiding
|
||||||
|
- Always show the most important columns (e.g., name, status, primary action)
|
||||||
|
- Hide supplementary information first (e.g., dates, secondary details)
|
||||||
|
- Consider hiding decorative elements (icons) on mobile while keeping text
|
||||||
|
|
||||||
|
### 2. Mobile-First Design
|
||||||
|
- Ensure at least 2-3 columns are visible on mobile
|
||||||
|
- Test on actual devices, not just browser dev tools
|
||||||
|
- Consider the minimum viable information for each row
|
||||||
|
|
||||||
|
### 3. Touch-Friendly Actions
|
||||||
|
- Action buttons should be at least 44x44px on mobile
|
||||||
|
- Use appropriate spacing between interactive elements
|
||||||
|
- Consider grouping actions in a dropdown on mobile
|
||||||
|
|
||||||
|
### 4. Performance
|
||||||
|
- The responsive system uses CSS classes, so there's no JavaScript overhead
|
||||||
|
- Column visibility is handled by Tailwind's responsive utilities
|
||||||
|
- No re-renders needed when resizing
|
||||||
|
|
||||||
|
## Migration Guide
|
||||||
|
|
||||||
|
If you have existing data tables, update them as follows:
|
||||||
|
|
||||||
|
### Before:
|
||||||
|
```tsx
|
||||||
|
{
|
||||||
|
accessorKey: "phone",
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataTableColumnHeader column={column} title="Phone" />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="hidden md:inline">{row.original.phone || "—"}</span>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### After:
|
||||||
|
```tsx
|
||||||
|
{
|
||||||
|
accessorKey: "phone",
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataTableColumnHeader column={column} title="Phone" />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => row.original.phone || "—",
|
||||||
|
meta: {
|
||||||
|
headerClassName: "hidden md:table-cell",
|
||||||
|
cellClassName: "hidden md:table-cell",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Patterns
|
||||||
|
|
||||||
|
### Status Columns
|
||||||
|
Always visible, use color and icons to convey information efficiently:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
{
|
||||||
|
accessorKey: "status",
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataTableColumnHeader column={column} title="Status" />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => <StatusBadge status={row.original.status} />,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Date Columns
|
||||||
|
Often hidden on mobile, show relative dates when space is limited:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
{
|
||||||
|
accessorKey: "createdAt",
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataTableColumnHeader column={column} title="Created" />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const date = row.getValue("createdAt") as Date;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Full date on larger screens */}
|
||||||
|
<span className="hidden sm:inline">{formatDate(date)}</span>
|
||||||
|
{/* Relative date on mobile */}
|
||||||
|
<span className="sm:hidden">{formatRelativeDate(date)}</span>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Action Columns
|
||||||
|
Keep actions accessible but space-efficient:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
{
|
||||||
|
id: "actions",
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const item = row.original;
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-end gap-1">
|
||||||
|
{/* Show individual buttons on larger screens */}
|
||||||
|
<div className="hidden sm:flex sm:gap-1">
|
||||||
|
<EditButton item={item} />
|
||||||
|
<DeleteButton item={item} />
|
||||||
|
</div>
|
||||||
|
{/* Dropdown menu on mobile */}
|
||||||
|
<div className="sm:hidden">
|
||||||
|
<ActionsDropdown item={item} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing Checklist
|
||||||
|
|
||||||
|
- [ ] Table is readable on 320px wide screens
|
||||||
|
- [ ] Headers and cells align properly at all breakpoints
|
||||||
|
- [ ] Touch targets are at least 44x44px on mobile
|
||||||
|
- [ ] Horizontal scrolling works smoothly when needed
|
||||||
|
- [ ] Critical information is always visible
|
||||||
|
- [ ] Loading states work correctly
|
||||||
|
- [ ] Empty states are responsive
|
||||||
|
- [ ] Pagination controls are touch-friendly
|
||||||
|
|
||||||
|
## Accessibility Notes
|
||||||
|
|
||||||
|
- Hidden columns are properly hidden from screen readers
|
||||||
|
- Table remains navigable with keyboard at all screen sizes
|
||||||
|
- Sort controls are accessible on mobile
|
||||||
|
- Focus indicators are visible on all interactive elements
|
||||||
279
docs/forms-guide.md
Normal file
279
docs/forms-guide.md
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
# Forms Improvement Guide
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The business and client creation/editing forms have been significantly improved with better organization, shared components, enhanced validation, and improved user experience.
|
||||||
|
|
||||||
|
## Key Improvements
|
||||||
|
|
||||||
|
### 1. Shared Components & Utilities
|
||||||
|
|
||||||
|
#### Address Form Component (`src/components/ui/address-form.tsx`)
|
||||||
|
A reusable address form component that handles:
|
||||||
|
- Country-aware formatting (US ZIP codes, Canadian postal codes)
|
||||||
|
- State dropdown for US addresses, text input for other countries
|
||||||
|
- Popular countries listed first in country dropdown
|
||||||
|
- Automatic field adjustments based on country selection
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<AddressForm
|
||||||
|
addressLine1={formData.addressLine1}
|
||||||
|
addressLine2={formData.addressLine2}
|
||||||
|
city={formData.city}
|
||||||
|
state={formData.state}
|
||||||
|
postalCode={formData.postalCode}
|
||||||
|
country={formData.country}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
errors={errors}
|
||||||
|
required={false}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Form Constants & Utilities (`src/lib/form-constants.ts`)
|
||||||
|
Centralized location for:
|
||||||
|
- US states list with proper formatting
|
||||||
|
- All countries with ISO codes
|
||||||
|
- Popular countries for quick selection
|
||||||
|
- Format functions for phone, postal codes, tax IDs, and URLs
|
||||||
|
- Validation utilities and messages
|
||||||
|
|
||||||
|
### 2. Enhanced Form Validation
|
||||||
|
|
||||||
|
#### Real-time Validation
|
||||||
|
- Errors clear as soon as user starts typing
|
||||||
|
- Field-specific validation messages
|
||||||
|
- Visual feedback with red borders on invalid fields
|
||||||
|
|
||||||
|
#### Smart Validation Rules
|
||||||
|
- Email: Proper email format checking
|
||||||
|
- Phone: US phone number format validation
|
||||||
|
- Address: Required fields only if any address field is filled
|
||||||
|
- URL: Automatic https:// prefix addition
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Example validation
|
||||||
|
if (formData.email && !isValidEmail(formData.email)) {
|
||||||
|
newErrors.email = VALIDATION_MESSAGES.email;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Better Form Organization
|
||||||
|
|
||||||
|
#### Card-based Sections
|
||||||
|
Forms are now organized into logical sections using cards:
|
||||||
|
- **Basic Information**: Core fields like name, tax ID
|
||||||
|
- **Contact Information**: Email, phone, website
|
||||||
|
- **Address**: Complete address form with smart country handling
|
||||||
|
- **Settings**: Business-specific settings like default business flag
|
||||||
|
|
||||||
|
#### Consistent Layout
|
||||||
|
- Maximum width container for better readability
|
||||||
|
- Responsive grid layouts that stack on mobile
|
||||||
|
- Proper spacing between sections
|
||||||
|
- Clear visual hierarchy
|
||||||
|
|
||||||
|
### 4. Improved User Experience
|
||||||
|
|
||||||
|
#### Loading States
|
||||||
|
- Skeleton loader while fetching data in edit mode
|
||||||
|
- Disabled form fields during submission
|
||||||
|
- Loading spinner in submit button
|
||||||
|
|
||||||
|
#### Unsaved Changes Warning
|
||||||
|
```typescript
|
||||||
|
const handleCancel = () => {
|
||||||
|
if (isDirty) {
|
||||||
|
const confirmed = window.confirm(
|
||||||
|
"You have unsaved changes. Are you sure you want to leave?"
|
||||||
|
);
|
||||||
|
if (!confirmed) return;
|
||||||
|
}
|
||||||
|
router.push("/dashboard/businesses");
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Smart Field Formatting
|
||||||
|
- Phone numbers: Auto-format as (555) 123-4567
|
||||||
|
- Tax ID: Auto-format as 12-3456789
|
||||||
|
- Postal codes: Format based on country (US vs Canadian)
|
||||||
|
- Website URLs: Auto-add https:// if missing
|
||||||
|
|
||||||
|
### 5. Responsive Design
|
||||||
|
|
||||||
|
#### Mobile Optimizations
|
||||||
|
- Form sections stack vertically on small screens
|
||||||
|
- Touch-friendly input sizes
|
||||||
|
- Proper button positioning
|
||||||
|
- Readable font sizes
|
||||||
|
|
||||||
|
#### Desktop Enhancements
|
||||||
|
- Two-column layouts for related fields
|
||||||
|
- Optimal reading width
|
||||||
|
- Side-by-side form actions
|
||||||
|
|
||||||
|
### 6. Code Reusability
|
||||||
|
|
||||||
|
#### Shared Between Business & Client Forms
|
||||||
|
- Address form component
|
||||||
|
- Validation logic
|
||||||
|
- Format functions
|
||||||
|
- Constants (states, countries)
|
||||||
|
- Error handling patterns
|
||||||
|
|
||||||
|
#### TypeScript Interfaces
|
||||||
|
```typescript
|
||||||
|
interface FormData {
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
phone: string;
|
||||||
|
// ... other fields
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FormErrors {
|
||||||
|
name?: string;
|
||||||
|
email?: string;
|
||||||
|
// ... validation errors
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage Examples
|
||||||
|
|
||||||
|
### Basic Form Implementation
|
||||||
|
```tsx
|
||||||
|
export function BusinessForm({ businessId, mode }: BusinessFormProps) {
|
||||||
|
const [formData, setFormData] = useState<FormData>(initialFormData);
|
||||||
|
const [errors, setErrors] = useState<FormErrors>({});
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [isDirty, setIsDirty] = useState(false);
|
||||||
|
|
||||||
|
// Handle input changes
|
||||||
|
const handleInputChange = (field: string, value: string | boolean) => {
|
||||||
|
setFormData((prev) => ({ ...prev, [field]: value }));
|
||||||
|
setIsDirty(true);
|
||||||
|
|
||||||
|
// Clear error when user types
|
||||||
|
if (errors[field as keyof FormErrors]) {
|
||||||
|
setErrors((prev) => ({ ...prev, [field]: undefined }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Validate and submit
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!validateForm()) {
|
||||||
|
toast.error("Please correct the errors in the form");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Submit logic...
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Field with Icon and Validation
|
||||||
|
```tsx
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="email">
|
||||||
|
Email
|
||||||
|
<span className="text-muted-foreground ml-1 text-xs">(Optional)</span>
|
||||||
|
</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Mail className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
value={formData.email}
|
||||||
|
onChange={(e) => handleInputChange("email", e.target.value)}
|
||||||
|
placeholder={PLACEHOLDERS.email}
|
||||||
|
className={`pl-10 ${errors.email ? "border-destructive" : ""}`}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{errors.email && (
|
||||||
|
<p className="text-sm text-destructive">{errors.email}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
### 1. Form State Management
|
||||||
|
- Use controlled components for all inputs
|
||||||
|
- Track dirty state for unsaved changes warnings
|
||||||
|
- Clear errors when user corrects them
|
||||||
|
- Disable form during submission
|
||||||
|
|
||||||
|
### 2. Validation Strategy
|
||||||
|
- Validate on submit, not on blur (less annoying)
|
||||||
|
- Clear errors immediately when user starts fixing them
|
||||||
|
- Show field-level errors below each input
|
||||||
|
- Use consistent error message format
|
||||||
|
|
||||||
|
### 3. Accessibility
|
||||||
|
- Proper label associations with htmlFor
|
||||||
|
- Required field indicators
|
||||||
|
- Error messages linked to fields
|
||||||
|
- Keyboard navigation support
|
||||||
|
- Focus management
|
||||||
|
|
||||||
|
### 4. Performance
|
||||||
|
- Memoize expensive computations
|
||||||
|
- Use debouncing for format functions if needed
|
||||||
|
- Lazy load country lists
|
||||||
|
- Optimize re-renders with proper state management
|
||||||
|
|
||||||
|
## Migration Guide
|
||||||
|
|
||||||
|
### From Old Forms
|
||||||
|
1. Replace inline state/country arrays with imported constants
|
||||||
|
2. Use `AddressForm` component instead of individual address fields
|
||||||
|
3. Apply format functions from `form-constants.ts`
|
||||||
|
4. Update validation to use shared utilities
|
||||||
|
5. Wrap sections in Card components
|
||||||
|
6. Add loading and dirty state tracking
|
||||||
|
|
||||||
|
### Example Migration
|
||||||
|
```tsx
|
||||||
|
// Before
|
||||||
|
const US_STATES = [
|
||||||
|
{ value: "AL", label: "Alabama" },
|
||||||
|
// ... duplicated in each form
|
||||||
|
];
|
||||||
|
|
||||||
|
// After
|
||||||
|
import { US_STATES, formatPhoneNumber } from "~/lib/form-constants";
|
||||||
|
import { AddressForm } from "~/components/ui/address-form";
|
||||||
|
```
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
### Planned Improvements
|
||||||
|
1. **Field-level permissions**: Disable fields based on user role
|
||||||
|
2. **Auto-save**: Save draft as user types
|
||||||
|
3. **Multi-step forms**: Break long forms into steps
|
||||||
|
4. **Conditional fields**: Show/hide fields based on other values
|
||||||
|
5. **Bulk operations**: Create multiple records at once
|
||||||
|
6. **Import from templates**: Pre-fill common business types
|
||||||
|
|
||||||
|
### Extensibility
|
||||||
|
The form system is designed to be easily extended:
|
||||||
|
- Add new format functions to `form-constants.ts`
|
||||||
|
- Create additional shared form components
|
||||||
|
- Extend validation rules as needed
|
||||||
|
- Add new field types with consistent patterns
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Common Issues
|
||||||
|
|
||||||
|
1. **Validation not working**: Ensure field names match FormErrors interface
|
||||||
|
2. **Format function not applying**: Check that onChange uses the format function
|
||||||
|
3. **Country dropdown not searching**: Verify SearchableSelect has search enabled
|
||||||
|
4. **Address validation failing**: Check if country field affects validation rules
|
||||||
|
|
||||||
|
### Debug Tips
|
||||||
|
- Use React DevTools to inspect form state
|
||||||
|
- Check console for validation errors
|
||||||
|
- Verify API responses match expected format
|
||||||
|
- Test with different country selections
|
||||||
@@ -33,6 +33,7 @@
|
|||||||
"@libsql/client": "^0.14.0",
|
"@libsql/client": "^0.14.0",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.14",
|
"@radix-ui/react-alert-dialog": "^1.1.14",
|
||||||
"@radix-ui/react-checkbox": "^1.3.2",
|
"@radix-ui/react-checkbox": "^1.3.2",
|
||||||
|
"@radix-ui/react-collapsible": "^1.1.11",
|
||||||
"@radix-ui/react-dialog": "^1.1.14",
|
"@radix-ui/react-dialog": "^1.1.14",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
||||||
"@radix-ui/react-label": "^2.1.7",
|
"@radix-ui/react-label": "^2.1.7",
|
||||||
@@ -42,10 +43,12 @@
|
|||||||
"@radix-ui/react-select": "^2.2.5",
|
"@radix-ui/react-select": "^2.2.5",
|
||||||
"@radix-ui/react-separator": "^1.1.7",
|
"@radix-ui/react-separator": "^1.1.7",
|
||||||
"@radix-ui/react-slot": "^1.2.3",
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
|
"@radix-ui/react-switch": "^1.2.5",
|
||||||
"@radix-ui/react-tabs": "^1.1.12",
|
"@radix-ui/react-tabs": "^1.1.12",
|
||||||
"@react-pdf/renderer": "^4.3.0",
|
"@react-pdf/renderer": "^4.3.0",
|
||||||
"@t3-oss/env-nextjs": "^0.12.0",
|
"@t3-oss/env-nextjs": "^0.12.0",
|
||||||
"@tanstack/react-query": "^5.69.0",
|
"@tanstack/react-query": "^5.69.0",
|
||||||
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"@trpc/client": "^11.0.0",
|
"@trpc/client": "^11.0.0",
|
||||||
"@trpc/react-query": "^11.0.0",
|
"@trpc/react-query": "^11.0.0",
|
||||||
"@trpc/server": "^11.0.0",
|
"@trpc/server": "^11.0.0",
|
||||||
@@ -59,7 +62,7 @@
|
|||||||
"file-saver": "^2.0.5",
|
"file-saver": "^2.0.5",
|
||||||
"lucide": "^0.525.0",
|
"lucide": "^0.525.0",
|
||||||
"lucide-react": "^0.525.0",
|
"lucide-react": "^0.525.0",
|
||||||
"next": "^15.2.3",
|
"next": "^15.4.1",
|
||||||
"next-auth": "5.0.0-beta.25",
|
"next-auth": "5.0.0-beta.25",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-day-picker": "^9.8.0",
|
"react-day-picker": "^9.8.0",
|
||||||
@@ -101,6 +104,8 @@
|
|||||||
"@tailwindcss/oxide",
|
"@tailwindcss/oxide",
|
||||||
"better-sqlite3",
|
"better-sqlite3",
|
||||||
"core-js",
|
"core-js",
|
||||||
|
"esbuild",
|
||||||
|
"sharp",
|
||||||
"unrs-resolver"
|
"unrs-resolver"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,25 +49,25 @@ function RegisterForm() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-green-50 to-emerald-100 p-4 dark:from-gray-900 dark:to-gray-800">
|
<div className="bg-gradient-auth flex min-h-screen items-center justify-center p-4">
|
||||||
<div className="w-full max-w-md space-y-8">
|
<div className="w-full max-w-md space-y-8">
|
||||||
{/* Logo and Welcome */}
|
{/* Logo and Welcome */}
|
||||||
<div className="space-y-4 text-center">
|
<div className="space-y-4 text-center">
|
||||||
<Logo size="lg" className="mx-auto" />
|
<Logo size="lg" className="mx-auto" />
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
|
<h1 className="text-foreground text-2xl font-bold">
|
||||||
Join beenvoice
|
Join beenvoice
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-2 text-gray-600 dark:text-gray-300">
|
<p className="text-muted-foreground mt-2">
|
||||||
Create your account to get started
|
Create your account to get started
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Registration Form */}
|
{/* Registration Form */}
|
||||||
<Card className="border-0 shadow-xl dark:bg-gray-800">
|
<Card className="border-0 shadow-xl">
|
||||||
<CardHeader className="space-y-1">
|
<CardHeader className="space-y-1">
|
||||||
<CardTitle className="text-center text-xl dark:text-white">
|
<CardTitle className="text-center text-xl">
|
||||||
Create Account
|
Create Account
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@@ -77,7 +77,7 @@ function RegisterForm() {
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="firstName">First Name</Label>
|
<Label htmlFor="firstName">First Name</Label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<User className="absolute top-3 left-3 h-4 w-4 text-gray-400 dark:text-gray-500" />
|
<User className="text-muted-foreground absolute top-3 left-3 h-4 w-4" />
|
||||||
<Input
|
<Input
|
||||||
id="firstName"
|
id="firstName"
|
||||||
type="text"
|
type="text"
|
||||||
@@ -93,7 +93,7 @@ function RegisterForm() {
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="lastName">Last Name</Label>
|
<Label htmlFor="lastName">Last Name</Label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<User className="absolute top-3 left-3 h-4 w-4 text-gray-400 dark:text-gray-500" />
|
<User className="text-muted-foreground absolute top-3 left-3 h-4 w-4" />
|
||||||
<Input
|
<Input
|
||||||
id="lastName"
|
id="lastName"
|
||||||
type="text"
|
type="text"
|
||||||
@@ -109,7 +109,7 @@ function RegisterForm() {
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="email">Email</Label>
|
<Label htmlFor="email">Email</Label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Mail className="absolute top-3 left-3 h-4 w-4 text-gray-400 dark:text-gray-500" />
|
<Mail className="text-muted-foreground absolute top-3 left-3 h-4 w-4" />
|
||||||
<Input
|
<Input
|
||||||
id="email"
|
id="email"
|
||||||
type="email"
|
type="email"
|
||||||
@@ -124,7 +124,7 @@ function RegisterForm() {
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="password">Password</Label>
|
<Label htmlFor="password">Password</Label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Lock className="absolute top-3 left-3 h-4 w-4 text-gray-400 dark:text-gray-500" />
|
<Lock className="text-muted-foreground absolute top-3 left-3 h-4 w-4" />
|
||||||
<Input
|
<Input
|
||||||
id="password"
|
id="password"
|
||||||
type="password"
|
type="password"
|
||||||
@@ -136,7 +136,7 @@ function RegisterForm() {
|
|||||||
placeholder="Create a password"
|
placeholder="Create a password"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
<p className="text-muted-foreground text-xs">
|
||||||
Must be at least 6 characters
|
Must be at least 6 characters
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -152,7 +152,7 @@ function RegisterForm() {
|
|||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
<div className="mt-6 text-center text-sm">
|
<div className="mt-6 text-center text-sm">
|
||||||
<span className="text-gray-600 dark:text-gray-300">
|
<span className="text-muted-foreground">
|
||||||
Already have an account?{" "}
|
Already have an account?{" "}
|
||||||
</span>
|
</span>
|
||||||
<Link
|
<Link
|
||||||
@@ -167,10 +167,10 @@ function RegisterForm() {
|
|||||||
|
|
||||||
{/* Features */}
|
{/* Features */}
|
||||||
<div className="space-y-4 text-center">
|
<div className="space-y-4 text-center">
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
<p className="text-muted-foreground text-sm">
|
||||||
Start invoicing like a pro
|
Start invoicing like a pro
|
||||||
</p>
|
</p>
|
||||||
<div className="flex justify-center space-x-6 text-xs text-gray-400 dark:text-gray-500">
|
<div className="text-muted-foreground flex justify-center space-x-6 text-xs">
|
||||||
<span>✓ Free to start</span>
|
<span>✓ Free to start</span>
|
||||||
<span>✓ No credit card</span>
|
<span>✓ No credit card</span>
|
||||||
<span>✓ Cancel anytime</span>
|
<span>✓ Cancel anytime</span>
|
||||||
@@ -185,17 +185,15 @@ export default function RegisterPage() {
|
|||||||
return (
|
return (
|
||||||
<Suspense
|
<Suspense
|
||||||
fallback={
|
fallback={
|
||||||
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-green-50 to-emerald-100 p-4 dark:from-gray-900 dark:to-gray-800">
|
<div className="bg-gradient-auth flex min-h-screen items-center justify-center p-4">
|
||||||
<div className="w-full max-w-md space-y-8">
|
<div className="w-full max-w-md space-y-8">
|
||||||
<div className="space-y-4 text-center">
|
<div className="space-y-4 text-center">
|
||||||
<Logo size="lg" className="mx-auto" />
|
<Logo size="lg" className="mx-auto" />
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
|
<h1 className="text-foreground text-2xl font-bold">
|
||||||
Join beenvoice
|
Join beenvoice
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-2 text-gray-600 dark:text-gray-300">
|
<p className="text-muted-foreground mt-2">Loading...</p>
|
||||||
Loading...
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -42,34 +42,30 @@ function SignInForm() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-green-50 to-emerald-100 p-4 dark:from-gray-900 dark:to-gray-800">
|
<div className="bg-gradient-auth flex min-h-screen items-center justify-center p-4">
|
||||||
<div className="w-full max-w-md space-y-8">
|
<div className="w-full max-w-md space-y-8">
|
||||||
{/* Logo and Welcome */}
|
{/* Logo and Welcome */}
|
||||||
<div className="space-y-4 text-center">
|
<div className="space-y-4 text-center">
|
||||||
<Logo size="lg" className="mx-auto" />
|
<Logo size="lg" className="mx-auto" />
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
|
<h1 className="text-foreground text-2xl font-bold">Welcome back</h1>
|
||||||
Welcome back
|
<p className="text-muted-foreground mt-2">
|
||||||
</h1>
|
|
||||||
<p className="mt-2 text-gray-600 dark:text-gray-300">
|
|
||||||
Sign in to your beenvoice account
|
Sign in to your beenvoice account
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Sign In Form */}
|
{/* Sign In Form */}
|
||||||
<Card className="border-0 shadow-xl dark:bg-gray-800">
|
<Card className="border-0 shadow-xl">
|
||||||
<CardHeader className="space-y-1">
|
<CardHeader className="space-y-1">
|
||||||
<CardTitle className="text-center text-xl dark:text-white">
|
<CardTitle className="text-center text-xl">Sign In</CardTitle>
|
||||||
Sign In
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<form onSubmit={handleSignIn} className="space-y-4">
|
<form onSubmit={handleSignIn} className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="email">Email</Label>
|
<Label htmlFor="email">Email</Label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Mail className="absolute top-3 left-3 h-4 w-4 text-gray-400 dark:text-gray-500" />
|
<Mail className="text-muted-foreground absolute top-3 left-3 h-4 w-4" />
|
||||||
<Input
|
<Input
|
||||||
id="email"
|
id="email"
|
||||||
type="email"
|
type="email"
|
||||||
@@ -85,7 +81,7 @@ function SignInForm() {
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="password">Password</Label>
|
<Label htmlFor="password">Password</Label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Lock className="absolute top-3 left-3 h-4 w-4 text-gray-400 dark:text-gray-500" />
|
<Lock className="text-muted-foreground absolute top-3 left-3 h-4 w-4" />
|
||||||
<Input
|
<Input
|
||||||
id="password"
|
id="password"
|
||||||
type="password"
|
type="password"
|
||||||
@@ -109,7 +105,7 @@ function SignInForm() {
|
|||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
<div className="mt-6 text-center text-sm">
|
<div className="mt-6 text-center text-sm">
|
||||||
<span className="text-gray-600 dark:text-gray-300">
|
<span className="text-muted-foreground">
|
||||||
Don't have an account?{" "}
|
Don't have an account?{" "}
|
||||||
</span>
|
</span>
|
||||||
<Link
|
<Link
|
||||||
@@ -124,10 +120,10 @@ function SignInForm() {
|
|||||||
|
|
||||||
{/* Features */}
|
{/* Features */}
|
||||||
<div className="space-y-4 text-center">
|
<div className="space-y-4 text-center">
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
<p className="text-muted-foreground text-sm">
|
||||||
Simple invoicing for freelancers and small businesses
|
Simple invoicing for freelancers and small businesses
|
||||||
</p>
|
</p>
|
||||||
<div className="flex justify-center space-x-6 text-xs text-gray-400 dark:text-gray-500">
|
<div className="text-muted-foreground flex justify-center space-x-6 text-xs">
|
||||||
<span>✓ Easy client management</span>
|
<span>✓ Easy client management</span>
|
||||||
<span>✓ Professional invoices</span>
|
<span>✓ Professional invoices</span>
|
||||||
<span>✓ Payment tracking</span>
|
<span>✓ Payment tracking</span>
|
||||||
@@ -142,17 +138,15 @@ export default function SignInPage() {
|
|||||||
return (
|
return (
|
||||||
<Suspense
|
<Suspense
|
||||||
fallback={
|
fallback={
|
||||||
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-green-50 to-emerald-100 p-4 dark:from-gray-900 dark:to-gray-800">
|
<div className="bg-gradient-auth flex min-h-screen items-center justify-center p-4">
|
||||||
<div className="w-full max-w-md space-y-8">
|
<div className="w-full max-w-md space-y-8">
|
||||||
<div className="space-y-4 text-center">
|
<div className="space-y-4 text-center">
|
||||||
<Logo size="lg" className="mx-auto" />
|
<Logo size="lg" className="mx-auto" />
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
|
<h1 className="text-foreground text-2xl font-bold">
|
||||||
Welcome back
|
Welcome back
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-2 text-gray-600 dark:text-gray-300">
|
<p className="text-muted-foreground mt-2">Loading...</p>
|
||||||
Loading...
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
44
src/app/clients/[id]/edit/page.tsx
Normal file
44
src/app/clients/[id]/edit/page.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { auth } from "~/server/auth";
|
||||||
|
import { HydrateClient } from "~/trpc/server";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import { ClientForm } from "~/components/client-form";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
interface EditClientPageProps {
|
||||||
|
params: Promise<{
|
||||||
|
id: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function EditClientPage({ params }: EditClientPageProps) {
|
||||||
|
const { id } = await params;
|
||||||
|
const session = await auth();
|
||||||
|
|
||||||
|
if (!session?.user) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<h1 className="mb-4 text-4xl font-bold">Access Denied</h1>
|
||||||
|
<p className="text-muted-foreground mb-8">
|
||||||
|
Please sign in to edit clients
|
||||||
|
</p>
|
||||||
|
<Link href="/api/auth/signin">
|
||||||
|
<Button size="lg">Sign In</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<HydrateClient>
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="mb-8">
|
||||||
|
<h2 className="mb-2 text-3xl font-bold">Edit Client</h2>
|
||||||
|
<p className="text-muted-foreground">Update client information</p>
|
||||||
|
</div>
|
||||||
|
<ClientForm mode="edit" clientId={id} />
|
||||||
|
</div>
|
||||||
|
</HydrateClient>
|
||||||
|
);
|
||||||
|
}
|
||||||
37
src/app/clients/new/page.tsx
Normal file
37
src/app/clients/new/page.tsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { auth } from "~/server/auth";
|
||||||
|
import { HydrateClient } from "~/trpc/server";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import { ClientForm } from "~/components/client-form";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export default async function NewClientPage() {
|
||||||
|
const session = await auth();
|
||||||
|
|
||||||
|
if (!session?.user) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<h1 className="text-4xl font-bold mb-4">Access Denied</h1>
|
||||||
|
<p className="text-muted-foreground mb-8">Please sign in to create clients</p>
|
||||||
|
<Link href="/api/auth/signin">
|
||||||
|
<Button size="lg">Sign In</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<HydrateClient>
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="mb-8">
|
||||||
|
<h2 className="text-3xl font-bold mb-2">Add New Client</h2>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Create a new client profile
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<ClientForm mode="create" />
|
||||||
|
</div>
|
||||||
|
</HydrateClient>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
import { api } from "~/trpc/react";
|
import { api } from "~/trpc/react";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
|
import { StatusBadge, type StatusType } from "~/components/ui/status-badge";
|
||||||
import {
|
import {
|
||||||
Users,
|
Users,
|
||||||
FileText,
|
FileText,
|
||||||
@@ -44,60 +45,60 @@ export function DashboardStats() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mb-8 grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-4">
|
<div className="mb-8 grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-4">
|
||||||
<Card className="border-0 bg-white/80 shadow-xl backdrop-blur-sm transition-all duration-300 hover:shadow-2xl dark:bg-gray-800/80">
|
<Card className="shadow-xl backdrop-blur-sm transition-all duration-300 hover:shadow-2xl">
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<CardTitle className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
<CardTitle className="text-muted-foreground text-sm font-medium">
|
||||||
Total Clients
|
Total Clients
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<div className="rounded-lg bg-emerald-100 p-2 dark:bg-emerald-900/30">
|
<div className="rounded-lg bg-emerald-100 p-2">
|
||||||
<Users className="h-4 w-4 text-emerald-600 dark:text-emerald-400" />
|
<Users className="h-4 w-4 text-emerald-600" />
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-3xl font-bold text-emerald-600 dark:text-emerald-400">
|
<div className="text-3xl font-bold text-emerald-600">
|
||||||
{totalClients}
|
{totalClients}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
<p className="text-muted-foreground text-xs">
|
||||||
{totalClients > lastMonthClients ? "+" : ""}
|
{totalClients > lastMonthClients ? "+" : ""}
|
||||||
{totalClients - lastMonthClients} from last month
|
{totalClients - lastMonthClients} from last month
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="border-0 bg-white/80 shadow-xl backdrop-blur-sm transition-all duration-300 hover:shadow-2xl dark:bg-gray-800/80">
|
<Card className="shadow-xl backdrop-blur-sm transition-all duration-300 hover:shadow-2xl">
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<CardTitle className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
<CardTitle className="text-muted-foreground text-sm font-medium">
|
||||||
Total Invoices
|
Total Invoices
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<div className="rounded-lg bg-blue-100 p-2 dark:bg-blue-900/30">
|
<div className="rounded-lg bg-blue-100 p-2">
|
||||||
<FileText className="h-4 w-4 text-blue-600 dark:text-blue-400" />
|
<FileText className="h-4 w-4 text-blue-600" />
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-3xl font-bold text-blue-600 dark:text-blue-400">
|
<div className="text-3xl font-bold text-blue-600">
|
||||||
{totalInvoices}
|
{totalInvoices}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
<p className="text-muted-foreground text-xs">
|
||||||
{totalInvoices > lastMonthInvoices ? "+" : ""}
|
{totalInvoices > lastMonthInvoices ? "+" : ""}
|
||||||
{totalInvoices - lastMonthInvoices} from last month
|
{totalInvoices - lastMonthInvoices} from last month
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="border-0 bg-white/80 shadow-xl backdrop-blur-sm transition-all duration-300 hover:shadow-2xl dark:bg-gray-800/80">
|
<Card className="shadow-xl backdrop-blur-sm transition-all duration-300 hover:shadow-2xl">
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<CardTitle className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
<CardTitle className="text-muted-foreground text-sm font-medium">
|
||||||
Revenue
|
Revenue
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<div className="rounded-lg bg-teal-100 p-2 dark:bg-teal-900/30">
|
<div className="rounded-lg bg-teal-100 p-2">
|
||||||
<TrendingUp className="h-4 w-4 text-teal-600 dark:text-teal-400" />
|
<TrendingUp className="h-4 w-4 text-teal-600" />
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-3xl font-bold text-teal-600 dark:text-teal-400">
|
<div className="text-3xl font-bold text-teal-600">
|
||||||
${totalRevenue.toFixed(2)}
|
${totalRevenue.toFixed(2)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
<p className="text-muted-foreground text-xs">
|
||||||
{totalRevenue > lastMonthRevenue ? "+" : ""}
|
{totalRevenue > lastMonthRevenue ? "+" : ""}
|
||||||
{(
|
{(
|
||||||
((totalRevenue - lastMonthRevenue) / (lastMonthRevenue || 1)) *
|
((totalRevenue - lastMonthRevenue) / (lastMonthRevenue || 1)) *
|
||||||
@@ -108,22 +109,20 @@ export function DashboardStats() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="border-0 bg-white/80 shadow-xl backdrop-blur-sm transition-all duration-300 hover:shadow-2xl dark:bg-gray-800/80">
|
<Card className="shadow-xl backdrop-blur-sm transition-all duration-300 hover:shadow-2xl">
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<CardTitle className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
<CardTitle className="text-muted-foreground text-sm font-medium">
|
||||||
Pending Invoices
|
Pending Invoices
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<div className="rounded-lg bg-orange-100 p-2 dark:bg-orange-900/30">
|
<div className="rounded-lg bg-orange-100 p-2">
|
||||||
<Calendar className="h-4 w-4 text-orange-600 dark:text-orange-400" />
|
<Calendar className="h-4 w-4 text-orange-600" />
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-3xl font-bold text-orange-600 dark:text-orange-400">
|
<div className="text-3xl font-bold text-orange-600">
|
||||||
{pendingInvoices}
|
{pendingInvoices}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
<p className="text-muted-foreground text-xs">Due this month</p>
|
||||||
Due this month
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
@@ -134,34 +133,27 @@ export function DashboardStats() {
|
|||||||
export function DashboardCards() {
|
export function DashboardCards() {
|
||||||
return (
|
return (
|
||||||
<div className="mb-8 grid grid-cols-1 gap-8 lg:grid-cols-2">
|
<div className="mb-8 grid grid-cols-1 gap-8 lg:grid-cols-2">
|
||||||
<Card className="border-0 bg-white/80 shadow-xl backdrop-blur-sm transition-all duration-300 hover:shadow-2xl dark:bg-gray-800/80">
|
<Card className="shadow-xl backdrop-blur-sm transition-all duration-300 hover:shadow-2xl">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2 text-emerald-700 dark:text-emerald-400">
|
<CardTitle className="flex items-center gap-2 text-emerald-700">
|
||||||
<div className="rounded-lg bg-emerald-100 p-2 dark:bg-emerald-900/30">
|
<div className="rounded-lg bg-emerald-100 p-2">
|
||||||
<Users className="h-5 w-5" />
|
<Users className="h-5 w-5" />
|
||||||
</div>
|
</div>
|
||||||
Manage Clients
|
Manage Clients
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<p className="text-gray-600 dark:text-gray-300">
|
<p className="text-muted-foreground">
|
||||||
Add new clients and manage your existing client relationships.
|
Add new clients and manage your existing client relationships.
|
||||||
</p>
|
</p>
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<Button
|
<Button asChild variant="brand">
|
||||||
asChild
|
|
||||||
className="bg-gradient-to-r from-emerald-600 to-teal-600 font-medium text-white shadow-lg transition-all duration-200 hover:from-emerald-700 hover:to-teal-700 hover:shadow-xl"
|
|
||||||
>
|
|
||||||
<Link href="/dashboard/clients/new">
|
<Link href="/dashboard/clients/new">
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
Add Client
|
Add Client
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button variant="outline" asChild className="font-medium">
|
||||||
variant="outline"
|
|
||||||
asChild
|
|
||||||
className="border-gray-300 font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-800"
|
|
||||||
>
|
|
||||||
<Link href="/dashboard/clients">
|
<Link href="/dashboard/clients">
|
||||||
View All Clients
|
View All Clients
|
||||||
<ArrowRight className="ml-2 h-4 w-4" />
|
<ArrowRight className="ml-2 h-4 w-4" />
|
||||||
@@ -171,34 +163,27 @@ export function DashboardCards() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="border-0 bg-white/80 shadow-xl backdrop-blur-sm transition-all duration-300 hover:shadow-2xl dark:bg-gray-800/80">
|
<Card className="shadow-xl backdrop-blur-sm transition-all duration-300 hover:shadow-2xl">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2 text-emerald-700 dark:text-emerald-400">
|
<CardTitle className="flex items-center gap-2 text-emerald-700">
|
||||||
<div className="rounded-lg bg-emerald-100 p-2 dark:bg-emerald-900/30">
|
<div className="rounded-lg bg-emerald-100 p-2">
|
||||||
<FileText className="h-5 w-5" />
|
<FileText className="h-5 w-5" />
|
||||||
</div>
|
</div>
|
||||||
Create Invoices
|
Create Invoices
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<p className="text-gray-600 dark:text-gray-300">
|
<p className="text-muted-foreground">
|
||||||
Generate professional invoices and track payments.
|
Generate professional invoices and track payments.
|
||||||
</p>
|
</p>
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<Button
|
<Button asChild variant="brand">
|
||||||
asChild
|
|
||||||
className="bg-gradient-to-r from-emerald-600 to-teal-600 font-medium text-white shadow-lg transition-all duration-200 hover:from-emerald-700 hover:to-teal-700 hover:shadow-xl"
|
|
||||||
>
|
|
||||||
<Link href="/dashboard/invoices/new">
|
<Link href="/dashboard/invoices/new">
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
New Invoice
|
New Invoice
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button variant="outline" asChild className="font-medium">
|
||||||
variant="outline"
|
|
||||||
asChild
|
|
||||||
className="border-gray-300 font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-800"
|
|
||||||
>
|
|
||||||
<Link href="/dashboard/invoices">
|
<Link href="/dashboard/invoices">
|
||||||
View All Invoices
|
View All Invoices
|
||||||
<ArrowRight className="ml-2 h-4 w-4" />
|
<ArrowRight className="ml-2 h-4 w-4" />
|
||||||
@@ -222,22 +207,20 @@ export function DashboardActivity() {
|
|||||||
const recentInvoices = invoices?.slice(0, 5) ?? [];
|
const recentInvoices = invoices?.slice(0, 5) ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="border-0 bg-white/80 shadow-xl backdrop-blur-sm dark:bg-gray-800/80">
|
<Card className="shadow-xl backdrop-blur-sm">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-emerald-700 dark:text-emerald-400">
|
<CardTitle className="text-emerald-700">Recent Activity</CardTitle>
|
||||||
Recent Activity
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{recentInvoices.length === 0 ? (
|
{recentInvoices.length === 0 ? (
|
||||||
<div className="py-12 text-center text-gray-500 dark:text-gray-400">
|
<div className="text-muted-foreground py-12 text-center">
|
||||||
<div className="mx-auto mb-4 flex h-20 w-20 items-center justify-center rounded-full bg-gray-100 p-4 dark:bg-gray-700">
|
<div className="bg-muted mx-auto mb-4 flex h-20 w-20 items-center justify-center rounded-full p-4">
|
||||||
<FileText className="h-8 w-8 text-gray-400 dark:text-gray-500" />
|
<FileText className="text-muted-foreground h-8 w-8" />
|
||||||
</div>
|
</div>
|
||||||
<p className="mb-2 text-lg font-medium dark:text-gray-300">
|
<p className="text-foreground mb-2 text-lg font-medium">
|
||||||
No recent activity
|
No recent activity
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm dark:text-gray-400">
|
<p className="text-muted-foreground text-sm">
|
||||||
Start by adding your first client or creating an invoice
|
Start by adding your first client or creating an invoice
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -246,37 +229,24 @@ export function DashboardActivity() {
|
|||||||
{recentInvoices.map((invoice) => (
|
{recentInvoices.map((invoice) => (
|
||||||
<div
|
<div
|
||||||
key={invoice.id}
|
key={invoice.id}
|
||||||
className="flex items-center justify-between rounded-lg bg-gray-50 p-4 dark:bg-gray-700"
|
className="bg-muted/50 flex items-center justify-between rounded-lg p-4"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="rounded-lg bg-emerald-100 p-2 dark:bg-emerald-900/30">
|
<div className="rounded-lg bg-emerald-100 p-2">
|
||||||
<FileText className="h-4 w-4 text-emerald-600 dark:text-emerald-400" />
|
<FileText className="h-4 w-4 text-emerald-600" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium text-gray-900 dark:text-white">
|
<p className="text-foreground font-medium">
|
||||||
Invoice #{invoice.invoiceNumber}
|
Invoice #{invoice.invoiceNumber}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
<p className="text-muted-foreground text-sm">
|
||||||
{invoice.client?.name ?? "Unknown Client"} • $
|
{invoice.client?.name ?? "Unknown Client"} • $
|
||||||
{invoice.totalAmount.toFixed(2)}
|
{invoice.totalAmount.toFixed(2)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span
|
<StatusBadge status={invoice.status as StatusType} />
|
||||||
className={`rounded-full px-2 py-1 text-xs font-medium ${
|
|
||||||
invoice.status === "paid"
|
|
||||||
? "bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400"
|
|
||||||
: invoice.status === "sent"
|
|
||||||
? "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400"
|
|
||||||
: invoice.status === "overdue"
|
|
||||||
? "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400"
|
|
||||||
: "bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{invoice.status.charAt(0).toUpperCase() +
|
|
||||||
invoice.status.slice(1)}
|
|
||||||
</span>
|
|
||||||
<Button variant="ghost" size="sm" asChild>
|
<Button variant="ghost" size="sm" asChild>
|
||||||
<Link href={`/dashboard/invoices/${invoice.id}`}>
|
<Link href={`/dashboard/invoices/${invoice.id}`}>
|
||||||
<ArrowRight className="h-4 w-4" />
|
<ArrowRight className="h-4 w-4" />
|
||||||
|
|||||||
@@ -1,11 +1,24 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { BusinessForm } from "~/components/business-form";
|
import Link from "next/link";
|
||||||
import { useParams } from "next/navigation";
|
import { useParams } from "next/navigation";
|
||||||
|
import { BusinessForm } from "~/components/business-form";
|
||||||
|
import { PageHeader } from "~/components/page-header";
|
||||||
|
|
||||||
export default function EditBusinessPage() {
|
export default function EditBusinessPage() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const businessId = Array.isArray(params?.id) ? params.id[0] : params?.id;
|
const businessId = Array.isArray(params?.id) ? params.id[0] : params?.id;
|
||||||
if (!businessId) return null;
|
if (!businessId) return null;
|
||||||
return <BusinessForm businessId={businessId} mode="edit" />;
|
|
||||||
}
|
return (
|
||||||
|
<div>
|
||||||
|
<PageHeader
|
||||||
|
title="Edit Business"
|
||||||
|
description="Update business information below."
|
||||||
|
variant="gradient"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<BusinessForm businessId={businessId} mode="edit" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
287
src/app/dashboard/businesses/[id]/page.tsx
Normal file
287
src/app/dashboard/businesses/[id]/page.tsx
Normal file
@@ -0,0 +1,287 @@
|
|||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { api } from "~/trpc/server";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import { Badge } from "~/components/ui/badge";
|
||||||
|
import { PageHeader } from "~/components/page-header";
|
||||||
|
import Link from "next/link";
|
||||||
|
import {
|
||||||
|
Edit,
|
||||||
|
Mail,
|
||||||
|
Phone,
|
||||||
|
MapPin,
|
||||||
|
Building,
|
||||||
|
Calendar,
|
||||||
|
DollarSign,
|
||||||
|
Globe,
|
||||||
|
Hash,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
interface BusinessDetailPageProps {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function BusinessDetailPage({
|
||||||
|
params,
|
||||||
|
}: BusinessDetailPageProps) {
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
const business = await api.businesses.getById({ id });
|
||||||
|
|
||||||
|
if (!business) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDate = (date: Date) => {
|
||||||
|
return new Intl.DateTimeFormat("en-US", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "long",
|
||||||
|
day: "numeric",
|
||||||
|
}).format(date);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="mx-auto max-w-4xl space-y-6">
|
||||||
|
<PageHeader
|
||||||
|
title={business.name}
|
||||||
|
description="Business Details"
|
||||||
|
variant="gradient"
|
||||||
|
>
|
||||||
|
<Link href={`/dashboard/businesses/${business.id}/edit`}>
|
||||||
|
<Button className="bg-gradient-to-r from-emerald-600 to-teal-600 hover:from-emerald-700 hover:to-teal-700">
|
||||||
|
<Edit className="mr-2 h-4 w-4" />
|
||||||
|
Edit Business
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||||
|
{/* Business Information Card */}
|
||||||
|
<div className="lg:col-span-2">
|
||||||
|
<Card className="border-0 shadow-xl backdrop-blur-sm">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center space-x-2 text-green-600">
|
||||||
|
<Building className="h-5 w-5" />
|
||||||
|
<span>Business Information</span>
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
{/* Basic Info */}
|
||||||
|
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||||
|
{business.email && (
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<div className="rounded-lg bg-emerald-100 p-2 dark:bg-emerald-900/30">
|
||||||
|
<Mail className="h-4 w-4 text-emerald-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground text-sm font-medium">
|
||||||
|
Email
|
||||||
|
</p>
|
||||||
|
<p className="text-foreground text-sm">
|
||||||
|
{business.email}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{business.phone && (
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<div className="rounded-lg bg-emerald-100 p-2 dark:bg-emerald-900/30">
|
||||||
|
<Phone className="h-4 w-4 text-emerald-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground text-sm font-medium">
|
||||||
|
Phone
|
||||||
|
</p>
|
||||||
|
<p className="text-foreground text-sm">
|
||||||
|
{business.phone}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{business.website && (
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<div className="rounded-lg bg-emerald-100 p-2 dark:bg-emerald-900/30">
|
||||||
|
<Globe className="h-4 w-4 text-emerald-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground text-sm font-medium">
|
||||||
|
Website
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
href={business.website}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-foreground text-sm hover:text-emerald-600 hover:underline"
|
||||||
|
>
|
||||||
|
{business.website}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{business.taxId && (
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<div className="rounded-lg bg-emerald-100 p-2 dark:bg-emerald-900/30">
|
||||||
|
<Hash className="h-4 w-4 text-emerald-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground text-sm font-medium">
|
||||||
|
Tax ID
|
||||||
|
</p>
|
||||||
|
<p className="text-foreground text-sm">
|
||||||
|
{business.taxId}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Address */}
|
||||||
|
{(business.addressLine1 ?? business.city ?? business.state) && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<div className="rounded-lg bg-emerald-100 p-2 dark:bg-emerald-900/30">
|
||||||
|
<MapPin className="h-4 w-4 text-emerald-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground text-sm font-medium">
|
||||||
|
Address
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-foreground ml-11 space-y-1 text-sm">
|
||||||
|
{business.addressLine1 && <p>{business.addressLine1}</p>}
|
||||||
|
{business.addressLine2 && <p>{business.addressLine2}</p>}
|
||||||
|
{(business.city ?? business.state ?? business.postalCode) && (
|
||||||
|
<p>
|
||||||
|
{[business.city, business.state, business.postalCode]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(", ")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{business.country && <p>{business.country}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Business Since */}
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<div className="rounded-lg bg-emerald-100 p-2 dark:bg-emerald-900/30">
|
||||||
|
<Calendar className="h-4 w-4 text-emerald-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||||
|
Business Added
|
||||||
|
</p>
|
||||||
|
<p className="text-sm dark:text-gray-300">
|
||||||
|
{formatDate(business.createdAt)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Default Business Badge */}
|
||||||
|
{business.isDefault && (
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<div className="rounded-lg bg-emerald-100 p-2 dark:bg-emerald-900/30">
|
||||||
|
<Building className="h-4 w-4 text-emerald-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||||
|
Status
|
||||||
|
</p>
|
||||||
|
<Badge className="bg-emerald-100 text-emerald-700 hover:bg-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-400">
|
||||||
|
Default Business
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Settings & Actions Card */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card className="border-0 bg-white/80 shadow-xl backdrop-blur-sm dark:bg-gray-800/80">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center space-x-2 text-emerald-700 dark:text-emerald-400">
|
||||||
|
<Building className="h-5 w-5" />
|
||||||
|
<span>Business Settings</span>
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Default Business
|
||||||
|
</p>
|
||||||
|
<p className="text-lg font-semibold">
|
||||||
|
{business.isDefault ? (
|
||||||
|
<Badge className="bg-emerald-100 text-emerald-700 hover:bg-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-400">
|
||||||
|
Yes
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="outline">No</Badge>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||||
|
Quick Actions
|
||||||
|
</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Link href={`/dashboard/businesses/${business.id}/edit`}>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="w-full justify-start"
|
||||||
|
>
|
||||||
|
<Edit className="mr-2 h-4 w-4" />
|
||||||
|
Edit Business
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<Link href="/dashboard/invoices/new">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="w-full justify-start"
|
||||||
|
>
|
||||||
|
<DollarSign className="mr-2 h-4 w-4" />
|
||||||
|
Create Invoice
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Information Card */}
|
||||||
|
<Card className="border-0 bg-white/80 shadow-xl backdrop-blur-sm dark:bg-gray-800/80">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg dark:text-white">
|
||||||
|
About This Business
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-3 text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
<p>
|
||||||
|
This business profile is used for generating invoices and
|
||||||
|
represents your company information to clients.
|
||||||
|
</p>
|
||||||
|
{business.isDefault && (
|
||||||
|
<p className="text-emerald-600 dark:text-emerald-400">
|
||||||
|
This is your default business and will be automatically
|
||||||
|
selected when creating new invoices.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import type { ColumnDef } from "@tanstack/react-table";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import { DataTable, DataTableColumnHeader } from "~/components/ui/data-table";
|
||||||
|
import { Building, Pencil, Trash2, ExternalLink } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "~/components/ui/dialog";
|
||||||
|
import { api } from "~/trpc/react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
// Type for business data
|
||||||
|
interface Business {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email: string | null;
|
||||||
|
phone: string | null;
|
||||||
|
addressLine1: string | null;
|
||||||
|
addressLine2: string | null;
|
||||||
|
city: string | null;
|
||||||
|
state: string | null;
|
||||||
|
postalCode: string | null;
|
||||||
|
country: string | null;
|
||||||
|
website: string | null;
|
||||||
|
taxId: string | null;
|
||||||
|
logoUrl: string | null;
|
||||||
|
createdById: string;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BusinessesDataTableProps {
|
||||||
|
businesses: Business[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatAddress = (business: Business) => {
|
||||||
|
const parts = [
|
||||||
|
business.addressLine1,
|
||||||
|
business.addressLine2,
|
||||||
|
business.city,
|
||||||
|
business.state,
|
||||||
|
business.postalCode,
|
||||||
|
].filter(Boolean);
|
||||||
|
return parts.join(", ") || "—";
|
||||||
|
};
|
||||||
|
|
||||||
|
export function BusinessesDataTable({
|
||||||
|
businesses: initialBusinesses,
|
||||||
|
}: BusinessesDataTableProps) {
|
||||||
|
const [businesses, setBusinesses] = useState(initialBusinesses);
|
||||||
|
const [businessToDelete, setBusinessToDelete] = useState<Business | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
const utils = api.useUtils();
|
||||||
|
|
||||||
|
const deleteBusinessMutation = api.businesses.delete.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Business deleted successfully");
|
||||||
|
setBusinesses(businesses.filter((b) => b.id !== businessToDelete?.id));
|
||||||
|
setBusinessToDelete(null);
|
||||||
|
void utils.businesses.getAll.invalidate();
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(`Failed to delete business: ${error.message}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleDelete = () => {
|
||||||
|
if (!businessToDelete) return;
|
||||||
|
deleteBusinessMutation.mutate({ id: businessToDelete.id });
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns: ColumnDef<Business>[] = [
|
||||||
|
{
|
||||||
|
accessorKey: "name",
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataTableColumnHeader column={column} title="Name" />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const business = row.original;
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="bg-status-info-muted hidden rounded-lg p-2 sm:flex">
|
||||||
|
<Building className="text-status-info h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate font-medium">{business.name}</p>
|
||||||
|
<p className="text-muted-foreground truncate text-sm">
|
||||||
|
{business.email ?? "—"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "phone",
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataTableColumnHeader column={column} title="Phone" />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => row.original.phone ?? "—",
|
||||||
|
meta: {
|
||||||
|
headerClassName: "hidden md:table-cell",
|
||||||
|
cellClassName: "hidden md:table-cell",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "address",
|
||||||
|
header: "Address",
|
||||||
|
cell: ({ row }) => formatAddress(row.original),
|
||||||
|
meta: {
|
||||||
|
headerClassName: "hidden lg:table-cell",
|
||||||
|
cellClassName: "hidden lg:table-cell",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "taxId",
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataTableColumnHeader column={column} title="Tax ID" />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => row.original.taxId ?? "—",
|
||||||
|
meta: {
|
||||||
|
headerClassName: "hidden xl:table-cell",
|
||||||
|
cellClassName: "hidden xl:table-cell",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "website",
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataTableColumnHeader column={column} title="Website" />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const website = row.original.website;
|
||||||
|
if (!website) return "—";
|
||||||
|
|
||||||
|
// Add https:// if not present
|
||||||
|
const url = website.startsWith("http") ? website : `https://${website}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Desktop: Show full URL */}
|
||||||
|
<a
|
||||||
|
href={url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-primary hidden hover:underline sm:inline"
|
||||||
|
>
|
||||||
|
{website}
|
||||||
|
</a>
|
||||||
|
{/* Mobile: Show link button */}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 w-8 p-0 sm:hidden"
|
||||||
|
asChild
|
||||||
|
>
|
||||||
|
<a href={url} target="_blank" rel="noopener noreferrer">
|
||||||
|
<ExternalLink className="h-3.5 w-3.5" />
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "actions",
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const business = row.original;
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-end gap-1">
|
||||||
|
<Link href={`/dashboard/businesses/${business.id}/edit`}>
|
||||||
|
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
|
||||||
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
onClick={() => setBusinessToDelete(business)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
data={businesses}
|
||||||
|
searchKey="name"
|
||||||
|
searchPlaceholder="Search businesses..."
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Delete confirmation dialog */}
|
||||||
|
<Dialog
|
||||||
|
open={!!businessToDelete}
|
||||||
|
onOpenChange={(open) => !open && setBusinessToDelete(null)}
|
||||||
|
>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Are you sure?</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
This action cannot be undone. This will permanently delete the
|
||||||
|
business "{businessToDelete?.name}" and remove all associated
|
||||||
|
data.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setBusinessToDelete(null)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={deleteBusinessMutation.isPending}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,15 +1,19 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { api } from "~/trpc/react";
|
import { api } from "~/trpc/react";
|
||||||
import { UniversalTable } from "~/components/ui/universal-table";
|
import { DataTableSkeleton } from "~/components/ui/data-table";
|
||||||
import { TableSkeleton } from "~/components/ui/skeleton";
|
import { BusinessesDataTable } from "./businesses-data-table";
|
||||||
|
|
||||||
export function BusinessesTable() {
|
export function BusinessesTable() {
|
||||||
const { isLoading } = api.businesses.getAll.useQuery();
|
const { data: businesses, isLoading } = api.businesses.getAll.useQuery();
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <TableSkeleton rows={8} />;
|
return <DataTableSkeleton columns={6} rows={8} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <UniversalTable resource="businesses" />;
|
if (!businesses) {
|
||||||
}
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <BusinessesDataTable businesses={businesses} />;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,20 @@
|
|||||||
|
import Link from "next/link";
|
||||||
import { BusinessForm } from "~/components/business-form";
|
import { BusinessForm } from "~/components/business-form";
|
||||||
|
import { PageHeader } from "~/components/page-header";
|
||||||
|
import { HydrateClient } from "~/trpc/server";
|
||||||
|
|
||||||
export default function NewBusinessPage() {
|
export default function NewBusinessPage() {
|
||||||
return <BusinessForm mode="create" />;
|
return (
|
||||||
}
|
<div>
|
||||||
|
<PageHeader
|
||||||
|
title="Add Business"
|
||||||
|
description="Enter business details below to add a new business."
|
||||||
|
variant="gradient"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<HydrateClient>
|
||||||
|
<BusinessForm mode="create" />
|
||||||
|
</HydrateClient>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,35 +1,30 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { HydrateClient } from "~/trpc/server";
|
||||||
import { api, HydrateClient } from "~/trpc/server";
|
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { Plus } from "lucide-react";
|
import { Plus } from "lucide-react";
|
||||||
import { BusinessesTable } from "./_components/businesses-table";
|
import { BusinessesTable } from "./_components/businesses-table";
|
||||||
|
import { PageHeader } from "~/components/page-header";
|
||||||
|
import { PageContent, PageSection } from "~/components/ui/page-layout";
|
||||||
|
|
||||||
export default async function BusinessesPage() {
|
export default async function BusinessesPage() {
|
||||||
return (
|
return (
|
||||||
<div>
|
<>
|
||||||
<div className="mb-8 flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
<PageHeader
|
||||||
<div>
|
title="Businesses"
|
||||||
<h1 className="bg-gradient-to-r from-emerald-600 to-teal-600 bg-clip-text text-3xl font-bold text-transparent dark:from-emerald-400 dark:to-teal-400">
|
description="Manage your businesses and their information."
|
||||||
Businesses
|
variant="gradient"
|
||||||
</h1>
|
>
|
||||||
<p className="mt-1 text-lg text-gray-600 dark:text-gray-300">
|
<Button asChild variant="brand">
|
||||||
Manage your businesses and their information.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
asChild
|
|
||||||
size="lg"
|
|
||||||
className="bg-gradient-to-r from-emerald-600 to-teal-600 font-medium text-white shadow-lg hover:from-emerald-700 hover:to-teal-700 hover:shadow-xl"
|
|
||||||
>
|
|
||||||
<Link href="/dashboard/businesses/new">
|
<Link href="/dashboard/businesses/new">
|
||||||
<Plus className="mr-2 h-5 w-5" /> Add Business
|
<Plus className="mr-2 h-5 w-5" />
|
||||||
|
<span>Add Business</span>
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</PageHeader>
|
||||||
|
|
||||||
<HydrateClient>
|
<HydrateClient>
|
||||||
<BusinessesTable />
|
<BusinessesTable />
|
||||||
</HydrateClient>
|
</HydrateClient>
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
|
import Link from "next/link";
|
||||||
import { HydrateClient } from "~/trpc/server";
|
import { HydrateClient } from "~/trpc/server";
|
||||||
import { ClientForm } from "~/components/client-form";
|
import { ClientForm } from "~/components/client-form";
|
||||||
|
import { PageHeader } from "~/components/page-header";
|
||||||
|
|
||||||
interface EditClientPageProps {
|
interface EditClientPageProps {
|
||||||
params: Promise<{ id: string }>;
|
params: Promise<{ id: string }>;
|
||||||
@@ -10,14 +12,11 @@ export default async function EditClientPage({ params }: EditClientPageProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-8">
|
<PageHeader
|
||||||
<h1 className="bg-gradient-to-r from-emerald-600 to-teal-600 bg-clip-text text-3xl font-bold text-transparent">
|
title="Edit Client"
|
||||||
Edit Client
|
description="Update client information below."
|
||||||
</h1>
|
variant="gradient"
|
||||||
<p className="mt-1 text-lg text-gray-600">
|
/>
|
||||||
Update client information below.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<HydrateClient>
|
<HydrateClient>
|
||||||
<ClientForm mode="edit" clientId={id} />
|
<ClientForm mode="edit" clientId={id} />
|
||||||
</HydrateClient>
|
</HydrateClient>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { api } from "~/trpc/server";
|
|||||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { Badge } from "~/components/ui/badge";
|
import { Badge } from "~/components/ui/badge";
|
||||||
|
import { PageHeader } from "~/components/page-header";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import {
|
import {
|
||||||
Edit,
|
Edit,
|
||||||
@@ -53,32 +54,27 @@ export default async function ClientDetailPage({
|
|||||||
client.invoices?.filter((invoice) => invoice.status === "sent").length || 0;
|
client.invoices?.filter((invoice) => invoice.status === "sent").length || 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4 md:mr-4 md:ml-72 md:p-6">
|
<div>
|
||||||
<div className="mx-auto max-w-4xl space-y-6">
|
<div className="mx-auto max-w-4xl space-y-6">
|
||||||
{/* Header */}
|
<PageHeader
|
||||||
<div className="flex items-center justify-between">
|
title={client.name}
|
||||||
<div>
|
description="Client Details"
|
||||||
<h1 className="bg-gradient-to-r from-emerald-600 to-teal-600 bg-clip-text text-3xl font-bold text-transparent">
|
variant="gradient"
|
||||||
{client.name}
|
>
|
||||||
</h1>
|
<Link href={`/dashboard/clients/${client.id}/edit`}>
|
||||||
<p className="text-muted-foreground dark:text-gray-300">
|
<Button variant="brand">
|
||||||
Client Details
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Link href={`/clients/${client.id}/edit`}>
|
|
||||||
<Button className="bg-gradient-to-r from-emerald-600 to-teal-600 hover:from-emerald-700 hover:to-teal-700">
|
|
||||||
<Edit className="mr-2 h-4 w-4" />
|
<Edit className="mr-2 h-4 w-4" />
|
||||||
Edit Client
|
Edit Client
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</PageHeader>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||||
{/* Client Information Card */}
|
{/* Client Information Card */}
|
||||||
<div className="lg:col-span-2">
|
<div className="lg:col-span-2">
|
||||||
<Card className="border-0 bg-white/80 shadow-xl backdrop-blur-sm dark:bg-gray-800/80">
|
<Card className="border-0 shadow-xl backdrop-blur-sm">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center space-x-2 text-emerald-700 dark:text-emerald-400">
|
<CardTitle className="flex items-center space-x-2 text-green-600">
|
||||||
<Building className="h-5 w-5" />
|
<Building className="h-5 w-5" />
|
||||||
<span>Contact Information</span>
|
<span>Contact Information</span>
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
@@ -92,10 +88,10 @@ export default async function ClientDetailPage({
|
|||||||
<Mail className="h-4 w-4 text-emerald-600" />
|
<Mail className="h-4 w-4 text-emerald-600" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-gray-500 dark:text-gray-400">
|
<p className="text-muted-foreground text-sm font-medium">
|
||||||
Email
|
Email
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm dark:text-gray-300">
|
<p className="text-foreground text-sm">
|
||||||
{client.email}
|
{client.email}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -108,10 +104,10 @@ export default async function ClientDetailPage({
|
|||||||
<Phone className="h-4 w-4 text-emerald-600" />
|
<Phone className="h-4 w-4 text-emerald-600" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-gray-500 dark:text-gray-400">
|
<p className="text-muted-foreground text-sm font-medium">
|
||||||
Phone
|
Phone
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm dark:text-gray-300">
|
<p className="text-foreground text-sm">
|
||||||
{client.phone}
|
{client.phone}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -127,12 +123,12 @@ export default async function ClientDetailPage({
|
|||||||
<MapPin className="h-4 w-4 text-emerald-600" />
|
<MapPin className="h-4 w-4 text-emerald-600" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-gray-500 dark:text-gray-400">
|
<p className="text-muted-foreground text-sm font-medium">
|
||||||
Address
|
Address
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-11 space-y-1 text-sm dark:text-gray-300">
|
<div className="text-foreground ml-11 space-y-1 text-sm">
|
||||||
{client.addressLine1 && <p>{client.addressLine1}</p>}
|
{client.addressLine1 && <p>{client.addressLine1}</p>}
|
||||||
{client.addressLine2 && <p>{client.addressLine2}</p>}
|
{client.addressLine2 && <p>{client.addressLine2}</p>}
|
||||||
{(client.city ?? client.state ?? client.postalCode) && (
|
{(client.city ?? client.state ?? client.postalCode) && (
|
||||||
|
|||||||
201
src/app/dashboard/clients/_components/clients-data-table.tsx
Normal file
201
src/app/dashboard/clients/_components/clients-data-table.tsx
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import type { ColumnDef } from "@tanstack/react-table";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import { DataTable, DataTableColumnHeader } from "~/components/ui/data-table";
|
||||||
|
import { UserPlus, Pencil, Trash2 } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "~/components/ui/dialog";
|
||||||
|
import { api } from "~/trpc/react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
// Type for client data
|
||||||
|
interface Client {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email: string | null;
|
||||||
|
phone: string | null;
|
||||||
|
addressLine1: string | null;
|
||||||
|
addressLine2: string | null;
|
||||||
|
city: string | null;
|
||||||
|
state: string | null;
|
||||||
|
postalCode: string | null;
|
||||||
|
country: string | null;
|
||||||
|
createdById: string;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ClientsDataTableProps {
|
||||||
|
clients: Client[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatAddress = (client: Client) => {
|
||||||
|
const parts = [
|
||||||
|
client.addressLine1,
|
||||||
|
client.addressLine2,
|
||||||
|
client.city,
|
||||||
|
client.state,
|
||||||
|
client.postalCode,
|
||||||
|
].filter(Boolean);
|
||||||
|
return parts.join(", ") || "—";
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ClientsDataTable({
|
||||||
|
clients: initialClients,
|
||||||
|
}: ClientsDataTableProps) {
|
||||||
|
const [clients, setClients] = useState(initialClients);
|
||||||
|
const [clientToDelete, setClientToDelete] = useState<Client | null>(null);
|
||||||
|
|
||||||
|
const utils = api.useUtils();
|
||||||
|
|
||||||
|
const deleteClientMutation = api.clients.delete.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Client deleted successfully");
|
||||||
|
setClients(clients.filter((c) => c.id !== clientToDelete?.id));
|
||||||
|
setClientToDelete(null);
|
||||||
|
void utils.clients.getAll.invalidate();
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(`Failed to delete client: ${error.message}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleDelete = () => {
|
||||||
|
if (!clientToDelete) return;
|
||||||
|
deleteClientMutation.mutate({ id: clientToDelete.id });
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns: ColumnDef<Client>[] = [
|
||||||
|
{
|
||||||
|
accessorKey: "name",
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataTableColumnHeader column={column} title="Name" />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const client = row.original;
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="bg-status-info-muted hidden rounded-lg p-2 sm:flex">
|
||||||
|
<UserPlus className="text-status-info h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate font-medium">{client.name}</p>
|
||||||
|
<p className="text-muted-foreground truncate text-sm">
|
||||||
|
{client.email || "—"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "phone",
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataTableColumnHeader column={column} title="Phone" />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => row.original.phone || "—",
|
||||||
|
meta: {
|
||||||
|
headerClassName: "hidden md:table-cell",
|
||||||
|
cellClassName: "hidden md:table-cell",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "address",
|
||||||
|
header: "Address",
|
||||||
|
cell: ({ row }) => formatAddress(row.original),
|
||||||
|
meta: {
|
||||||
|
headerClassName: "hidden lg:table-cell",
|
||||||
|
cellClassName: "hidden lg:table-cell",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "createdAt",
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataTableColumnHeader column={column} title="Created" />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const date = row.getValue("createdAt") as Date;
|
||||||
|
return new Intl.DateTimeFormat("en-US", {
|
||||||
|
month: "short",
|
||||||
|
day: "2-digit",
|
||||||
|
year: "numeric",
|
||||||
|
}).format(new Date(date));
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
headerClassName: "hidden xl:table-cell",
|
||||||
|
cellClassName: "hidden xl:table-cell",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "actions",
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const client = row.original;
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-end gap-1">
|
||||||
|
<Link href={`/dashboard/clients/${client.id}/edit`}>
|
||||||
|
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
|
||||||
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
onClick={() => setClientToDelete(client)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
data={clients}
|
||||||
|
searchKey="name"
|
||||||
|
searchPlaceholder="Search clients..."
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Delete confirmation dialog */}
|
||||||
|
<Dialog
|
||||||
|
open={!!clientToDelete}
|
||||||
|
onOpenChange={(open) => !open && setClientToDelete(null)}
|
||||||
|
>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Are you sure?</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
This action cannot be undone. This will permanently delete the
|
||||||
|
client "{clientToDelete?.name}" and remove all associated data.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setClientToDelete(null)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={deleteClientMutation.isPending}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,15 +1,19 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { api } from "~/trpc/react";
|
import { api } from "~/trpc/react";
|
||||||
import { UniversalTable } from "~/components/ui/universal-table";
|
import { DataTableSkeleton } from "~/components/ui/data-table";
|
||||||
import { TableSkeleton } from "~/components/ui/skeleton";
|
import { ClientsDataTable } from "./clients-data-table";
|
||||||
|
|
||||||
export function ClientsTable() {
|
export function ClientsTable() {
|
||||||
const { isLoading } = api.clients.getAll.useQuery();
|
const { data: clients, isLoading } = api.clients.getAll.useQuery();
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <TableSkeleton rows={8} />;
|
return <DataTableSkeleton columns={5} rows={8} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <UniversalTable resource="clients" />;
|
if (!clients) {
|
||||||
}
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <ClientsDataTable clients={clients} />;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,17 +1,16 @@
|
|||||||
|
import Link from "next/link";
|
||||||
import { HydrateClient } from "~/trpc/server";
|
import { HydrateClient } from "~/trpc/server";
|
||||||
import { ClientForm } from "~/components/client-form";
|
import { ClientForm } from "~/components/client-form";
|
||||||
|
import { PageHeader } from "~/components/page-header";
|
||||||
|
|
||||||
export default async function NewClientPage() {
|
export default async function NewClientPage() {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-8">
|
<PageHeader
|
||||||
<h1 className="bg-gradient-to-r from-emerald-600 to-teal-600 bg-clip-text text-3xl font-bold text-transparent">
|
title="Add Client"
|
||||||
Add Client
|
description="Enter client details below to add a new client."
|
||||||
</h1>
|
variant="gradient"
|
||||||
<p className="mt-1 text-lg text-gray-600">
|
/>
|
||||||
Enter client details below to add a new client.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<HydrateClient>
|
<HydrateClient>
|
||||||
<ClientForm mode="create" />
|
<ClientForm mode="create" />
|
||||||
</HydrateClient>
|
</HydrateClient>
|
||||||
|
|||||||
@@ -1,35 +1,30 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { HydrateClient } from "~/trpc/server";
|
||||||
import { api, HydrateClient } from "~/trpc/server";
|
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { Plus } from "lucide-react";
|
import { Plus } from "lucide-react";
|
||||||
import { ClientsTable } from "./_components/clients-table";
|
import { ClientsTable } from "./_components/clients-table";
|
||||||
|
import { PageHeader } from "~/components/page-header";
|
||||||
|
import { PageContent, PageSection } from "~/components/ui/page-layout";
|
||||||
|
|
||||||
export default async function ClientsPage() {
|
export default async function ClientsPage() {
|
||||||
return (
|
return (
|
||||||
<div>
|
<>
|
||||||
<div className="mb-8 flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
<PageHeader
|
||||||
<div>
|
title="Clients"
|
||||||
<h1 className="bg-gradient-to-r from-emerald-600 to-teal-600 bg-clip-text text-3xl font-bold text-transparent">
|
description="Manage your clients and their information."
|
||||||
Clients
|
variant="gradient"
|
||||||
</h1>
|
>
|
||||||
<p className="mt-1 text-lg text-gray-600">
|
<Button asChild variant="brand">
|
||||||
Manage your clients and their information.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
asChild
|
|
||||||
size="lg"
|
|
||||||
className="bg-gradient-to-r from-emerald-600 to-teal-600 font-medium text-white shadow-lg hover:from-emerald-700 hover:to-teal-700 hover:shadow-xl"
|
|
||||||
>
|
|
||||||
<Link href="/dashboard/clients/new">
|
<Link href="/dashboard/clients/new">
|
||||||
<Plus className="mr-2 h-5 w-5" /> Add Client
|
<Plus className="mr-2 h-5 w-5" />
|
||||||
|
<span>Add Client</span>
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</PageHeader>
|
||||||
|
|
||||||
<HydrateClient>
|
<HydrateClient>
|
||||||
<ClientsTable />
|
<ClientsTable />
|
||||||
</HydrateClient>
|
</HydrateClient>
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { generateInvoicePDF } from "~/lib/pdf-export";
|
||||||
|
import { Download, Loader2 } from "lucide-react";
|
||||||
|
|
||||||
|
interface Invoice {
|
||||||
|
id: string;
|
||||||
|
invoiceNumber: string;
|
||||||
|
issueDate: Date;
|
||||||
|
dueDate: Date;
|
||||||
|
status: string;
|
||||||
|
totalAmount: number;
|
||||||
|
taxRate: number;
|
||||||
|
notes?: string | null;
|
||||||
|
business?: {
|
||||||
|
name: string;
|
||||||
|
email?: string | null;
|
||||||
|
phone?: string | null;
|
||||||
|
addressLine1?: string | null;
|
||||||
|
addressLine2?: string | null;
|
||||||
|
city?: string | null;
|
||||||
|
state?: string | null;
|
||||||
|
postalCode?: string | null;
|
||||||
|
country?: string | null;
|
||||||
|
website?: string | null;
|
||||||
|
taxId?: string | null;
|
||||||
|
} | null;
|
||||||
|
client: {
|
||||||
|
name: string;
|
||||||
|
email?: string | null;
|
||||||
|
phone?: string | null;
|
||||||
|
addressLine1?: string | null;
|
||||||
|
addressLine2?: string | null;
|
||||||
|
city?: string | null;
|
||||||
|
state?: string | null;
|
||||||
|
postalCode?: string | null;
|
||||||
|
country?: string | null;
|
||||||
|
};
|
||||||
|
items: Array<{
|
||||||
|
date: Date;
|
||||||
|
description: string;
|
||||||
|
hours: number;
|
||||||
|
rate: number;
|
||||||
|
amount: number;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PDFDownloadButtonProps {
|
||||||
|
invoice: Invoice;
|
||||||
|
variant?: "button" | "menu" | "icon";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PDFDownloadButton({
|
||||||
|
invoice,
|
||||||
|
variant = "button",
|
||||||
|
}: PDFDownloadButtonProps) {
|
||||||
|
const [isGenerating, setIsGenerating] = useState(false);
|
||||||
|
|
||||||
|
const handleDownloadPDF = async () => {
|
||||||
|
if (isGenerating) return;
|
||||||
|
|
||||||
|
setIsGenerating(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Transform the invoice data to match the PDF interface
|
||||||
|
const pdfData = {
|
||||||
|
invoiceNumber: invoice.invoiceNumber,
|
||||||
|
issueDate: invoice.issueDate,
|
||||||
|
dueDate: invoice.dueDate,
|
||||||
|
status: invoice.status,
|
||||||
|
totalAmount: invoice.totalAmount,
|
||||||
|
taxRate: invoice.taxRate,
|
||||||
|
notes: invoice.notes,
|
||||||
|
business: invoice.business,
|
||||||
|
client: invoice.client,
|
||||||
|
items: invoice.items,
|
||||||
|
};
|
||||||
|
|
||||||
|
await generateInvoicePDF(pdfData);
|
||||||
|
|
||||||
|
toast.success("PDF downloaded successfully");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("PDF generation error:", error);
|
||||||
|
toast.error(
|
||||||
|
error instanceof Error ? error.message : "Failed to generate PDF",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setIsGenerating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (variant === "menu") {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={handleDownloadPDF}
|
||||||
|
disabled={isGenerating}
|
||||||
|
className="hover:bg-accent flex w-full items-center gap-2 px-2 py-1.5 text-sm"
|
||||||
|
>
|
||||||
|
{isGenerating ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Download className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
{isGenerating ? "Generating..." : "Download PDF"}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (variant === "icon") {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
onClick={handleDownloadPDF}
|
||||||
|
disabled={isGenerating}
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
>
|
||||||
|
{isGenerating ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Download className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
onClick={handleDownloadPDF}
|
||||||
|
disabled={isGenerating}
|
||||||
|
className="w-full justify-start"
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
{isGenerating ? (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Download className="mr-2 h-4 w-4" />
|
||||||
|
)}
|
||||||
|
{isGenerating ? "Generating..." : "Download PDF"}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
770
src/app/dashboard/invoices/[id]/edit/page.tsx
Normal file
770
src/app/dashboard/invoices/[id]/edit/page.tsx
Normal file
@@ -0,0 +1,770 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useRef } from "react";
|
||||||
|
import { useRouter, useParams } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { api } from "~/trpc/react";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||||
|
import { Input } from "~/components/ui/input";
|
||||||
|
import { NumberInput } from "~/components/ui/number-input";
|
||||||
|
import { Label } from "~/components/ui/label";
|
||||||
|
import { Textarea } from "~/components/ui/textarea";
|
||||||
|
import { PageHeader } from "~/components/page-header";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "~/components/ui/select";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
} from "~/components/ui/alert-dialog";
|
||||||
|
import { Badge } from "~/components/ui/badge";
|
||||||
|
import { Separator } from "~/components/ui/separator";
|
||||||
|
import { DatePicker } from "~/components/ui/date-picker";
|
||||||
|
import { FloatingActionBar } from "~/components/ui/floating-action-bar";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
Save,
|
||||||
|
Plus,
|
||||||
|
Trash2,
|
||||||
|
FileText,
|
||||||
|
Building,
|
||||||
|
User,
|
||||||
|
Loader2,
|
||||||
|
Send,
|
||||||
|
DollarSign,
|
||||||
|
Hash,
|
||||||
|
Edit3,
|
||||||
|
Eye,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
interface EditInvoicePageProps {}
|
||||||
|
|
||||||
|
interface InvoiceItem {
|
||||||
|
id?: string;
|
||||||
|
tempId: string;
|
||||||
|
date: Date;
|
||||||
|
description: string;
|
||||||
|
hours: number;
|
||||||
|
rate: number;
|
||||||
|
amount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InvoiceFormData {
|
||||||
|
invoiceNumber: string;
|
||||||
|
businessId: string | undefined;
|
||||||
|
clientId: string;
|
||||||
|
issueDate: Date;
|
||||||
|
dueDate: Date;
|
||||||
|
notes: string;
|
||||||
|
taxRate: number;
|
||||||
|
items: InvoiceItem[];
|
||||||
|
status: "draft" | "sent" | "paid" | "overdue";
|
||||||
|
}
|
||||||
|
|
||||||
|
function InvoiceItemCard({
|
||||||
|
item,
|
||||||
|
index,
|
||||||
|
onUpdate,
|
||||||
|
onDelete,
|
||||||
|
_isLast,
|
||||||
|
}: {
|
||||||
|
item: InvoiceItem;
|
||||||
|
index: number;
|
||||||
|
onUpdate: (
|
||||||
|
index: number,
|
||||||
|
field: keyof InvoiceItem,
|
||||||
|
value: string | number | Date,
|
||||||
|
) => void;
|
||||||
|
onDelete: (index: number) => void;
|
||||||
|
_isLast: boolean;
|
||||||
|
}) {
|
||||||
|
const handleFieldChange = (
|
||||||
|
field: keyof InvoiceItem,
|
||||||
|
value: string | number | Date,
|
||||||
|
) => {
|
||||||
|
onUpdate(index, field, value);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="border-border/50 border p-3 shadow-sm">
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* Header with item number and delete */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-muted-foreground text-xs font-medium">
|
||||||
|
Item {index + 1}
|
||||||
|
</span>
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 w-6 p-0 text-red-500 hover:text-red-700"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Delete Item</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
Are you sure you want to delete this line item? This action
|
||||||
|
cannot be undone.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={() => onDelete(index)}
|
||||||
|
className="bg-red-600 hover:bg-red-700"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<Textarea
|
||||||
|
value={item.description}
|
||||||
|
onChange={(e) => handleFieldChange("description", e.target.value)}
|
||||||
|
placeholder="Description of work..."
|
||||||
|
className="min-h-[48px] resize-none text-sm"
|
||||||
|
rows={1}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Date, Hours, Rate, Amount in compact grid */}
|
||||||
|
<div className="grid grid-cols-2 gap-2 text-sm sm:grid-cols-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs font-medium">Date</Label>
|
||||||
|
<DatePicker
|
||||||
|
date={item.date}
|
||||||
|
onDateChange={(date) =>
|
||||||
|
handleFieldChange("date", date ?? new Date())
|
||||||
|
}
|
||||||
|
className="[&>button]:h-8 [&>button]:text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs font-medium">Hours</Label>
|
||||||
|
<NumberInput
|
||||||
|
value={item.hours}
|
||||||
|
onChange={(value) => handleFieldChange("hours", value)}
|
||||||
|
min={0}
|
||||||
|
step={0.25}
|
||||||
|
placeholder="0"
|
||||||
|
className="text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs font-medium">Rate</Label>
|
||||||
|
<NumberInput
|
||||||
|
value={item.rate}
|
||||||
|
onChange={(value) => handleFieldChange("rate", value)}
|
||||||
|
min={0}
|
||||||
|
step={0.25}
|
||||||
|
placeholder="0.00"
|
||||||
|
prefix="$"
|
||||||
|
className="text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs font-medium">Amount</Label>
|
||||||
|
<div className="bg-muted/30 flex h-8 items-center rounded-md border px-2">
|
||||||
|
<span className="font-mono text-xs font-medium text-emerald-600">
|
||||||
|
${(item.hours * item.rate).toFixed(2)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InvoiceEditor({ invoiceId }: { invoiceId: string }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [formData, setFormData] = useState<InvoiceFormData | null>(null);
|
||||||
|
|
||||||
|
// Floating action bar ref
|
||||||
|
const footerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Queries
|
||||||
|
const { data: invoice, isLoading: invoiceLoading } =
|
||||||
|
api.invoices.getById.useQuery({
|
||||||
|
id: invoiceId,
|
||||||
|
});
|
||||||
|
const { data: clients, isLoading: clientsLoading } =
|
||||||
|
api.clients.getAll.useQuery();
|
||||||
|
const { data: businesses, isLoading: businessesLoading } =
|
||||||
|
api.businesses.getAll.useQuery();
|
||||||
|
|
||||||
|
// Mutations
|
||||||
|
const updateInvoice = api.invoices.update.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Invoice updated successfully");
|
||||||
|
router.push(`/dashboard/invoices/${invoiceId}`);
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(error.message || "Failed to update invoice");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialize form data when invoice loads
|
||||||
|
useEffect(() => {
|
||||||
|
if (invoice) {
|
||||||
|
const transformedItems: InvoiceItem[] =
|
||||||
|
invoice.items?.map((item, index) => ({
|
||||||
|
id: item.id,
|
||||||
|
tempId: item.id || `temp-${index}`,
|
||||||
|
date: item.date || new Date(),
|
||||||
|
description: item.description,
|
||||||
|
hours: item.hours,
|
||||||
|
rate: item.rate,
|
||||||
|
amount: item.amount,
|
||||||
|
})) || [];
|
||||||
|
|
||||||
|
setFormData({
|
||||||
|
invoiceNumber: invoice.invoiceNumber,
|
||||||
|
businessId: invoice.businessId ?? undefined,
|
||||||
|
clientId: invoice.clientId,
|
||||||
|
issueDate: new Date(invoice.issueDate),
|
||||||
|
dueDate: new Date(invoice.dueDate),
|
||||||
|
notes: invoice.notes ?? "",
|
||||||
|
taxRate: invoice.taxRate,
|
||||||
|
items: transformedItems ?? [],
|
||||||
|
status: invoice.status as "draft" | "sent" | "paid" | "overdue",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [invoice]);
|
||||||
|
|
||||||
|
const handleItemUpdate = (
|
||||||
|
index: number,
|
||||||
|
field: keyof InvoiceItem,
|
||||||
|
value: string | number | Date,
|
||||||
|
) => {
|
||||||
|
if (!formData) return;
|
||||||
|
|
||||||
|
const updatedItems = [...formData.items];
|
||||||
|
const currentItem = updatedItems[index];
|
||||||
|
if (currentItem) {
|
||||||
|
updatedItems[index] = { ...currentItem, [field]: value };
|
||||||
|
|
||||||
|
// Recalculate amount for hours or rate changes
|
||||||
|
if (field === "hours" || field === "rate") {
|
||||||
|
const updatedItem = updatedItems[index];
|
||||||
|
if (!updatedItem) return;
|
||||||
|
updatedItem.amount = updatedItem.hours * updatedItem.rate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setFormData({ ...formData, items: updatedItems });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleItemDelete = (index: number) => {
|
||||||
|
if (!formData) return;
|
||||||
|
|
||||||
|
if (formData.items.length === 1) {
|
||||||
|
toast.error("At least one line item is required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedItems = formData.items.filter((_, i) => i !== index);
|
||||||
|
setFormData({ ...formData, items: updatedItems });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddItem = () => {
|
||||||
|
if (!formData) return;
|
||||||
|
|
||||||
|
const newItem: InvoiceItem = {
|
||||||
|
tempId: `item-${Date.now()}`,
|
||||||
|
date: new Date(),
|
||||||
|
description: "",
|
||||||
|
hours: 0,
|
||||||
|
rate: 0,
|
||||||
|
amount: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
items: [...formData.items, newItem],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveDraft = async () => {
|
||||||
|
await handleSave("draft");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdateInvoice = async () => {
|
||||||
|
await handleSave(formData?.status ?? "draft");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async (status: "draft" | "sent" | "paid" | "overdue") => {
|
||||||
|
if (!formData) return;
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
if (!formData.clientId) {
|
||||||
|
toast.error("Please select a client");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (formData.items.length === 0) {
|
||||||
|
toast.error("At least one line item is required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if all items have required fields
|
||||||
|
const invalidItems = formData.items.some(
|
||||||
|
(item) => !item.description.trim() || item.hours <= 0 || item.rate <= 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (invalidItems) {
|
||||||
|
toast.error("All line items must have description, hours, and rate");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
await updateInvoice.mutateAsync({
|
||||||
|
id: invoiceId,
|
||||||
|
...formData,
|
||||||
|
businessId: formData.businessId ?? undefined,
|
||||||
|
status,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const calculateSubtotal = () => {
|
||||||
|
if (!formData) return 0;
|
||||||
|
return formData.items.reduce((sum, item) => sum + item.amount, 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const calculateTax = () => {
|
||||||
|
if (!formData) return 0;
|
||||||
|
return (calculateSubtotal() * formData.taxRate) / 100;
|
||||||
|
};
|
||||||
|
|
||||||
|
const calculateTotal = () => {
|
||||||
|
return calculateSubtotal() + calculateTax();
|
||||||
|
};
|
||||||
|
|
||||||
|
const isFormValid = () => {
|
||||||
|
if (!formData) return false;
|
||||||
|
return (
|
||||||
|
formData.clientId &&
|
||||||
|
formData.items.length > 0 &&
|
||||||
|
formData.items.every(
|
||||||
|
(item) => item.description.trim() && item.hours > 0 && item.rate > 0,
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusBadge = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case "draft":
|
||||||
|
return <Badge variant="secondary">Draft</Badge>;
|
||||||
|
case "sent":
|
||||||
|
return <Badge variant="default">Sent</Badge>;
|
||||||
|
case "paid":
|
||||||
|
return (
|
||||||
|
<Badge variant="outline" className="border-green-500 text-green-700">
|
||||||
|
Paid
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
case "overdue":
|
||||||
|
return <Badge variant="destructive">Overdue</Badge>;
|
||||||
|
default:
|
||||||
|
return <Badge variant="secondary">{status}</Badge>;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (invoiceLoading || clientsLoading || businessesLoading || !formData) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<PageHeader
|
||||||
|
title="Edit Invoice"
|
||||||
|
description="Loading invoice data..."
|
||||||
|
variant="gradient"
|
||||||
|
/>
|
||||||
|
<Card className="shadow-xl">
|
||||||
|
<CardContent className="flex items-center justify-center p-8">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-emerald-600" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<PageHeader
|
||||||
|
title={`Edit Invoice`}
|
||||||
|
description="Update invoice details and line items"
|
||||||
|
variant="gradient"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{getStatusBadge(formData.status)}
|
||||||
|
<Link href={`/dashboard/invoices/${invoiceId}`}>
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
<Eye className="mr-2 h-4 w-4" />
|
||||||
|
<span className="hidden sm:inline">View Invoice</span>
|
||||||
|
<span className="sm:hidden">View</span>
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Invoice Header */}
|
||||||
|
<Card className="shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<FileText className="h-5 w-5 text-emerald-600" />
|
||||||
|
Invoice Details
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-sm font-medium">Invoice Number</Label>
|
||||||
|
<div className="bg-muted/30 flex h-10 items-center rounded-md border px-3">
|
||||||
|
<Hash className="text-muted-foreground mr-2 h-4 w-4" />
|
||||||
|
<span className="font-mono text-sm font-medium">
|
||||||
|
{formData.invoiceNumber}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DatePicker
|
||||||
|
date={formData.issueDate}
|
||||||
|
onDateChange={(date) =>
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
issueDate: date ?? new Date(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
label="Issue Date"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DatePicker
|
||||||
|
date={formData.dueDate}
|
||||||
|
onDateChange={(date) =>
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
dueDate: date ?? new Date(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
label="Due Date"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Business & Client */}
|
||||||
|
<Card className="shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Building className="h-5 w-5 text-emerald-600" />
|
||||||
|
Business & Client
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-sm font-medium">From Business</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Building className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
||||||
|
<Select
|
||||||
|
value={formData.businessId ?? ""}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
businessId: value || undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="pl-9">
|
||||||
|
<SelectValue placeholder="Select business..." />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{businesses?.map((business) => (
|
||||||
|
<SelectItem key={business.id} value={business.id}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span>{business.name}</span>
|
||||||
|
{business.isDefault && (
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
Default
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-sm font-medium">Client *</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<User className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
||||||
|
<Select
|
||||||
|
value={formData.clientId}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
setFormData({ ...formData, clientId: value })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="pl-9">
|
||||||
|
<SelectValue placeholder="Select client..." />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{clients?.map((client) => (
|
||||||
|
<SelectItem key={client.id} value={client.id}>
|
||||||
|
<div className="flex w-full items-center justify-between">
|
||||||
|
<span className="font-medium">{client.name}</span>
|
||||||
|
<span className="text-muted-foreground ml-2 text-sm">
|
||||||
|
{client.email}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Line Items */}
|
||||||
|
<Card className="shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Edit3 className="h-5 w-5 text-emerald-600" />
|
||||||
|
Line Items ({formData.items.length})
|
||||||
|
</CardTitle>
|
||||||
|
<Button
|
||||||
|
onClick={handleAddItem}
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="shrink-0"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4 sm:mr-2" />
|
||||||
|
<span className="hidden sm:inline">Add Item</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{formData.items.map((item, index) => (
|
||||||
|
<InvoiceItemCard
|
||||||
|
key={item.tempId}
|
||||||
|
item={item}
|
||||||
|
index={index}
|
||||||
|
onUpdate={handleItemUpdate}
|
||||||
|
onDelete={handleItemDelete}
|
||||||
|
_isLast={index === formData.items.length - 1}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Notes & Totals */}
|
||||||
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-5">
|
||||||
|
{/* Notes */}
|
||||||
|
<Card className="shadow-lg lg:col-span-3">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<FileText className="h-5 w-5 text-emerald-600" />
|
||||||
|
Notes
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Textarea
|
||||||
|
value={formData.notes}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({ ...formData, notes: e.target.value })
|
||||||
|
}
|
||||||
|
placeholder="Payment terms, additional notes..."
|
||||||
|
rows={4}
|
||||||
|
className="resize-none"
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Tax & Totals */}
|
||||||
|
<Card className="shadow-lg lg:col-span-2">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<DollarSign className="h-5 w-5 text-emerald-600" />
|
||||||
|
Tax & Totals
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-sm font-medium">Tax Rate (%)</Label>
|
||||||
|
<NumberInput
|
||||||
|
value={formData.taxRate}
|
||||||
|
onChange={(value) =>
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
taxRate: value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
step={0.01}
|
||||||
|
placeholder="0.00"
|
||||||
|
suffix="%"
|
||||||
|
className="h-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-muted/20 rounded-lg border p-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">Subtotal:</span>
|
||||||
|
<span className="font-mono font-medium">
|
||||||
|
${calculateSubtotal().toFixed(2)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
Tax ({formData.taxRate}%):
|
||||||
|
</span>
|
||||||
|
<span className="font-mono font-medium">
|
||||||
|
${calculateTax().toFixed(2)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Separator />
|
||||||
|
<div className="flex justify-between text-base font-bold">
|
||||||
|
<span>Total:</span>
|
||||||
|
<span className="font-mono text-emerald-600">
|
||||||
|
${calculateTotal().toFixed(2)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Form Actions - original position */}
|
||||||
|
<div
|
||||||
|
ref={footerRef}
|
||||||
|
className="border-border/40 bg-background/60 flex items-center justify-between rounded-2xl border p-4 shadow-lg backdrop-blur-xl backdrop-saturate-150"
|
||||||
|
>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
Editing invoice {formData.invoiceNumber}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Link href={`/dashboard/invoices/${invoiceId}`}>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
disabled={isLoading}
|
||||||
|
className="border-border/40 hover:bg-accent/50"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<Button
|
||||||
|
onClick={handleSaveDraft}
|
||||||
|
disabled={isLoading || !isFormValid()}
|
||||||
|
variant="outline"
|
||||||
|
className="border-border/40 hover:bg-accent/50"
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Save className="mr-2 h-4 w-4" />
|
||||||
|
)}
|
||||||
|
Save Draft
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleUpdateInvoice}
|
||||||
|
disabled={isLoading || !isFormValid()}
|
||||||
|
className="bg-gradient-to-r from-emerald-600 to-teal-600 shadow-md transition-all duration-200 hover:from-emerald-700 hover:to-teal-700 hover:shadow-lg"
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Send className="mr-2 h-4 w-4" />
|
||||||
|
)}
|
||||||
|
Update Invoice
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FloatingActionBar
|
||||||
|
triggerRef={footerRef}
|
||||||
|
title={`Editing invoice ${formData.invoiceNumber}`}
|
||||||
|
>
|
||||||
|
<Link href={`/dashboard/invoices/${invoiceId}`}>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
disabled={isLoading}
|
||||||
|
className="border-border/40 hover:bg-accent/50"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<Button
|
||||||
|
onClick={handleSaveDraft}
|
||||||
|
disabled={isLoading || !isFormValid()}
|
||||||
|
variant="outline"
|
||||||
|
className="border-border/40 hover:bg-accent/50"
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Save className="mr-2 h-4 w-4" />
|
||||||
|
)}
|
||||||
|
Save Draft
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleUpdateInvoice}
|
||||||
|
disabled={isLoading || !isFormValid()}
|
||||||
|
className="bg-gradient-to-r from-emerald-600 to-teal-600 shadow-md transition-all duration-200 hover:from-emerald-700 hover:to-teal-700 hover:shadow-lg"
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Send className="mr-2 h-4 w-4" />
|
||||||
|
)}
|
||||||
|
Update Invoice
|
||||||
|
</Button>
|
||||||
|
</FloatingActionBar>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EditInvoicePage() {
|
||||||
|
const params = useParams();
|
||||||
|
const invoiceId = Array.isArray(params?.id) ? params.id[0] : params?.id;
|
||||||
|
|
||||||
|
if (!invoiceId) return null;
|
||||||
|
|
||||||
|
return <InvoiceEditor invoiceId={invoiceId} />;
|
||||||
|
}
|
||||||
@@ -1,72 +1,534 @@
|
|||||||
import { api, HydrateClient } from "~/trpc/server";
|
import { Suspense } from "react";
|
||||||
import { InvoiceView } from "~/components/invoice-view";
|
|
||||||
import { InvoiceForm } from "~/components/invoice-form";
|
|
||||||
|
|
||||||
import Link from "next/link";
|
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { Edit, Eye, ArrowLeft } from "lucide-react";
|
import Link from "next/link";
|
||||||
import { UnifiedInvoicePage } from "./_components/unified-invoice-page";
|
import { api, HydrateClient } from "~/trpc/server";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import { Badge } from "~/components/ui/badge";
|
||||||
|
import { StatusBadge, type StatusType } from "~/components/ui/status-badge";
|
||||||
|
import { Separator } from "~/components/ui/separator";
|
||||||
|
import { PageHeader } from "~/components/page-header";
|
||||||
|
import { PDFDownloadButton } from "./_components/pdf-download-button";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "~/components/ui/dropdown-menu";
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
Edit,
|
||||||
|
Send,
|
||||||
|
Copy,
|
||||||
|
MoreHorizontal,
|
||||||
|
CheckCircle,
|
||||||
|
Clock,
|
||||||
|
Calendar,
|
||||||
|
FileText,
|
||||||
|
Building,
|
||||||
|
User,
|
||||||
|
DollarSign,
|
||||||
|
Hash,
|
||||||
|
MapPin,
|
||||||
|
Mail,
|
||||||
|
Phone,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
interface InvoicePageProps {
|
interface InvoicePageProps {
|
||||||
params: Promise<{ id: string }>;
|
params: Promise<{ id: string }>;
|
||||||
searchParams: Promise<{ mode?: string }>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function InvoicePage({
|
function InvoiceStatusBadge({
|
||||||
params,
|
status,
|
||||||
searchParams,
|
dueDate,
|
||||||
}: InvoicePageProps) {
|
}: {
|
||||||
const { id } = await params;
|
status: string;
|
||||||
const { mode = "view" } = await searchParams;
|
dueDate: Date;
|
||||||
|
}) {
|
||||||
|
const getStatus = (): "draft" | "sent" | "paid" | "overdue" => {
|
||||||
|
if (status === "paid") return "paid";
|
||||||
|
if (status === "draft") return "draft";
|
||||||
|
if (status === "sent") {
|
||||||
|
const due = new Date(dueDate);
|
||||||
|
return due < new Date() ? "overdue" : "sent";
|
||||||
|
}
|
||||||
|
return "draft";
|
||||||
|
};
|
||||||
|
|
||||||
|
const actualStatus = getStatus();
|
||||||
|
|
||||||
|
const icons = {
|
||||||
|
draft: FileText,
|
||||||
|
sent: Clock,
|
||||||
|
paid: CheckCircle,
|
||||||
|
overdue: Clock,
|
||||||
|
};
|
||||||
|
|
||||||
|
const Icon = icons[actualStatus];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<StatusBadge status={actualStatus} className="flex items-center gap-1">
|
||||||
<div className="mb-6">
|
<Icon className="h-3 w-3" />
|
||||||
<div className="mb-4 flex items-center justify-between">
|
{actualStatus.charAt(0).toUpperCase() + actualStatus.slice(1)}
|
||||||
<div>
|
</StatusBadge>
|
||||||
<h1 className="bg-gradient-to-r from-emerald-600 to-teal-600 bg-clip-text text-3xl font-bold text-transparent">
|
);
|
||||||
Invoice Details
|
}
|
||||||
</h1>
|
|
||||||
<p className="mt-1 text-lg text-gray-600 dark:text-gray-300">
|
|
||||||
View and manage invoice information.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="relative flex rounded-lg border border-gray-200 bg-gray-100 p-1 dark:border-gray-700 dark:bg-gray-800">
|
async function InvoiceDetails({ invoiceId }: { invoiceId: string }) {
|
||||||
<div
|
const invoice = await api.invoices.getById({ id: invoiceId });
|
||||||
className={`absolute top-1 bottom-1 rounded-md bg-white shadow-sm transition-all duration-300 ease-in-out dark:bg-gray-700 ${
|
|
||||||
mode === "view" ? "left-1 w-10" : "left-11 w-10"
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
<Link
|
|
||||||
href={`/dashboard/invoices/${id}?mode=view`}
|
|
||||||
className={`relative z-10 rounded-md px-3 py-2 transition-all duration-200 ${
|
|
||||||
mode === "view"
|
|
||||||
? "text-emerald-600"
|
|
||||||
: "text-gray-600 hover:bg-gray-50 hover:text-gray-800 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-gray-300"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<Eye className="h-4 w-4" />
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href={`/dashboard/invoices/${id}?mode=edit`}
|
|
||||||
className={`relative z-10 rounded-md px-3 py-2 transition-all duration-200 ${
|
|
||||||
mode === "edit"
|
|
||||||
? "text-emerald-600"
|
|
||||||
: "text-gray-600 hover:bg-gray-50 hover:text-gray-800 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-gray-300"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<Edit className="h-4 w-4" />
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-4">
|
if (!invoice) {
|
||||||
<HydrateClient>
|
notFound();
|
||||||
<UnifiedInvoicePage invoiceId={id} mode={mode} />
|
}
|
||||||
</HydrateClient>
|
|
||||||
</div>
|
const formatDate = (date: Date) => {
|
||||||
|
return new Intl.DateTimeFormat("en-US", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
}).format(new Date(date));
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatCurrency = (amount: number) => {
|
||||||
|
return new Intl.NumberFormat("en-US", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "USD",
|
||||||
|
}).format(amount);
|
||||||
|
};
|
||||||
|
|
||||||
|
const subtotal =
|
||||||
|
invoice.items?.reduce((sum, item) => sum + item.hours * item.rate, 0) || 0;
|
||||||
|
const taxAmount = (subtotal * (invoice.taxRate || 0)) / 100;
|
||||||
|
const total = subtotal + taxAmount;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Invoice Header */}
|
||||||
|
<Card className="border-0 shadow-lg">
|
||||||
|
<CardContent className="p-6">
|
||||||
|
<div className="flex flex-col gap-6 lg:flex-row lg:items-start lg:justify-between">
|
||||||
|
{/* Invoice Info */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="rounded-lg bg-emerald-100 p-3 dark:bg-emerald-900/30">
|
||||||
|
<Hash className="h-6 w-6 text-emerald-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-emerald-700 dark:text-emerald-400">
|
||||||
|
{invoice.invoiceNumber}
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground text-sm">Invoice</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Calendar className="text-muted-foreground h-4 w-4" />
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground text-xs font-medium">
|
||||||
|
Issued
|
||||||
|
</p>
|
||||||
|
<p className="text-sm font-semibold">
|
||||||
|
{formatDate(invoice.issueDate)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Clock className="text-muted-foreground h-4 w-4" />
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground text-xs font-medium">
|
||||||
|
Due
|
||||||
|
</p>
|
||||||
|
<p className="text-sm font-semibold">
|
||||||
|
{formatDate(invoice.dueDate)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<DollarSign className="text-muted-foreground h-4 w-4" />
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground text-xs font-medium">
|
||||||
|
Amount
|
||||||
|
</p>
|
||||||
|
<p className="text-sm font-semibold text-emerald-600">
|
||||||
|
{formatCurrency(total)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<FileText className="text-muted-foreground h-4 w-4" />
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground text-xs font-medium">
|
||||||
|
Status
|
||||||
|
</p>
|
||||||
|
<InvoiceStatusBadge
|
||||||
|
status={invoice.status}
|
||||||
|
dueDate={invoice.dueDate}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex flex-col gap-2 sm:flex-row lg:flex-col">
|
||||||
|
<Link href={`/dashboard/invoices/${invoice.id}/edit`}>
|
||||||
|
<Button className="w-full">
|
||||||
|
<Edit className="mr-2 h-4 w-4" />
|
||||||
|
Edit Invoice
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<PDFDownloadButton invoice={invoice} variant="button" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Business & Client Info */}
|
||||||
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||||
|
{/* From Business */}
|
||||||
|
<Card className="border-0 shadow-md">
|
||||||
|
<CardHeader className="pb-4">
|
||||||
|
<CardTitle className="flex items-center gap-2 text-lg">
|
||||||
|
<Building className="h-4 w-4 text-emerald-600" />
|
||||||
|
From
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
{invoice.business ? (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold">{invoice.business.name}</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{invoice.business.email && (
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<Mail className="text-muted-foreground h-3 w-3" />
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{invoice.business.email}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{invoice.business.phone && (
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<Phone className="text-muted-foreground h-3 w-3" />
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{invoice.business.phone}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{invoice.business.addressLine1 && (
|
||||||
|
<div className="flex items-start gap-2 text-sm">
|
||||||
|
<MapPin className="text-muted-foreground mt-0.5 h-3 w-3" />
|
||||||
|
<div className="text-muted-foreground">
|
||||||
|
<p>{invoice.business.addressLine1}</p>
|
||||||
|
{invoice.business.addressLine2 && (
|
||||||
|
<p>{invoice.business.addressLine2}</p>
|
||||||
|
)}
|
||||||
|
<p>
|
||||||
|
{[
|
||||||
|
invoice.business.city,
|
||||||
|
invoice.business.state,
|
||||||
|
invoice.business.postalCode,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(", ")}
|
||||||
|
</p>
|
||||||
|
{invoice.business.country && (
|
||||||
|
<p>{invoice.business.country}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-muted-foreground text-sm italic">
|
||||||
|
No business information
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* To Client */}
|
||||||
|
<Card className="border-0 shadow-md">
|
||||||
|
<CardHeader className="pb-4">
|
||||||
|
<CardTitle className="flex items-center gap-2 text-lg">
|
||||||
|
<User className="h-4 w-4 text-emerald-600" />
|
||||||
|
Bill To
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold">{invoice.client.name}</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{invoice.client.email && (
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<Mail className="text-muted-foreground h-3 w-3" />
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{invoice.client.email}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{invoice.client.phone && (
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<Phone className="text-muted-foreground h-3 w-3" />
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{invoice.client.phone}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{invoice.client.addressLine1 && (
|
||||||
|
<div className="flex items-start gap-2 text-sm">
|
||||||
|
<MapPin className="text-muted-foreground mt-0.5 h-3 w-3" />
|
||||||
|
<div className="text-muted-foreground">
|
||||||
|
<p>{invoice.client.addressLine1}</p>
|
||||||
|
{invoice.client.addressLine2 && (
|
||||||
|
<p>{invoice.client.addressLine2}</p>
|
||||||
|
)}
|
||||||
|
<p>
|
||||||
|
{[
|
||||||
|
invoice.client.city,
|
||||||
|
invoice.client.state,
|
||||||
|
invoice.client.postalCode,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(", ")}
|
||||||
|
</p>
|
||||||
|
{invoice.client.country && <p>{invoice.client.country}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Line Items */}
|
||||||
|
<Card className="border-0 shadow-lg">
|
||||||
|
<CardHeader className="border-b">
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<FileText className="h-5 w-5 text-emerald-600" />
|
||||||
|
Line Items ({invoice.items?.length || 0})
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
{invoice.items && invoice.items.length > 0 ? (
|
||||||
|
<div className="space-y-0">
|
||||||
|
{/* Header - Hidden on mobile */}
|
||||||
|
<div className="border-muted/30 bg-muted/20 hidden grid-cols-12 gap-4 border-b px-6 py-3 text-sm font-medium md:grid">
|
||||||
|
<div className="col-span-2">Date</div>
|
||||||
|
<div className="col-span-5">Description</div>
|
||||||
|
<div className="col-span-2 text-right">Hours</div>
|
||||||
|
<div className="col-span-2 text-right">Rate</div>
|
||||||
|
<div className="col-span-1 text-right">Amount</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Items */}
|
||||||
|
{invoice.items.map((item, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="border-muted/30 grid grid-cols-1 gap-2 border-b px-6 py-4 last:border-b-0 md:grid-cols-12 md:items-center md:gap-4"
|
||||||
|
>
|
||||||
|
{/* Mobile Layout */}
|
||||||
|
<div className="md:hidden">
|
||||||
|
<div className="mb-2 flex items-start justify-between">
|
||||||
|
<p className="font-medium">{item.description}</p>
|
||||||
|
<span className="font-mono text-sm font-semibold text-emerald-600">
|
||||||
|
{formatCurrency(item.hours * item.rate)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-3 gap-2 text-sm">
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground text-xs">
|
||||||
|
Date
|
||||||
|
</span>
|
||||||
|
<p>{formatDate(item.date)}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground text-xs">
|
||||||
|
Hours
|
||||||
|
</span>
|
||||||
|
<p className="font-mono">{item.hours}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground text-xs">
|
||||||
|
Rate
|
||||||
|
</span>
|
||||||
|
<p className="font-mono">
|
||||||
|
{formatCurrency(item.rate)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Desktop Layout */}
|
||||||
|
<div className="text-muted-foreground col-span-2 hidden text-sm md:block">
|
||||||
|
{formatDate(item.date)}
|
||||||
|
</div>
|
||||||
|
<div className="col-span-5 hidden font-medium md:block">
|
||||||
|
{item.description}
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2 hidden text-right font-mono text-sm md:block">
|
||||||
|
{item.hours}
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2 hidden text-right font-mono text-sm md:block">
|
||||||
|
{formatCurrency(item.rate)}
|
||||||
|
</div>
|
||||||
|
<div className="col-span-1 hidden text-right font-mono font-semibold text-emerald-600 md:block">
|
||||||
|
{formatCurrency(item.hours * item.rate)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-muted-foreground py-12 text-center">
|
||||||
|
<FileText className="mx-auto mb-2 h-8 w-8" />
|
||||||
|
<p>No line items found</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Totals & Notes */}
|
||||||
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||||
|
{/* Notes */}
|
||||||
|
{invoice.notes && (
|
||||||
|
<Card className="border-0 shadow-md lg:col-span-2">
|
||||||
|
<CardHeader className="pb-4">
|
||||||
|
<CardTitle className="text-lg">Notes</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-muted-foreground leading-relaxed">
|
||||||
|
{invoice.notes}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Totals */}
|
||||||
|
<Card
|
||||||
|
className={`border-0 shadow-md ${!invoice.notes ? "lg:col-start-3" : ""}`}
|
||||||
|
>
|
||||||
|
<CardHeader className="pb-4">
|
||||||
|
<CardTitle className="flex items-center gap-2 text-lg">
|
||||||
|
<DollarSign className="h-4 w-4 text-emerald-600" />
|
||||||
|
Total
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">Subtotal:</span>
|
||||||
|
<span className="font-mono">{formatCurrency(subtotal)}</span>
|
||||||
|
</div>
|
||||||
|
{invoice.taxRate > 0 && (
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
Tax ({invoice.taxRate}%):
|
||||||
|
</span>
|
||||||
|
<span className="font-mono">{formatCurrency(taxAmount)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Separator />
|
||||||
|
<div className="flex justify-between text-lg font-bold">
|
||||||
|
<span>Total:</span>
|
||||||
|
<span className="font-mono text-emerald-600">
|
||||||
|
{formatCurrency(total)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status Actions */}
|
||||||
|
<div className="pt-2">
|
||||||
|
{invoice.status === "draft" && (
|
||||||
|
<Button className="w-full bg-gradient-to-r from-emerald-600 to-teal-600 hover:from-emerald-700 hover:to-teal-700">
|
||||||
|
<Send className="mr-2 h-4 w-4" />
|
||||||
|
Send Invoice
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{invoice.status === "sent" && (
|
||||||
|
<Button className="w-full bg-gradient-to-r from-green-600 to-emerald-600 hover:from-green-700 hover:to-emerald-700">
|
||||||
|
<CheckCircle className="mr-2 h-4 w-4" />
|
||||||
|
Mark as Paid
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(invoice.status === "paid" || invoice.status === "overdue") && (
|
||||||
|
<div className="text-center">
|
||||||
|
<InvoiceStatusBadge
|
||||||
|
status={invoice.status}
|
||||||
|
dueDate={invoice.dueDate}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default async function InvoicePage({ params }: InvoicePageProps) {
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<PageHeader
|
||||||
|
title="Invoice Details"
|
||||||
|
description="View and manage invoice information"
|
||||||
|
variant="gradient"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Link href="/dashboard/invoices">
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link href={`/dashboard/invoices/${id}/edit`}>
|
||||||
|
<Edit className="mr-2 h-4 w-4" />
|
||||||
|
Edit Invoice
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem>
|
||||||
|
<Send className="mr-2 h-4 w-4" />
|
||||||
|
Download PDF
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem>
|
||||||
|
<Send className="mr-2 h-4 w-4" />
|
||||||
|
Send Invoice
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem>
|
||||||
|
<Copy className="mr-2 h-4 w-4" />
|
||||||
|
Duplicate
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<HydrateClient>
|
||||||
|
<Suspense fallback={<div>Loading invoice details...</div>}>
|
||||||
|
<InvoiceDetails invoiceId={id} />
|
||||||
|
</Suspense>
|
||||||
|
</HydrateClient>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
247
src/app/dashboard/invoices/_components/invoices-data-table.tsx
Normal file
247
src/app/dashboard/invoices/_components/invoices-data-table.tsx
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import type { ColumnDef } from "@tanstack/react-table";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import { StatusBadge, type StatusType } from "~/components/ui/status-badge";
|
||||||
|
import { PDFDownloadButton } from "~/app/dashboard/invoices/[id]/_components/pdf-download-button";
|
||||||
|
import { DataTable, DataTableColumnHeader } from "~/components/ui/data-table";
|
||||||
|
import { EmptyState } from "~/components/ui/page-layout";
|
||||||
|
import { Plus, FileText, Eye, Edit } from "lucide-react";
|
||||||
|
|
||||||
|
// Type for invoice data
|
||||||
|
interface Invoice {
|
||||||
|
id: string;
|
||||||
|
invoiceNumber: string;
|
||||||
|
clientId: string;
|
||||||
|
businessId: string | null;
|
||||||
|
issueDate: Date;
|
||||||
|
dueDate: Date;
|
||||||
|
status: string;
|
||||||
|
totalAmount: number;
|
||||||
|
taxRate: number;
|
||||||
|
notes: string | null;
|
||||||
|
createdById: string;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date | null;
|
||||||
|
client?: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email: string | null;
|
||||||
|
phone: string | null;
|
||||||
|
} | null;
|
||||||
|
business?: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email: string | null;
|
||||||
|
phone: string | null;
|
||||||
|
} | null;
|
||||||
|
items?: Array<{
|
||||||
|
id: string;
|
||||||
|
invoiceId: string;
|
||||||
|
date: Date;
|
||||||
|
description: string;
|
||||||
|
hours: number;
|
||||||
|
rate: number;
|
||||||
|
amount: number;
|
||||||
|
position: number;
|
||||||
|
createdAt: Date;
|
||||||
|
}> | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InvoicesDataTableProps {
|
||||||
|
invoices: Invoice[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStatusType = (invoice: Invoice): StatusType => {
|
||||||
|
if (invoice.status === "paid") return "paid";
|
||||||
|
if (invoice.status === "draft") return "draft";
|
||||||
|
if (invoice.status === "sent") {
|
||||||
|
const dueDate = new Date(invoice.dueDate);
|
||||||
|
return dueDate < new Date() ? "overdue" : "sent";
|
||||||
|
}
|
||||||
|
return "draft";
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (date: Date) => {
|
||||||
|
return new Intl.DateTimeFormat("en-US", {
|
||||||
|
month: "short",
|
||||||
|
day: "2-digit",
|
||||||
|
year: "numeric",
|
||||||
|
}).format(new Date(date));
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatCurrency = (amount: number) => {
|
||||||
|
return new Intl.NumberFormat("en-US", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "USD",
|
||||||
|
}).format(amount);
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns: ColumnDef<Invoice>[] = [
|
||||||
|
{
|
||||||
|
accessorKey: "invoiceNumber",
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataTableColumnHeader column={column} title="Invoice" />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const invoice = row.original;
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="bg-status-success-muted hidden rounded-lg p-2 sm:flex">
|
||||||
|
<FileText className="text-status-success h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate font-medium">{invoice.invoiceNumber}</p>
|
||||||
|
<p className="text-muted-foreground truncate text-sm">
|
||||||
|
{invoice.items?.length || 0} items
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "client.name",
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataTableColumnHeader column={column} title="Client" />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const invoice = row.original;
|
||||||
|
return (
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate font-medium">{invoice.client?.name || "—"}</p>
|
||||||
|
<p className="text-muted-foreground truncate text-sm">
|
||||||
|
{invoice.client?.email || "—"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "issueDate",
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataTableColumnHeader column={column} title="Issue Date" />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => formatDate(row.getValue("issueDate")),
|
||||||
|
meta: {
|
||||||
|
headerClassName: "hidden md:table-cell",
|
||||||
|
cellClassName: "hidden md:table-cell",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "dueDate",
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataTableColumnHeader column={column} title="Due Date" />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => formatDate(row.getValue("dueDate")),
|
||||||
|
meta: {
|
||||||
|
headerClassName: "hidden lg:table-cell",
|
||||||
|
cellClassName: "hidden lg:table-cell",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "totalAmount",
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataTableColumnHeader column={column} title="Amount" />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const amount = row.getValue("totalAmount") as number;
|
||||||
|
return <p className="font-semibold">{formatCurrency(amount)}</p>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "status",
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataTableColumnHeader column={column} title="Status" />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const invoice = row.original;
|
||||||
|
return <StatusBadge status={getStatusType(invoice)} />;
|
||||||
|
},
|
||||||
|
filterFn: (row, id, value) => {
|
||||||
|
const invoice = row.original;
|
||||||
|
const status = getStatusType(invoice);
|
||||||
|
return value.includes(status);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "actions",
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const invoice = row.original;
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-end gap-1">
|
||||||
|
<Link href={`/dashboard/invoices/${invoice.id}`}>
|
||||||
|
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
|
||||||
|
<Eye className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<Link href={`/dashboard/invoices/${invoice.id}/edit`}>
|
||||||
|
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
|
||||||
|
<Edit className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
{invoice.items && invoice.client && (
|
||||||
|
<PDFDownloadButton
|
||||||
|
invoice={{
|
||||||
|
id: invoice.id,
|
||||||
|
invoiceNumber: invoice.invoiceNumber,
|
||||||
|
issueDate: invoice.issueDate,
|
||||||
|
dueDate: invoice.dueDate,
|
||||||
|
status: invoice.status,
|
||||||
|
totalAmount: invoice.totalAmount,
|
||||||
|
taxRate: invoice.taxRate,
|
||||||
|
notes: invoice.notes,
|
||||||
|
business: invoice.business
|
||||||
|
? {
|
||||||
|
name: invoice.business.name,
|
||||||
|
email: invoice.business.email,
|
||||||
|
phone: invoice.business.phone,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
client: {
|
||||||
|
name: invoice.client.name,
|
||||||
|
email: invoice.client.email,
|
||||||
|
phone: invoice.client.phone,
|
||||||
|
},
|
||||||
|
items: invoice.items.map((item) => ({
|
||||||
|
date: item.date,
|
||||||
|
description: item.description,
|
||||||
|
hours: item.hours,
|
||||||
|
rate: item.rate,
|
||||||
|
amount: item.amount,
|
||||||
|
})),
|
||||||
|
}}
|
||||||
|
variant="icon"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
|
||||||
|
const filterableColumns = [
|
||||||
|
{
|
||||||
|
id: "status",
|
||||||
|
title: "Status",
|
||||||
|
options: [
|
||||||
|
{ label: "Draft", value: "draft" },
|
||||||
|
{ label: "Sent", value: "sent" },
|
||||||
|
{ label: "Paid", value: "paid" },
|
||||||
|
{ label: "Overdue", value: "overdue" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
data={invoices}
|
||||||
|
searchKey="invoiceNumber"
|
||||||
|
searchPlaceholder="Search invoices..."
|
||||||
|
filterableColumns={filterableColumns}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { api } from "~/trpc/react";
|
|
||||||
import { UniversalTable } from "~/components/ui/universal-table";
|
|
||||||
import { TableSkeleton } from "~/components/ui/skeleton";
|
|
||||||
|
|
||||||
export function InvoicesTable() {
|
|
||||||
const { isLoading } = api.invoices.getAll.useQuery();
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return <TableSkeleton rows={8} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return <UniversalTable resource="invoices" />;
|
|
||||||
}
|
|
||||||
@@ -1,20 +1,471 @@
|
|||||||
|
import { Suspense } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
import { HydrateClient } from "~/trpc/server";
|
import { HydrateClient } from "~/trpc/server";
|
||||||
import { CSVImportPage } from "~/components/csv-import-page";
|
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import { Badge } from "~/components/ui/badge";
|
||||||
|
import { Separator } from "~/components/ui/separator";
|
||||||
|
import { PageHeader } from "~/components/page-header";
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
Upload,
|
||||||
|
FileText,
|
||||||
|
Download,
|
||||||
|
CheckCircle,
|
||||||
|
AlertCircle,
|
||||||
|
Info,
|
||||||
|
Zap,
|
||||||
|
FileSpreadsheet,
|
||||||
|
Eye,
|
||||||
|
RefreshCw,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
// Import Statistics Component
|
||||||
|
function ImportStats() {
|
||||||
|
const stats = [
|
||||||
|
{
|
||||||
|
title: "Supported Formats",
|
||||||
|
value: "CSV",
|
||||||
|
icon: FileSpreadsheet,
|
||||||
|
color: "text-blue-600",
|
||||||
|
bgColor: "bg-blue-50 dark:bg-blue-900/20",
|
||||||
|
description: "Excel & Google Sheets exports",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Max File Size",
|
||||||
|
value: "10MB",
|
||||||
|
icon: Upload,
|
||||||
|
color: "text-green-600",
|
||||||
|
bgColor: "bg-green-50 dark:bg-green-900/20",
|
||||||
|
description: "Up to 1000 invoices",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Processing Time",
|
||||||
|
value: "< 1min",
|
||||||
|
icon: Zap,
|
||||||
|
color: "text-purple-600",
|
||||||
|
bgColor: "bg-purple-50 dark:bg-purple-900/20",
|
||||||
|
description: "Average processing speed",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Success Rate",
|
||||||
|
value: "99.9%",
|
||||||
|
icon: CheckCircle,
|
||||||
|
color: "text-emerald-600",
|
||||||
|
bgColor: "bg-emerald-50 dark:bg-emerald-900/20",
|
||||||
|
description: "Import success rate",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||||
|
{stats.map((stat) => {
|
||||||
|
const Icon = stat.icon;
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
key={stat.title}
|
||||||
|
className="border-0 shadow-md transition-shadow hover:shadow-lg"
|
||||||
|
>
|
||||||
|
<CardContent className="p-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-muted-foreground text-sm font-medium">
|
||||||
|
{stat.title}
|
||||||
|
</p>
|
||||||
|
<p className="text-2xl font-bold">{stat.value}</p>
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
{stat.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className={`rounded-full p-3 ${stat.bgColor}`}>
|
||||||
|
<Icon className={`h-6 w-6 ${stat.color}`} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// File Upload Component
|
||||||
|
function FileUploadArea() {
|
||||||
|
return (
|
||||||
|
<Card className="border-0 shadow-lg">
|
||||||
|
<CardHeader className="border-b">
|
||||||
|
<CardTitle className="flex items-center gap-2 text-lg">
|
||||||
|
<Upload className="h-5 w-5 text-emerald-600" />
|
||||||
|
Upload CSV File
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-8">
|
||||||
|
<div className="mx-auto max-w-xl">
|
||||||
|
{/* Drop Zone */}
|
||||||
|
<div className="rounded-lg border-2 border-dashed border-emerald-300 bg-emerald-50/50 p-12 text-center transition-colors hover:border-emerald-400 hover:bg-emerald-50 dark:border-emerald-700 dark:bg-emerald-900/10 dark:hover:bg-emerald-900/20">
|
||||||
|
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-emerald-100 dark:bg-emerald-900/30">
|
||||||
|
<Upload className="h-8 w-8 text-emerald-600" />
|
||||||
|
</div>
|
||||||
|
<h3 className="mb-2 text-lg font-semibold">
|
||||||
|
Drop your CSV file here
|
||||||
|
</h3>
|
||||||
|
<p className="text-muted-foreground mb-4">
|
||||||
|
or click to browse and select a file
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
className="bg-gradient-to-r from-emerald-600 to-teal-600 hover:from-emerald-700 hover:to-teal-700"
|
||||||
|
>
|
||||||
|
<Upload className="mr-2 h-4 w-4" />
|
||||||
|
Choose File
|
||||||
|
</Button>
|
||||||
|
<p className="text-muted-foreground mt-4 text-sm">
|
||||||
|
Maximum file size: 10MB • Supported format: CSV
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Upload Progress (hidden by default) */}
|
||||||
|
<div className="mt-6 hidden">
|
||||||
|
<div className="mb-2 flex items-center justify-between">
|
||||||
|
<span className="text-sm font-medium">Uploading...</span>
|
||||||
|
<span className="text-sm text-emerald-600">75%</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700">
|
||||||
|
<div
|
||||||
|
className="h-full bg-gradient-to-r from-emerald-600 to-teal-600 transition-all duration-300"
|
||||||
|
style={{ width: "75%" }}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// CSV Format Instructions
|
||||||
|
function FormatInstructions() {
|
||||||
|
return (
|
||||||
|
<div className="grid gap-6 lg:grid-cols-2">
|
||||||
|
{/* Required Format */}
|
||||||
|
<Card className="border-0 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-lg">
|
||||||
|
<FileText className="h-5 w-5 text-blue-600" />
|
||||||
|
Required CSV Format
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="rounded-lg bg-gray-50 p-4 dark:bg-gray-800/50">
|
||||||
|
<p className="font-mono text-sm text-gray-700 dark:text-gray-300">
|
||||||
|
client_name,client_email,invoice_number,issue_date,due_date,description,hours,rate,tax_rate
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h4 className="font-semibold">Required Columns:</h4>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
{[
|
||||||
|
{ field: "client_name", desc: "Full name of the client" },
|
||||||
|
{ field: "client_email", desc: "Client email address" },
|
||||||
|
{ field: "invoice_number", desc: "Unique invoice identifier" },
|
||||||
|
{ field: "issue_date", desc: "Date issued (YYYY-MM-DD)" },
|
||||||
|
{ field: "due_date", desc: "Payment due date (YYYY-MM-DD)" },
|
||||||
|
{ field: "description", desc: "Work description" },
|
||||||
|
{ field: "hours", desc: "Number of hours worked" },
|
||||||
|
{ field: "rate", desc: "Hourly rate (decimal)" },
|
||||||
|
].map((col) => (
|
||||||
|
<div key={col.field} className="flex items-start gap-3">
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{col.field}
|
||||||
|
</Badge>
|
||||||
|
<span className="text-muted-foreground text-sm">
|
||||||
|
{col.desc}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-2">
|
||||||
|
<h4 className="mb-2 font-semibold">Optional Columns:</h4>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
tax_rate
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
notes
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
client_phone
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Sample Data & Download */}
|
||||||
|
<Card className="border-0 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-lg">
|
||||||
|
<Download className="h-5 w-5 text-green-600" />
|
||||||
|
Sample Template
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Download our sample CSV template to see the exact format required
|
||||||
|
for importing invoices.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="rounded-lg bg-green-50 p-4 dark:bg-green-900/20">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<Info className="mt-0.5 h-5 w-5 text-green-600" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-green-800 dark:text-green-400">
|
||||||
|
Pro Tip
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-green-700 dark:text-green-300">
|
||||||
|
The template includes sample data and formatting examples to
|
||||||
|
help you get started quickly.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Button variant="outline" className="w-full justify-start">
|
||||||
|
<Download className="mr-2 h-4 w-4" />
|
||||||
|
Download Sample CSV Template
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button variant="outline" className="w-full justify-start">
|
||||||
|
<Eye className="mr-2 h-4 w-4" />
|
||||||
|
View Template in Browser
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h4 className="text-sm font-semibold">Sample Row:</h4>
|
||||||
|
<div className="rounded-lg bg-gray-50 p-3 dark:bg-gray-800/50">
|
||||||
|
<p className="font-mono text-xs break-all text-gray-600 dark:text-gray-400">
|
||||||
|
"Acme
|
||||||
|
Corp","john@acme.com","INV-001","2024-01-15","2024-02-14","Web
|
||||||
|
development work","40","75.00","8.5"
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Important Notes Section
|
||||||
|
function ImportantNotes() {
|
||||||
|
return (
|
||||||
|
<Card className="border-0 border-l-4 border-l-amber-500 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-lg">
|
||||||
|
<AlertCircle className="h-5 w-5 text-amber-600" />
|
||||||
|
Important Notes
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<h4 className="mb-2 font-semibold">Before Importing:</h4>
|
||||||
|
<ul className="text-muted-foreground space-y-1 text-sm">
|
||||||
|
<li>• Ensure all client emails are valid</li>
|
||||||
|
<li>• Use YYYY-MM-DD format for dates</li>
|
||||||
|
<li>• Invoice numbers must be unique</li>
|
||||||
|
<li>• Rates should be in decimal format (e.g., 75.50)</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="mb-2 font-semibold">What Happens:</h4>
|
||||||
|
<ul className="text-muted-foreground space-y-1 text-sm">
|
||||||
|
<li>• New clients will be created automatically</li>
|
||||||
|
<li>• Existing clients will be matched by email</li>
|
||||||
|
<li>• Invoices will be created in "draft" status</li>
|
||||||
|
<li>• You can review and edit before sending</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Import History Component
|
||||||
|
function ImportHistory() {
|
||||||
|
const mockHistory = [
|
||||||
|
{
|
||||||
|
id: "1",
|
||||||
|
filename: "january_invoices.csv",
|
||||||
|
date: "2024-01-15",
|
||||||
|
status: "completed",
|
||||||
|
imported: 25,
|
||||||
|
errors: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "2",
|
||||||
|
filename: "december_invoices.csv",
|
||||||
|
date: "2024-01-01",
|
||||||
|
status: "completed",
|
||||||
|
imported: 18,
|
||||||
|
errors: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "3",
|
||||||
|
filename: "november_invoices.csv",
|
||||||
|
date: "2023-12-01",
|
||||||
|
status: "completed",
|
||||||
|
imported: 32,
|
||||||
|
errors: 1,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const getStatusBadge = (status: string) => {
|
||||||
|
if (status === "completed") {
|
||||||
|
return (
|
||||||
|
<Badge className="bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400">
|
||||||
|
<CheckCircle className="mr-1 h-3 w-3" />
|
||||||
|
Completed
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (status === "processing") {
|
||||||
|
return (
|
||||||
|
<Badge className="bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400">
|
||||||
|
<RefreshCw className="mr-1 h-3 w-3" />
|
||||||
|
Processing
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Badge variant="outline">
|
||||||
|
<AlertCircle className="mr-1 h-3 w-3" />
|
||||||
|
Failed
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="border-0 shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-lg">
|
||||||
|
<FileText className="h-5 w-5 text-purple-600" />
|
||||||
|
Recent Imports
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="bg-muted/50">
|
||||||
|
<tr className="border-b">
|
||||||
|
<th className="p-4 text-left text-sm font-medium">File</th>
|
||||||
|
<th className="p-4 text-left text-sm font-medium">Date</th>
|
||||||
|
<th className="p-4 text-left text-sm font-medium">Status</th>
|
||||||
|
<th className="p-4 text-right text-sm font-medium">Imported</th>
|
||||||
|
<th className="p-4 text-right text-sm font-medium">Errors</th>
|
||||||
|
<th className="p-4 text-center text-sm font-medium">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{mockHistory.map((item) => (
|
||||||
|
<tr
|
||||||
|
key={item.id}
|
||||||
|
className="hover:bg-muted/20 border-b transition-colors"
|
||||||
|
>
|
||||||
|
<td className="p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="rounded-lg bg-purple-100 p-2 dark:bg-purple-900/30">
|
||||||
|
<FileSpreadsheet className="h-4 w-4 text-purple-600" />
|
||||||
|
</div>
|
||||||
|
<span className="font-medium">{item.filename}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="p-4 text-sm">
|
||||||
|
{new Date(item.date).toLocaleDateString()}
|
||||||
|
</td>
|
||||||
|
<td className="p-4">{getStatusBadge(item.status)}</td>
|
||||||
|
<td className="p-4 text-right font-medium">
|
||||||
|
{item.imported}
|
||||||
|
</td>
|
||||||
|
<td className="p-4 text-right">
|
||||||
|
{item.errors > 0 ? (
|
||||||
|
<span className="text-red-600">{item.errors}</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">0</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="p-4 text-center">
|
||||||
|
<Button variant="ghost" size="sm">
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{mockHistory.length === 0 && (
|
||||||
|
<div className="py-8 text-center">
|
||||||
|
<p className="text-muted-foreground">No import history yet</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default async function ImportPage() {
|
export default async function ImportPage() {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="space-y-8">
|
||||||
<div className="mb-8">
|
<PageHeader
|
||||||
<h1 className="bg-gradient-to-r from-emerald-600 to-teal-600 bg-clip-text text-3xl font-bold text-transparent">
|
title="Import Invoices"
|
||||||
Import Invoices
|
description="Upload CSV files to create invoices in batch"
|
||||||
</h1>
|
variant="gradient"
|
||||||
<p className="mt-1 text-lg text-gray-600">
|
>
|
||||||
Upload CSV files to create invoices in batch.
|
<Link href="/dashboard/invoices">
|
||||||
</p>
|
<Button variant="outline" size="lg">
|
||||||
</div>
|
<ArrowLeft className="mr-2 h-5 w-5" />
|
||||||
|
Back to Invoices
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
<HydrateClient>
|
<HydrateClient>
|
||||||
<CSVImportPage />
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||||
|
{[...Array(4)].map((_, i) => (
|
||||||
|
<Card key={i} className="border-0 shadow-md">
|
||||||
|
<CardContent className="p-6">
|
||||||
|
<div className="animate-pulse">
|
||||||
|
<div className="bg-muted mb-2 h-4 w-1/2 rounded"></div>
|
||||||
|
<div className="bg-muted mb-2 h-8 w-3/4 rounded"></div>
|
||||||
|
<div className="bg-muted h-3 w-1/3 rounded"></div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ImportStats />
|
||||||
|
</Suspense>
|
||||||
|
|
||||||
|
<FileUploadArea />
|
||||||
|
|
||||||
|
<FormatInstructions />
|
||||||
|
|
||||||
|
<ImportantNotes />
|
||||||
|
|
||||||
|
<ImportHistory />
|
||||||
</HydrateClient>
|
</HydrateClient>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,20 +1,740 @@
|
|||||||
import { HydrateClient } from "~/trpc/server";
|
"use client";
|
||||||
import { InvoiceForm } from "~/components/invoice-form";
|
|
||||||
|
import React, { useState, useEffect, useRef } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { api } from "~/trpc/react";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||||
|
import { Input } from "~/components/ui/input";
|
||||||
|
import { NumberInput } from "~/components/ui/number-input";
|
||||||
|
import { Label } from "~/components/ui/label";
|
||||||
|
import { Textarea } from "~/components/ui/textarea";
|
||||||
|
import { PageHeader } from "~/components/page-header";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "~/components/ui/select";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
} from "~/components/ui/alert-dialog";
|
||||||
|
import { Badge } from "~/components/ui/badge";
|
||||||
|
import { Separator } from "~/components/ui/separator";
|
||||||
|
import { DatePicker } from "~/components/ui/date-picker";
|
||||||
|
import { FloatingActionBar } from "~/components/ui/floating-action-bar";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
Save,
|
||||||
|
Plus,
|
||||||
|
Trash2,
|
||||||
|
FileText,
|
||||||
|
Building,
|
||||||
|
User,
|
||||||
|
Loader2,
|
||||||
|
Send,
|
||||||
|
DollarSign,
|
||||||
|
Hash,
|
||||||
|
Edit3,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
interface InvoiceItem {
|
||||||
|
tempId: string;
|
||||||
|
date: Date;
|
||||||
|
description: string;
|
||||||
|
hours: number;
|
||||||
|
rate: number;
|
||||||
|
amount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InvoiceFormData {
|
||||||
|
invoiceNumber: string;
|
||||||
|
businessId: string | undefined;
|
||||||
|
clientId: string;
|
||||||
|
issueDate: Date;
|
||||||
|
dueDate: Date;
|
||||||
|
notes: string;
|
||||||
|
taxRate: number;
|
||||||
|
items: InvoiceItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function InvoiceItemCard({
|
||||||
|
item,
|
||||||
|
index,
|
||||||
|
onUpdate,
|
||||||
|
onDelete,
|
||||||
|
_isLast,
|
||||||
|
}: {
|
||||||
|
item: InvoiceItem;
|
||||||
|
index: number;
|
||||||
|
onUpdate: (
|
||||||
|
index: number,
|
||||||
|
field: keyof InvoiceItem,
|
||||||
|
value: string | number | Date,
|
||||||
|
) => void;
|
||||||
|
onDelete: (index: number) => void;
|
||||||
|
_isLast: boolean;
|
||||||
|
}) {
|
||||||
|
const handleFieldChange = (
|
||||||
|
field: keyof InvoiceItem,
|
||||||
|
value: string | number | Date,
|
||||||
|
) => {
|
||||||
|
onUpdate(index, field, value);
|
||||||
|
};
|
||||||
|
|
||||||
export default async function NewInvoicePage() {
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<Card className="border-border/50 border p-3 shadow-sm">
|
||||||
<div className="mb-8">
|
<div className="space-y-3">
|
||||||
<h1 className="bg-gradient-to-r from-emerald-600 to-teal-600 bg-clip-text text-3xl font-bold text-transparent">
|
{/* Header with item number and delete */}
|
||||||
Create Invoice
|
<div className="flex items-center justify-between">
|
||||||
</h1>
|
<span className="text-muted-foreground text-xs font-medium">
|
||||||
<p className="mt-1 text-lg text-gray-600">
|
Item {index + 1}
|
||||||
Fill out the details below to create a new invoice.
|
</span>
|
||||||
</p>
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 w-6 p-0 text-red-500 hover:text-red-700"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Delete Item</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
Are you sure you want to delete this line item? This action
|
||||||
|
cannot be undone.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={() => onDelete(index)}
|
||||||
|
className="bg-red-600 hover:bg-red-700"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<Textarea
|
||||||
|
value={item.description}
|
||||||
|
onChange={(e) => handleFieldChange("description", e.target.value)}
|
||||||
|
placeholder="Description of work..."
|
||||||
|
className="min-h-[48px] resize-none text-sm"
|
||||||
|
rows={1}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Date, Hours, Rate, Amount in compact grid */}
|
||||||
|
<div className="grid grid-cols-2 gap-2 text-sm sm:grid-cols-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs font-medium">Date</Label>
|
||||||
|
<DatePicker
|
||||||
|
date={item.date}
|
||||||
|
onDateChange={(date) =>
|
||||||
|
handleFieldChange("date", date ?? new Date())
|
||||||
|
}
|
||||||
|
className="[&>button]:h-8 [&>button]:text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs font-medium">Hours</Label>
|
||||||
|
<NumberInput
|
||||||
|
value={item.hours}
|
||||||
|
onChange={(value) => handleFieldChange("hours", value)}
|
||||||
|
min={0}
|
||||||
|
step={0.25}
|
||||||
|
placeholder="0"
|
||||||
|
className="text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs font-medium">Rate</Label>
|
||||||
|
<NumberInput
|
||||||
|
value={item.rate}
|
||||||
|
onChange={(value) => handleFieldChange("rate", value)}
|
||||||
|
min={0}
|
||||||
|
step={0.25}
|
||||||
|
placeholder="0.00"
|
||||||
|
prefix="$"
|
||||||
|
className="text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs font-medium">Amount</Label>
|
||||||
|
<div className="bg-muted/30 flex h-8 items-center rounded-md border px-2">
|
||||||
|
<span className="font-mono text-xs font-medium text-emerald-600">
|
||||||
|
${(item.hours * item.rate).toFixed(2)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<HydrateClient>
|
</Card>
|
||||||
<InvoiceForm />
|
);
|
||||||
</HydrateClient>
|
}
|
||||||
|
|
||||||
|
export default function NewInvoicePage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
// Initialize form data with defaults
|
||||||
|
const today = new Date();
|
||||||
|
const thirtyDaysFromNow = new Date(today);
|
||||||
|
thirtyDaysFromNow.setDate(today.getDate() + 30);
|
||||||
|
|
||||||
|
// Auto-generate invoice number
|
||||||
|
const generateInvoiceNumber = () => {
|
||||||
|
const date = new Date();
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||||
|
const timestamp = Date.now().toString().slice(-4);
|
||||||
|
return `INV-${year}${month}-${timestamp}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const [formData, setFormData] = useState<InvoiceFormData>({
|
||||||
|
invoiceNumber: generateInvoiceNumber(),
|
||||||
|
businessId: undefined,
|
||||||
|
clientId: "",
|
||||||
|
issueDate: today,
|
||||||
|
dueDate: thirtyDaysFromNow,
|
||||||
|
notes: "",
|
||||||
|
taxRate: 0,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
tempId: `item-${Date.now()}`,
|
||||||
|
date: today,
|
||||||
|
description: "",
|
||||||
|
hours: 0,
|
||||||
|
rate: 0,
|
||||||
|
amount: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Floating action bar ref
|
||||||
|
const footerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Queries
|
||||||
|
const { data: clients, isLoading: clientsLoading } =
|
||||||
|
api.clients.getAll.useQuery();
|
||||||
|
const { data: businesses, isLoading: businessesLoading } =
|
||||||
|
api.businesses.getAll.useQuery();
|
||||||
|
|
||||||
|
// Set default business when data loads
|
||||||
|
useEffect(() => {
|
||||||
|
if (businesses && !formData.businessId) {
|
||||||
|
const defaultBusiness = businesses.find((b) => b.isDefault);
|
||||||
|
if (defaultBusiness) {
|
||||||
|
setFormData((prev) => ({ ...prev, businessId: defaultBusiness.id }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [businesses, formData.businessId]);
|
||||||
|
|
||||||
|
// Mutations
|
||||||
|
const createInvoice = api.invoices.create.useMutation({
|
||||||
|
onSuccess: (invoice) => {
|
||||||
|
toast.success("Invoice created successfully");
|
||||||
|
router.push(`/dashboard/invoices/${invoice.id}`);
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(error.message || "Failed to create invoice");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleItemUpdate = (
|
||||||
|
index: number,
|
||||||
|
field: keyof InvoiceItem,
|
||||||
|
value: string | number | Date,
|
||||||
|
) => {
|
||||||
|
const updatedItems = [...formData.items];
|
||||||
|
const currentItem = updatedItems[index];
|
||||||
|
if (currentItem) {
|
||||||
|
updatedItems[index] = { ...currentItem, [field]: value };
|
||||||
|
|
||||||
|
// Recalculate amount for hours or rate changes
|
||||||
|
if (field === "hours" || field === "rate") {
|
||||||
|
const updatedItem = updatedItems[index];
|
||||||
|
if (!updatedItem) return;
|
||||||
|
updatedItem.amount = updatedItem.hours * updatedItem.rate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setFormData({ ...formData, items: updatedItems });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleItemDelete = (index: number) => {
|
||||||
|
if (formData.items.length === 1) {
|
||||||
|
toast.error("At least one line item is required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedItems = formData.items.filter((_, i) => i !== index);
|
||||||
|
setFormData({ ...formData, items: updatedItems });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddItem = () => {
|
||||||
|
const newItem: InvoiceItem = {
|
||||||
|
tempId: `item-${Date.now()}`,
|
||||||
|
date: new Date(),
|
||||||
|
description: "",
|
||||||
|
hours: 0,
|
||||||
|
rate: 0,
|
||||||
|
amount: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
items: [...formData.items, newItem],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveDraft = async () => {
|
||||||
|
await handleSave("draft");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateInvoice = async () => {
|
||||||
|
await handleSave("sent");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async (status: "draft" | "sent") => {
|
||||||
|
// Validation
|
||||||
|
if (!formData.clientId) {
|
||||||
|
toast.error("Please select a client");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (formData.items.length === 0) {
|
||||||
|
toast.error("At least one line item is required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if all items have required fields
|
||||||
|
const invalidItems = formData.items.some(
|
||||||
|
(item) => !item.description.trim() || item.hours <= 0 || item.rate <= 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (invalidItems) {
|
||||||
|
toast.error("All line items must have description, hours, and rate");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
await createInvoice.mutateAsync({
|
||||||
|
...formData,
|
||||||
|
businessId: formData.businessId ?? undefined,
|
||||||
|
status,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const calculateSubtotal = () => {
|
||||||
|
return formData.items.reduce((sum, item) => sum + item.amount, 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const calculateTax = () => {
|
||||||
|
return (calculateSubtotal() * formData.taxRate) / 100;
|
||||||
|
};
|
||||||
|
|
||||||
|
const calculateTotal = () => {
|
||||||
|
return calculateSubtotal() + calculateTax();
|
||||||
|
};
|
||||||
|
|
||||||
|
const isFormValid = () => {
|
||||||
|
return (
|
||||||
|
formData.clientId &&
|
||||||
|
formData.items.length > 0 &&
|
||||||
|
formData.items.every(
|
||||||
|
(item) => item.description.trim() && item.hours > 0 && item.rate > 0,
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (clientsLoading || businessesLoading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<PageHeader
|
||||||
|
title="Create Invoice"
|
||||||
|
description="Loading form data..."
|
||||||
|
variant="gradient"
|
||||||
|
/>
|
||||||
|
<Card className="shadow-xl">
|
||||||
|
<CardContent className="flex items-center justify-center p-8">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-emerald-600" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<PageHeader
|
||||||
|
title="Create Invoice"
|
||||||
|
description="Fill out the details below to create a new invoice"
|
||||||
|
variant="gradient"
|
||||||
|
>
|
||||||
|
<Link href="/dashboard/invoices">
|
||||||
|
<Button variant="outline" size="sm" className="w-full sm:w-auto">
|
||||||
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
|
<span className="hidden sm:inline">Back to Invoices</span>
|
||||||
|
<span className="sm:hidden">Back</span>
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Invoice Header */}
|
||||||
|
<Card className="shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<FileText className="h-5 w-5 text-emerald-600" />
|
||||||
|
Invoice Details
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-sm font-medium">Invoice Number</Label>
|
||||||
|
<div className="bg-muted/30 flex h-10 items-center rounded-md border px-3">
|
||||||
|
<Hash className="text-muted-foreground mr-2 h-4 w-4" />
|
||||||
|
<span className="font-mono text-sm font-medium">
|
||||||
|
{formData.invoiceNumber}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DatePicker
|
||||||
|
date={formData.issueDate}
|
||||||
|
onDateChange={(date) =>
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
issueDate: date ?? new Date(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
label="Issue Date"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DatePicker
|
||||||
|
date={formData.dueDate}
|
||||||
|
onDateChange={(date) =>
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
dueDate: date ?? new Date(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
label="Due Date"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Business & Client */}
|
||||||
|
<Card className="shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Building className="h-5 w-5 text-emerald-600" />
|
||||||
|
Business & Client
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-sm font-medium">From Business</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Building className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
||||||
|
<Select
|
||||||
|
value={formData.businessId ?? ""}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
businessId: value || undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="pl-9">
|
||||||
|
<SelectValue placeholder="Select business..." />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{businesses?.map((business) => (
|
||||||
|
<SelectItem key={business.id} value={business.id}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span>{business.name}</span>
|
||||||
|
{business.isDefault && (
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
Default
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
{(!businesses || businesses.length === 0) && (
|
||||||
|
<p className="text-sm text-red-600">
|
||||||
|
No businesses found.{" "}
|
||||||
|
<Link
|
||||||
|
href="/dashboard/businesses/new"
|
||||||
|
className="underline hover:text-red-700"
|
||||||
|
>
|
||||||
|
Create one first
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-sm font-medium">Client *</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<User className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
||||||
|
<Select
|
||||||
|
value={formData.clientId}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
setFormData({ ...formData, clientId: value })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="pl-9">
|
||||||
|
<SelectValue placeholder="Select client..." />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{clients?.map((client) => (
|
||||||
|
<SelectItem key={client.id} value={client.id}>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">{client.name}</div>
|
||||||
|
<div className="text-muted-foreground text-sm">
|
||||||
|
{client.email}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
{(!clients || clients.length === 0) && (
|
||||||
|
<p className="text-sm text-red-600">
|
||||||
|
No clients found.{" "}
|
||||||
|
<Link
|
||||||
|
href="/dashboard/clients/new"
|
||||||
|
className="underline hover:text-red-700"
|
||||||
|
>
|
||||||
|
Create one first
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Line Items */}
|
||||||
|
<Card className="shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Edit3 className="h-5 w-5 text-emerald-600" />
|
||||||
|
Line Items ({formData.items.length})
|
||||||
|
</CardTitle>
|
||||||
|
<Button
|
||||||
|
onClick={handleAddItem}
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="shrink-0"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4 sm:mr-2" />
|
||||||
|
<span className="hidden sm:inline">Add Item</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{formData.items.map((item, index) => (
|
||||||
|
<InvoiceItemCard
|
||||||
|
key={item.tempId}
|
||||||
|
item={item}
|
||||||
|
index={index}
|
||||||
|
onUpdate={handleItemUpdate}
|
||||||
|
onDelete={handleItemDelete}
|
||||||
|
_isLast={index === formData.items.length - 1}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Tax & Totals */}
|
||||||
|
<Card className="shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<DollarSign className="h-5 w-5 text-emerald-600" />
|
||||||
|
Tax & Totals
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-sm font-medium">Tax Rate (%)</Label>
|
||||||
|
<NumberInput
|
||||||
|
value={formData.taxRate}
|
||||||
|
onChange={(value) =>
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
taxRate: value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
step={0.01}
|
||||||
|
placeholder="0.00"
|
||||||
|
suffix="%"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-sm font-medium">Notes</Label>
|
||||||
|
<Textarea
|
||||||
|
value={formData.notes}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({ ...formData, notes: e.target.value })
|
||||||
|
}
|
||||||
|
placeholder="Payment terms, additional notes..."
|
||||||
|
rows={4}
|
||||||
|
className="resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="bg-muted/20 rounded-lg border p-4">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">Subtotal:</span>
|
||||||
|
<span className="font-mono font-medium">
|
||||||
|
${calculateSubtotal().toFixed(2)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
Tax ({formData.taxRate}%):
|
||||||
|
</span>
|
||||||
|
<span className="font-mono font-medium">
|
||||||
|
${calculateTax().toFixed(2)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Separator />
|
||||||
|
<div className="flex justify-between text-lg font-bold">
|
||||||
|
<span>Total:</span>
|
||||||
|
<span className="font-mono text-emerald-600">
|
||||||
|
${calculateTotal().toFixed(2)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div
|
||||||
|
ref={footerRef}
|
||||||
|
className="flex flex-col gap-3 border-t pt-6 sm:flex-row sm:justify-between"
|
||||||
|
>
|
||||||
|
<Link href="/dashboard/invoices">
|
||||||
|
<Button variant="outline" className="w-full sm:w-auto">
|
||||||
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||||
|
<Button
|
||||||
|
onClick={handleSaveDraft}
|
||||||
|
disabled={isLoading || !isFormValid()}
|
||||||
|
variant="outline"
|
||||||
|
className="w-full sm:w-auto"
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Save className="mr-2 h-4 w-4" />
|
||||||
|
)}
|
||||||
|
Save Draft
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleCreateInvoice}
|
||||||
|
disabled={isLoading || !isFormValid()}
|
||||||
|
className="w-full bg-gradient-to-r from-emerald-600 to-teal-600 hover:from-emerald-700 hover:to-teal-700 sm:w-auto"
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Send className="mr-2 h-4 w-4" />
|
||||||
|
)}
|
||||||
|
Create Invoice
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FloatingActionBar triggerRef={footerRef} title="Creating a new invoice">
|
||||||
|
<Link href="/dashboard/invoices">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
disabled={isLoading}
|
||||||
|
className="border-border/40 hover:bg-accent/50"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<Button
|
||||||
|
onClick={handleSaveDraft}
|
||||||
|
disabled={isLoading || !isFormValid()}
|
||||||
|
variant="outline"
|
||||||
|
className="border-border/40 hover:bg-accent/50"
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Save className="mr-2 h-4 w-4" />
|
||||||
|
)}
|
||||||
|
Save Draft
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleCreateInvoice}
|
||||||
|
disabled={isLoading || !isFormValid()}
|
||||||
|
className="bg-gradient-to-r from-emerald-600 to-teal-600 shadow-md transition-all duration-200 hover:from-emerald-700 hover:to-teal-700 hover:shadow-lg"
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Send className="mr-2 h-4 w-4" />
|
||||||
|
)}
|
||||||
|
Create Invoice
|
||||||
|
</Button>
|
||||||
|
</FloatingActionBar>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,47 +1,49 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { Suspense } from "react";
|
||||||
import { api, HydrateClient } from "~/trpc/server";
|
import { api, HydrateClient } from "~/trpc/server";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
|
import { PageHeader } from "~/components/page-header";
|
||||||
import { Plus, Upload } from "lucide-react";
|
import { Plus, Upload } from "lucide-react";
|
||||||
import { InvoicesTable } from "./_components/invoices-table";
|
import { InvoicesDataTable } from "./_components/invoices-data-table";
|
||||||
|
import { DataTableSkeleton } from "~/components/ui/data-table";
|
||||||
|
|
||||||
|
// Invoices Table Component
|
||||||
|
async function InvoicesTable() {
|
||||||
|
const invoices = await api.invoices.getAll();
|
||||||
|
|
||||||
|
return <InvoicesDataTable invoices={invoices} />;
|
||||||
|
}
|
||||||
|
|
||||||
export default async function InvoicesPage() {
|
export default async function InvoicesPage() {
|
||||||
return (
|
return (
|
||||||
<div>
|
<>
|
||||||
<div className="mb-8 flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
<PageHeader
|
||||||
<div>
|
title="Invoices"
|
||||||
<h1 className="bg-gradient-to-r from-emerald-600 to-teal-600 bg-clip-text text-3xl font-bold text-transparent">
|
description="Manage your invoices and track payments"
|
||||||
Invoices
|
variant="gradient"
|
||||||
</h1>
|
>
|
||||||
<p className="mt-1 text-lg text-gray-600">
|
<Button asChild variant="outline" className="shadow-sm">
|
||||||
Manage your invoices and payments.
|
<Link href="/dashboard/invoices/import">
|
||||||
</p>
|
<Upload className="mr-2 h-5 w-5" />
|
||||||
</div>
|
<span>Import CSV</span>
|
||||||
<div className="flex gap-3">
|
</Link>
|
||||||
<Button
|
</Button>
|
||||||
asChild
|
<Button
|
||||||
variant="outline"
|
asChild
|
||||||
size="lg"
|
className="bg-gradient-to-r from-emerald-600 to-teal-600 shadow-md transition-all duration-200 hover:from-emerald-700 hover:to-teal-700 hover:shadow-lg"
|
||||||
className="border-gray-200 bg-white/80 font-medium text-gray-700 shadow-lg hover:bg-gray-50 hover:shadow-xl"
|
>
|
||||||
>
|
<Link href="/dashboard/invoices/new">
|
||||||
<Link href="/dashboard/invoices/import">
|
<Plus className="mr-2 h-5 w-5" />
|
||||||
<Upload className="mr-2 h-5 w-5" /> Import CSV
|
<span>Create Invoice</span>
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
</PageHeader>
|
||||||
asChild
|
|
||||||
size="lg"
|
|
||||||
className="bg-gradient-to-r from-emerald-600 to-teal-600 font-medium text-white shadow-lg hover:from-emerald-700 hover:to-teal-700 hover:shadow-xl"
|
|
||||||
>
|
|
||||||
<Link href="/dashboard/invoices/new">
|
|
||||||
<Plus className="mr-2 h-5 w-5" /> Add Invoice
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<HydrateClient>
|
<HydrateClient>
|
||||||
<InvoicesTable />
|
<Suspense fallback={<DataTableSkeleton columns={7} rows={5} />}>
|
||||||
|
<InvoicesTable />
|
||||||
|
</Suspense>
|
||||||
</HydrateClient>
|
</HydrateClient>
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,25 +2,29 @@ import { Navbar } from "~/components/Navbar";
|
|||||||
import { Sidebar } from "~/components/Sidebar";
|
import { Sidebar } from "~/components/Sidebar";
|
||||||
import { DashboardBreadcrumbs } from "~/components/dashboard-breadcrumbs";
|
import { DashboardBreadcrumbs } from "~/components/dashboard-breadcrumbs";
|
||||||
|
|
||||||
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
|
export default function DashboardLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Navbar />
|
<Navbar />
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
{/* Mobile layout - no left margin */}
|
{/* Mobile layout - no left margin */}
|
||||||
<main className="min-h-screen pt-24 md:hidden">
|
<main className="min-h-screen pt-20 md:hidden">
|
||||||
<div className="px-4 sm:px-6 pt-4 pb-6">
|
<div className="px-4 pt-4 pb-6 sm:px-6">
|
||||||
<DashboardBreadcrumbs />
|
<DashboardBreadcrumbs />
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
{/* Desktop layout - with sidebar margin */}
|
{/* Desktop layout - with sidebar margin */}
|
||||||
<main className="min-h-screen pt-24 hidden md:block ml-70">
|
<main className="hidden min-h-screen pt-20 md:ml-[276px] md:block">
|
||||||
<div className="px-8 pt-6 pb-6">
|
<div className="px-6 pt-6 pb-6">
|
||||||
<DashboardBreadcrumbs />
|
<DashboardBreadcrumbs />
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,31 +1,36 @@
|
|||||||
import { auth } from "~/server/auth";
|
import { auth } from "~/server/auth";
|
||||||
import { api, HydrateClient } from "~/trpc/server";
|
import { HydrateClient } from "~/trpc/server";
|
||||||
import {
|
import {
|
||||||
DashboardStats,
|
DashboardStats,
|
||||||
DashboardCards,
|
DashboardCards,
|
||||||
DashboardActivity,
|
DashboardActivity,
|
||||||
} from "./_components/dashboard-components";
|
} from "./_components/dashboard-components";
|
||||||
|
import { DashboardPageHeader } from "~/components/page-header";
|
||||||
|
import { PageContent, PageSection } from "~/components/ui/page-layout";
|
||||||
|
|
||||||
export default async function DashboardPage() {
|
export default async function DashboardPage() {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<PageContent>
|
||||||
{/* Header */}
|
<DashboardPageHeader
|
||||||
<div className="mb-8">
|
title={`Welcome back, ${session?.user?.name?.split(" ")[0] ?? "User"}!`}
|
||||||
<h1 className="bg-gradient-to-r from-emerald-600 to-teal-600 bg-clip-text text-4xl font-bold text-transparent">
|
description="Here's what's happening with your invoicing business"
|
||||||
Welcome back, {session?.user?.name?.split(" ")[0] ?? "User"}!
|
/>
|
||||||
</h1>
|
|
||||||
<p className="mt-2 text-lg text-gray-600 dark:text-gray-300">
|
|
||||||
Here's what's happening with your invoicing business
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<HydrateClient>
|
<HydrateClient>
|
||||||
<DashboardStats />
|
<PageSection>
|
||||||
<DashboardCards />
|
<DashboardStats />
|
||||||
<DashboardActivity />
|
</PageSection>
|
||||||
|
|
||||||
|
<PageSection>
|
||||||
|
<DashboardCards />
|
||||||
|
</PageSection>
|
||||||
|
|
||||||
|
<PageSection>
|
||||||
|
<DashboardActivity />
|
||||||
|
</PageSection>
|
||||||
</HydrateClient>
|
</HydrateClient>
|
||||||
</div>
|
</PageContent>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ import {
|
|||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from "~/components/ui/dialog";
|
} from "~/components/ui/dialog";
|
||||||
import { Textarea } from "~/components/ui/textarea";
|
import { Textarea } from "~/components/ui/textarea";
|
||||||
|
import { PageHeader } from "~/components/page-header";
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
@@ -230,34 +231,26 @@ export default function SettingsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
{/* Header */}
|
<PageHeader
|
||||||
<div>
|
title="Settings"
|
||||||
<h1 className="bg-gradient-to-r from-emerald-600 to-teal-600 bg-clip-text text-4xl font-bold text-transparent dark:from-emerald-400 dark:to-teal-400">
|
description="Manage your account and data preferences"
|
||||||
Settings
|
variant="large-gradient"
|
||||||
</h1>
|
/>
|
||||||
<p className="mt-2 text-lg text-gray-600 dark:text-gray-300">
|
|
||||||
Manage your account and data preferences
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-8 lg:grid-cols-2">
|
<div className="grid gap-8 lg:grid-cols-2">
|
||||||
{/* Profile Section */}
|
{/* Profile Section */}
|
||||||
<Card className="dark:border-gray-700 dark:bg-gray-800/80">
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2 dark:text-white">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<User className="h-5 w-5 dark:text-emerald-400" />
|
<User className="h-5 w-5 text-emerald-600" />
|
||||||
Profile
|
Profile
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription className="dark:text-gray-300">
|
<CardDescription>Update your personal information</CardDescription>
|
||||||
Update your personal information
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<form onSubmit={handleUpdateProfile} className="space-y-4">
|
<form onSubmit={handleUpdateProfile} className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="name" className="dark:text-gray-300">
|
<Label htmlFor="name">Name</Label>
|
||||||
Name
|
|
||||||
</Label>
|
|
||||||
<Input
|
<Input
|
||||||
id="name"
|
id="name"
|
||||||
value={name}
|
value={name}
|
||||||
@@ -266,16 +259,14 @@ export default function SettingsPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="email" className="dark:text-gray-300">
|
<Label htmlFor="email">Email</Label>
|
||||||
Email
|
|
||||||
</Label>
|
|
||||||
<Input
|
<Input
|
||||||
id="email"
|
id="email"
|
||||||
value={session?.user?.email ?? ""}
|
value={session?.user?.email ?? ""}
|
||||||
disabled
|
disabled
|
||||||
className="bg-gray-50 dark:bg-gray-700 dark:text-gray-400"
|
className="bg-muted"
|
||||||
/>
|
/>
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
<p className="text-muted-foreground text-sm">
|
||||||
Email cannot be changed
|
Email cannot be changed
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -293,41 +284,33 @@ export default function SettingsPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Data Statistics */}
|
{/* Data Statistics */}
|
||||||
<Card className="dark:border-gray-700 dark:bg-gray-800/80">
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2 dark:text-white">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<Database className="h-5 w-5 dark:text-emerald-400" />
|
<Database className="h-5 w-5 text-emerald-600" />
|
||||||
Your Data
|
Your Data
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription className="dark:text-gray-300">
|
<CardDescription>Overview of your account data</CardDescription>
|
||||||
Overview of your account data
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="grid grid-cols-3 gap-4 text-center">
|
<div className="grid grid-cols-3 gap-4 text-center">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-2xl font-bold text-emerald-600 dark:text-emerald-400">
|
<div className="text-2xl font-bold text-emerald-600">
|
||||||
{dataStats?.clients ?? 0}
|
{dataStats?.clients ?? 0}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
<div className="text-muted-foreground text-sm">Clients</div>
|
||||||
Clients
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-2xl font-bold text-emerald-600 dark:text-emerald-400">
|
<div className="text-2xl font-bold text-emerald-600">
|
||||||
{dataStats?.businesses ?? 0}
|
{dataStats?.businesses ?? 0}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
<div className="text-muted-foreground text-sm">Businesses</div>
|
||||||
Businesses
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-2xl font-bold text-emerald-600 dark:text-emerald-400">
|
<div className="text-2xl font-bold text-emerald-600">
|
||||||
{dataStats?.invoices ?? 0}
|
{dataStats?.invoices ?? 0}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
<div className="text-muted-foreground text-sm">Invoices</div>
|
||||||
Invoices
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -335,13 +318,13 @@ export default function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Backup & Restore Section */}
|
{/* Backup & Restore Section */}
|
||||||
<Card className="dark:border-gray-700 dark:bg-gray-800/80">
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2 dark:text-white">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<Shield className="h-5 w-5 dark:text-emerald-400" />
|
<Shield className="h-5 w-5 text-emerald-600" />
|
||||||
Backup & Restore
|
Backup & Restore
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription className="dark:text-gray-300">
|
<CardDescription>
|
||||||
Export your data for backup or import from a previous backup
|
Export your data for backup or import from a previous backup
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@@ -349,8 +332,8 @@ export default function SettingsPage() {
|
|||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
{/* Export Data */}
|
{/* Export Data */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<h3 className="font-semibold dark:text-white">Export Data</h3>
|
<h3 className="font-semibold">Export Data</h3>
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-300">
|
<p className="text-muted-foreground text-sm">
|
||||||
Download all your clients, businesses, and invoices as a JSON
|
Download all your clients, businesses, and invoices as a JSON
|
||||||
backup file.
|
backup file.
|
||||||
</p>
|
</p>
|
||||||
@@ -367,8 +350,8 @@ export default function SettingsPage() {
|
|||||||
|
|
||||||
{/* Import Data */}
|
{/* Import Data */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<h3 className="font-semibold dark:text-white">Import Data</h3>
|
<h3 className="font-semibold">Import Data</h3>
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-300">
|
<p className="text-muted-foreground text-sm">
|
||||||
Restore your data from a previous backup file.
|
Restore your data from a previous backup file.
|
||||||
</p>
|
</p>
|
||||||
<Dialog
|
<Dialog
|
||||||
@@ -381,12 +364,10 @@ export default function SettingsPage() {
|
|||||||
Import Data
|
Import Data
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent className="max-w-2xl dark:border-gray-700 dark:bg-gray-800">
|
<DialogContent className="max-w-2xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="dark:text-white">
|
<DialogTitle>Import Backup Data</DialogTitle>
|
||||||
Import Backup Data
|
<DialogDescription>
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription className="dark:text-gray-300">
|
|
||||||
Paste the contents of your backup JSON file below. This
|
Paste the contents of your backup JSON file below. This
|
||||||
will add the data to your existing account.
|
will add the data to your existing account.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
@@ -424,11 +405,9 @@ export default function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-lg bg-blue-50 p-4 dark:border dark:border-blue-800/30 dark:bg-blue-900/20">
|
<div className="rounded-lg border border-blue-200 bg-blue-50 p-4">
|
||||||
<h4 className="font-medium text-blue-900 dark:text-blue-300">
|
<h4 className="font-medium text-blue-900">Backup Tips</h4>
|
||||||
Backup Tips
|
<ul className="mt-2 space-y-1 text-sm text-blue-800">
|
||||||
</h4>
|
|
||||||
<ul className="mt-2 space-y-1 text-sm text-blue-800 dark:text-blue-200">
|
|
||||||
<li>• Regular backups help protect your data</li>
|
<li>• Regular backups help protect your data</li>
|
||||||
<li>
|
<li>
|
||||||
• Backup files contain all your business data in JSON format
|
• Backup files contain all your business data in JSON format
|
||||||
@@ -443,23 +422,21 @@ export default function SettingsPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Danger Zone */}
|
{/* Danger Zone */}
|
||||||
<Card className="border-red-200 dark:border-red-800/50 dark:bg-gray-800/80">
|
<Card className="border-red-200">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2 text-red-600 dark:text-red-400">
|
<CardTitle className="flex items-center gap-2 text-red-600">
|
||||||
<AlertTriangle className="h-5 w-5" />
|
<AlertTriangle className="h-5 w-5" />
|
||||||
Danger Zone
|
Danger Zone
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription className="dark:text-gray-300">
|
<CardDescription>
|
||||||
Irreversible actions for your account data
|
Irreversible actions for your account data
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="rounded-lg bg-red-50 p-4 dark:border dark:border-red-800/30 dark:bg-red-900/20">
|
<div className="rounded-lg border border-red-200 bg-red-50 p-4">
|
||||||
<h4 className="font-medium text-red-900 dark:text-red-300">
|
<h4 className="font-medium text-red-900">Delete All Data</h4>
|
||||||
Delete All Data
|
<p className="mt-1 text-sm text-red-800">
|
||||||
</h4>
|
|
||||||
<p className="mt-1 text-sm text-red-800 dark:text-red-200">
|
|
||||||
This will permanently delete all your clients, businesses,
|
This will permanently delete all your clients, businesses,
|
||||||
invoices, and related data. This action cannot be undone.
|
invoices, and related data. This action cannot be undone.
|
||||||
</p>
|
</p>
|
||||||
@@ -469,12 +446,10 @@ export default function SettingsPage() {
|
|||||||
<AlertDialogTrigger asChild>
|
<AlertDialogTrigger asChild>
|
||||||
<Button variant="destructive">Delete All Data</Button>
|
<Button variant="destructive">Delete All Data</Button>
|
||||||
</AlertDialogTrigger>
|
</AlertDialogTrigger>
|
||||||
<AlertDialogContent className="dark:border-gray-700 dark:bg-gray-800">
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle className="dark:text-white">
|
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
|
||||||
Are you absolutely sure?
|
<AlertDialogDescription className="space-y-2">
|
||||||
</AlertDialogTitle>
|
|
||||||
<AlertDialogDescription className="space-y-2 dark:text-gray-300">
|
|
||||||
<p>
|
<p>
|
||||||
This action cannot be undone. This will permanently delete
|
This action cannot be undone. This will permanently delete
|
||||||
all your:
|
all your:
|
||||||
@@ -487,7 +462,7 @@ export default function SettingsPage() {
|
|||||||
</ul>
|
</ul>
|
||||||
<p className="font-medium">
|
<p className="font-medium">
|
||||||
Type{" "}
|
Type{" "}
|
||||||
<span className="rounded bg-gray-100 px-1 font-mono dark:bg-gray-700 dark:text-gray-200">
|
<span className="bg-muted rounded px-1 font-mono">
|
||||||
DELETE ALL DATA
|
DELETE ALL DATA
|
||||||
</span>{" "}
|
</span>{" "}
|
||||||
to confirm:
|
to confirm:
|
||||||
|
|||||||
202
src/app/demo/table-layout/page.tsx
Normal file
202
src/app/demo/table-layout/page.tsx
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { DataTable } from "~/components/ui/data-table";
|
||||||
|
import { PageHeader } from "~/components/page-header";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import { Plus } from "lucide-react";
|
||||||
|
import type { ColumnDef } from "@tanstack/react-table";
|
||||||
|
import { DataTableColumnHeader } from "~/components/ui/data-table";
|
||||||
|
import { DashboardBreadcrumbs } from "~/components/dashboard-breadcrumbs";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
// Sample data type
|
||||||
|
interface DemoItem {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
phone: string;
|
||||||
|
status: string;
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate sample data
|
||||||
|
const sampleData: DemoItem[] = Array.from({ length: 50 }, (_, i) => ({
|
||||||
|
id: `item-${i + 1}`,
|
||||||
|
name: `Item ${i + 1}`,
|
||||||
|
email: `item${i + 1}@example.com`,
|
||||||
|
phone: `555-${String(Math.floor(Math.random() * 9000) + 1000)}`,
|
||||||
|
status: ["active", "pending", "inactive"][
|
||||||
|
Math.floor(Math.random() * 3)
|
||||||
|
] as string,
|
||||||
|
createdAt: new Date(Date.now() - Math.random() * 10000000000),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Define columns with responsive behavior
|
||||||
|
const columns: ColumnDef<DemoItem>[] = [
|
||||||
|
{
|
||||||
|
accessorKey: "name",
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataTableColumnHeader column={column} title="Name" />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{row.original.name}</p>
|
||||||
|
<p className="text-muted-foreground text-xs">{row.original.email}</p>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "phone",
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataTableColumnHeader column={column} title="Phone" />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => row.original.phone,
|
||||||
|
meta: {
|
||||||
|
headerClassName: "hidden md:table-cell",
|
||||||
|
cellClassName: "hidden md:table-cell",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "status",
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataTableColumnHeader column={column} title="Status" />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center rounded-full px-2 py-1 text-xs font-medium ${
|
||||||
|
row.original.status === "active"
|
||||||
|
? "bg-green-100 text-green-700"
|
||||||
|
: row.original.status === "pending"
|
||||||
|
? "bg-yellow-100 text-yellow-700"
|
||||||
|
: "bg-gray-100 text-gray-700"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{row.original.status}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "createdAt",
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataTableColumnHeader column={column} title="Created" />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const date = row.getValue("createdAt") as Date;
|
||||||
|
return new Intl.DateTimeFormat("en-US", {
|
||||||
|
month: "short",
|
||||||
|
day: "2-digit",
|
||||||
|
year: "numeric",
|
||||||
|
}).format(date);
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
headerClassName: "hidden lg:table-cell",
|
||||||
|
cellClassName: "hidden lg:table-cell",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function TableLayoutDemo() {
|
||||||
|
const [data] = useState(sampleData);
|
||||||
|
|
||||||
|
const filterableColumns = [
|
||||||
|
{
|
||||||
|
id: "status",
|
||||||
|
title: "Status",
|
||||||
|
options: [
|
||||||
|
{ label: "Active", value: "active" },
|
||||||
|
{ label: "Pending", value: "pending" },
|
||||||
|
{ label: "Inactive", value: "inactive" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto py-10">
|
||||||
|
<DashboardBreadcrumbs />
|
||||||
|
|
||||||
|
<PageHeader
|
||||||
|
title="Table Layout & Breadcrumb Demo"
|
||||||
|
description="This demo showcases the improved responsive layouts and dynamic breadcrumbs. The breadcrumbs automatically handle pluralization and capitalization. Navigate to different pages to see how they adapt."
|
||||||
|
variant="gradient"
|
||||||
|
>
|
||||||
|
<Button variant="brand" size="lg">
|
||||||
|
<Plus className="mr-2 h-5 w-5" />
|
||||||
|
Add Item
|
||||||
|
</Button>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<div className="mt-8">
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
data={data}
|
||||||
|
searchPlaceholder="Search items..."
|
||||||
|
filterableColumns={filterableColumns}
|
||||||
|
showColumnVisibility={true}
|
||||||
|
showPagination={true}
|
||||||
|
showSearch={true}
|
||||||
|
pageSize={10}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-16 space-y-4">
|
||||||
|
<h2 className="text-2xl font-bold">Layout Improvements</h2>
|
||||||
|
<div className="text-muted-foreground space-y-2 text-sm">
|
||||||
|
<p>
|
||||||
|
• Page header: Description text wraps below the title and action
|
||||||
|
buttons
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
• Filter bar: Search and filters stay inline on mobile with proper
|
||||||
|
wrapping
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
• Pagination bar: Entry count and controls remain on the same line
|
||||||
|
on mobile
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
• Columns: Responsive hiding with both headers and cells hidden
|
||||||
|
together
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
• Compact design: Tighter padding for more efficient space usage
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 className="mt-8 text-2xl font-bold">Dynamic Breadcrumbs</h2>
|
||||||
|
<div className="text-muted-foreground space-y-2 text-sm">
|
||||||
|
<p>
|
||||||
|
• Automatic pluralization: "Business" becomes "Businesses" on list
|
||||||
|
pages
|
||||||
|
</p>
|
||||||
|
<p>• Smart capitalization: Route segments are properly capitalized</p>
|
||||||
|
<p>• Context awareness: Shows resource names instead of UUIDs</p>
|
||||||
|
<p>
|
||||||
|
• Clean presentation: Edit pages show the resource name, not "Edit"
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 space-y-2">
|
||||||
|
<p className="text-sm font-medium">Try these example routes:</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Link href="/dashboard/businesses">
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
Businesses List
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<Link href="/dashboard/clients">
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
Clients List
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<Link href="/dashboard/invoices">
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
Invoices List
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import "~/styles/globals.css";
|
import "~/styles/globals.css";
|
||||||
|
|
||||||
import { type Metadata } from "next";
|
import { type Metadata } from "next";
|
||||||
import { Geist } from "next/font/google";
|
import { Geist, Azeret_Mono } from "next/font/google";
|
||||||
|
|
||||||
import { TRPCReactProvider } from "~/trpc/react";
|
import { TRPCReactProvider } from "~/trpc/react";
|
||||||
import { Toaster } from "~/components/ui/toaster";
|
import { Toaster } from "~/components/ui/toaster";
|
||||||
@@ -18,12 +18,18 @@ const geist = Geist({
|
|||||||
variable: "--font-geist-sans",
|
variable: "--font-geist-sans",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const azeretMono = Azeret_Mono({
|
||||||
|
subsets: ["latin"],
|
||||||
|
variable: "--font-azeret-mono",
|
||||||
|
display: "swap",
|
||||||
|
});
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
children,
|
children,
|
||||||
}: Readonly<{ children: React.ReactNode }>) {
|
}: Readonly<{ children: React.ReactNode }>) {
|
||||||
return (
|
return (
|
||||||
<html lang="en" className={`${geist.variable}`}>
|
<html lang="en" className={`${geist.variable} ${azeretMono.variable}`}>
|
||||||
<body className="relative min-h-screen overflow-x-hidden bg-gradient-to-br from-emerald-100 via-white via-60% to-teal-100 font-sans antialiased before:pointer-events-none before:fixed before:inset-0 before:z-0 before:bg-[radial-gradient(ellipse_at_80%_0%,rgba(16,185,129,0.10)_0%,transparent_60%)] before:content-[''] dark:from-gray-900 dark:via-gray-800 dark:to-gray-900 dark:before:bg-[radial-gradient(ellipse_at_80%_0%,rgba(34,197,94,0.15)_0%,transparent_60%)]">
|
<body className="bg-gradient-dashboard bg-radial-overlay relative min-h-screen overflow-x-hidden font-sans antialiased">
|
||||||
<TRPCReactProvider>{children}</TRPCReactProvider>
|
<TRPCReactProvider>{children}</TRPCReactProvider>
|
||||||
<Toaster />
|
<Toaster />
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
127
src/app/page.tsx
127
src/app/page.tsx
@@ -23,26 +23,19 @@ import {
|
|||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gradient-to-br from-green-50 to-emerald-100 dark:from-gray-900 dark:to-gray-800">
|
<div className="bg-gradient-auth min-h-screen">
|
||||||
<AuthRedirect />
|
<AuthRedirect />
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<header className="border-b border-green-200 bg-white/80 backdrop-blur-sm dark:border-gray-700 dark:bg-gray-900/80">
|
<header className="border-border bg-card/80 border-b backdrop-blur-sm">
|
||||||
<div className="container mx-auto px-4 py-4">
|
<div className="container mx-auto px-4 py-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Logo />
|
<Logo />
|
||||||
<div className="flex items-center space-x-4">
|
<div className="flex items-center space-x-4">
|
||||||
<Link href="/auth/signin">
|
<Link href="/auth/signin">
|
||||||
<Button
|
<Button variant="ghost">Sign In</Button>
|
||||||
variant="ghost"
|
|
||||||
className="dark:text-gray-300 dark:hover:bg-gray-800"
|
|
||||||
>
|
|
||||||
Sign In
|
|
||||||
</Button>
|
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/auth/register">
|
<Link href="/auth/register">
|
||||||
<Button className="dark:bg-green-600 dark:hover:bg-green-700">
|
<Button>Get Started</Button>
|
||||||
Get Started
|
|
||||||
</Button>
|
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -52,33 +45,23 @@ export default function HomePage() {
|
|||||||
{/* Hero Section */}
|
{/* Hero Section */}
|
||||||
<section className="px-4 py-20">
|
<section className="px-4 py-20">
|
||||||
<div className="container mx-auto max-w-4xl text-center">
|
<div className="container mx-auto max-w-4xl text-center">
|
||||||
<h1 className="mb-6 text-5xl font-bold text-gray-900 md:text-6xl dark:text-white">
|
<h1 className="text-foreground mb-6 text-5xl font-bold md:text-6xl">
|
||||||
Simple Invoicing for
|
Simple Invoicing for
|
||||||
<span className="text-green-600 dark:text-green-400">
|
<span className="text-green-600"> Freelancers</span>
|
||||||
{" "}
|
|
||||||
Freelancers
|
|
||||||
</span>
|
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mx-auto mb-8 max-w-2xl text-xl text-gray-600 dark:text-gray-300">
|
<p className="text-muted-foreground mx-auto mb-8 max-w-2xl text-xl">
|
||||||
Create professional invoices, manage clients, and get paid faster
|
Create professional invoices, manage clients, and get paid faster
|
||||||
with beenvoice. The invoicing app that works as hard as you do.
|
with beenvoice. The invoicing app that works as hard as you do.
|
||||||
</p>
|
</p>
|
||||||
<div className="flex flex-col justify-center gap-4 sm:flex-row">
|
<div className="flex flex-col justify-center gap-4 sm:flex-row">
|
||||||
<Link href="/auth/register">
|
<Link href="/auth/register">
|
||||||
<Button
|
<Button size="lg" className="px-8 py-6 text-lg">
|
||||||
size="lg"
|
|
||||||
className="px-8 py-6 text-lg dark:bg-green-600 dark:hover:bg-green-700"
|
|
||||||
>
|
|
||||||
Start Free Trial
|
Start Free Trial
|
||||||
<ArrowRight className="ml-2 h-5 w-5" />
|
<ArrowRight className="ml-2 h-5 w-5" />
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="#features">
|
<Link href="#features">
|
||||||
<Button
|
<Button variant="outline" size="lg" className="px-8 py-6 text-lg">
|
||||||
variant="outline"
|
|
||||||
size="lg"
|
|
||||||
className="px-8 py-6 text-lg dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-800"
|
|
||||||
>
|
|
||||||
See How It Works
|
See How It Works
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
@@ -87,30 +70,28 @@ export default function HomePage() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Features Section */}
|
{/* Features Section */}
|
||||||
<section id="features" className="bg-white px-4 py-20 dark:bg-gray-800">
|
<section id="features" className="bg-card px-4 py-20">
|
||||||
<div className="container mx-auto max-w-6xl">
|
<div className="container mx-auto max-w-6xl">
|
||||||
<div className="mb-16 text-center">
|
<div className="mb-16 text-center">
|
||||||
<h2 className="mb-4 text-4xl font-bold text-gray-900 dark:text-white">
|
<h2 className="text-card-foreground mb-4 text-4xl font-bold">
|
||||||
Everything you need to invoice like a pro
|
Everything you need to invoice like a pro
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mx-auto max-w-2xl text-xl text-gray-600 dark:text-gray-300">
|
<p className="text-muted-foreground mx-auto max-w-2xl text-xl">
|
||||||
Powerful features designed for freelancers and small businesses
|
Powerful features designed for freelancers and small businesses
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-8 md:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-8 md:grid-cols-2 lg:grid-cols-3">
|
||||||
<Card className="border-0 shadow-lg dark:bg-gray-700">
|
<Card className="border-0 shadow-lg">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<Users className="mb-4 h-12 w-12 text-green-600 dark:text-green-400" />
|
<Users className="mb-4 h-12 w-12 text-green-600" />
|
||||||
<CardTitle className="dark:text-white">
|
<CardTitle>Client Management</CardTitle>
|
||||||
Client Management
|
<CardDescription>
|
||||||
</CardTitle>
|
|
||||||
<CardDescription className="dark:text-gray-300">
|
|
||||||
Keep all your client information organized in one place
|
Keep all your client information organized in one place
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<ul className="space-y-2 text-sm text-gray-600 dark:text-gray-300">
|
<ul className="text-muted-foreground space-y-2 text-sm">
|
||||||
<li className="flex items-center">
|
<li className="flex items-center">
|
||||||
<CheckCircle className="mr-2 h-4 w-4 text-green-500" />
|
<CheckCircle className="mr-2 h-4 w-4 text-green-500" />
|
||||||
Store contact details and addresses
|
Store contact details and addresses
|
||||||
@@ -127,18 +108,16 @@ export default function HomePage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="border-0 shadow-lg dark:bg-gray-700">
|
<Card className="border-0 shadow-lg">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<FileText className="mb-4 h-12 w-12 text-green-600 dark:text-green-400" />
|
<FileText className="mb-4 h-12 w-12 text-green-600" />
|
||||||
<CardTitle className="dark:text-white">
|
<CardTitle>Professional Invoices</CardTitle>
|
||||||
Professional Invoices
|
<CardDescription>
|
||||||
</CardTitle>
|
|
||||||
<CardDescription className="dark:text-gray-300">
|
|
||||||
Create beautiful, detailed invoices with line items
|
Create beautiful, detailed invoices with line items
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<ul className="space-y-2 text-sm text-gray-600 dark:text-gray-300">
|
<ul className="text-muted-foreground space-y-2 text-sm">
|
||||||
<li className="flex items-center">
|
<li className="flex items-center">
|
||||||
<CheckCircle className="mr-2 h-4 w-4 text-green-500" />
|
<CheckCircle className="mr-2 h-4 w-4 text-green-500" />
|
||||||
Add multiple line items with dates
|
Add multiple line items with dates
|
||||||
@@ -155,18 +134,16 @@ export default function HomePage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="border-0 shadow-lg dark:bg-gray-700">
|
<Card className="border-0 shadow-lg">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<DollarSign className="mb-4 h-12 w-12 text-green-600 dark:text-green-400" />
|
<DollarSign className="mb-4 h-12 w-12 text-green-600" />
|
||||||
<CardTitle className="dark:text-white">
|
<CardTitle>Payment Tracking</CardTitle>
|
||||||
Payment Tracking
|
<CardDescription>
|
||||||
</CardTitle>
|
|
||||||
<CardDescription className="dark:text-gray-300">
|
|
||||||
Monitor invoice status and track payments
|
Monitor invoice status and track payments
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<ul className="space-y-2 text-sm text-gray-600 dark:text-gray-300">
|
<ul className="text-muted-foreground space-y-2 text-sm">
|
||||||
<li className="flex items-center">
|
<li className="flex items-center">
|
||||||
<CheckCircle className="mr-2 h-4 w-4 text-green-500" />
|
<CheckCircle className="mr-2 h-4 w-4 text-green-500" />
|
||||||
Track draft, sent, paid, and overdue status
|
Track draft, sent, paid, and overdue status
|
||||||
@@ -187,21 +164,21 @@ export default function HomePage() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Benefits Section */}
|
{/* Benefits Section */}
|
||||||
<section className="bg-gray-50 px-4 py-20 dark:bg-gray-900">
|
<section className="bg-muted/50 px-4 py-20">
|
||||||
<div className="container mx-auto max-w-4xl text-center">
|
<div className="container mx-auto max-w-4xl text-center">
|
||||||
<h2 className="mb-16 text-4xl font-bold text-gray-900 dark:text-white">
|
<h2 className="text-foreground mb-16 text-4xl font-bold">
|
||||||
Why choose beenvoice?
|
Why choose beenvoice?
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div className="grid gap-12 md:grid-cols-2">
|
<div className="grid gap-12 md:grid-cols-2">
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-start space-x-4">
|
<div className="flex items-start space-x-4">
|
||||||
<Zap className="mt-1 h-8 w-8 text-green-600 dark:text-green-400" />
|
<Zap className="mt-1 h-8 w-8 text-green-600" />
|
||||||
<div className="text-left">
|
<div className="text-left">
|
||||||
<h3 className="mb-2 text-xl font-semibold dark:text-white">
|
<h3 className="text-foreground mb-2 text-xl font-semibold">
|
||||||
Lightning Fast
|
Lightning Fast
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-gray-600 dark:text-gray-300">
|
<p className="text-muted-foreground">
|
||||||
Create invoices in seconds, not minutes. Our streamlined
|
Create invoices in seconds, not minutes. Our streamlined
|
||||||
interface gets you back to work faster.
|
interface gets you back to work faster.
|
||||||
</p>
|
</p>
|
||||||
@@ -209,12 +186,12 @@ export default function HomePage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-start space-x-4">
|
<div className="flex items-start space-x-4">
|
||||||
<Shield className="mt-1 h-8 w-8 text-green-600 dark:text-green-400" />
|
<Shield className="mt-1 h-8 w-8 text-green-600" />
|
||||||
<div className="text-left">
|
<div className="text-left">
|
||||||
<h3 className="mb-2 text-xl font-semibold dark:text-white">
|
<h3 className="text-foreground mb-2 text-xl font-semibold">
|
||||||
Secure & Private
|
Secure & Private
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-gray-600 dark:text-gray-300">
|
<p className="text-muted-foreground">
|
||||||
Your data is encrypted and secure. We never share your
|
Your data is encrypted and secure. We never share your
|
||||||
information with third parties.
|
information with third parties.
|
||||||
</p>
|
</p>
|
||||||
@@ -224,12 +201,12 @@ export default function HomePage() {
|
|||||||
|
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-start space-x-4">
|
<div className="flex items-start space-x-4">
|
||||||
<Star className="mt-1 h-8 w-8 text-green-600 dark:text-green-400" />
|
<Star className="mt-1 h-8 w-8 text-green-600" />
|
||||||
<div className="text-left">
|
<div className="text-left">
|
||||||
<h3 className="mb-2 text-xl font-semibold dark:text-white">
|
<h3 className="text-foreground mb-2 text-xl font-semibold">
|
||||||
Professional Quality
|
Professional Quality
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-gray-600 dark:text-gray-300">
|
<p className="text-muted-foreground">
|
||||||
Generate invoices that look professional and build trust
|
Generate invoices that look professional and build trust
|
||||||
with your clients.
|
with your clients.
|
||||||
</p>
|
</p>
|
||||||
@@ -237,12 +214,12 @@ export default function HomePage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-start space-x-4">
|
<div className="flex items-start space-x-4">
|
||||||
<Clock className="mt-1 h-8 w-8 text-green-600 dark:text-green-400" />
|
<Clock className="mt-1 h-8 w-8 text-green-600" />
|
||||||
<div className="text-left">
|
<div className="text-left">
|
||||||
<h3 className="mb-2 text-xl font-semibold dark:text-white">
|
<h3 className="text-foreground mb-2 text-xl font-semibold">
|
||||||
Save Time
|
Save Time
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-gray-600 dark:text-gray-300">
|
<p className="text-muted-foreground">
|
||||||
Automated calculations, templates, and client management
|
Automated calculations, templates, and client management
|
||||||
save you hours every month.
|
save you hours every month.
|
||||||
</p>
|
</p>
|
||||||
@@ -254,48 +231,44 @@ export default function HomePage() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* CTA Section */}
|
{/* CTA Section */}
|
||||||
<section className="bg-green-600 px-4 py-20 dark:bg-green-700">
|
<section className="bg-green-600 px-4 py-20">
|
||||||
<div className="container mx-auto max-w-2xl text-center">
|
<div className="container mx-auto max-w-2xl text-center">
|
||||||
<h2 className="mb-4 text-4xl font-bold text-white">
|
<h2 className="mb-4 text-4xl font-bold text-white">
|
||||||
Ready to get started?
|
Ready to get started?
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mb-8 text-xl text-green-100 dark:text-green-200">
|
<p className="mb-8 text-xl text-green-100">
|
||||||
Join thousands of freelancers who trust beenvoice for their
|
Join thousands of freelancers who trust beenvoice for their
|
||||||
invoicing needs.
|
invoicing needs.
|
||||||
</p>
|
</p>
|
||||||
<Link href="/auth/register">
|
<Link href="/auth/register">
|
||||||
<Button
|
<Button size="lg" variant="secondary" className="px-8 py-6 text-lg">
|
||||||
size="lg"
|
|
||||||
variant="secondary"
|
|
||||||
className="px-8 py-6 text-lg dark:bg-white dark:text-green-700 dark:hover:bg-gray-100"
|
|
||||||
>
|
|
||||||
Start Your Free Trial
|
Start Your Free Trial
|
||||||
<ArrowRight className="ml-2 h-5 w-5" />
|
<ArrowRight className="ml-2 h-5 w-5" />
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
<p className="mt-4 text-sm text-green-200 dark:text-green-300">
|
<p className="mt-4 text-sm text-green-200">
|
||||||
No credit card required • Cancel anytime
|
No credit card required • Cancel anytime
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<footer className="bg-gray-900 px-4 py-12 text-white dark:bg-black">
|
<footer className="bg-card text-card-foreground border-border border-t px-4 py-12">
|
||||||
<div className="container mx-auto text-center">
|
<div className="container mx-auto text-center">
|
||||||
<Logo className="mx-auto mb-4" />
|
<Logo className="mx-auto mb-4" />
|
||||||
<p className="mb-4 text-gray-400 dark:text-gray-500">
|
<p className="text-muted-foreground mb-4">
|
||||||
Simple invoicing for freelancers and small businesses
|
Simple invoicing for freelancers and small businesses
|
||||||
</p>
|
</p>
|
||||||
<div className="flex justify-center space-x-6 text-sm text-gray-400 dark:text-gray-500">
|
<div className="text-muted-foreground flex justify-center space-x-6 text-sm">
|
||||||
<Link
|
<Link
|
||||||
href="/auth/signin"
|
href="/auth/signin"
|
||||||
className="hover:text-white dark:hover:text-gray-300"
|
className="hover:text-foreground transition-colors"
|
||||||
>
|
>
|
||||||
Sign In
|
Sign In
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
href="/auth/register"
|
href="/auth/register"
|
||||||
className="hover:text-white dark:hover:text-gray-300"
|
className="hover:text-foreground transition-colors"
|
||||||
>
|
>
|
||||||
Register
|
Register
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -1,33 +1,45 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useSession, signOut } from "next-auth/react";
|
import { useSession, signOut } from "next-auth/react";
|
||||||
|
import { useState } from "react";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
|
import { Skeleton } from "~/components/ui/skeleton";
|
||||||
import { Logo } from "./logo";
|
import { Logo } from "./logo";
|
||||||
import { SidebarTrigger } from "./SidebarTrigger";
|
import { SidebarTrigger } from "./SidebarTrigger";
|
||||||
|
|
||||||
export function Navbar() {
|
export function Navbar() {
|
||||||
const { data: session } = useSession();
|
const { data: session, status } = useSession();
|
||||||
|
const [isMobileNavOpen, setIsMobileNavOpen] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="fixed top-4 right-4 left-4 z-30 md:top-6 md:right-6 md:left-6">
|
<header className="fixed top-2 right-2 left-2 z-30 md:top-3 md:right-3 md:left-3">
|
||||||
<div className="rounded-xl border-0 bg-white/60 shadow-2xl backdrop-blur-md dark:bg-gray-900/60">
|
<div className="bg-background/60 border-border/40 relative rounded-2xl border shadow-lg backdrop-blur-xl backdrop-saturate-150">
|
||||||
<div className="flex h-14 items-center justify-between px-4 md:h-16 md:px-8">
|
<div className="flex h-14 items-center justify-between px-4 md:h-16 md:px-8">
|
||||||
<div className="flex items-center gap-4 md:gap-6">
|
<div className="flex items-center gap-4 md:gap-6">
|
||||||
<SidebarTrigger />
|
<SidebarTrigger
|
||||||
|
isOpen={isMobileNavOpen}
|
||||||
|
onToggle={() => setIsMobileNavOpen(!isMobileNavOpen)}
|
||||||
|
/>
|
||||||
<Link href="/dashboard" className="flex items-center gap-2">
|
<Link href="/dashboard" className="flex items-center gap-2">
|
||||||
<Logo size="md" />
|
<Logo size="md" />
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 md:gap-4">
|
<div className="flex items-center gap-2 md:gap-4">
|
||||||
{session?.user ? (
|
{status === "loading" ? (
|
||||||
<>
|
<>
|
||||||
<span className="hidden text-xs font-medium text-gray-700 sm:inline md:text-sm dark:text-gray-300">
|
<Skeleton className="bg-muted/20 hidden h-5 w-20 sm:inline" />
|
||||||
|
<Skeleton className="bg-muted/20 h-8 w-16" />
|
||||||
|
</>
|
||||||
|
) : session?.user ? (
|
||||||
|
<>
|
||||||
|
<span className="text-muted-foreground hidden text-xs font-medium sm:inline md:text-sm">
|
||||||
{session.user.name ?? session.user.email}
|
{session.user.name ?? session.user.email}
|
||||||
</span>
|
</span>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => signOut({ callbackUrl: "/" })}
|
onClick={() => signOut({ callbackUrl: "/" })}
|
||||||
className="border-gray-300 text-xs text-gray-700 hover:bg-gray-50 md:text-sm dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-800"
|
className="border-border/40 hover:bg-accent/50 text-xs md:text-sm"
|
||||||
>
|
>
|
||||||
Sign Out
|
Sign Out
|
||||||
</Button>
|
</Button>
|
||||||
@@ -38,7 +50,7 @@ export function Navbar() {
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="text-xs text-gray-700 hover:bg-gray-100 md:text-sm dark:text-gray-300 dark:hover:bg-gray-800"
|
className="hover:bg-accent/50 text-xs md:text-sm"
|
||||||
>
|
>
|
||||||
Sign In
|
Sign In
|
||||||
</Button>
|
</Button>
|
||||||
@@ -46,7 +58,7 @@ export function Navbar() {
|
|||||||
<Link href="/auth/register">
|
<Link href="/auth/register">
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
className="bg-gradient-to-r from-emerald-600 to-teal-600 text-xs font-medium text-white hover:from-emerald-700 hover:to-teal-700 md:text-sm dark:from-emerald-500 dark:to-teal-500 dark:hover:from-emerald-600 dark:hover:to-teal-600"
|
className="bg-gradient-to-r from-emerald-600 to-teal-600 text-xs font-medium text-white shadow-md transition-all duration-200 hover:from-emerald-700 hover:to-teal-700 hover:shadow-lg md:text-sm"
|
||||||
>
|
>
|
||||||
Register
|
Register
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -2,66 +2,62 @@
|
|||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import {
|
import { useSession } from "next-auth/react";
|
||||||
Settings,
|
import { Skeleton } from "~/components/ui/skeleton";
|
||||||
LayoutDashboard,
|
import { navigationConfig } from "~/lib/navigation";
|
||||||
Users,
|
|
||||||
FileText,
|
|
||||||
Building,
|
|
||||||
} from "lucide-react";
|
|
||||||
|
|
||||||
const navLinks = [
|
|
||||||
{ name: "Dashboard", href: "/dashboard", icon: LayoutDashboard },
|
|
||||||
{ name: "Clients", href: "/dashboard/clients", icon: Users },
|
|
||||||
{ name: "Businesses", href: "/dashboard/businesses", icon: Building },
|
|
||||||
{ name: "Invoices", href: "/dashboard/invoices", icon: FileText },
|
|
||||||
];
|
|
||||||
|
|
||||||
export function Sidebar() {
|
export function Sidebar() {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
const { status } = useSession();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="fixed top-28 bottom-6 left-6 z-20 hidden w-64 flex-col justify-between rounded-xl border-0 bg-white/60 p-8 shadow-2xl backdrop-blur-md md:flex dark:bg-gray-900/60">
|
<aside className="border-border/40 bg-background/60 fixed top-[5.75rem] bottom-3 left-3 z-20 hidden w-64 flex-col justify-between rounded-2xl border p-6 shadow-lg backdrop-blur-xl backdrop-saturate-150 md:flex">
|
||||||
<nav className="flex flex-col gap-1">
|
<nav className="flex flex-col">
|
||||||
<div className="mb-2 text-xs font-semibold tracking-wider text-gray-400 uppercase dark:text-gray-500">
|
{navigationConfig.map((section, sectionIndex) => (
|
||||||
Main
|
<div key={section.title} className={sectionIndex > 0 ? "mt-6" : ""}>
|
||||||
</div>
|
{sectionIndex > 0 && (
|
||||||
{navLinks.map((link) => {
|
<div className="border-border/40 my-4 border-t" />
|
||||||
const Icon = link.icon;
|
)}
|
||||||
return (
|
<div className="text-muted-foreground mb-3 text-xs font-semibold tracking-wider uppercase">
|
||||||
<Link
|
{section.title}
|
||||||
key={link.href}
|
</div>
|
||||||
href={link.href}
|
<div className="flex flex-col gap-0.5">
|
||||||
aria-current={pathname === link.href ? "page" : undefined}
|
{status === "loading" ? (
|
||||||
className={`flex items-center gap-3 rounded-lg px-4 py-2 text-base font-medium transition-all duration-200 ${
|
<>
|
||||||
pathname === link.href
|
{Array.from({ length: section.links.length }).map((_, i) => (
|
||||||
? "bg-emerald-100 text-emerald-700 shadow-lg dark:bg-emerald-900/30 dark:text-emerald-400"
|
<div
|
||||||
: "text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800"
|
key={i}
|
||||||
}`}
|
className="flex items-center gap-3 rounded-lg px-3 py-2.5"
|
||||||
>
|
>
|
||||||
<Icon className="h-5 w-5" />
|
<Skeleton className="bg-muted/20 h-4 w-4" />
|
||||||
{link.name}
|
<Skeleton className="bg-muted/20 h-4 w-20" />
|
||||||
</Link>
|
</div>
|
||||||
);
|
))}
|
||||||
})}
|
</>
|
||||||
|
) : (
|
||||||
|
section.links.map((link) => {
|
||||||
|
const Icon = link.icon;
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={link.href}
|
||||||
|
href={link.href}
|
||||||
|
aria-current={pathname === link.href ? "page" : undefined}
|
||||||
|
className={`flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-all duration-200 ${
|
||||||
|
pathname === link.href
|
||||||
|
? "bg-gradient-to-r from-emerald-600/10 to-teal-600/10 text-emerald-700 shadow-sm dark:from-emerald-500/20 dark:to-teal-500/20 dark:text-emerald-400"
|
||||||
|
: "text-foreground hover:bg-accent/50 hover:text-accent-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
{link.name}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
<div>
|
|
||||||
<div className="my-4 border-t border-gray-200 dark:border-gray-700" />
|
|
||||||
<div className="mb-2 text-xs font-semibold tracking-wider text-gray-400 uppercase dark:text-gray-500">
|
|
||||||
Account
|
|
||||||
</div>
|
|
||||||
<Link
|
|
||||||
href="/dashboard/settings"
|
|
||||||
className={`flex items-center gap-3 rounded-lg px-4 py-2 text-base font-medium transition-all duration-200 ${
|
|
||||||
pathname === "/dashboard/settings"
|
|
||||||
? "bg-emerald-100 text-emerald-700 shadow-lg dark:bg-emerald-900/30 dark:text-emerald-400"
|
|
||||||
: "text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<Settings className="h-5 w-5" />
|
|
||||||
Settings
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,97 +1,95 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import {
|
|
||||||
Sheet,
|
|
||||||
SheetContent,
|
|
||||||
SheetTrigger,
|
|
||||||
SheetHeader,
|
|
||||||
SheetTitle,
|
|
||||||
} from "~/components/ui/sheet";
|
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import {
|
import { Skeleton } from "~/components/ui/skeleton";
|
||||||
MenuIcon,
|
import { MenuIcon, X } from "lucide-react";
|
||||||
Settings,
|
|
||||||
LayoutDashboard,
|
|
||||||
Users,
|
|
||||||
FileText,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
|
import { useSession } from "next-auth/react";
|
||||||
|
import { navigationConfig } from "~/lib/navigation";
|
||||||
|
|
||||||
const navLinks = [
|
interface SidebarTriggerProps {
|
||||||
{ name: "Dashboard", href: "/dashboard", icon: LayoutDashboard },
|
isOpen: boolean;
|
||||||
{ name: "Clients", href: "/dashboard/clients", icon: Users },
|
onToggle: () => void;
|
||||||
{ name: "Invoices", href: "/dashboard/invoices", icon: FileText },
|
}
|
||||||
];
|
|
||||||
|
|
||||||
export function SidebarTrigger() {
|
export function SidebarTrigger({ isOpen, onToggle }: SidebarTriggerProps) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const [open, setOpen] = useState(false);
|
const { status } = useSession();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sheet open={open} onOpenChange={setOpen}>
|
<>
|
||||||
<SheetTrigger asChild>
|
<Button
|
||||||
<Button
|
variant="outline"
|
||||||
variant="outline"
|
size="icon"
|
||||||
size="icon"
|
aria-label="Toggle navigation"
|
||||||
aria-label="Open sidebar"
|
onClick={onToggle}
|
||||||
className="h-8 w-8 border-gray-200 bg-white/80 shadow-lg backdrop-blur-sm hover:bg-white md:hidden dark:border-gray-600 dark:bg-gray-900/80 dark:hover:bg-gray-800"
|
className="bg-card/80 h-8 w-8 shadow-lg backdrop-blur-sm md:hidden"
|
||||||
>
|
|
||||||
<MenuIcon className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</SheetTrigger>
|
|
||||||
<SheetContent
|
|
||||||
side="left"
|
|
||||||
className="w-80 max-w-[85vw] border-0 bg-white/95 p-0 backdrop-blur-sm dark:bg-gray-900/95"
|
|
||||||
>
|
>
|
||||||
<SheetHeader className="border-b border-gray-200 p-4 dark:border-gray-700">
|
{isOpen ? <X className="h-4 w-4" /> : <MenuIcon className="h-4 w-4" />}
|
||||||
<SheetTitle className="dark:text-white">Navigation</SheetTitle>
|
</Button>
|
||||||
</SheetHeader>
|
|
||||||
|
|
||||||
{/* Navigation */}
|
{/* Mobile dropdown navigation */}
|
||||||
<nav className="flex flex-1 flex-col gap-1 p-4">
|
{isOpen && (
|
||||||
<div className="mb-2 text-xs font-semibold tracking-wider text-gray-400 uppercase dark:text-gray-500">
|
<div className="bg-background/95 border-border/40 absolute top-full right-0 left-0 z-40 mt-2 rounded-2xl border shadow-2xl backdrop-blur-xl md:hidden">
|
||||||
Main
|
{/* Navigation content */}
|
||||||
</div>
|
<nav className="flex flex-col p-4">
|
||||||
{navLinks.map((link) => {
|
{navigationConfig.map((section, sectionIndex) => (
|
||||||
const Icon = link.icon;
|
<div
|
||||||
return (
|
key={section.title}
|
||||||
<Link
|
className={sectionIndex > 0 ? "mt-4" : ""}
|
||||||
key={link.href}
|
|
||||||
href={link.href}
|
|
||||||
aria-current={pathname === link.href ? "page" : undefined}
|
|
||||||
className={`flex items-center gap-3 rounded-lg px-3 py-3 text-base font-medium transition-all duration-200 ${
|
|
||||||
pathname === link.href
|
|
||||||
? "bg-emerald-100 text-emerald-700 shadow-lg dark:bg-emerald-900/30 dark:text-emerald-400"
|
|
||||||
: "text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800"
|
|
||||||
}`}
|
|
||||||
onClick={() => setOpen(false)}
|
|
||||||
>
|
>
|
||||||
<Icon className="h-5 w-5" />
|
{sectionIndex > 0 && (
|
||||||
{link.name}
|
<div className="border-border/40 my-3 border-t" />
|
||||||
</Link>
|
)}
|
||||||
);
|
<div className="text-muted-foreground mb-2 text-xs font-semibold tracking-wider uppercase">
|
||||||
})}
|
{section.title}
|
||||||
|
</div>
|
||||||
<div className="my-4 border-t border-gray-200 dark:border-gray-700" />
|
<div className="flex flex-col gap-0.5">
|
||||||
<div className="mb-2 text-xs font-semibold tracking-wider text-gray-400 uppercase dark:text-gray-500">
|
{status === "loading" ? (
|
||||||
Account
|
<>
|
||||||
</div>
|
{Array.from({ length: section.links.length }).map(
|
||||||
<Link
|
(_, i) => (
|
||||||
href="/dashboard/settings"
|
<div
|
||||||
className={`flex items-center gap-3 rounded-lg px-3 py-3 text-base font-medium transition-all duration-200 ${
|
key={i}
|
||||||
pathname === "/dashboard/settings"
|
className="flex items-center gap-3 rounded-lg px-3 py-2.5"
|
||||||
? "bg-emerald-100 text-emerald-700 shadow-lg dark:bg-emerald-900/30 dark:text-emerald-400"
|
>
|
||||||
: "text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800"
|
<Skeleton className="bg-muted/20 h-4 w-4" />
|
||||||
}`}
|
<Skeleton className="bg-muted/20 h-4 w-20" />
|
||||||
onClick={() => setOpen(false)}
|
</div>
|
||||||
>
|
),
|
||||||
<Settings className="h-5 w-5" />
|
)}
|
||||||
Settings
|
</>
|
||||||
</Link>
|
) : (
|
||||||
</nav>
|
section.links.map((link) => {
|
||||||
</SheetContent>
|
const Icon = link.icon;
|
||||||
</Sheet>
|
return (
|
||||||
|
<Link
|
||||||
|
key={link.href}
|
||||||
|
href={link.href}
|
||||||
|
aria-current={
|
||||||
|
pathname === link.href ? "page" : undefined
|
||||||
|
}
|
||||||
|
className={`flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-all duration-200 ${
|
||||||
|
pathname === link.href
|
||||||
|
? "bg-gradient-to-r from-emerald-600/10 to-teal-600/10 text-emerald-700 shadow-sm dark:from-emerald-500/20 dark:to-teal-500/20 dark:text-emerald-400"
|
||||||
|
: "text-foreground hover:bg-accent/50 hover:text-accent-foreground"
|
||||||
|
}`}
|
||||||
|
onClick={onToggle}
|
||||||
|
>
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
{link.name}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,36 +1,72 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Building, Mail, MapPin, Phone, Save } from "lucide-react";
|
import { UserPlus, Mail, Phone, Save, Loader2, ArrowLeft } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState, useRef } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||||
import { Input } from "~/components/ui/input";
|
import { Input } from "~/components/ui/input";
|
||||||
import { Label } from "~/components/ui/label";
|
import { Label } from "~/components/ui/label";
|
||||||
import { FormSkeleton } from "~/components/ui/skeleton";
|
import { FormSkeleton } from "~/components/ui/skeleton";
|
||||||
|
import { AddressForm } from "~/components/ui/address-form";
|
||||||
|
import { FloatingActionBar } from "~/components/ui/floating-action-bar";
|
||||||
import { api } from "~/trpc/react";
|
import { api } from "~/trpc/react";
|
||||||
|
import {
|
||||||
|
formatPhoneNumber,
|
||||||
|
isValidEmail,
|
||||||
|
VALIDATION_MESSAGES,
|
||||||
|
PLACEHOLDERS,
|
||||||
|
} from "~/lib/form-constants";
|
||||||
|
|
||||||
interface ClientFormProps {
|
interface ClientFormProps {
|
||||||
clientId?: string;
|
clientId?: string;
|
||||||
mode: "create" | "edit";
|
mode: "create" | "edit";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface FormData {
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
phone: string;
|
||||||
|
addressLine1: string;
|
||||||
|
addressLine2: string;
|
||||||
|
city: string;
|
||||||
|
state: string;
|
||||||
|
postalCode: string;
|
||||||
|
country: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FormErrors {
|
||||||
|
name?: string;
|
||||||
|
email?: string;
|
||||||
|
phone?: string;
|
||||||
|
addressLine1?: string;
|
||||||
|
city?: string;
|
||||||
|
state?: string;
|
||||||
|
postalCode?: string;
|
||||||
|
country?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialFormData: FormData = {
|
||||||
|
name: "",
|
||||||
|
email: "",
|
||||||
|
phone: "",
|
||||||
|
addressLine1: "",
|
||||||
|
addressLine2: "",
|
||||||
|
city: "",
|
||||||
|
state: "",
|
||||||
|
postalCode: "",
|
||||||
|
country: "United States",
|
||||||
|
};
|
||||||
|
|
||||||
export function ClientForm({ clientId, mode }: ClientFormProps) {
|
export function ClientForm({ clientId, mode }: ClientFormProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState<FormData>(initialFormData);
|
||||||
name: "",
|
const [errors, setErrors] = useState<FormErrors>({});
|
||||||
email: "",
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
phone: "",
|
const [isDirty, setIsDirty] = useState(false);
|
||||||
addressLine1: "",
|
const footerRef = useRef<HTMLDivElement>(null);
|
||||||
addressLine2: "",
|
|
||||||
city: "",
|
|
||||||
state: "",
|
|
||||||
postalCode: "",
|
|
||||||
country: "",
|
|
||||||
});
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
|
|
||||||
// Fetch client data if editing
|
// Fetch client data if editing
|
||||||
const { data: client, isLoading: isLoadingClient } =
|
const { data: client, isLoading: isLoadingClient } =
|
||||||
@@ -71,14 +107,80 @@ export function ClientForm({ clientId, mode }: ClientFormProps) {
|
|||||||
city: client.city ?? "",
|
city: client.city ?? "",
|
||||||
state: client.state ?? "",
|
state: client.state ?? "",
|
||||||
postalCode: client.postalCode ?? "",
|
postalCode: client.postalCode ?? "",
|
||||||
country: client.country ?? "",
|
country: client.country ?? "United States",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [client, mode]);
|
}, [client, mode]);
|
||||||
|
|
||||||
|
const handleInputChange = (field: string, value: string) => {
|
||||||
|
setFormData((prev) => ({ ...prev, [field]: value }));
|
||||||
|
setIsDirty(true);
|
||||||
|
|
||||||
|
// Clear error for this field when user starts typing
|
||||||
|
if (errors[field as keyof FormErrors]) {
|
||||||
|
setErrors((prev) => ({ ...prev, [field]: undefined }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePhoneChange = (value: string) => {
|
||||||
|
const formatted = formatPhoneNumber(value);
|
||||||
|
handleInputChange("phone", formatted);
|
||||||
|
};
|
||||||
|
|
||||||
|
const validateForm = (): boolean => {
|
||||||
|
const newErrors: FormErrors = {};
|
||||||
|
|
||||||
|
// Required fields
|
||||||
|
if (!formData.name.trim()) {
|
||||||
|
newErrors.name = VALIDATION_MESSAGES.required;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Email validation
|
||||||
|
if (formData.email && !isValidEmail(formData.email)) {
|
||||||
|
newErrors.email = VALIDATION_MESSAGES.email;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phone validation (basic check for US format)
|
||||||
|
if (formData.phone) {
|
||||||
|
const phoneDigits = formData.phone.replace(/\D/g, "");
|
||||||
|
if (phoneDigits.length > 0 && phoneDigits.length < 10) {
|
||||||
|
newErrors.phone = VALIDATION_MESSAGES.phone;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Address validation if any address field is filled
|
||||||
|
const hasAddressData =
|
||||||
|
formData.addressLine1 ||
|
||||||
|
formData.city ||
|
||||||
|
formData.state ||
|
||||||
|
formData.postalCode;
|
||||||
|
|
||||||
|
if (hasAddressData) {
|
||||||
|
if (!formData.addressLine1)
|
||||||
|
newErrors.addressLine1 = VALIDATION_MESSAGES.required;
|
||||||
|
if (!formData.city) newErrors.city = VALIDATION_MESSAGES.required;
|
||||||
|
if (!formData.country) newErrors.country = VALIDATION_MESSAGES.required;
|
||||||
|
|
||||||
|
if (formData.country === "US") {
|
||||||
|
if (!formData.state) newErrors.state = VALIDATION_MESSAGES.required;
|
||||||
|
if (!formData.postalCode)
|
||||||
|
newErrors.postalCode = VALIDATION_MESSAGES.required;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setErrors(newErrors);
|
||||||
|
return Object.keys(newErrors).length === 0;
|
||||||
|
};
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setLoading(true);
|
|
||||||
|
if (!validateForm()) {
|
||||||
|
toast.error("Please correct the errors in the form");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSubmitting(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (mode === "create") {
|
if (mode === "create") {
|
||||||
@@ -90,551 +192,233 @@ export function ClientForm({ clientId, mode }: ClientFormProps) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setIsSubmitting(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleInputChange = (field: string, value: string) => {
|
const handleCancel = () => {
|
||||||
setFormData((prev) => ({ ...prev, [field]: value }));
|
if (isDirty) {
|
||||||
};
|
const confirmed = window.confirm(
|
||||||
|
"You have unsaved changes. Are you sure you want to leave?",
|
||||||
// Phone number formatting
|
);
|
||||||
const formatPhoneNumber = (value: string) => {
|
if (!confirmed) return;
|
||||||
const phoneNumber = value.replace(/\D/g, "");
|
|
||||||
if (phoneNumber.length <= 3) {
|
|
||||||
return phoneNumber;
|
|
||||||
} else if (phoneNumber.length <= 6) {
|
|
||||||
return `(${phoneNumber.slice(0, 3)}) ${phoneNumber.slice(3)}`;
|
|
||||||
} else {
|
|
||||||
return `(${phoneNumber.slice(0, 3)}) ${phoneNumber.slice(3, 6)}-${phoneNumber.slice(6, 10)}`;
|
|
||||||
}
|
}
|
||||||
|
router.push("/dashboard/clients");
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePhoneChange = (value: string) => {
|
|
||||||
const formatted = formatPhoneNumber(value);
|
|
||||||
handleInputChange("phone", formatted);
|
|
||||||
};
|
|
||||||
|
|
||||||
const US_STATES = [
|
|
||||||
"",
|
|
||||||
"AL",
|
|
||||||
"AK",
|
|
||||||
"AZ",
|
|
||||||
"AR",
|
|
||||||
"CA",
|
|
||||||
"CO",
|
|
||||||
"CT",
|
|
||||||
"DE",
|
|
||||||
"FL",
|
|
||||||
"GA",
|
|
||||||
"HI",
|
|
||||||
"ID",
|
|
||||||
"IL",
|
|
||||||
"IN",
|
|
||||||
"IA",
|
|
||||||
"KS",
|
|
||||||
"KY",
|
|
||||||
"LA",
|
|
||||||
"ME",
|
|
||||||
"MD",
|
|
||||||
"MA",
|
|
||||||
"MI",
|
|
||||||
"MN",
|
|
||||||
"MS",
|
|
||||||
"MO",
|
|
||||||
"MT",
|
|
||||||
"NE",
|
|
||||||
"NV",
|
|
||||||
"NH",
|
|
||||||
"NJ",
|
|
||||||
"NM",
|
|
||||||
"NY",
|
|
||||||
"NC",
|
|
||||||
"ND",
|
|
||||||
"OH",
|
|
||||||
"OK",
|
|
||||||
"OR",
|
|
||||||
"PA",
|
|
||||||
"RI",
|
|
||||||
"SC",
|
|
||||||
"SD",
|
|
||||||
"TN",
|
|
||||||
"TX",
|
|
||||||
"UT",
|
|
||||||
"VT",
|
|
||||||
"VA",
|
|
||||||
"WA",
|
|
||||||
"WV",
|
|
||||||
"WI",
|
|
||||||
"WY",
|
|
||||||
];
|
|
||||||
|
|
||||||
const MOST_USED_COUNTRIES = [
|
|
||||||
"United States",
|
|
||||||
"United Kingdom",
|
|
||||||
"Canada",
|
|
||||||
"Australia",
|
|
||||||
"Germany",
|
|
||||||
"France",
|
|
||||||
"India",
|
|
||||||
];
|
|
||||||
const ALL_COUNTRIES = [
|
|
||||||
"Afghanistan",
|
|
||||||
"Albania",
|
|
||||||
"Algeria",
|
|
||||||
"Andorra",
|
|
||||||
"Angola",
|
|
||||||
"Antigua and Barbuda",
|
|
||||||
"Argentina",
|
|
||||||
"Armenia",
|
|
||||||
"Australia",
|
|
||||||
"Austria",
|
|
||||||
"Azerbaijan",
|
|
||||||
"Bahamas",
|
|
||||||
"Bahrain",
|
|
||||||
"Bangladesh",
|
|
||||||
"Barbados",
|
|
||||||
"Belarus",
|
|
||||||
"Belgium",
|
|
||||||
"Belize",
|
|
||||||
"Benin",
|
|
||||||
"Bhutan",
|
|
||||||
"Bolivia",
|
|
||||||
"Bosnia and Herzegovina",
|
|
||||||
"Botswana",
|
|
||||||
"Brazil",
|
|
||||||
"Brunei",
|
|
||||||
"Bulgaria",
|
|
||||||
"Burkina Faso",
|
|
||||||
"Burundi",
|
|
||||||
"Cabo Verde",
|
|
||||||
"Cambodia",
|
|
||||||
"Cameroon",
|
|
||||||
"Canada",
|
|
||||||
"Central African Republic",
|
|
||||||
"Chad",
|
|
||||||
"Chile",
|
|
||||||
"China",
|
|
||||||
"Colombia",
|
|
||||||
"Comoros",
|
|
||||||
"Congo",
|
|
||||||
"Costa Rica",
|
|
||||||
"Croatia",
|
|
||||||
"Cuba",
|
|
||||||
"Cyprus",
|
|
||||||
"Czech Republic",
|
|
||||||
"Denmark",
|
|
||||||
"Djibouti",
|
|
||||||
"Dominica",
|
|
||||||
"Dominican Republic",
|
|
||||||
"East Timor",
|
|
||||||
"Ecuador",
|
|
||||||
"Egypt",
|
|
||||||
"El Salvador",
|
|
||||||
"Equatorial Guinea",
|
|
||||||
"Eritrea",
|
|
||||||
"Estonia",
|
|
||||||
"Eswatini",
|
|
||||||
"Ethiopia",
|
|
||||||
"Fiji",
|
|
||||||
"Finland",
|
|
||||||
"France",
|
|
||||||
"Gabon",
|
|
||||||
"Gambia",
|
|
||||||
"Georgia",
|
|
||||||
"Germany",
|
|
||||||
"Ghana",
|
|
||||||
"Greece",
|
|
||||||
"Grenada",
|
|
||||||
"Guatemala",
|
|
||||||
"Guinea",
|
|
||||||
"Guinea-Bissau",
|
|
||||||
"Guyana",
|
|
||||||
"Haiti",
|
|
||||||
"Honduras",
|
|
||||||
"Hungary",
|
|
||||||
"Iceland",
|
|
||||||
"India",
|
|
||||||
"Indonesia",
|
|
||||||
"Iran",
|
|
||||||
"Iraq",
|
|
||||||
"Ireland",
|
|
||||||
"Israel",
|
|
||||||
"Italy",
|
|
||||||
"Ivory Coast",
|
|
||||||
"Jamaica",
|
|
||||||
"Japan",
|
|
||||||
"Jordan",
|
|
||||||
"Kazakhstan",
|
|
||||||
"Kenya",
|
|
||||||
"Kiribati",
|
|
||||||
"Kuwait",
|
|
||||||
"Kyrgyzstan",
|
|
||||||
"Laos",
|
|
||||||
"Latvia",
|
|
||||||
"Lebanon",
|
|
||||||
"Lesotho",
|
|
||||||
"Liberia",
|
|
||||||
"Libya",
|
|
||||||
"Liechtenstein",
|
|
||||||
"Lithuania",
|
|
||||||
"Luxembourg",
|
|
||||||
"Madagascar",
|
|
||||||
"Malawi",
|
|
||||||
"Malaysia",
|
|
||||||
"Maldives",
|
|
||||||
"Mali",
|
|
||||||
"Malta",
|
|
||||||
"Marshall Islands",
|
|
||||||
"Mauritania",
|
|
||||||
"Mauritius",
|
|
||||||
"Mexico",
|
|
||||||
"Micronesia",
|
|
||||||
"Moldova",
|
|
||||||
"Monaco",
|
|
||||||
"Mongolia",
|
|
||||||
"Montenegro",
|
|
||||||
"Morocco",
|
|
||||||
"Mozambique",
|
|
||||||
"Myanmar",
|
|
||||||
"Namibia",
|
|
||||||
"Nauru",
|
|
||||||
"Nepal",
|
|
||||||
"Netherlands",
|
|
||||||
"New Zealand",
|
|
||||||
"Nicaragua",
|
|
||||||
"Niger",
|
|
||||||
"Nigeria",
|
|
||||||
"North Korea",
|
|
||||||
"North Macedonia",
|
|
||||||
"Norway",
|
|
||||||
"Oman",
|
|
||||||
"Pakistan",
|
|
||||||
"Palau",
|
|
||||||
"Palestine",
|
|
||||||
"Panama",
|
|
||||||
"Papua New Guinea",
|
|
||||||
"Paraguay",
|
|
||||||
"Peru",
|
|
||||||
"Philippines",
|
|
||||||
"Poland",
|
|
||||||
"Portugal",
|
|
||||||
"Qatar",
|
|
||||||
"Romania",
|
|
||||||
"Russia",
|
|
||||||
"Rwanda",
|
|
||||||
"Saint Kitts and Nevis",
|
|
||||||
"Saint Lucia",
|
|
||||||
"Saint Vincent and the Grenadines",
|
|
||||||
"Samoa",
|
|
||||||
"San Marino",
|
|
||||||
"Sao Tome and Principe",
|
|
||||||
"Saudi Arabia",
|
|
||||||
"Senegal",
|
|
||||||
"Serbia",
|
|
||||||
"Seychelles",
|
|
||||||
"Sierra Leone",
|
|
||||||
"Singapore",
|
|
||||||
"Slovakia",
|
|
||||||
"Slovenia",
|
|
||||||
"Solomon Islands",
|
|
||||||
"Somalia",
|
|
||||||
"South Africa",
|
|
||||||
"South Korea",
|
|
||||||
"South Sudan",
|
|
||||||
"Spain",
|
|
||||||
"Sri Lanka",
|
|
||||||
"Sudan",
|
|
||||||
"Suriname",
|
|
||||||
"Sweden",
|
|
||||||
"Switzerland",
|
|
||||||
"Syria",
|
|
||||||
"Taiwan",
|
|
||||||
"Tajikistan",
|
|
||||||
"Tanzania",
|
|
||||||
"Thailand",
|
|
||||||
"Togo",
|
|
||||||
"Tonga",
|
|
||||||
"Trinidad and Tobago",
|
|
||||||
"Tunisia",
|
|
||||||
"Turkey",
|
|
||||||
"Turkmenistan",
|
|
||||||
"Tuvalu",
|
|
||||||
"Uganda",
|
|
||||||
"Ukraine",
|
|
||||||
"United Arab Emirates",
|
|
||||||
"United Kingdom",
|
|
||||||
"United States",
|
|
||||||
"Uruguay",
|
|
||||||
"Uzbekistan",
|
|
||||||
"Vanuatu",
|
|
||||||
"Vatican City",
|
|
||||||
"Venezuela",
|
|
||||||
"Vietnam",
|
|
||||||
"Yemen",
|
|
||||||
"Zambia",
|
|
||||||
"Zimbabwe",
|
|
||||||
];
|
|
||||||
const OTHER_COUNTRIES = ALL_COUNTRIES.filter(
|
|
||||||
(c) => !MOST_USED_COUNTRIES.includes(c),
|
|
||||||
).sort();
|
|
||||||
|
|
||||||
if (mode === "edit" && isLoadingClient) {
|
if (mode === "edit" && isLoadingClient) {
|
||||||
return (
|
return <FormSkeleton />;
|
||||||
<Card className="my-8 w-full border-0 bg-white/80 px-0 shadow-xl backdrop-blur-sm dark:bg-gray-800/80">
|
|
||||||
<CardContent className="p-8">
|
|
||||||
<FormSkeleton />
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="my-8 w-full border-0 bg-white/80 px-0 shadow-xl backdrop-blur-sm dark:bg-gray-800/80">
|
<div className="mx-auto max-w-6xl">
|
||||||
<CardContent>
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
<form onSubmit={handleSubmit} className="space-y-8">
|
{/* Main Form Container - styled like data table */}
|
||||||
{/* Basic Information Section */}
|
<div className="space-y-4">
|
||||||
<div className="space-y-6">
|
{/* Basic Information */}
|
||||||
<div className="flex items-center space-x-2 text-emerald-700 dark:text-emerald-400">
|
<Card>
|
||||||
<Building className="h-5 w-5" />
|
<CardHeader>
|
||||||
<h3 className="text-lg font-semibold dark:text-white">
|
<div className="flex items-center gap-3">
|
||||||
Business Information
|
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-r from-emerald-600/10 to-teal-600/10">
|
||||||
</h3>
|
<UserPlus className="h-5 w-5 text-emerald-700 dark:text-emerald-400" />
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
<div>
|
||||||
|
<CardTitle>Basic Information</CardTitle>
|
||||||
|
<p className="text-muted-foreground mt-1 text-sm">
|
||||||
|
Enter the client's primary details
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label
|
<Label htmlFor="name" className="text-sm font-medium">
|
||||||
htmlFor="name"
|
Client Name<span className="text-destructive ml-1">*</span>
|
||||||
className="text-sm font-medium text-gray-700 dark:text-gray-300"
|
|
||||||
>
|
|
||||||
Business Name / Full Name *
|
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="name"
|
id="name"
|
||||||
value={formData.name}
|
value={formData.name}
|
||||||
onChange={(e) => handleInputChange("name", e.target.value)}
|
onChange={(e) => handleInputChange("name", e.target.value)}
|
||||||
required
|
placeholder={PLACEHOLDERS.name}
|
||||||
placeholder="Enter business name or full name"
|
className={`${errors.name ? "border-destructive" : ""}`}
|
||||||
className="h-12 border-gray-200 focus:border-emerald-500 focus:ring-emerald-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
disabled={isSubmitting}
|
||||||
/>
|
/>
|
||||||
|
{errors.name && (
|
||||||
|
<p className="text-destructive text-sm">{errors.name}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
|
||||||
<Label
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
htmlFor="email"
|
<div className="space-y-2">
|
||||||
className="text-sm font-medium text-gray-700 dark:text-gray-300"
|
<Label htmlFor="email" className="text-sm font-medium">
|
||||||
>
|
Email
|
||||||
Email Address
|
<span className="text-muted-foreground ml-1 text-xs font-normal">
|
||||||
</Label>
|
(Optional)
|
||||||
<div className="relative">
|
</span>
|
||||||
<Mail className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 transform text-gray-400 dark:text-gray-500" />
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="email"
|
id="email"
|
||||||
type="email"
|
type="email"
|
||||||
value={formData.email}
|
value={formData.email}
|
||||||
onChange={(e) => handleInputChange("email", e.target.value)}
|
onChange={(e) => handleInputChange("email", e.target.value)}
|
||||||
placeholder="business@example.com"
|
placeholder={PLACEHOLDERS.email}
|
||||||
className="h-12 border-gray-200 pl-10 focus:border-emerald-500 focus:ring-emerald-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
className={`${errors.email ? "border-destructive" : ""}`}
|
||||||
|
disabled={isSubmitting}
|
||||||
/>
|
/>
|
||||||
|
{errors.email && (
|
||||||
|
<p className="text-destructive text-sm">{errors.email}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Contact Information Section */}
|
<div className="space-y-2">
|
||||||
<div className="space-y-6">
|
<Label htmlFor="phone" className="text-sm font-medium">
|
||||||
<div className="flex items-center space-x-2 text-emerald-700 dark:text-emerald-400">
|
Phone
|
||||||
<Phone className="h-5 w-5" />
|
<span className="text-muted-foreground ml-1 text-xs font-normal">
|
||||||
<h3 className="text-lg font-semibold dark:text-white">
|
(Optional)
|
||||||
Contact Information
|
</span>
|
||||||
</h3>
|
</Label>
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label
|
|
||||||
htmlFor="phone"
|
|
||||||
className="text-sm font-medium text-gray-700 dark:text-gray-300"
|
|
||||||
>
|
|
||||||
Phone Number
|
|
||||||
</Label>
|
|
||||||
<div className="relative">
|
|
||||||
<Phone className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 transform text-gray-400 dark:text-gray-500" />
|
|
||||||
<Input
|
<Input
|
||||||
id="phone"
|
id="phone"
|
||||||
type="tel"
|
type="tel"
|
||||||
value={formData.phone}
|
value={formData.phone}
|
||||||
onChange={(e) => handlePhoneChange(e.target.value)}
|
onChange={(e) => handlePhoneChange(e.target.value)}
|
||||||
placeholder="(555) 123-4567"
|
placeholder={PLACEHOLDERS.phone}
|
||||||
maxLength={14}
|
className={`${errors.phone ? "border-destructive" : ""}`}
|
||||||
className="h-12 border-gray-200 pl-10 focus:border-emerald-500 focus:ring-emerald-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
disabled={isSubmitting}
|
||||||
/>
|
/>
|
||||||
|
{errors.phone && (
|
||||||
|
<p className="text-destructive text-sm">{errors.phone}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
|
||||||
Format: (555) 123-4567
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</CardContent>
|
||||||
</div>
|
</Card>
|
||||||
|
|
||||||
{/* Address Section */}
|
{/* Address */}
|
||||||
<div className="space-y-6">
|
<Card>
|
||||||
<div className="flex items-center space-x-2 text-emerald-700 dark:text-emerald-400">
|
<CardHeader>
|
||||||
<MapPin className="h-5 w-5" />
|
<div className="flex items-center gap-3">
|
||||||
<h3 className="text-lg font-semibold dark:text-white">
|
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-r from-emerald-600/10 to-teal-600/10">
|
||||||
Address Information
|
<svg
|
||||||
</h3>
|
className="h-5 w-5 text-emerald-700 dark:text-emerald-400"
|
||||||
</div>
|
fill="none"
|
||||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
viewBox="0 0 24 24"
|
||||||
<div className="space-y-2">
|
stroke="currentColor"
|
||||||
<Label
|
>
|
||||||
htmlFor="addressLine1"
|
<path
|
||||||
className="text-sm font-medium text-gray-700 dark:text-gray-300"
|
strokeLinecap="round"
|
||||||
>
|
strokeLinejoin="round"
|
||||||
Address Line 1
|
strokeWidth={2}
|
||||||
</Label>
|
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"
|
||||||
<Input
|
/>
|
||||||
id="addressLine1"
|
<path
|
||||||
value={formData.addressLine1}
|
strokeLinecap="round"
|
||||||
onChange={(e) =>
|
strokeLinejoin="round"
|
||||||
handleInputChange("addressLine1", e.target.value)
|
strokeWidth={2}
|
||||||
}
|
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"
|
||||||
placeholder="123 Main Street"
|
/>
|
||||||
className="h-12 border-gray-200 focus:border-emerald-500 focus:ring-emerald-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
</svg>
|
||||||
/>
|
</div>
|
||||||
|
<div>
|
||||||
|
<CardTitle>Address</CardTitle>
|
||||||
|
<p className="text-muted-foreground mt-1 text-sm">
|
||||||
|
Client's physical location
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
</CardHeader>
|
||||||
<Label
|
<CardContent>
|
||||||
htmlFor="addressLine2"
|
<AddressForm
|
||||||
className="text-sm font-medium text-gray-700 dark:text-gray-300"
|
addressLine1={formData.addressLine1}
|
||||||
>
|
addressLine2={formData.addressLine2}
|
||||||
Address Line 2
|
city={formData.city}
|
||||||
</Label>
|
state={formData.state}
|
||||||
<Input
|
postalCode={formData.postalCode}
|
||||||
id="addressLine2"
|
country={formData.country}
|
||||||
value={formData.addressLine2}
|
onChange={handleInputChange}
|
||||||
onChange={(e) =>
|
errors={errors}
|
||||||
handleInputChange("addressLine2", e.target.value)
|
required={false}
|
||||||
}
|
/>
|
||||||
placeholder="Suite 100"
|
</CardContent>
|
||||||
className="h-12 border-gray-200 focus:border-emerald-500 focus:ring-emerald-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
</Card>
|
||||||
/>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-3">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label
|
|
||||||
htmlFor="city"
|
|
||||||
className="text-sm font-medium text-gray-700 dark:text-gray-300"
|
|
||||||
>
|
|
||||||
City
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="city"
|
|
||||||
value={formData.city}
|
|
||||||
onChange={(e) => handleInputChange("city", e.target.value)}
|
|
||||||
placeholder="New York"
|
|
||||||
className="h-12 border-gray-200 focus:border-emerald-500 focus:ring-emerald-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label
|
|
||||||
htmlFor="state"
|
|
||||||
className="text-sm font-medium text-gray-700 dark:text-gray-300"
|
|
||||||
>
|
|
||||||
State / Province
|
|
||||||
</Label>
|
|
||||||
<select
|
|
||||||
id="state"
|
|
||||||
value={formData.state}
|
|
||||||
onChange={(e) => handleInputChange("state", e.target.value)}
|
|
||||||
className="h-12 w-full rounded-md border border-gray-200 bg-white px-3 py-2 text-sm focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
|
||||||
>
|
|
||||||
{US_STATES.map((state) => (
|
|
||||||
<option key={state} value={state}>
|
|
||||||
{state || "Select State"}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label
|
|
||||||
htmlFor="postalCode"
|
|
||||||
className="text-sm font-medium text-gray-700 dark:text-gray-300"
|
|
||||||
>
|
|
||||||
Postal Code
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="postalCode"
|
|
||||||
value={formData.postalCode}
|
|
||||||
onChange={(e) =>
|
|
||||||
handleInputChange("postalCode", e.target.value)
|
|
||||||
}
|
|
||||||
placeholder="12345"
|
|
||||||
className="h-12 border-gray-200 focus:border-emerald-500 focus:ring-emerald-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label
|
|
||||||
htmlFor="country"
|
|
||||||
className="text-sm font-medium text-gray-700 dark:text-gray-300"
|
|
||||||
>
|
|
||||||
Country
|
|
||||||
</Label>
|
|
||||||
<select
|
|
||||||
id="country"
|
|
||||||
value={formData.country}
|
|
||||||
onChange={(e) => handleInputChange("country", e.target.value)}
|
|
||||||
className="h-12 w-full rounded-md border border-gray-200 bg-white px-3 py-2 text-sm focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
|
||||||
>
|
|
||||||
<option value="">Select Country</option>
|
|
||||||
<optgroup label="Most Used">
|
|
||||||
{MOST_USED_COUNTRIES.map((country) => (
|
|
||||||
<option key={country} value={country}>
|
|
||||||
{country}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</optgroup>
|
|
||||||
<optgroup label="All Countries">
|
|
||||||
{OTHER_COUNTRIES.map((country) => (
|
|
||||||
<option key={country} value={country}>
|
|
||||||
{country}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</optgroup>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Submit Button */}
|
{/* Form Actions - original position */}
|
||||||
<div className="flex gap-3 pt-6">
|
<div
|
||||||
|
ref={footerRef}
|
||||||
|
className="border-border/40 bg-background/60 flex items-center justify-between rounded-2xl border p-4 shadow-lg backdrop-blur-xl backdrop-saturate-150"
|
||||||
|
>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
{mode === "create"
|
||||||
|
? "Creating a new client"
|
||||||
|
: "Editing client details"}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleCancel}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="border-border/40 hover:bg-accent/50"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={loading}
|
disabled={isSubmitting || !isDirty}
|
||||||
className="bg-gradient-to-r from-emerald-600 to-teal-600 font-medium text-white shadow-lg transition-all duration-200 hover:from-emerald-700 hover:to-teal-700 hover:shadow-xl"
|
className="bg-gradient-to-r from-emerald-600 to-teal-600 shadow-md transition-all duration-200 hover:from-emerald-700 hover:to-teal-700 hover:shadow-lg"
|
||||||
>
|
>
|
||||||
{loading ? (
|
{isSubmitting ? (
|
||||||
<>
|
<>
|
||||||
<div className="mr-2 h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent" />
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
{mode === "create" ? "Creating..." : "Updating..."}
|
{mode === "create" ? "Creating..." : "Saving..."}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Save className="mr-2 h-4 w-4" />
|
<Save className="mr-2 h-4 w-4" />
|
||||||
{mode === "create" ? "Create Client" : "Update Client"}
|
{mode === "create" ? "Create Client" : "Save Changes"}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => router.push("/dashboard/clients")}
|
|
||||||
className="border-gray-300 font-medium text-gray-700 hover:bg-gray-50"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</div>
|
||||||
</CardContent>
|
</form>
|
||||||
</Card>
|
|
||||||
|
<FloatingActionBar
|
||||||
|
triggerRef={footerRef}
|
||||||
|
title={
|
||||||
|
mode === "create" ? "Creating a new client" : "Editing client details"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleCancel}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="border-border/40 hover:bg-accent/50"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={isSubmitting || !isDirty}
|
||||||
|
className="bg-gradient-to-r from-emerald-600 to-teal-600 shadow-md transition-all duration-200 hover:from-emerald-700 hover:to-teal-700 hover:shadow-lg"
|
||||||
|
>
|
||||||
|
{isSubmitting ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
{mode === "create" ? "Creating..." : "Saving..."}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Save className="mr-2 h-4 w-4" />
|
||||||
|
{mode === "create" ? "Create Client" : "Save Changes"}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</FloatingActionBar>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,29 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { AlertCircle, Clock, DollarSign, Eye, FileText, Trash2, Upload, Users } from "lucide-react";
|
import {
|
||||||
|
AlertCircle,
|
||||||
|
Clock,
|
||||||
|
DollarSign,
|
||||||
|
Eye,
|
||||||
|
FileText,
|
||||||
|
Trash2,
|
||||||
|
Upload,
|
||||||
|
Users,
|
||||||
|
} from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Badge } from "~/components/ui/badge";
|
import { Badge } from "~/components/ui/badge";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||||
import { DatePicker } from "~/components/ui/date-picker";
|
import { DatePicker } from "~/components/ui/date-picker";
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "~/components/ui/dialog";
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "~/components/ui/dialog";
|
||||||
import { FileUpload } from "~/components/ui/file-upload";
|
import { FileUpload } from "~/components/ui/file-upload";
|
||||||
import { Input } from "~/components/ui/input";
|
import { Input } from "~/components/ui/input";
|
||||||
import { Label } from "~/components/ui/label";
|
import { Label } from "~/components/ui/label";
|
||||||
@@ -47,12 +63,15 @@ export function CSVImportPage() {
|
|||||||
const [files, setFiles] = useState<FileData[]>([]);
|
const [files, setFiles] = useState<FileData[]>([]);
|
||||||
const [globalClientId, setGlobalClientId] = useState("");
|
const [globalClientId, setGlobalClientId] = useState("");
|
||||||
const [previewModalOpen, setPreviewModalOpen] = useState(false);
|
const [previewModalOpen, setPreviewModalOpen] = useState(false);
|
||||||
const [selectedFileIndex, setSelectedFileIndex] = useState<number | null>(null);
|
const [selectedFileIndex, setSelectedFileIndex] = useState<number | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
const [isProcessing, setIsProcessing] = useState(false);
|
const [isProcessing, setIsProcessing] = useState(false);
|
||||||
const [progressCount, setProgressCount] = useState(0);
|
const [progressCount, setProgressCount] = useState(0);
|
||||||
|
|
||||||
// Fetch clients for dropdown
|
// Fetch clients for dropdown
|
||||||
const { data: clients, isLoading: loadingClients } = api.clients.getAll.useQuery();
|
const { data: clients, isLoading: loadingClients } =
|
||||||
|
api.clients.getAll.useQuery();
|
||||||
|
|
||||||
const createInvoice = api.invoices.create.useMutation({
|
const createInvoice = api.invoices.create.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -65,7 +84,7 @@ export function CSVImportPage() {
|
|||||||
|
|
||||||
const parseCSVLine = (line: string): string[] => {
|
const parseCSVLine = (line: string): string[] => {
|
||||||
const result: string[] = [];
|
const result: string[] = [];
|
||||||
let current = '';
|
let current = "";
|
||||||
let inQuotes = false;
|
let inQuotes = false;
|
||||||
let i = 0;
|
let i = 0;
|
||||||
|
|
||||||
@@ -83,10 +102,10 @@ export function CSVImportPage() {
|
|||||||
inQuotes = !inQuotes;
|
inQuotes = !inQuotes;
|
||||||
i++;
|
i++;
|
||||||
}
|
}
|
||||||
} else if (char === ',' && !inQuotes) {
|
} else if (char === "," && !inQuotes) {
|
||||||
// End of field
|
// End of field
|
||||||
result.push(current.trim());
|
result.push(current.trim());
|
||||||
current = '';
|
current = "";
|
||||||
i++;
|
i++;
|
||||||
} else {
|
} else {
|
||||||
// Regular character
|
// Regular character
|
||||||
@@ -101,39 +120,40 @@ export function CSVImportPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const parseCSV = (csvText: string): CSVRow[] => {
|
const parseCSV = (csvText: string): CSVRow[] => {
|
||||||
const lines = csvText.split('\n');
|
const lines = csvText.split("\n");
|
||||||
const headers = parseCSVLine(lines[0] ?? '');
|
const headers = parseCSVLine(lines[0] ?? "");
|
||||||
|
|
||||||
// Validate headers
|
// Validate headers
|
||||||
const requiredHeaders = ['DATE', 'DESCRIPTION', 'HOURS', 'RATE', 'AMOUNT'];
|
const requiredHeaders = ["DATE", "DESCRIPTION", "HOURS", "RATE", "AMOUNT"];
|
||||||
const missingHeaders = requiredHeaders.filter(h => !headers?.includes(h));
|
const missingHeaders = requiredHeaders.filter((h) => !headers?.includes(h));
|
||||||
|
|
||||||
if (missingHeaders.length > 0) {
|
if (missingHeaders.length > 0) {
|
||||||
throw new Error(`Missing required headers: ${missingHeaders.join(', ')}`);
|
throw new Error(`Missing required headers: ${missingHeaders.join(", ")}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return lines.slice(1)
|
return lines
|
||||||
.filter(line => line.trim())
|
.slice(1)
|
||||||
.map(line => {
|
.filter((line) => line.trim())
|
||||||
|
.map((line) => {
|
||||||
const values = parseCSVLine(line);
|
const values = parseCSVLine(line);
|
||||||
return {
|
return {
|
||||||
DATE: values[0] ?? '',
|
DATE: values[0] ?? "",
|
||||||
DESCRIPTION: values[1] ?? '',
|
DESCRIPTION: values[1] ?? "",
|
||||||
HOURS: parseFloat(values[2] ?? '0') || 0,
|
HOURS: parseFloat(values[2] ?? "0") || 0,
|
||||||
RATE: parseFloat(values[3] ?? '0') || 0,
|
RATE: parseFloat(values[3] ?? "0") || 0,
|
||||||
AMOUNT: parseFloat(values[4] ?? '0') || 0,
|
AMOUNT: parseFloat(values[4] ?? "0") || 0,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.filter(row => row.DESCRIPTION && row.HOURS > 0 && row.RATE > 0);
|
.filter((row) => row.DESCRIPTION && row.HOURS > 0 && row.RATE > 0);
|
||||||
};
|
};
|
||||||
|
|
||||||
const parseDate = (dateStr: string): Date => {
|
const parseDate = (dateStr: string): Date => {
|
||||||
// Handle m/dd/yy format
|
// Handle m/dd/yy format
|
||||||
const parts = dateStr.split('/');
|
const parts = dateStr.split("/");
|
||||||
if (parts.length === 3) {
|
if (parts.length === 3) {
|
||||||
const month = parseInt(parts[0] ?? '1') - 1; // 0-based month
|
const month = parseInt(parts[0] ?? "1") - 1; // 0-based month
|
||||||
const day = parseInt(parts[1] ?? '1');
|
const day = parseInt(parts[1] ?? "1");
|
||||||
const year = parseInt(parts[2] ?? '2000') + 2000; // Assume 20xx
|
const year = parseInt(parts[2] ?? "2000") + 2000; // Assume 20xx
|
||||||
return new Date(year, month, day);
|
return new Date(year, month, day);
|
||||||
}
|
}
|
||||||
// Fallback to standard date parsing
|
// Fallback to standard date parsing
|
||||||
@@ -169,7 +189,7 @@ export function CSVImportPage() {
|
|||||||
const csvData = parseCSV(text);
|
const csvData = parseCSV(text);
|
||||||
|
|
||||||
// Parse items for invoice creation
|
// Parse items for invoice creation
|
||||||
const items = csvData.map(row => ({
|
const items = csvData.map((row) => ({
|
||||||
date: parseDate(row.DATE),
|
date: parseDate(row.DATE),
|
||||||
description: row.DESCRIPTION,
|
description: row.DESCRIPTION,
|
||||||
hours: row.HOURS,
|
hours: row.HOURS,
|
||||||
@@ -181,24 +201,29 @@ export function CSVImportPage() {
|
|||||||
file,
|
file,
|
||||||
parsedItems: items,
|
parsedItems: items,
|
||||||
previewData: csvData,
|
previewData: csvData,
|
||||||
invoiceNumber: issueDate ? `INV-${issueDate.toISOString().slice(0, 10).replace(/-/g, '')}-${Date.now().toString().slice(-6)}` : `INV-${Date.now()}`,
|
invoiceNumber: issueDate
|
||||||
|
? `INV-${issueDate.toISOString().slice(0, 10).replace(/-/g, "")}-${Date.now().toString().slice(-6)}`
|
||||||
|
: `INV-${Date.now()}`,
|
||||||
clientId: globalClientId, // Use global client if set
|
clientId: globalClientId, // Use global client if set
|
||||||
issueDate,
|
issueDate,
|
||||||
dueDate,
|
dueDate,
|
||||||
status: errors.length > 0 ? "error" : "pending",
|
status: errors.length > 0 ? "error" : "pending",
|
||||||
errors,
|
errors,
|
||||||
hasDateError
|
hasDateError,
|
||||||
};
|
};
|
||||||
|
|
||||||
setFiles(prev => [...prev, fileData]);
|
setFiles((prev) => [...prev, fileData]);
|
||||||
|
|
||||||
if (errors.length > 0) {
|
if (errors.length > 0) {
|
||||||
toast.error(`${file.name} has ${errors.length} error${errors.length > 1 ? 's' : ''}`);
|
toast.error(
|
||||||
|
`${file.name} has ${errors.length} error${errors.length > 1 ? "s" : ""}`,
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
toast.success(`Parsed ${items.length} items from ${file.name}`);
|
toast.success(`Parsed ${items.length} items from ${file.name}`);
|
||||||
}
|
}
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred';
|
const errorMessage =
|
||||||
|
error instanceof Error ? error.message : "Unknown error occurred";
|
||||||
const fileData: FileData = {
|
const fileData: FileData = {
|
||||||
file,
|
file,
|
||||||
parsedItems: [],
|
parsedItems: [],
|
||||||
@@ -209,61 +234,74 @@ export function CSVImportPage() {
|
|||||||
dueDate: null,
|
dueDate: null,
|
||||||
status: "error",
|
status: "error",
|
||||||
errors: [`Error parsing CSV: ${errorMessage}`],
|
errors: [`Error parsing CSV: ${errorMessage}`],
|
||||||
hasDateError: true
|
hasDateError: true,
|
||||||
};
|
};
|
||||||
setFiles(prev => [...prev, fileData]);
|
setFiles((prev) => [...prev, fileData]);
|
||||||
toast.error(`Error parsing ${file.name}: ${errorMessage}`);
|
toast.error(`Error parsing ${file.name}: ${errorMessage}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeFile = (index: number) => {
|
const removeFile = (index: number) => {
|
||||||
setFiles(prev => prev.filter((_, i) => i !== index));
|
setFiles((prev) => prev.filter((_, i) => i !== index));
|
||||||
};
|
};
|
||||||
|
|
||||||
// Apply global client to all files that don't have a client selected
|
// Apply global client to all files that don't have a client selected
|
||||||
const applyGlobalClient = (clientId: string) => {
|
const applyGlobalClient = (clientId: string) => {
|
||||||
setFiles(prev => prev.map(file => ({
|
setFiles((prev) =>
|
||||||
...file,
|
prev.map((file) => ({
|
||||||
clientId: file.clientId || clientId // Only apply if no client is already selected
|
...file,
|
||||||
})));
|
clientId: file.clientId || clientId, // Only apply if no client is already selected
|
||||||
|
})),
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateFileData = (index: number, updates: Partial<FileData>) => {
|
const updateFileData = (index: number, updates: Partial<FileData>) => {
|
||||||
setFiles(prev => prev.map((file, i) => {
|
setFiles((prev) =>
|
||||||
if (i !== index) return file;
|
prev.map((file, i) => {
|
||||||
|
if (i !== index) return file;
|
||||||
const updatedFile = { ...file, ...updates };
|
|
||||||
|
const updatedFile = { ...file, ...updates };
|
||||||
// Recalculate errors if issue date or due date was updated
|
|
||||||
if (updates.issueDate !== undefined || updates.dueDate !== undefined) {
|
// Recalculate errors if issue date or due date was updated
|
||||||
const newErrors = [...updatedFile.errors];
|
if (updates.issueDate !== undefined || updates.dueDate !== undefined) {
|
||||||
|
const newErrors = [...updatedFile.errors];
|
||||||
// Remove filename format error if a valid issue date is now set
|
|
||||||
if (updatedFile.issueDate && newErrors.includes("Filename must be in YYYY-MM-DD.csv format")) {
|
// Remove filename format error if a valid issue date is now set
|
||||||
const errorIndex = newErrors.indexOf("Filename must be in YYYY-MM-DD.csv format");
|
if (
|
||||||
if (errorIndex > -1) {
|
updatedFile.issueDate &&
|
||||||
newErrors.splice(errorIndex, 1);
|
newErrors.includes("Filename must be in YYYY-MM-DD.csv format")
|
||||||
|
) {
|
||||||
|
const errorIndex = newErrors.indexOf(
|
||||||
|
"Filename must be in YYYY-MM-DD.csv format",
|
||||||
|
);
|
||||||
|
if (errorIndex > -1) {
|
||||||
|
newErrors.splice(errorIndex, 1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
// Remove invalid date error if a valid issue date is now set
|
||||||
// Remove invalid date error if a valid issue date is now set
|
if (
|
||||||
if (updatedFile.issueDate && newErrors.includes("Invalid date in filename")) {
|
updatedFile.issueDate &&
|
||||||
const errorIndex = newErrors.indexOf("Invalid date in filename");
|
newErrors.includes("Invalid date in filename")
|
||||||
if (errorIndex > -1) {
|
) {
|
||||||
newErrors.splice(errorIndex, 1);
|
const errorIndex = newErrors.indexOf("Invalid date in filename");
|
||||||
|
if (errorIndex > -1) {
|
||||||
|
newErrors.splice(errorIndex, 1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updatedFile.errors = newErrors;
|
||||||
|
updatedFile.status = newErrors.length > 0 ? "error" : "pending";
|
||||||
|
updatedFile.hasDateError = newErrors.some(
|
||||||
|
(error) =>
|
||||||
|
error.includes("Filename") || error.includes("Invalid date"),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
updatedFile.errors = newErrors;
|
return updatedFile;
|
||||||
updatedFile.status = newErrors.length > 0 ? "error" : "pending";
|
}),
|
||||||
updatedFile.hasDateError = newErrors.some(error =>
|
);
|
||||||
error.includes("Filename") || error.includes("Invalid date")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return updatedFile;
|
|
||||||
}));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const openPreview = (index: number) => {
|
const openPreview = (index: number) => {
|
||||||
@@ -273,13 +311,13 @@ export function CSVImportPage() {
|
|||||||
|
|
||||||
const validateFiles = () => {
|
const validateFiles = () => {
|
||||||
const errors: string[] = [];
|
const errors: string[] = [];
|
||||||
|
|
||||||
files.forEach((fileData) => {
|
files.forEach((fileData) => {
|
||||||
// Check for existing errors
|
// Check for existing errors
|
||||||
if (fileData.errors.length > 0) {
|
if (fileData.errors.length > 0) {
|
||||||
errors.push(`${fileData.file.name}: ${fileData.errors.join(', ')}`);
|
errors.push(`${fileData.file.name}: ${fileData.errors.join(", ")}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!fileData.clientId && !globalClientId) {
|
if (!fileData.clientId && !globalClientId) {
|
||||||
errors.push(`${fileData.file.name}: Client not selected`);
|
errors.push(`${fileData.file.name}: Client not selected`);
|
||||||
}
|
}
|
||||||
@@ -300,7 +338,7 @@ export function CSVImportPage() {
|
|||||||
const processBatch = async () => {
|
const processBatch = async () => {
|
||||||
const errors = validateFiles();
|
const errors = validateFiles();
|
||||||
if (errors.length > 0) {
|
if (errors.length > 0) {
|
||||||
toast.error(`Please fix the following issues:\n${errors.join('\n')}`);
|
toast.error(`Please fix the following issues:\n${errors.join("\n")}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -336,7 +374,7 @@ export function CSVImportPage() {
|
|||||||
dueDate: fileData.dueDate,
|
dueDate: fileData.dueDate,
|
||||||
status: "draft" as const,
|
status: "draft" as const,
|
||||||
notes: `Imported from CSV: ${fileData.file.name}`,
|
notes: `Imported from CSV: ${fileData.file.name}`,
|
||||||
items: fileData.parsedItems.map(item => ({
|
items: fileData.parsedItems.map((item) => ({
|
||||||
date: item.date,
|
date: item.date,
|
||||||
description: item.description,
|
description: item.description,
|
||||||
hours: item.hours,
|
hours: item.hours,
|
||||||
@@ -345,26 +383,36 @@ export function CSVImportPage() {
|
|||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log('Creating invoice with data:', invoiceData);
|
console.log("Creating invoice with data:", invoiceData);
|
||||||
await createInvoice.mutateAsync(invoiceData);
|
await createInvoice.mutateAsync(invoiceData);
|
||||||
console.log('Invoice created successfully');
|
console.log("Invoice created successfully");
|
||||||
successCount++;
|
successCount++;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
errorCount++;
|
errorCount++;
|
||||||
console.error(`Failed to create invoice for ${fileData.file.name}:`, error);
|
console.error(
|
||||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
`Failed to create invoice for ${fileData.file.name}:`,
|
||||||
toast.error(`Failed to create invoice for ${fileData.file.name}: ${errorMessage}`);
|
error,
|
||||||
|
);
|
||||||
|
const errorMessage =
|
||||||
|
error instanceof Error ? error.message : "Unknown error";
|
||||||
|
toast.error(
|
||||||
|
`Failed to create invoice for ${fileData.file.name}: ${errorMessage}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
setProgressCount(prev => prev + 1);
|
setProgressCount((prev) => prev + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsProcessing(false);
|
setIsProcessing(false);
|
||||||
|
|
||||||
if (successCount > 0) {
|
if (successCount > 0) {
|
||||||
toast.success(`Successfully created ${successCount} invoice${successCount > 1 ? 's' : ''}`);
|
toast.success(
|
||||||
|
`Successfully created ${successCount} invoice${successCount > 1 ? "s" : ""}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (errorCount > 0) {
|
if (errorCount > 0) {
|
||||||
toast.error(`Failed to create ${errorCount} invoice${errorCount > 1 ? 's' : ''}`);
|
toast.error(
|
||||||
|
`Failed to create ${errorCount} invoice${errorCount > 1 ? "s" : ""}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (successCount > 0) {
|
if (successCount > 0) {
|
||||||
@@ -373,19 +421,24 @@ export function CSVImportPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const totalFiles = files.length;
|
const totalFiles = files.length;
|
||||||
const readyFiles = files.filter(f =>
|
const readyFiles = files.filter(
|
||||||
f.errors.length === 0 &&
|
(f) =>
|
||||||
(f.clientId || globalClientId) &&
|
f.errors.length === 0 &&
|
||||||
f.issueDate &&
|
(f.clientId || globalClientId) &&
|
||||||
f.dueDate
|
f.issueDate &&
|
||||||
|
f.dueDate,
|
||||||
).length;
|
).length;
|
||||||
const totalItems = files.reduce((sum, f) => sum + f.parsedItems.length, 0);
|
const totalItems = files.reduce((sum, f) => sum + f.parsedItems.length, 0);
|
||||||
const totalAmount = files.reduce((sum, f) => sum + f.parsedItems.reduce((itemSum, item) => itemSum + item.amount, 0), 0);
|
const totalAmount = files.reduce(
|
||||||
|
(sum, f) =>
|
||||||
|
sum + f.parsedItems.reduce((itemSum, item) => itemSum + item.amount, 0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Global Client Selection */}
|
{/* Global Client Selection */}
|
||||||
<Card className="shadow-xl border-0 bg-white/80 backdrop-blur-sm">
|
<Card className="border-0 bg-white/80 shadow-xl backdrop-blur-sm">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2 text-emerald-800">
|
<CardTitle className="flex items-center gap-2 text-emerald-800">
|
||||||
<Users className="h-5 w-5" />
|
<Users className="h-5 w-5" />
|
||||||
@@ -394,7 +447,7 @@ export function CSVImportPage() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="global-client" className="text-sm font-medium text-gray-700">
|
<Label htmlFor="global-client" className="text-sm font-medium">
|
||||||
Select Default Client (Optional)
|
Select Default Client (Optional)
|
||||||
</Label>
|
</Label>
|
||||||
<select
|
<select
|
||||||
@@ -411,19 +464,22 @@ export function CSVImportPage() {
|
|||||||
disabled={loadingClients}
|
disabled={loadingClients}
|
||||||
>
|
>
|
||||||
<option value="">No default client (select individually)</option>
|
<option value="">No default client (select individually)</option>
|
||||||
{clients?.map(client => (
|
{clients?.map((client) => (
|
||||||
<option key={client.id} value={client.id}>{client.name}</option>
|
<option key={client.id} value={client.id}>
|
||||||
|
{client.name}
|
||||||
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
<p className="text-xs text-gray-500">
|
<p className="text-xs text-gray-500">
|
||||||
This client will be automatically selected for all uploaded files. You can still change individual files below.
|
This client will be automatically selected for all uploaded files.
|
||||||
|
You can still change individual files below.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* File Upload Area */}
|
{/* File Upload Area */}
|
||||||
<Card className="shadow-xl border-0 bg-white/80 backdrop-blur-sm">
|
<Card className="border-0 bg-white/80 shadow-xl backdrop-blur-sm">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2 text-emerald-800">
|
<CardTitle className="flex items-center gap-2 text-emerald-800">
|
||||||
<Upload className="h-5 w-5" />
|
<Upload className="h-5 w-5" />
|
||||||
@@ -442,24 +498,33 @@ export function CSVImportPage() {
|
|||||||
|
|
||||||
{/* Summary Stats */}
|
{/* Summary Stats */}
|
||||||
{totalFiles > 0 && (
|
{totalFiles > 0 && (
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 p-4 bg-emerald-50/50 rounded-lg">
|
<div className="grid grid-cols-2 gap-4 rounded-lg bg-emerald-50/50 p-4 md:grid-cols-4">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="text-2xl font-bold text-emerald-600">{totalFiles}</div>
|
<div className="text-2xl font-bold text-emerald-600">
|
||||||
|
{totalFiles}
|
||||||
|
</div>
|
||||||
<div className="text-sm text-gray-600">Files</div>
|
<div className="text-sm text-gray-600">Files</div>
|
||||||
<div className="text-xs text-gray-500">of 50 max</div>
|
<div className="text-xs text-gray-500">of 50 max</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="text-2xl font-bold text-emerald-600">{totalItems}</div>
|
<div className="text-2xl font-bold text-emerald-600">
|
||||||
|
{totalItems}
|
||||||
|
</div>
|
||||||
<div className="text-sm text-gray-600">Total Items</div>
|
<div className="text-sm text-gray-600">Total Items</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="text-2xl font-bold text-emerald-600">
|
<div className="text-2xl font-bold text-emerald-600">
|
||||||
{totalAmount.toLocaleString('en-US', { style: 'currency', currency: 'USD' })}
|
{totalAmount.toLocaleString("en-US", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "USD",
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-gray-600">Total Amount</div>
|
<div className="text-sm text-gray-600">Total Amount</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="text-2xl font-bold text-emerald-600">{readyFiles}/{totalFiles}</div>
|
<div className="text-2xl font-bold text-emerald-600">
|
||||||
|
{readyFiles}/{totalFiles}
|
||||||
|
</div>
|
||||||
<div className="text-sm text-gray-600">Ready</div>
|
<div className="text-sm text-gray-600">Ready</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -469,21 +534,30 @@ export function CSVImportPage() {
|
|||||||
|
|
||||||
{/* File List */}
|
{/* File List */}
|
||||||
{files.length > 0 && (
|
{files.length > 0 && (
|
||||||
<Card className="shadow-xl border-0 bg-white/80 backdrop-blur-sm">
|
<Card className="border-0 bg-white/80 shadow-xl backdrop-blur-sm">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-emerald-800">Uploaded Files</CardTitle>
|
<CardTitle className="text-emerald-800">Uploaded Files</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{files.map((fileData, index) => (
|
{files.map((fileData, index) => (
|
||||||
<div key={index} className="border border-gray-200 rounded-lg p-4 bg-white">
|
<div
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-4">
|
key={index}
|
||||||
|
className="rounded-lg border border-gray-200 bg-white p-4"
|
||||||
|
>
|
||||||
|
<div className="mb-4 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<FileText className="h-5 w-5 text-emerald-600" />
|
<FileText className="h-5 w-5 text-emerald-600" />
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-medium text-gray-900 truncate">{fileData.file.name}</h3>
|
<h3 className="truncate font-medium text-gray-900">
|
||||||
|
{fileData.file.name}
|
||||||
|
</h3>
|
||||||
<p className="text-sm text-gray-500">
|
<p className="text-sm text-gray-500">
|
||||||
{fileData.parsedItems.length} items • {fileData.parsedItems.reduce((sum, item) => sum + item.hours, 0).toFixed(1)} hours
|
{fileData.parsedItems.length} items •{" "}
|
||||||
|
{fileData.parsedItems
|
||||||
|
.reduce((sum, item) => sum + item.hours, 0)
|
||||||
|
.toFixed(1)}{" "}
|
||||||
|
hours
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -508,62 +582,81 @@ export function CSVImportPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label className="text-xs font-medium text-gray-700">Invoice Number</Label>
|
<Label className="text-xs font-medium text-gray-700">
|
||||||
|
Invoice Number
|
||||||
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
value={fileData.invoiceNumber}
|
value={fileData.invoiceNumber}
|
||||||
className="h-9 text-sm bg-gray-50"
|
className="h-9 text-sm"
|
||||||
placeholder="Auto-generated"
|
placeholder="Auto-generated"
|
||||||
readOnly
|
readOnly
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label className="text-xs font-medium text-gray-700">Client</Label>
|
<Label className="text-xs font-medium">Client</Label>
|
||||||
<select
|
<select
|
||||||
value={fileData.clientId}
|
value={fileData.clientId}
|
||||||
onChange={(e) => updateFileData(index, { clientId: e.target.value })}
|
onChange={(e) =>
|
||||||
className="h-9 w-full rounded-md border border-gray-200 bg-white px-3 py-1 text-sm text-gray-700 focus:border-emerald-500 focus:ring-emerald-500"
|
updateFileData(index, { clientId: e.target.value })
|
||||||
|
}
|
||||||
|
className="h-9 w-full rounded-md border px-3 py-1 text-sm"
|
||||||
>
|
>
|
||||||
<option value="">Select client</option>
|
<option value="">Select client</option>
|
||||||
{clients?.map(client => (
|
{clients?.map((client) => (
|
||||||
<option key={client.id} value={client.id}>{client.name}</option>
|
<option key={client.id} value={client.id}>
|
||||||
|
{client.name}
|
||||||
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label className="text-xs font-medium text-gray-700">Issue Date</Label>
|
<Label className="text-xs font-medium text-gray-700">
|
||||||
|
Issue Date
|
||||||
|
</Label>
|
||||||
<DatePicker
|
<DatePicker
|
||||||
date={fileData.issueDate ?? undefined}
|
date={fileData.issueDate ?? undefined}
|
||||||
onDateChange={(date) => updateFileData(index, { issueDate: date ?? null })}
|
onDateChange={(date) =>
|
||||||
|
updateFileData(index, { issueDate: date ?? null })
|
||||||
|
}
|
||||||
placeholder="Select issue date"
|
placeholder="Select issue date"
|
||||||
className="h-9"
|
className="h-9"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label className="text-xs font-medium text-gray-700">Due Date</Label>
|
<Label className="text-xs font-medium text-gray-700">
|
||||||
|
Due Date
|
||||||
|
</Label>
|
||||||
<DatePicker
|
<DatePicker
|
||||||
date={fileData.dueDate ?? undefined}
|
date={fileData.dueDate ?? undefined}
|
||||||
onDateChange={(date) => updateFileData(index, { dueDate: date ?? null })}
|
onDateChange={(date) =>
|
||||||
|
updateFileData(index, { dueDate: date ?? null })
|
||||||
|
}
|
||||||
placeholder="Select due date"
|
placeholder="Select due date"
|
||||||
className="h-9"
|
className="h-9"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Error Display */}
|
{/* Error Display */}
|
||||||
{fileData.errors.length > 0 && (
|
{fileData.errors.length > 0 && (
|
||||||
<div className="mt-4 p-3 bg-red-50 border border-red-200 rounded-lg">
|
<div className="mt-4 rounded-lg border border-red-200 bg-red-50 p-3">
|
||||||
<div className="flex items-center gap-2 mb-2">
|
<div className="mb-2 flex items-center gap-2">
|
||||||
<AlertCircle className="h-4 w-4 text-red-600" />
|
<AlertCircle className="h-4 w-4 text-red-600" />
|
||||||
<span className="text-sm font-medium text-red-800">Issues Found</span>
|
<span className="text-sm font-medium text-red-800">
|
||||||
|
Issues Found
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<ul className="text-sm text-red-700 space-y-1">
|
<ul className="space-y-1 text-sm text-red-700">
|
||||||
{fileData.errors.map((error, errorIndex) => (
|
{fileData.errors.map((error, errorIndex) => (
|
||||||
<li key={errorIndex} className="flex items-start gap-2">
|
<li
|
||||||
|
key={errorIndex}
|
||||||
|
className="flex items-start gap-2"
|
||||||
|
>
|
||||||
<span className="text-red-600">•</span>
|
<span className="text-red-600">•</span>
|
||||||
<span>{error}</span>
|
<span>{error}</span>
|
||||||
</li>
|
</li>
|
||||||
@@ -574,20 +667,39 @@ export function CSVImportPage() {
|
|||||||
|
|
||||||
<div className="mt-4 flex items-center justify-between">
|
<div className="mt-4 flex items-center justify-between">
|
||||||
<div className="text-sm text-gray-600">
|
<div className="text-sm text-gray-600">
|
||||||
Total: {fileData.parsedItems.reduce((sum, item) => sum + item.amount, 0).toLocaleString('en-US', { style: 'currency', currency: 'USD' })}
|
Total:{" "}
|
||||||
|
{fileData.parsedItems
|
||||||
|
.reduce((sum, item) => sum + item.amount, 0)
|
||||||
|
.toLocaleString("en-US", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "USD",
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{fileData.errors.length > 0 && (
|
{fileData.errors.length > 0 && (
|
||||||
<Badge variant="destructive" className="text-xs">
|
<Badge variant="destructive" className="text-xs">
|
||||||
{fileData.errors.length} Error{fileData.errors.length !== 1 ? 's' : ''}
|
{fileData.errors.length} Error
|
||||||
|
{fileData.errors.length !== 1 ? "s" : ""}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
<Badge variant={
|
<Badge
|
||||||
fileData.errors.length > 0 ? "destructive" :
|
variant={
|
||||||
(fileData.clientId || globalClientId) && fileData.issueDate && fileData.dueDate ? "default" : "secondary"
|
fileData.errors.length > 0
|
||||||
}>
|
? "destructive"
|
||||||
{fileData.errors.length > 0 ? "Has Errors" :
|
: (fileData.clientId || globalClientId) &&
|
||||||
(fileData.clientId || globalClientId) && fileData.issueDate && fileData.dueDate ? "Ready" : "Pending"}
|
fileData.issueDate &&
|
||||||
|
fileData.dueDate
|
||||||
|
? "default"
|
||||||
|
: "secondary"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{fileData.errors.length > 0
|
||||||
|
? "Has Errors"
|
||||||
|
: (fileData.clientId || globalClientId) &&
|
||||||
|
fileData.issueDate &&
|
||||||
|
fileData.dueDate
|
||||||
|
? "Ready"
|
||||||
|
: "Pending"}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -600,25 +712,31 @@ export function CSVImportPage() {
|
|||||||
|
|
||||||
{/* Batch Actions */}
|
{/* Batch Actions */}
|
||||||
{files.length > 0 && (
|
{files.length > 0 && (
|
||||||
<Card className="shadow-xl border-0 bg-white/80 backdrop-blur-sm">
|
<Card className="border-0 bg-white/80 shadow-xl backdrop-blur-sm">
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
{isProcessing && (
|
{isProcessing && (
|
||||||
<div className="w-full flex flex-col gap-2">
|
<div className="flex w-full flex-col gap-2">
|
||||||
<span className="text-xs text-gray-500">Uploading invoices...</span>
|
<span className="text-xs text-gray-500">
|
||||||
<Progress value={Math.round((progressCount / totalFiles) * 100)} />
|
Uploading invoices...
|
||||||
|
</span>
|
||||||
|
<Progress
|
||||||
|
value={Math.round((progressCount / totalFiles) * 100)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex items-center justify-between">
|
||||||
<div className="text-sm text-gray-600">
|
<div className="text-sm text-gray-600">
|
||||||
{readyFiles} of {totalFiles} files ready for import
|
{readyFiles} of {totalFiles} files ready for import
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
onClick={processBatch}
|
onClick={processBatch}
|
||||||
disabled={readyFiles === 0 || isProcessing}
|
disabled={readyFiles === 0 || isProcessing}
|
||||||
className="bg-gradient-to-r from-emerald-600 to-teal-600 hover:from-emerald-700 hover:to-teal-700 text-white"
|
className="bg-gradient-to-r from-emerald-600 to-teal-600 text-white hover:from-emerald-700 hover:to-teal-700"
|
||||||
>
|
>
|
||||||
{isProcessing ? "Processing..." : `Import ${readyFiles} Invoice${readyFiles !== 1 ? 's' : ''}`}
|
{isProcessing
|
||||||
|
? "Processing..."
|
||||||
|
: `Import ${readyFiles} Invoice${readyFiles !== 1 ? "s" : ""}`}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -628,11 +746,12 @@ export function CSVImportPage() {
|
|||||||
|
|
||||||
{/* Preview Modal */}
|
{/* Preview Modal */}
|
||||||
<Dialog open={previewModalOpen} onOpenChange={setPreviewModalOpen}>
|
<Dialog open={previewModalOpen} onOpenChange={setPreviewModalOpen}>
|
||||||
<DialogContent className="max-w-4xl max-h-[90vh] flex flex-col bg-white/95 backdrop-blur-sm border-0 shadow-2xl">
|
<DialogContent className="flex max-h-[90vh] max-w-4xl flex-col border-0 bg-white/95 shadow-2xl backdrop-blur-sm">
|
||||||
<DialogHeader className="flex-shrink-0">
|
<DialogHeader className="flex-shrink-0">
|
||||||
<DialogTitle className="text-xl font-bold text-gray-800 flex items-center gap-2">
|
<DialogTitle className="flex items-center gap-2 text-xl font-bold text-gray-800">
|
||||||
<FileText className="h-5 w-5 text-emerald-600" />
|
<FileText className="h-5 w-5 text-emerald-600" />
|
||||||
{selectedFileIndex !== null && files[selectedFileIndex]?.file.name}
|
{selectedFileIndex !== null &&
|
||||||
|
files[selectedFileIndex]?.file.name}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription className="text-gray-600">
|
<DialogDescription className="text-gray-600">
|
||||||
Preview of parsed CSV data
|
Preview of parsed CSV data
|
||||||
@@ -640,49 +759,90 @@ export function CSVImportPage() {
|
|||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
{selectedFileIndex !== null && files[selectedFileIndex] && (
|
{selectedFileIndex !== null && files[selectedFileIndex] && (
|
||||||
<div className="flex-1 flex flex-col min-h-0 space-y-4">
|
<div className="flex min-h-0 flex-1 flex-col space-y-4">
|
||||||
<div className="flex-shrink-0 grid grid-cols-1 md:grid-cols-3 gap-4">
|
<div className="grid flex-shrink-0 grid-cols-1 gap-4 md:grid-cols-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<FileText className="h-4 w-4 text-emerald-600" />
|
<FileText className="h-4 w-4 text-emerald-600" />
|
||||||
<span className="text-sm text-gray-600">{files[selectedFileIndex].parsedItems.length} items</span>
|
<span className="text-sm text-gray-600">
|
||||||
|
{files[selectedFileIndex].parsedItems.length} items
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Clock className="h-4 w-4 text-emerald-600" />
|
<Clock className="h-4 w-4 text-emerald-600" />
|
||||||
<span className="text-sm text-gray-600">
|
<span className="text-sm text-gray-600">
|
||||||
{files[selectedFileIndex].parsedItems.reduce((sum, item) => sum + item.hours, 0).toFixed(1)} total hours
|
{files[selectedFileIndex].parsedItems
|
||||||
|
.reduce((sum, item) => sum + item.hours, 0)
|
||||||
|
.toFixed(1)}{" "}
|
||||||
|
total hours
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<DollarSign className="h-4 w-4 text-emerald-600" />
|
<DollarSign className="h-4 w-4 text-emerald-600" />
|
||||||
<span className="text-sm text-gray-600 font-medium">
|
<span className="text-sm font-medium text-gray-600">
|
||||||
{files[selectedFileIndex].parsedItems.reduce((sum, item) => sum + item.amount, 0).toLocaleString('en-US', { style: 'currency', currency: 'USD' })}
|
{files[selectedFileIndex].parsedItems
|
||||||
|
.reduce((sum, item) => sum + item.amount, 0)
|
||||||
|
.toLocaleString("en-US", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "USD",
|
||||||
|
})}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 min-h-0 overflow-hidden">
|
<div className="min-h-0 flex-1 overflow-hidden">
|
||||||
<div className="max-h-96 overflow-y-auto">
|
<div className="max-h-96 overflow-y-auto">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-sm min-w-[600px]">
|
<table className="w-full min-w-[600px] text-sm">
|
||||||
<thead className="bg-gray-50 sticky top-0">
|
<thead className="sticky top-0 bg-gray-50">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="text-left p-2 font-medium text-gray-700 whitespace-nowrap">Date</th>
|
<th className="p-2 text-left font-medium whitespace-nowrap text-gray-700">
|
||||||
<th className="text-left p-2 font-medium text-gray-700">Description</th>
|
Date
|
||||||
<th className="text-right p-2 font-medium text-gray-700 whitespace-nowrap">Hours</th>
|
</th>
|
||||||
<th className="text-right p-2 font-medium text-gray-700 whitespace-nowrap">Rate</th>
|
<th className="p-2 text-left font-medium text-gray-700">
|
||||||
<th className="text-right p-2 font-medium text-gray-700 whitespace-nowrap">Amount</th>
|
Description
|
||||||
|
</th>
|
||||||
|
<th className="p-2 text-right font-medium whitespace-nowrap text-gray-700">
|
||||||
|
Hours
|
||||||
|
</th>
|
||||||
|
<th className="p-2 text-right font-medium whitespace-nowrap text-gray-700">
|
||||||
|
Rate
|
||||||
|
</th>
|
||||||
|
<th className="p-2 text-right font-medium whitespace-nowrap text-gray-700">
|
||||||
|
Amount
|
||||||
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{files[selectedFileIndex].parsedItems.map((item, index) => (
|
{files[selectedFileIndex].parsedItems.map(
|
||||||
<tr key={index} className="border-b border-gray-100">
|
(item, index) => (
|
||||||
<td className="p-2 text-gray-600 whitespace-nowrap">{item.date.toLocaleDateString()}</td>
|
<tr
|
||||||
<td className="p-2 text-gray-600 max-w-xs truncate">{item.description}</td>
|
key={index}
|
||||||
<td className="p-2 text-gray-600 text-right whitespace-nowrap">{item.hours}</td>
|
className="border-b border-gray-100"
|
||||||
<td className="p-2 text-gray-600 text-right whitespace-nowrap">{item.rate.toLocaleString('en-US', { style: 'currency', currency: 'USD' })}</td>
|
>
|
||||||
<td className="p-2 text-gray-600 text-right font-medium whitespace-nowrap">{item.amount.toLocaleString('en-US', { style: 'currency', currency: 'USD' })}</td>
|
<td className="p-2 whitespace-nowrap text-gray-600">
|
||||||
</tr>
|
{item.date.toLocaleDateString()}
|
||||||
))}
|
</td>
|
||||||
|
<td className="max-w-xs truncate p-2 text-gray-600">
|
||||||
|
{item.description}
|
||||||
|
</td>
|
||||||
|
<td className="p-2 text-right whitespace-nowrap text-gray-600">
|
||||||
|
{item.hours}
|
||||||
|
</td>
|
||||||
|
<td className="p-2 text-right whitespace-nowrap text-gray-600">
|
||||||
|
{item.rate.toLocaleString("en-US", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "USD",
|
||||||
|
})}
|
||||||
|
</td>
|
||||||
|
<td className="p-2 text-right font-medium whitespace-nowrap text-gray-600">
|
||||||
|
{item.amount.toLocaleString("en-US", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "USD",
|
||||||
|
})}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
),
|
||||||
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -703,4 +863,4 @@ export function CSVImportPage() {
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,285 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
|
||||||
import { Button } from "~/components/ui/button";
|
|
||||||
import { Input } from "~/components/ui/input";
|
|
||||||
import { Label } from "~/components/ui/label";
|
|
||||||
import { Badge } from "~/components/ui/badge";
|
|
||||||
import {
|
|
||||||
Sun,
|
|
||||||
Moon,
|
|
||||||
Palette,
|
|
||||||
Check,
|
|
||||||
X,
|
|
||||||
Info,
|
|
||||||
AlertCircle,
|
|
||||||
Settings,
|
|
||||||
User,
|
|
||||||
Mail,
|
|
||||||
} from "lucide-react";
|
|
||||||
|
|
||||||
export function DarkModeTest() {
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen space-y-8 p-8">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="space-y-4 text-center">
|
|
||||||
<h1 className="text-4xl font-bold text-gray-900 dark:text-white">
|
|
||||||
Dark Mode Test Suite
|
|
||||||
</h1>
|
|
||||||
<p className="text-lg text-gray-600 dark:text-gray-300">
|
|
||||||
Testing media query-based dark mode implementation
|
|
||||||
</p>
|
|
||||||
<div className="flex items-center justify-center gap-4">
|
|
||||||
<div className="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
|
|
||||||
<Sun className="h-4 w-4" />
|
|
||||||
<span>Light Mode</span>
|
|
||||||
</div>
|
|
||||||
<div className="h-4 w-px bg-gray-300 dark:bg-gray-600" />
|
|
||||||
<div className="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
|
|
||||||
<Moon className="h-4 w-4" />
|
|
||||||
<span>Dark Mode (Auto)</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-8 md:grid-cols-2 lg:grid-cols-3">
|
|
||||||
{/* Color Test Card */}
|
|
||||||
<Card className="dark:bg-gray-800">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2">
|
|
||||||
<Palette className="h-5 w-5" />
|
|
||||||
Color Tests
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-3">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
|
||||||
Text Colors:
|
|
||||||
</p>
|
|
||||||
<div className="text-gray-900 dark:text-white">Primary Text</div>
|
|
||||||
<div className="text-gray-700 dark:text-gray-300">
|
|
||||||
Secondary Text
|
|
||||||
</div>
|
|
||||||
<div className="text-gray-500 dark:text-gray-400">Muted Text</div>
|
|
||||||
<div className="text-green-600 dark:text-green-400">
|
|
||||||
Success Text
|
|
||||||
</div>
|
|
||||||
<div className="text-red-600 dark:text-red-400">Error Text</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Button Test Card */}
|
|
||||||
<Card className="dark:bg-gray-800">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Button Variants</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-3">
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
<Button size="sm">Default</Button>
|
|
||||||
<Button variant="secondary" size="sm">
|
|
||||||
Secondary
|
|
||||||
</Button>
|
|
||||||
<Button variant="outline" size="sm">
|
|
||||||
Outline
|
|
||||||
</Button>
|
|
||||||
<Button variant="ghost" size="sm">
|
|
||||||
Ghost
|
|
||||||
</Button>
|
|
||||||
<Button variant="destructive" size="sm">
|
|
||||||
Destructive
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Form Elements Card */}
|
|
||||||
<Card className="dark:bg-gray-800">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Form Elements</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="test-input">Test Input</Label>
|
|
||||||
<div className="relative">
|
|
||||||
<Mail className="absolute top-3 left-3 h-4 w-4 text-gray-400 dark:text-gray-500" />
|
|
||||||
<Input
|
|
||||||
id="test-input"
|
|
||||||
placeholder="Enter text here..."
|
|
||||||
className="pl-10"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="test-select">Test Select</Label>
|
|
||||||
<select
|
|
||||||
id="test-select"
|
|
||||||
className="border-input placeholder:text-muted-foreground focus-visible:ring-ring dark:bg-input/30 flex h-9 w-full rounded-md border bg-transparent px-3 py-1 text-sm shadow-xs transition-colors focus-visible:ring-1 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
|
|
||||||
>
|
|
||||||
<option value="">Select an option</option>
|
|
||||||
<option value="1">Option 1</option>
|
|
||||||
<option value="2">Option 2</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Status Badges Card */}
|
|
||||||
<Card className="dark:bg-gray-800">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Status Indicators</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-3">
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
<Badge variant="default">Default</Badge>
|
|
||||||
<Badge variant="secondary">Secondary</Badge>
|
|
||||||
<Badge variant="destructive">Error</Badge>
|
|
||||||
<Badge variant="outline">Outline</Badge>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-center gap-2 text-sm">
|
|
||||||
<Check className="h-4 w-4 text-green-500" />
|
|
||||||
<span className="text-gray-700 dark:text-gray-300">
|
|
||||||
Success Status
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 text-sm">
|
|
||||||
<X className="h-4 w-4 text-red-500" />
|
|
||||||
<span className="text-gray-700 dark:text-gray-300">
|
|
||||||
Error Status
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 text-sm">
|
|
||||||
<Info className="h-4 w-4 text-blue-500" />
|
|
||||||
<span className="text-gray-700 dark:text-gray-300">
|
|
||||||
Info Status
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 text-sm">
|
|
||||||
<AlertCircle className="h-4 w-4 text-yellow-500" />
|
|
||||||
<span className="text-gray-700 dark:text-gray-300">
|
|
||||||
Warning Status
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Background Test Card */}
|
|
||||||
<Card className="dark:bg-gray-800">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Background Tests</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-3">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="rounded-md bg-gray-50 p-3 dark:bg-gray-700">
|
|
||||||
<p className="text-sm text-gray-700 dark:text-gray-300">
|
|
||||||
Light Background
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-md bg-gray-100 p-3 dark:bg-gray-600">
|
|
||||||
<p className="text-sm text-gray-700 dark:text-gray-300">
|
|
||||||
Medium Background
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-md border border-gray-200 bg-white p-3 dark:border-gray-600 dark:bg-gray-800">
|
|
||||||
<p className="text-sm text-gray-700 dark:text-gray-300">
|
|
||||||
Card Background
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Icon Test Card */}
|
|
||||||
<Card className="dark:bg-gray-800">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Icon Colors</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="grid grid-cols-4 gap-4">
|
|
||||||
<div className="flex flex-col items-center gap-1">
|
|
||||||
<User className="h-6 w-6 text-gray-700 dark:text-gray-300" />
|
|
||||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
|
||||||
Default
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col items-center gap-1">
|
|
||||||
<Settings className="h-6 w-6 text-green-600 dark:text-green-400" />
|
|
||||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
|
||||||
Success
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col items-center gap-1">
|
|
||||||
<AlertCircle className="h-6 w-6 text-red-600 dark:text-red-400" />
|
|
||||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
|
||||||
Error
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col items-center gap-1">
|
|
||||||
<Info className="h-6 w-6 text-blue-600 dark:text-blue-400" />
|
|
||||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
|
||||||
Info
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* System Information */}
|
|
||||||
<Card className="dark:bg-gray-800">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>System Information</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
|
||||||
Dark Mode Method:
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
|
||||||
Media Query (@media (prefers-color-scheme: dark))
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
|
||||||
Tailwind Config:
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
|
||||||
darkMode: "media"
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
|
||||||
CSS Variables:
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
|
||||||
oklch() color space
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Instructions */}
|
|
||||||
<Card className="border-blue-200 dark:border-blue-800 dark:bg-gray-800">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-blue-700 dark:text-blue-300">
|
|
||||||
Testing Instructions
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-2 text-sm text-blue-600 dark:text-blue-400">
|
|
||||||
<p>
|
|
||||||
• Change your system theme between light and dark to test automatic
|
|
||||||
switching
|
|
||||||
</p>
|
|
||||||
<p>• All UI elements should adapt colors automatically</p>
|
|
||||||
<p>• Text should remain readable in both modes</p>
|
|
||||||
<p>• Icons and buttons should have appropriate contrast</p>
|
|
||||||
<p>• Form elements should be clearly visible and functional</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,143 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
|
||||||
import { Sun, Moon, Monitor } from "lucide-react";
|
|
||||||
import { Button } from "~/components/ui/button";
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from "~/components/ui/dropdown-menu";
|
|
||||||
|
|
||||||
type Theme = "light" | "dark" | "system";
|
|
||||||
|
|
||||||
export function DarkModeToggle() {
|
|
||||||
const [theme, setTheme] = useState<Theme>("system");
|
|
||||||
const [mounted, setMounted] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setMounted(true);
|
|
||||||
|
|
||||||
// Get stored theme preference or default to system
|
|
||||||
const storedTheme = localStorage.getItem("theme") as Theme | null;
|
|
||||||
setTheme(storedTheme || "system");
|
|
||||||
|
|
||||||
// Listen for system preference changes when using system theme
|
|
||||||
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
|
||||||
const handleSystemChange = () => {
|
|
||||||
const currentTheme = localStorage.getItem("theme");
|
|
||||||
if (!currentTheme || currentTheme === "system") {
|
|
||||||
applyTheme("system");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
mediaQuery.addEventListener("change", handleSystemChange);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
mediaQuery.removeEventListener("change", handleSystemChange);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const applyTheme = (newTheme: Theme) => {
|
|
||||||
const root = document.documentElement;
|
|
||||||
|
|
||||||
if (newTheme === "light") {
|
|
||||||
root.classList.remove("dark");
|
|
||||||
root.classList.add("light");
|
|
||||||
} else if (newTheme === "dark") {
|
|
||||||
root.classList.remove("light");
|
|
||||||
root.classList.add("dark");
|
|
||||||
} else {
|
|
||||||
// System theme - remove manual classes and let CSS media query handle it
|
|
||||||
root.classList.remove("light", "dark");
|
|
||||||
const systemDark = window.matchMedia(
|
|
||||||
"(prefers-color-scheme: dark)",
|
|
||||||
).matches;
|
|
||||||
if (systemDark) {
|
|
||||||
root.classList.add("dark");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleThemeChange = (newTheme: Theme) => {
|
|
||||||
setTheme(newTheme);
|
|
||||||
|
|
||||||
if (newTheme === "system") {
|
|
||||||
localStorage.removeItem("theme");
|
|
||||||
} else {
|
|
||||||
localStorage.setItem("theme", newTheme);
|
|
||||||
}
|
|
||||||
|
|
||||||
applyTheme(newTheme);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Don't render until mounted to avoid hydration mismatch
|
|
||||||
if (!mounted) {
|
|
||||||
return (
|
|
||||||
<Button variant="ghost" size="icon" className="h-9 w-9">
|
|
||||||
<Monitor className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const getIcon = () => {
|
|
||||||
switch (theme) {
|
|
||||||
case "light":
|
|
||||||
return <Sun className="h-4 w-4" />;
|
|
||||||
case "dark":
|
|
||||||
return <Moon className="h-4 w-4" />;
|
|
||||||
case "system":
|
|
||||||
return <Monitor className="h-4 w-4" />;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getLabel = () => {
|
|
||||||
switch (theme) {
|
|
||||||
case "light":
|
|
||||||
return "Light mode";
|
|
||||||
case "dark":
|
|
||||||
return "Dark mode";
|
|
||||||
case "system":
|
|
||||||
return "System theme";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-9 w-9"
|
|
||||||
aria-label={getLabel()}
|
|
||||||
>
|
|
||||||
{getIcon()}
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end" className="w-48">
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => handleThemeChange("light")}
|
|
||||||
className={theme === "light" ? "bg-accent" : ""}
|
|
||||||
>
|
|
||||||
<Sun className="mr-2 h-4 w-4" />
|
|
||||||
Light
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => handleThemeChange("dark")}
|
|
||||||
className={theme === "dark" ? "bg-accent" : ""}
|
|
||||||
>
|
|
||||||
<Moon className="mr-2 h-4 w-4" />
|
|
||||||
Dark
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => handleThemeChange("system")}
|
|
||||||
className={theme === "system" ? "bg-accent" : ""}
|
|
||||||
>
|
|
||||||
<Monitor className="mr-2 h-4 w-4" />
|
|
||||||
System
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -15,6 +15,7 @@ import React from "react";
|
|||||||
import { api } from "~/trpc/react";
|
import { api } from "~/trpc/react";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { Skeleton } from "~/components/ui/skeleton";
|
import { Skeleton } from "~/components/ui/skeleton";
|
||||||
|
import { getRouteLabel, capitalize } from "~/lib/pluralize";
|
||||||
|
|
||||||
function isUUID(str: string) {
|
function isUUID(str: string) {
|
||||||
return /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(
|
return /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(
|
||||||
@@ -22,68 +23,126 @@ function isUUID(str: string) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Special segment labels
|
||||||
|
const SPECIAL_SEGMENTS: Record<string, string> = {
|
||||||
|
new: "New",
|
||||||
|
edit: "Edit",
|
||||||
|
import: "Import",
|
||||||
|
export: "Export",
|
||||||
|
dashboard: "Dashboard",
|
||||||
|
};
|
||||||
|
|
||||||
export function DashboardBreadcrumbs() {
|
export function DashboardBreadcrumbs() {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const segments = pathname.split("/").filter(Boolean);
|
const segments = pathname.split("/").filter(Boolean);
|
||||||
|
|
||||||
// Find clientId if present
|
// Determine resource type and ID from path
|
||||||
let clientId: string | undefined = undefined;
|
const resourceType = segments[1]; // e.g., 'clients', 'invoices', 'businesses'
|
||||||
if (segments[1] === "clients" && segments[2] && isUUID(segments[2])) {
|
const resourceId =
|
||||||
clientId = segments[2];
|
segments[2] && isUUID(segments[2]) ? segments[2] : undefined;
|
||||||
}
|
const action = segments[3]; // e.g., 'edit'
|
||||||
|
|
||||||
|
// Fetch client data if needed
|
||||||
const { data: client, isLoading: clientLoading } =
|
const { data: client, isLoading: clientLoading } =
|
||||||
api.clients.getById.useQuery(
|
api.clients.getById.useQuery(
|
||||||
{ id: clientId ?? "" },
|
{ id: resourceId ?? "" },
|
||||||
{ enabled: !!clientId },
|
{ enabled: resourceType === "clients" && !!resourceId },
|
||||||
);
|
);
|
||||||
|
|
||||||
// Find invoiceId if present
|
// Fetch invoice data if needed
|
||||||
let invoiceId: string | undefined = undefined;
|
|
||||||
if (segments[1] === "invoices" && segments[2] && isUUID(segments[2])) {
|
|
||||||
invoiceId = segments[2];
|
|
||||||
}
|
|
||||||
const { data: invoice, isLoading: invoiceLoading } =
|
const { data: invoice, isLoading: invoiceLoading } =
|
||||||
api.invoices.getById.useQuery(
|
api.invoices.getById.useQuery(
|
||||||
{ id: invoiceId ?? "" },
|
{ id: resourceId ?? "" },
|
||||||
{ enabled: !!invoiceId },
|
{ enabled: resourceType === "invoices" && !!resourceId },
|
||||||
|
);
|
||||||
|
|
||||||
|
// Fetch business data if needed
|
||||||
|
const { data: business, isLoading: businessLoading } =
|
||||||
|
api.businesses.getById.useQuery(
|
||||||
|
{ id: resourceId ?? "" },
|
||||||
|
{ enabled: resourceType === "businesses" && !!resourceId },
|
||||||
);
|
);
|
||||||
|
|
||||||
// Generate breadcrumb items based on pathname
|
// Generate breadcrumb items based on pathname
|
||||||
const breadcrumbs = React.useMemo(() => {
|
const breadcrumbs = React.useMemo(() => {
|
||||||
const items = [];
|
const items = [];
|
||||||
|
|
||||||
for (let i = 0; i < segments.length; i++) {
|
for (let i = 0; i < segments.length; i++) {
|
||||||
const segment = segments[i];
|
const segment = segments[i];
|
||||||
const path = `/${segments.slice(0, i + 1).join("/")}`;
|
const path = `/${segments.slice(0, i + 1).join("/")}`;
|
||||||
|
|
||||||
|
// Skip dashboard segment as it's always shown as root
|
||||||
if (segment === "dashboard") continue;
|
if (segment === "dashboard") continue;
|
||||||
|
|
||||||
let label: string | React.ReactElement = segment ?? "";
|
let label: string | React.ReactElement = "";
|
||||||
if (segment === "clients") label = "Clients";
|
let shouldShow = true;
|
||||||
if (isUUID(segment ?? "") && clientLoading)
|
|
||||||
label = <Skeleton className="inline-block h-5 w-24 align-middle" />;
|
// Handle UUID segments
|
||||||
else if (isUUID(segment ?? "") && client) label = client.name ?? "";
|
if (segment && isUUID(segment)) {
|
||||||
if (isUUID(segment ?? "") && invoiceLoading)
|
// Determine which resource we're looking at
|
||||||
label = <Skeleton className="inline-block h-5 w-24 align-middle" />;
|
const prevSegment = segments[i - 1];
|
||||||
else if (isUUID(segment ?? "") && invoice) {
|
|
||||||
const issueDate = new Date(invoice.issueDate);
|
if (prevSegment === "clients") {
|
||||||
label = format(issueDate, "MMM dd, yyyy");
|
if (clientLoading) {
|
||||||
|
label = <Skeleton className="inline-block h-5 w-24 align-middle" />;
|
||||||
|
} else if (client) {
|
||||||
|
label = client.name;
|
||||||
|
}
|
||||||
|
} else if (prevSegment === "invoices") {
|
||||||
|
if (invoiceLoading) {
|
||||||
|
label = <Skeleton className="inline-block h-5 w-24 align-middle" />;
|
||||||
|
} else if (invoice) {
|
||||||
|
// You can customize this - show invoice number or date
|
||||||
|
label =
|
||||||
|
invoice.invoiceNumber ||
|
||||||
|
format(new Date(invoice.issueDate), "MMM dd, yyyy");
|
||||||
|
}
|
||||||
|
} else if (prevSegment === "businesses") {
|
||||||
|
if (businessLoading) {
|
||||||
|
label = <Skeleton className="inline-block h-5 w-24 align-middle" />;
|
||||||
|
} else if (business) {
|
||||||
|
label = business.name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Handle action segments (edit, new, etc.)
|
||||||
|
else if (segment && SPECIAL_SEGMENTS[segment]) {
|
||||||
|
// Don't show 'edit' as the last breadcrumb when we have the resource name
|
||||||
|
if (segment === "edit" && i === segments.length - 1 && resourceId) {
|
||||||
|
shouldShow = false;
|
||||||
|
} else {
|
||||||
|
label = SPECIAL_SEGMENTS[segment];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Handle resource segments (clients, invoices, etc.)
|
||||||
|
else if (segment) {
|
||||||
|
// Use plural form for list pages, singular when there's a specific ID
|
||||||
|
const nextSegment = segments[i + 1];
|
||||||
|
const isListPage =
|
||||||
|
!nextSegment || (!isUUID(nextSegment) && nextSegment !== "new");
|
||||||
|
label = getRouteLabel(segment, isListPage);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldShow && label) {
|
||||||
|
items.push({
|
||||||
|
label,
|
||||||
|
href: path,
|
||||||
|
isLast: i === segments.length - 1,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
if (segment === "invoices") label = "Invoices";
|
|
||||||
if (segment === "new") label = "New";
|
|
||||||
// Only show 'Edit' if not the last segment
|
|
||||||
if (segment === "edit" && i !== segments.length - 1) label = "Edit";
|
|
||||||
// Don't show 'edit' as the last breadcrumb, just show the client name
|
|
||||||
if (segment === "edit" && i === segments.length - 1 && client) continue;
|
|
||||||
if (segment === "import") label = "Import";
|
|
||||||
items.push({
|
|
||||||
label,
|
|
||||||
href: path,
|
|
||||||
isLast:
|
|
||||||
i === segments.length - 1 ||
|
|
||||||
(segment === "edit" && i === segments.length - 1 && client),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return items;
|
return items;
|
||||||
}, [segments, client, invoice, clientLoading, invoiceLoading]);
|
}, [
|
||||||
|
segments,
|
||||||
|
client,
|
||||||
|
invoice,
|
||||||
|
business,
|
||||||
|
clientLoading,
|
||||||
|
invoiceLoading,
|
||||||
|
businessLoading,
|
||||||
|
resourceId,
|
||||||
|
]);
|
||||||
|
|
||||||
if (breadcrumbs.length === 0) return null;
|
if (breadcrumbs.length === 0) return null;
|
||||||
|
|
||||||
@@ -100,8 +159,8 @@ export function DashboardBreadcrumbs() {
|
|||||||
</Link>
|
</Link>
|
||||||
</BreadcrumbLink>
|
</BreadcrumbLink>
|
||||||
</BreadcrumbItem>
|
</BreadcrumbItem>
|
||||||
{breadcrumbs.map((crumb) => (
|
{breadcrumbs.map((crumb, index) => (
|
||||||
<React.Fragment key={crumb.href}>
|
<React.Fragment key={`${crumb.href}-${index}`}>
|
||||||
<BreadcrumbSeparator>
|
<BreadcrumbSeparator>
|
||||||
<ChevronRight className="h-3 w-3 sm:h-4 sm:w-4" />
|
<ChevronRight className="h-3 w-3 sm:h-4 sm:w-4" />
|
||||||
</BreadcrumbSeparator>
|
</BreadcrumbSeparator>
|
||||||
|
|||||||
@@ -42,23 +42,18 @@ const STATUS_OPTIONS = [
|
|||||||
{
|
{
|
||||||
value: "draft",
|
value: "draft",
|
||||||
label: "Draft",
|
label: "Draft",
|
||||||
color: "bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: "sent",
|
value: "sent",
|
||||||
label: "Sent",
|
label: "Sent",
|
||||||
color: "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: "paid",
|
value: "paid",
|
||||||
label: "Paid",
|
label: "Paid",
|
||||||
color:
|
|
||||||
"bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: "overdue",
|
value: "overdue",
|
||||||
label: "Overdue",
|
label: "Overdue",
|
||||||
color: "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400",
|
|
||||||
},
|
},
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
@@ -438,26 +433,20 @@ export function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
<CardContent className="space-y-6">
|
<CardContent className="space-y-6">
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 lg:gap-6 xl:grid-cols-4">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 lg:gap-6 xl:grid-cols-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label
|
<Label htmlFor="invoiceNumber" className="text-sm font-medium">
|
||||||
htmlFor="invoiceNumber"
|
|
||||||
className="text-sm font-medium text-gray-700 dark:text-gray-300"
|
|
||||||
>
|
|
||||||
Invoice Number
|
Invoice Number
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="invoiceNumber"
|
id="invoiceNumber"
|
||||||
value={formData.invoiceNumber}
|
value={formData.invoiceNumber}
|
||||||
className="h-10 border-gray-200 bg-gray-50 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
className="bg-muted"
|
||||||
placeholder="Auto-generated"
|
placeholder="Auto-generated"
|
||||||
readOnly
|
readOnly
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label
|
<Label htmlFor="businessId" className="text-sm font-medium">
|
||||||
htmlFor="businessId"
|
|
||||||
className="text-sm font-medium text-gray-700 dark:text-gray-300"
|
|
||||||
>
|
|
||||||
Business *
|
Business *
|
||||||
</Label>
|
</Label>
|
||||||
<SearchableSelect
|
<SearchableSelect
|
||||||
@@ -478,10 +467,7 @@ export function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label
|
<Label htmlFor="clientId" className="text-sm font-medium">
|
||||||
htmlFor="clientId"
|
|
||||||
className="text-sm font-medium text-gray-700 dark:text-gray-300"
|
|
||||||
>
|
|
||||||
Client *
|
Client *
|
||||||
</Label>
|
</Label>
|
||||||
<SearchableSelect
|
<SearchableSelect
|
||||||
@@ -502,10 +488,7 @@ export function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label
|
<Label htmlFor="status" className="text-sm font-medium">
|
||||||
htmlFor="status"
|
|
||||||
className="text-sm font-medium text-gray-700 dark:text-gray-300"
|
|
||||||
>
|
|
||||||
Status
|
Status
|
||||||
</Label>
|
</Label>
|
||||||
<Select
|
<Select
|
||||||
@@ -517,7 +500,7 @@ export function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="h-10 border-gray-200 bg-gray-50 dark:border-gray-600 dark:bg-gray-700">
|
<SelectTrigger className="h-10">
|
||||||
<SelectValue placeholder="Select status" />
|
<SelectValue placeholder="Select status" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -530,10 +513,7 @@ export function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label
|
<Label htmlFor="issueDate" className="text-sm font-medium">
|
||||||
htmlFor="issueDate"
|
|
||||||
className="text-sm font-medium text-gray-700 dark:text-gray-300"
|
|
||||||
>
|
|
||||||
Issue Date *
|
Issue Date *
|
||||||
</Label>
|
</Label>
|
||||||
<DatePicker
|
<DatePicker
|
||||||
@@ -547,10 +527,7 @@ export function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label
|
<Label htmlFor="dueDate" className="text-sm font-medium">
|
||||||
htmlFor="dueDate"
|
|
||||||
className="text-sm font-medium text-gray-700 dark:text-gray-300"
|
|
||||||
>
|
|
||||||
Due Date *
|
Due Date *
|
||||||
</Label>
|
</Label>
|
||||||
<DatePicker
|
<DatePicker
|
||||||
@@ -564,10 +541,7 @@ export function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label
|
<Label htmlFor="defaultRate" className="text-sm font-medium">
|
||||||
htmlFor="defaultRate"
|
|
||||||
className="text-sm font-medium text-gray-700 dark:text-gray-300"
|
|
||||||
>
|
|
||||||
Default Rate ($/hr)
|
Default Rate ($/hr)
|
||||||
</Label>
|
</Label>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
@@ -580,14 +554,14 @@ export function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
setDefaultRate(parseFloat(e.target.value) || 0)
|
setDefaultRate(parseFloat(e.target.value) || 0)
|
||||||
}
|
}
|
||||||
placeholder="0.00"
|
placeholder="0.00"
|
||||||
className="h-10 border-gray-200 focus:border-emerald-500 focus:ring-emerald-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
className=""
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={applyDefaultRate}
|
onClick={applyDefaultRate}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-10 border-emerald-200 text-emerald-700 hover:bg-emerald-50 dark:border-emerald-800 dark:text-emerald-400 dark:hover:bg-emerald-900/20"
|
className="border-primary text-primary hover:bg-primary/10"
|
||||||
>
|
>
|
||||||
Apply
|
Apply
|
||||||
</Button>
|
</Button>
|
||||||
@@ -595,10 +569,7 @@ export function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label
|
<Label htmlFor="taxRate" className="text-sm font-medium">
|
||||||
htmlFor="taxRate"
|
|
||||||
className="text-sm font-medium text-gray-700 dark:text-gray-300"
|
|
||||||
>
|
|
||||||
Tax Rate (%)
|
Tax Rate (%)
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -615,18 +586,18 @@ export function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
placeholder="0.00"
|
placeholder="0.00"
|
||||||
className="h-10 border-gray-200 focus:border-emerald-500 focus:ring-emerald-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
className=""
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{selectedBusiness && (
|
{selectedBusiness && (
|
||||||
<div className="rounded-lg border border-emerald-200 bg-emerald-50 p-4 dark:border-emerald-800 dark:bg-emerald-900/20">
|
<div className="rounded-lg border border-emerald-200 bg-emerald-50 p-4 dark:border-emerald-800 dark:bg-emerald-900/20">
|
||||||
<div className="mb-2 flex items-center gap-2 text-emerald-700 dark:text-emerald-400">
|
<div className="mb-2 flex items-center gap-2 text-green-600">
|
||||||
<Building className="h-4 w-4" />
|
<Building className="h-4 w-4" />
|
||||||
<span className="font-medium">Business Information</span>
|
<span className="font-medium">Business Information</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-gray-700 dark:text-gray-300">
|
<div className="text-muted-foreground text-sm">
|
||||||
<p className="font-medium">{selectedBusiness.name}</p>
|
<p className="font-medium">{selectedBusiness.name}</p>
|
||||||
{selectedBusiness.email && <p>{selectedBusiness.email}</p>}
|
{selectedBusiness.email && <p>{selectedBusiness.email}</p>}
|
||||||
{selectedBusiness.phone && <p>{selectedBusiness.phone}</p>}
|
{selectedBusiness.phone && <p>{selectedBusiness.phone}</p>}
|
||||||
@@ -652,11 +623,11 @@ export function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
|
|
||||||
{selectedClient && (
|
{selectedClient && (
|
||||||
<div className="rounded-lg border border-emerald-200 bg-emerald-50 p-4 dark:border-emerald-800 dark:bg-emerald-900/20">
|
<div className="rounded-lg border border-emerald-200 bg-emerald-50 p-4 dark:border-emerald-800 dark:bg-emerald-900/20">
|
||||||
<div className="mb-2 flex items-center gap-2 text-emerald-700 dark:text-emerald-400">
|
<div className="mb-2 flex items-center gap-2 text-green-600">
|
||||||
<User className="h-4 w-4" />
|
<User className="h-4 w-4" />
|
||||||
<span className="font-medium">Client Information</span>
|
<span className="font-medium">Client Information</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-gray-700 dark:text-gray-300">
|
<div className="text-muted-foreground text-sm">
|
||||||
<p className="font-medium">{selectedClient.name}</p>
|
<p className="font-medium">{selectedClient.name}</p>
|
||||||
{selectedClient.email && <p>{selectedClient.email}</p>}
|
{selectedClient.email && <p>{selectedClient.email}</p>}
|
||||||
{selectedClient.phone && <p>{selectedClient.phone}</p>}
|
{selectedClient.phone && <p>{selectedClient.phone}</p>}
|
||||||
@@ -665,10 +636,7 @@ export function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label
|
<Label htmlFor="notes" className="text-sm font-medium">
|
||||||
htmlFor="notes"
|
|
||||||
className="text-sm font-medium text-gray-700 dark:text-gray-300"
|
|
||||||
>
|
|
||||||
Notes
|
Notes
|
||||||
</Label>
|
</Label>
|
||||||
<textarea
|
<textarea
|
||||||
@@ -705,7 +673,7 @@ export function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
{/* Items Table Header */}
|
{/* Items Table Header */}
|
||||||
<div className="grid grid-cols-12 items-center gap-2 rounded-lg bg-gray-50 px-4 py-3 text-sm font-medium text-gray-700 dark:bg-gray-700 dark:text-gray-300">
|
<div className="bg-muted text-muted-foreground grid grid-cols-12 items-center gap-2 rounded-lg px-4 py-3 text-sm font-medium">
|
||||||
<div className="col-span-1 text-center">⋮⋮</div>
|
<div className="col-span-1 text-center">⋮⋮</div>
|
||||||
<div className="col-span-2">Date</div>
|
<div className="col-span-2">Date</div>
|
||||||
<div className="col-span-4">Description</div>
|
<div className="col-span-4">Description</div>
|
||||||
@@ -761,7 +729,7 @@ export function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-lg font-medium text-gray-700 dark:text-gray-300">
|
<div className="text-foreground text-lg font-medium">
|
||||||
Total Amount
|
Total Amount
|
||||||
</div>
|
</div>
|
||||||
<div className="text-3xl font-bold text-emerald-600 dark:text-emerald-400">
|
<div className="text-3xl font-bold text-emerald-600 dark:text-emerald-400">
|
||||||
@@ -801,7 +769,7 @@ export function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => router.push("/dashboard/invoices")}
|
onClick={() => router.push("/dashboard/invoices")}
|
||||||
className="border-gray-300 font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-800"
|
className="font-medium"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
@@ -812,7 +780,7 @@ export function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
>
|
>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<>
|
<>
|
||||||
<div className="mr-2 h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent" />
|
<div className="border-primary-foreground mr-2 h-4 w-4 animate-spin rounded-full border-2 border-t-transparent" />
|
||||||
{invoiceId ? "Updating..." : "Creating..."}
|
{invoiceId ? "Updating..." : "Creating..."}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -3,27 +3,36 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { api } from "~/trpc/react";
|
import { api } from "~/trpc/react";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "~/components/ui/card";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { Input } from "~/components/ui/input";
|
import { Input } from "~/components/ui/input";
|
||||||
import { Label } from "~/components/ui/label";
|
import { Label } from "~/components/ui/label";
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "~/components/ui/dialog";
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "~/components/ui/dialog";
|
||||||
|
import { StatusBadge, type StatusType } from "~/components/ui/status-badge";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { FileText, Calendar, DollarSign, Edit, Trash2, Eye, Plus, User } from "lucide-react";
|
import {
|
||||||
|
FileText,
|
||||||
const statusColors = {
|
Calendar,
|
||||||
draft: "bg-gray-100 text-gray-800",
|
DollarSign,
|
||||||
sent: "bg-blue-100 text-blue-800",
|
Edit,
|
||||||
paid: "bg-green-100 text-green-800",
|
Trash2,
|
||||||
overdue: "bg-red-100 text-red-800",
|
Eye,
|
||||||
};
|
Plus,
|
||||||
|
User,
|
||||||
const statusLabels = {
|
} from "lucide-react";
|
||||||
draft: "Draft",
|
|
||||||
sent: "Sent",
|
|
||||||
paid: "Paid",
|
|
||||||
overdue: "Overdue",
|
|
||||||
};
|
|
||||||
|
|
||||||
export function InvoiceList() {
|
export function InvoiceList() {
|
||||||
const [searchTerm, setSearchTerm] = useState("");
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
@@ -43,10 +52,14 @@ export function InvoiceList() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const filteredInvoices = invoices?.filter(invoice =>
|
const filteredInvoices =
|
||||||
invoice.invoiceNumber.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
invoices?.filter(
|
||||||
invoice.client.name.toLowerCase().includes(searchTerm.toLowerCase())
|
(invoice) =>
|
||||||
) || [];
|
invoice.invoiceNumber
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(searchTerm.toLowerCase()) ||
|
||||||
|
invoice.client.name.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||||
|
) || [];
|
||||||
|
|
||||||
const handleDelete = (invoiceId: string) => {
|
const handleDelete = (invoiceId: string) => {
|
||||||
setInvoiceToDelete(invoiceId);
|
setInvoiceToDelete(invoiceId);
|
||||||
@@ -64,9 +77,9 @@ export function InvoiceList() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const formatCurrency = (amount: number) => {
|
const formatCurrency = (amount: number) => {
|
||||||
return new Intl.NumberFormat('en-US', {
|
return new Intl.NumberFormat("en-US", {
|
||||||
style: 'currency',
|
style: "currency",
|
||||||
currency: 'USD',
|
currency: "USD",
|
||||||
}).format(amount);
|
}).format(amount);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -76,12 +89,12 @@ export function InvoiceList() {
|
|||||||
{[...Array(3)].map((_, i) => (
|
{[...Array(3)].map((_, i) => (
|
||||||
<Card key={i}>
|
<Card key={i}>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="h-4 bg-muted rounded animate-pulse" />
|
<div className="bg-muted h-4 animate-pulse rounded" />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="h-3 bg-muted rounded animate-pulse" />
|
<div className="bg-muted h-3 animate-pulse rounded" />
|
||||||
<div className="h-3 bg-muted rounded w-2/3 animate-pulse" />
|
<div className="bg-muted h-3 w-2/3 animate-pulse rounded" />
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -158,26 +171,25 @@ export function InvoiceList() {
|
|||||||
</div>
|
</div>
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className={`px-2 py-1 rounded-full text-xs font-medium ${statusColors[invoice.status as keyof typeof statusColors]}`}>
|
<StatusBadge status={invoice.status as StatusType} />
|
||||||
{statusLabels[invoice.status as keyof typeof statusLabels]}
|
|
||||||
</span>
|
|
||||||
<span className="text-lg font-bold text-green-600">
|
<span className="text-lg font-bold text-green-600">
|
||||||
{formatCurrency(invoice.totalAmount)}
|
{formatCurrency(invoice.totalAmount)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-2">
|
<CardContent className="space-y-2">
|
||||||
<div className="flex items-center text-sm text-muted-foreground">
|
<div className="text-muted-foreground flex items-center text-sm">
|
||||||
<User className="mr-2 h-4 w-4" />
|
<User className="mr-2 h-4 w-4" />
|
||||||
{invoice.client.name}
|
{invoice.client.name}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center text-sm text-muted-foreground">
|
<div className="text-muted-foreground flex items-center text-sm">
|
||||||
<Calendar className="mr-2 h-4 w-4" />
|
<Calendar className="mr-2 h-4 w-4" />
|
||||||
Due: {formatDate(invoice.dueDate)}
|
Due: {formatDate(invoice.dueDate)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center text-sm text-muted-foreground">
|
<div className="text-muted-foreground flex items-center text-sm">
|
||||||
<FileText className="mr-2 h-4 w-4" />
|
<FileText className="mr-2 h-4 w-4" />
|
||||||
{invoice.items.length} item{invoice.items.length !== 1 ? 's' : ''}
|
{invoice.items.length} item
|
||||||
|
{invoice.items.length !== 1 ? "s" : ""}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -189,11 +201,15 @@ export function InvoiceList() {
|
|||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Delete Invoice</DialogTitle>
|
<DialogTitle>Delete Invoice</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Are you sure you want to delete this invoice? This action cannot be undone.
|
Are you sure you want to delete this invoice? This action cannot
|
||||||
|
be undone.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={() => setDeleteDialogOpen(false)}>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setDeleteDialogOpen(false)}
|
||||||
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="destructive" onClick={confirmDelete}>
|
<Button variant="destructive" onClick={confirmDelete}>
|
||||||
@@ -204,4 +220,4 @@ export function InvoiceList() {
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
import { api } from "~/trpc/react";
|
import { api } from "~/trpc/react";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { Badge } from "~/components/ui/badge";
|
import { Badge } from "~/components/ui/badge";
|
||||||
|
import { StatusBadge, type StatusType } from "~/components/ui/status-badge";
|
||||||
import { Separator } from "~/components/ui/separator";
|
import { Separator } from "~/components/ui/separator";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -15,7 +17,6 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "~/components/ui/dialog";
|
} from "~/components/ui/dialog";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import {
|
import {
|
||||||
Calendar,
|
Calendar,
|
||||||
@@ -41,28 +42,11 @@ interface InvoiceViewProps {
|
|||||||
invoiceId: string;
|
invoiceId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const statusConfig = {
|
const statusIconConfig = {
|
||||||
draft: {
|
draft: FileText,
|
||||||
label: "Draft",
|
sent: Send,
|
||||||
color: "bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300",
|
paid: DollarSign,
|
||||||
icon: FileText,
|
overdue: AlertCircle,
|
||||||
},
|
|
||||||
sent: {
|
|
||||||
label: "Sent",
|
|
||||||
color: "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400",
|
|
||||||
icon: Send,
|
|
||||||
},
|
|
||||||
paid: {
|
|
||||||
label: "Paid",
|
|
||||||
color:
|
|
||||||
"bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400",
|
|
||||||
icon: DollarSign,
|
|
||||||
},
|
|
||||||
overdue: {
|
|
||||||
label: "Overdue",
|
|
||||||
color: "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400",
|
|
||||||
icon: AlertCircle,
|
|
||||||
},
|
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export function InvoiceView({ invoiceId }: InvoiceViewProps) {
|
export function InvoiceView({ invoiceId }: InvoiceViewProps) {
|
||||||
@@ -168,7 +152,7 @@ export function InvoiceView({ invoiceId }: InvoiceViewProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const StatusIcon =
|
const StatusIcon =
|
||||||
statusConfig[invoice.status as keyof typeof statusConfig].icon;
|
statusIconConfig[invoice.status as keyof typeof statusIconConfig];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -227,22 +211,20 @@ export function InvoiceView({ invoiceId }: InvoiceViewProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3 text-right">
|
<div className="space-y-3 text-right">
|
||||||
<Badge
|
<StatusBadge
|
||||||
className={`${statusConfig[invoice.status as keyof typeof statusConfig].color} px-3 py-1 text-sm font-medium`}
|
status={invoice.status as StatusType}
|
||||||
|
className="px-3 py-1 text-sm font-medium"
|
||||||
>
|
>
|
||||||
<StatusIcon className="mr-1 h-3 w-3" />
|
<StatusIcon className="mr-1 h-3 w-3" />
|
||||||
{
|
</StatusBadge>
|
||||||
statusConfig[invoice.status as keyof typeof statusConfig]
|
|
||||||
.label
|
|
||||||
}
|
|
||||||
</Badge>
|
|
||||||
<div className="text-3xl font-bold text-emerald-600 dark:text-emerald-400">
|
<div className="text-3xl font-bold text-emerald-600 dark:text-emerald-400">
|
||||||
{formatCurrency(invoice.totalAmount)}
|
{formatCurrency(invoice.totalAmount)}
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
onClick={handlePDFExport}
|
onClick={handlePDFExport}
|
||||||
disabled={isExportingPDF}
|
disabled={isExportingPDF}
|
||||||
className="transform-none bg-gradient-to-r from-emerald-600 to-teal-600 font-medium text-white shadow-lg transition-shadow duration-200 hover:from-emerald-700 hover:to-teal-700 hover:shadow-xl"
|
variant="brand"
|
||||||
|
className="transform-none"
|
||||||
>
|
>
|
||||||
{isExportingPDF ? (
|
{isExportingPDF ? (
|
||||||
<>
|
<>
|
||||||
|
|||||||
79
src/components/page-header.tsx
Normal file
79
src/components/page-header.tsx
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
interface PageHeaderProps {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
children?: React.ReactNode; // For action buttons or other header content
|
||||||
|
className?: string;
|
||||||
|
variant?: "default" | "gradient" | "large" | "large-gradient";
|
||||||
|
titleClassName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PageHeader({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
children,
|
||||||
|
className = "",
|
||||||
|
variant = "default",
|
||||||
|
titleClassName,
|
||||||
|
}: PageHeaderProps) {
|
||||||
|
const getTitleClasses = () => {
|
||||||
|
const baseClasses = "font-bold";
|
||||||
|
|
||||||
|
switch (variant) {
|
||||||
|
case "gradient":
|
||||||
|
return `${baseClasses} text-3xl bg-gradient-to-r from-emerald-600 to-teal-600 bg-clip-text text-transparent`;
|
||||||
|
case "large":
|
||||||
|
return `${baseClasses} text-4xl text-foreground`;
|
||||||
|
case "large-gradient":
|
||||||
|
return `${baseClasses} text-4xl bg-gradient-to-r from-emerald-600 to-teal-600 bg-clip-text text-transparent`;
|
||||||
|
default:
|
||||||
|
return `${baseClasses} text-3xl text-foreground`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDescriptionSpacing = () => {
|
||||||
|
return variant === "large" || variant === "large-gradient"
|
||||||
|
? "mt-2"
|
||||||
|
: "mt-1";
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`mb-8 ${className}`}>
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<h1 className={titleClassName ?? getTitleClasses()}>{title}</h1>
|
||||||
|
{children && (
|
||||||
|
<div className="flex flex-shrink-0 gap-2 sm:gap-3 [&>*]:h-8 [&>*]:px-2 [&>*]:text-sm sm:[&>*]:h-10 sm:[&>*]:px-4 sm:[&>*]:text-base [&>*>span]:hidden sm:[&>*>span]:inline [&>*>svg]:mr-0 sm:[&>*>svg]:mr-2">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{description && (
|
||||||
|
<p
|
||||||
|
className={`text-muted-foreground ${getDescriptionSpacing()} text-lg`}
|
||||||
|
>
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convenience wrapper for dashboard page with larger gradient title
|
||||||
|
export function DashboardPageHeader({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
children,
|
||||||
|
className = "",
|
||||||
|
}: Omit<PageHeaderProps, "variant">) {
|
||||||
|
return (
|
||||||
|
<PageHeader
|
||||||
|
title={title}
|
||||||
|
description={description}
|
||||||
|
variant="large-gradient"
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</PageHeader>
|
||||||
|
);
|
||||||
|
}
|
||||||
230
src/components/ui/address-form.tsx
Normal file
230
src/components/ui/address-form.tsx
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { MapPin } from "lucide-react";
|
||||||
|
import { Input } from "~/components/ui/input";
|
||||||
|
import { Label } from "~/components/ui/label";
|
||||||
|
import { SearchableSelect } from "~/components/ui/select";
|
||||||
|
import {
|
||||||
|
US_STATES,
|
||||||
|
ALL_COUNTRIES,
|
||||||
|
POPULAR_COUNTRIES,
|
||||||
|
formatPostalCode,
|
||||||
|
PLACEHOLDERS,
|
||||||
|
} from "~/lib/form-constants";
|
||||||
|
|
||||||
|
interface AddressFormProps {
|
||||||
|
addressLine1: string;
|
||||||
|
addressLine2: string;
|
||||||
|
city: string;
|
||||||
|
state: string;
|
||||||
|
postalCode: string;
|
||||||
|
country: string;
|
||||||
|
onChange: (field: string, value: string) => void;
|
||||||
|
errors?: {
|
||||||
|
addressLine1?: string;
|
||||||
|
addressLine2?: string;
|
||||||
|
city?: string;
|
||||||
|
state?: string;
|
||||||
|
postalCode?: string;
|
||||||
|
country?: string;
|
||||||
|
};
|
||||||
|
required?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AddressForm({
|
||||||
|
addressLine1,
|
||||||
|
addressLine2,
|
||||||
|
city,
|
||||||
|
state,
|
||||||
|
postalCode,
|
||||||
|
country,
|
||||||
|
onChange,
|
||||||
|
errors = {},
|
||||||
|
required = false,
|
||||||
|
className = "",
|
||||||
|
}: AddressFormProps) {
|
||||||
|
const handlePostalCodeChange = (value: string) => {
|
||||||
|
const formatted = formatPostalCode(value, country || "US");
|
||||||
|
onChange("postalCode", formatted);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Combine popular and all countries, removing duplicates
|
||||||
|
const countryOptions = [
|
||||||
|
{ value: "__placeholder__", label: "Select a country", disabled: true },
|
||||||
|
{ value: "divider-popular", label: "Popular Countries", disabled: true },
|
||||||
|
...POPULAR_COUNTRIES,
|
||||||
|
{ value: "divider-all", label: "All Countries", disabled: true },
|
||||||
|
...ALL_COUNTRIES.filter(
|
||||||
|
(c) => !POPULAR_COUNTRIES.some((p) => p.value === c.value),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
const stateOptions = [
|
||||||
|
{ value: "__placeholder__", label: "Select a state", disabled: true },
|
||||||
|
...US_STATES,
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`space-y-4 ${className}`}>
|
||||||
|
<div className="flex items-center gap-2 text-sm font-medium">
|
||||||
|
<MapPin className="text-muted-foreground h-4 w-4" />
|
||||||
|
<span>Address Information</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4">
|
||||||
|
{/* Address Line 1 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="addressLine1">
|
||||||
|
Address Line 1
|
||||||
|
{required && <span className="text-destructive ml-1">*</span>}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="addressLine1"
|
||||||
|
value={addressLine1}
|
||||||
|
onChange={(e) => onChange("addressLine1", e.target.value)}
|
||||||
|
placeholder={PLACEHOLDERS.addressLine1}
|
||||||
|
className={errors.addressLine1 ? "border-destructive" : ""}
|
||||||
|
/>
|
||||||
|
{errors.addressLine1 && (
|
||||||
|
<p className="text-destructive text-sm">{errors.addressLine1}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Address Line 2 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="addressLine2">
|
||||||
|
Address Line 2
|
||||||
|
<span className="text-muted-foreground ml-1 text-xs">
|
||||||
|
(Optional)
|
||||||
|
</span>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="addressLine2"
|
||||||
|
value={addressLine2}
|
||||||
|
onChange={(e) => onChange("addressLine2", e.target.value)}
|
||||||
|
placeholder={PLACEHOLDERS.addressLine2}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* City and State/Province */}
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="city">
|
||||||
|
City{required && <span className="text-destructive ml-1">*</span>}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="city"
|
||||||
|
value={city}
|
||||||
|
onChange={(e) => onChange("city", e.target.value)}
|
||||||
|
placeholder={PLACEHOLDERS.city}
|
||||||
|
className={errors.city ? "border-destructive" : ""}
|
||||||
|
/>
|
||||||
|
{errors.city && (
|
||||||
|
<p className="text-destructive text-sm">{errors.city}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="state">
|
||||||
|
{country === "United States" ? "State" : "State/Province"}
|
||||||
|
{required && country === "United States" && (
|
||||||
|
<span className="text-destructive ml-1">*</span>
|
||||||
|
)}
|
||||||
|
</Label>
|
||||||
|
{country === "United States" ? (
|
||||||
|
<SearchableSelect
|
||||||
|
id="state"
|
||||||
|
options={stateOptions}
|
||||||
|
value={state || ""}
|
||||||
|
onValueChange={(value) => onChange("state", value)}
|
||||||
|
placeholder="Select a state"
|
||||||
|
className={errors.state ? "border-destructive" : ""}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Input
|
||||||
|
id="state"
|
||||||
|
value={state}
|
||||||
|
onChange={(e) => onChange("state", e.target.value)}
|
||||||
|
placeholder="State/Province"
|
||||||
|
className={errors.state ? "border-destructive" : ""}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{errors.state && (
|
||||||
|
<p className="text-destructive text-sm">{errors.state}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Postal Code and Country */}
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="postalCode">
|
||||||
|
{country === "United States" ? "ZIP Code" : "Postal Code"}
|
||||||
|
{required && <span className="text-destructive ml-1">*</span>}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="postalCode"
|
||||||
|
value={postalCode}
|
||||||
|
onChange={(e) => handlePostalCodeChange(e.target.value)}
|
||||||
|
placeholder={
|
||||||
|
country === "United States" ? "12345" : PLACEHOLDERS.postalCode
|
||||||
|
}
|
||||||
|
className={errors.postalCode ? "border-destructive" : ""}
|
||||||
|
maxLength={
|
||||||
|
country === "United States"
|
||||||
|
? 10
|
||||||
|
: country === "Canada"
|
||||||
|
? 7
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{errors.postalCode && (
|
||||||
|
<p className="text-destructive text-sm">{errors.postalCode}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="country">
|
||||||
|
Country
|
||||||
|
{required && <span className="text-destructive ml-1">*</span>}
|
||||||
|
</Label>
|
||||||
|
<SearchableSelect
|
||||||
|
id="country"
|
||||||
|
options={countryOptions}
|
||||||
|
value={country || ""}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
// Don't save the placeholder value
|
||||||
|
if (value !== "__placeholder__") {
|
||||||
|
onChange("country", value);
|
||||||
|
// Reset state when country changes from United States
|
||||||
|
if (value !== "United States" && state.length === 2) {
|
||||||
|
onChange("state", "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="Select a country"
|
||||||
|
className={errors.country ? "border-destructive" : ""}
|
||||||
|
renderOption={(option) => {
|
||||||
|
if (option.value?.startsWith("divider-")) {
|
||||||
|
return (
|
||||||
|
<div className="text-muted-foreground px-2 py-1 text-xs font-semibold">
|
||||||
|
{option.label}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return option.label;
|
||||||
|
}}
|
||||||
|
isOptionDisabled={(option) =>
|
||||||
|
option.disabled || option.value?.startsWith("divider-")
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{errors.country && (
|
||||||
|
<p className="text-destructive text-sm">{errors.country}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
import { Slot } from "@radix-ui/react-slot"
|
import { Slot } from "@radix-ui/react-slot";
|
||||||
import { cva, type VariantProps } from "class-variance-authority"
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
|
||||||
import { cn } from "~/lib/utils"
|
import { cn } from "~/lib/utils";
|
||||||
|
|
||||||
const badgeVariants = cva(
|
const badgeVariants = cva(
|
||||||
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
||||||
@@ -17,13 +17,17 @@ const badgeVariants = cva(
|
|||||||
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||||
outline:
|
outline:
|
||||||
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||||
|
success: "border-transparent bg-status-success [a&]:hover:opacity-90",
|
||||||
|
warning: "border-transparent bg-status-warning [a&]:hover:opacity-90",
|
||||||
|
error: "border-transparent bg-status-error [a&]:hover:opacity-90",
|
||||||
|
info: "border-transparent bg-status-info [a&]:hover:opacity-90",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
variant: "default",
|
variant: "default",
|
||||||
},
|
},
|
||||||
}
|
},
|
||||||
)
|
);
|
||||||
|
|
||||||
function Badge({
|
function Badge({
|
||||||
className,
|
className,
|
||||||
@@ -32,7 +36,7 @@ function Badge({
|
|||||||
...props
|
...props
|
||||||
}: React.ComponentProps<"span"> &
|
}: React.ComponentProps<"span"> &
|
||||||
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||||
const Comp = asChild ? Slot : "span"
|
const Comp = asChild ? Slot : "span";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Comp
|
<Comp
|
||||||
@@ -40,7 +44,7 @@ function Badge({
|
|||||||
className={cn(badgeVariants({ variant }), className)}
|
className={cn(badgeVariants({ variant }), className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Badge, badgeVariants }
|
export { Badge, badgeVariants };
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
import { Slot } from "@radix-ui/react-slot"
|
import { Slot } from "@radix-ui/react-slot";
|
||||||
import { cva, type VariantProps } from "class-variance-authority"
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
|
||||||
import { cn } from "~/lib/utils"
|
import { cn } from "~/lib/utils";
|
||||||
|
|
||||||
const buttonVariants = cva(
|
const buttonVariants = cva(
|
||||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||||
@@ -11,10 +11,12 @@ const buttonVariants = cva(
|
|||||||
variant: {
|
variant: {
|
||||||
default:
|
default:
|
||||||
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
|
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
|
||||||
|
brand:
|
||||||
|
"bg-brand-gradient text-white shadow-lg hover:bg-brand-gradient hover:shadow-xl font-medium",
|
||||||
destructive:
|
destructive:
|
||||||
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||||
outline:
|
outline:
|
||||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
"border border-border/40 bg-background/60 shadow-sm backdrop-blur-sm hover:bg-accent/50 hover:text-accent-foreground hover:border-border/60 transition-all duration-200",
|
||||||
secondary:
|
secondary:
|
||||||
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
|
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
|
||||||
ghost:
|
ghost:
|
||||||
@@ -32,8 +34,8 @@ const buttonVariants = cva(
|
|||||||
variant: "default",
|
variant: "default",
|
||||||
size: "default",
|
size: "default",
|
||||||
},
|
},
|
||||||
}
|
},
|
||||||
)
|
);
|
||||||
|
|
||||||
function Button({
|
function Button({
|
||||||
className,
|
className,
|
||||||
@@ -43,9 +45,9 @@ function Button({
|
|||||||
...props
|
...props
|
||||||
}: React.ComponentProps<"button"> &
|
}: React.ComponentProps<"button"> &
|
||||||
VariantProps<typeof buttonVariants> & {
|
VariantProps<typeof buttonVariants> & {
|
||||||
asChild?: boolean
|
asChild?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const Comp = asChild ? Slot : "button"
|
const Comp = asChild ? Slot : "button";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Comp
|
<Comp
|
||||||
@@ -53,7 +55,7 @@ function Button({
|
|||||||
className={cn(buttonVariants({ variant, size, className }))}
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Button, buttonVariants }
|
export { Button, buttonVariants };
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
|
|
||||||
import { cn } from "~/lib/utils"
|
import { cn } from "~/lib/utils";
|
||||||
|
|
||||||
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-slot="card"
|
data-slot="card"
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
"bg-background/60 text-card-foreground border-border/40 flex flex-col gap-6 rounded-2xl border py-6 shadow-lg backdrop-blur-xl backdrop-saturate-150",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
@@ -21,21 +21,21 @@ function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
|||||||
data-slot="card-header"
|
data-slot="card-header"
|
||||||
className={cn(
|
className={cn(
|
||||||
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-slot="card-title"
|
data-slot="card-title"
|
||||||
className={cn("leading-none font-semibold", className)}
|
className={cn("text-lg leading-none font-semibold", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
@@ -45,7 +45,7 @@ function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
|||||||
className={cn("text-muted-foreground text-sm", className)}
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
@@ -54,11 +54,11 @@ function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
|||||||
data-slot="card-action"
|
data-slot="card-action"
|
||||||
className={cn(
|
className={cn(
|
||||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
@@ -68,7 +68,7 @@ function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
|||||||
className={cn("px-6", className)}
|
className={cn("px-6", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
@@ -78,7 +78,7 @@ function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
|||||||
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
@@ -89,4 +89,4 @@ export {
|
|||||||
CardAction,
|
CardAction,
|
||||||
CardDescription,
|
CardDescription,
|
||||||
CardContent,
|
CardContent,
|
||||||
}
|
};
|
||||||
|
|||||||
33
src/components/ui/collapsible.tsx
Normal file
33
src/components/ui/collapsible.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
|
||||||
|
|
||||||
|
function Collapsible({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
|
||||||
|
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function CollapsibleTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
|
||||||
|
return (
|
||||||
|
<CollapsiblePrimitive.CollapsibleTrigger
|
||||||
|
data-slot="collapsible-trigger"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CollapsibleContent({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
|
||||||
|
return (
|
||||||
|
<CollapsiblePrimitive.CollapsibleContent
|
||||||
|
data-slot="collapsible-content"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
|
||||||
563
src/components/ui/data-table.tsx
Normal file
563
src/components/ui/data-table.tsx
Normal file
@@ -0,0 +1,563 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import type {
|
||||||
|
ColumnDef,
|
||||||
|
ColumnFiltersState,
|
||||||
|
SortingState,
|
||||||
|
VisibilityState,
|
||||||
|
} from "@tanstack/react-table";
|
||||||
|
import {
|
||||||
|
flexRender,
|
||||||
|
getCoreRowModel,
|
||||||
|
getFilteredRowModel,
|
||||||
|
getPaginationRowModel,
|
||||||
|
getSortedRowModel,
|
||||||
|
useReactTable,
|
||||||
|
} from "@tanstack/react-table";
|
||||||
|
import {
|
||||||
|
ArrowUpDown,
|
||||||
|
ChevronDown,
|
||||||
|
Search,
|
||||||
|
Filter,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
ChevronsLeft,
|
||||||
|
ChevronsRight,
|
||||||
|
X,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import { Input } from "~/components/ui/input";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "~/components/ui/dropdown-menu";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "~/components/ui/select";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "~/components/ui/table";
|
||||||
|
import { Card, CardContent } from "~/components/ui/card";
|
||||||
|
import { cn } from "~/lib/utils";
|
||||||
|
|
||||||
|
interface DataTableProps<TData, TValue> {
|
||||||
|
columns: ColumnDef<TData, TValue>[];
|
||||||
|
data: TData[];
|
||||||
|
searchKey?: string;
|
||||||
|
searchPlaceholder?: string;
|
||||||
|
showColumnVisibility?: boolean;
|
||||||
|
showPagination?: boolean;
|
||||||
|
showSearch?: boolean;
|
||||||
|
pageSize?: number;
|
||||||
|
className?: string;
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
actions?: React.ReactNode;
|
||||||
|
filterableColumns?: {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
options: { label: string; value: string }[];
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DataTable<TData, TValue>({
|
||||||
|
columns,
|
||||||
|
data,
|
||||||
|
searchKey,
|
||||||
|
searchPlaceholder = "Search...",
|
||||||
|
showColumnVisibility = true,
|
||||||
|
showPagination = true,
|
||||||
|
showSearch = true,
|
||||||
|
pageSize = 10,
|
||||||
|
className,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
actions,
|
||||||
|
filterableColumns = [],
|
||||||
|
}: DataTableProps<TData, TValue>) {
|
||||||
|
const [sorting, setSorting] = React.useState<SortingState>([]);
|
||||||
|
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>(
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
const [columnVisibility, setColumnVisibility] =
|
||||||
|
React.useState<VisibilityState>({});
|
||||||
|
const [rowSelection, setRowSelection] = React.useState({});
|
||||||
|
const [globalFilter, setGlobalFilter] = React.useState("");
|
||||||
|
|
||||||
|
// Create responsive columns that properly hide on mobile
|
||||||
|
const responsiveColumns = React.useMemo(() => {
|
||||||
|
return columns.map((column) => ({
|
||||||
|
...column,
|
||||||
|
// Add a meta property to control responsive visibility
|
||||||
|
meta: {
|
||||||
|
...((column as any).meta || {}),
|
||||||
|
headerClassName: (column as any).meta?.headerClassName || "",
|
||||||
|
cellClassName: (column as any).meta?.cellClassName || "",
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}, [columns]);
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data,
|
||||||
|
columns: responsiveColumns,
|
||||||
|
onSortingChange: setSorting,
|
||||||
|
onColumnFiltersChange: setColumnFilters,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getPaginationRowModel: getPaginationRowModel(),
|
||||||
|
getSortedRowModel: getSortedRowModel(),
|
||||||
|
getFilteredRowModel: getFilteredRowModel(),
|
||||||
|
onColumnVisibilityChange: setColumnVisibility,
|
||||||
|
onRowSelectionChange: setRowSelection,
|
||||||
|
onGlobalFilterChange: setGlobalFilter,
|
||||||
|
globalFilterFn: "includesString",
|
||||||
|
state: {
|
||||||
|
sorting,
|
||||||
|
columnFilters,
|
||||||
|
columnVisibility,
|
||||||
|
rowSelection,
|
||||||
|
globalFilter,
|
||||||
|
},
|
||||||
|
initialState: {
|
||||||
|
pagination: {
|
||||||
|
pageSize: pageSize,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const pageSizeOptions = [5, 10, 20, 30, 50, 100];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("space-y-4", className)}>
|
||||||
|
{/* Header Section */}
|
||||||
|
{(title ?? description) && (
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
{title && (
|
||||||
|
<h3 className="text-foreground text-lg font-semibold">{title}</h3>
|
||||||
|
)}
|
||||||
|
{description && (
|
||||||
|
<p className="text-muted-foreground mt-1 text-sm">
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{actions && (
|
||||||
|
<div className="flex flex-shrink-0 items-center gap-2">
|
||||||
|
{actions}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Filter Bar Card */}
|
||||||
|
{(showSearch || filterableColumns.length > 0 || showColumnVisibility) && (
|
||||||
|
<Card className="border-0 py-2 shadow-sm">
|
||||||
|
<CardContent className="px-3 py-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{showSearch && (
|
||||||
|
<div className="relative min-w-0 flex-1">
|
||||||
|
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
||||||
|
<Input
|
||||||
|
placeholder={searchPlaceholder}
|
||||||
|
value={globalFilter ?? ""}
|
||||||
|
onChange={(event) => setGlobalFilter(event.target.value)}
|
||||||
|
className="h-9 w-full pr-3 pl-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{filterableColumns.map((column) => (
|
||||||
|
<Select
|
||||||
|
key={column.id}
|
||||||
|
value={
|
||||||
|
(table.getColumn(column.id)?.getFilterValue() as string) ??
|
||||||
|
"all"
|
||||||
|
}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
table
|
||||||
|
.getColumn(column.id)
|
||||||
|
?.setFilterValue(value === "all" ? "" : value)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-9 w-9 p-0 sm:w-[180px] sm:px-3 [&>svg]:hidden sm:[&>svg]:inline-flex">
|
||||||
|
<div className="flex w-full items-center justify-center">
|
||||||
|
<Filter className="h-4 w-4 sm:hidden" />
|
||||||
|
<span className="hidden sm:inline">
|
||||||
|
<SelectValue placeholder={column.title} />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All {column.title}</SelectItem>
|
||||||
|
{column.options.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
))}
|
||||||
|
{filterableColumns.length > 0 && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-9 w-9 p-0 sm:w-auto sm:px-4"
|
||||||
|
onClick={() => {
|
||||||
|
table.resetColumnFilters();
|
||||||
|
setGlobalFilter("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4 sm:hidden" />
|
||||||
|
<span className="hidden sm:flex sm:items-center">
|
||||||
|
<Filter className="mr-2 h-3.5 w-3.5" />
|
||||||
|
Clear filters
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{showColumnVisibility && (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="hidden h-9 sm:flex"
|
||||||
|
>
|
||||||
|
Columns <ChevronDown className="ml-2 h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-[150px]">
|
||||||
|
{table
|
||||||
|
.getAllColumns()
|
||||||
|
.filter((column) => column.getCanHide())
|
||||||
|
.map((column) => {
|
||||||
|
return (
|
||||||
|
<DropdownMenuCheckboxItem
|
||||||
|
key={column.id}
|
||||||
|
className="capitalize"
|
||||||
|
checked={column.getIsVisible()}
|
||||||
|
onCheckedChange={(value) =>
|
||||||
|
column.toggleVisibility(!!value)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{column.id}
|
||||||
|
</DropdownMenuCheckboxItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Table Content Card */}
|
||||||
|
<Card className="overflow-hidden border-0 p-0 shadow-sm">
|
||||||
|
<div className="w-full overflow-x-auto">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
|
<TableRow
|
||||||
|
key={headerGroup.id}
|
||||||
|
className="bg-muted/50 hover:bg-muted/50"
|
||||||
|
>
|
||||||
|
{headerGroup.headers.map((header) => {
|
||||||
|
const meta = header.column.columnDef.meta as any;
|
||||||
|
return (
|
||||||
|
<TableHead
|
||||||
|
key={header.id}
|
||||||
|
className={cn(
|
||||||
|
"text-muted-foreground h-9 px-3 text-left align-middle text-xs font-medium sm:h-10 sm:px-4 sm:text-sm [&:has([role=checkbox])]:pr-3",
|
||||||
|
meta?.headerClassName,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{header.isPlaceholder
|
||||||
|
? null
|
||||||
|
: flexRender(
|
||||||
|
header.column.columnDef.header,
|
||||||
|
header.getContext(),
|
||||||
|
)}
|
||||||
|
</TableHead>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{table.getRowModel().rows?.length ? (
|
||||||
|
table.getRowModel().rows.map((row) => (
|
||||||
|
<TableRow
|
||||||
|
key={row.id}
|
||||||
|
data-state={row.getIsSelected() && "selected"}
|
||||||
|
className="hover:bg-muted/20 data-[state=selected]:bg-muted/50 border-b transition-colors"
|
||||||
|
>
|
||||||
|
{row.getVisibleCells().map((cell) => {
|
||||||
|
const meta = cell.column.columnDef.meta as any;
|
||||||
|
return (
|
||||||
|
<TableCell
|
||||||
|
key={cell.id}
|
||||||
|
className={cn(
|
||||||
|
"px-3 py-1.5 align-middle text-xs sm:px-4 sm:py-2 sm:text-sm [&:has([role=checkbox])]:pr-3",
|
||||||
|
meta?.cellClassName,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{flexRender(
|
||||||
|
cell.column.columnDef.cell,
|
||||||
|
cell.getContext(),
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell
|
||||||
|
colSpan={columns.length}
|
||||||
|
className="h-24 text-center"
|
||||||
|
>
|
||||||
|
<p className="text-muted-foreground">No results found</p>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Pagination Bar Card */}
|
||||||
|
{showPagination && (
|
||||||
|
<Card className="border-0 py-2 shadow-sm">
|
||||||
|
<CardContent className="px-3 py-0">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<p className="text-muted-foreground hidden text-xs sm:inline sm:text-sm">
|
||||||
|
{table.getFilteredRowModel().rows.length === 0
|
||||||
|
? "No entries"
|
||||||
|
: `Showing ${
|
||||||
|
table.getState().pagination.pageIndex *
|
||||||
|
table.getState().pagination.pageSize +
|
||||||
|
1
|
||||||
|
} to ${Math.min(
|
||||||
|
(table.getState().pagination.pageIndex + 1) *
|
||||||
|
table.getState().pagination.pageSize,
|
||||||
|
table.getFilteredRowModel().rows.length,
|
||||||
|
)} of ${table.getFilteredRowModel().rows.length} entries`}
|
||||||
|
</p>
|
||||||
|
<p className="text-muted-foreground text-xs sm:hidden">
|
||||||
|
{table.getFilteredRowModel().rows.length === 0
|
||||||
|
? "0"
|
||||||
|
: `${
|
||||||
|
table.getState().pagination.pageIndex *
|
||||||
|
table.getState().pagination.pageSize +
|
||||||
|
1
|
||||||
|
}-${Math.min(
|
||||||
|
(table.getState().pagination.pageIndex + 1) *
|
||||||
|
table.getState().pagination.pageSize,
|
||||||
|
table.getFilteredRowModel().rows.length,
|
||||||
|
)} of ${table.getFilteredRowModel().rows.length}`}
|
||||||
|
</p>
|
||||||
|
<Select
|
||||||
|
value={table.getState().pagination.pageSize.toString()}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
table.setPageSize(Number(value));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 w-[70px]">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{pageSizeOptions.map((size) => (
|
||||||
|
<SelectItem key={size} value={size.toString()}>
|
||||||
|
{size}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
|
onClick={() => table.setPageIndex(0)}
|
||||||
|
disabled={!table.getCanPreviousPage()}
|
||||||
|
>
|
||||||
|
<ChevronsLeft className="h-4 w-4" />
|
||||||
|
<span className="sr-only">First page</span>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
|
onClick={() => table.previousPage()}
|
||||||
|
disabled={!table.getCanPreviousPage()}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Previous page</span>
|
||||||
|
</Button>
|
||||||
|
<div className="flex items-center gap-1 px-2">
|
||||||
|
<span className="text-muted-foreground text-xs sm:text-sm">
|
||||||
|
Page{" "}
|
||||||
|
<span className="text-foreground font-medium">
|
||||||
|
{table.getState().pagination.pageIndex + 1}
|
||||||
|
</span>{" "}
|
||||||
|
of{" "}
|
||||||
|
<span className="text-foreground font-medium">
|
||||||
|
{table.getPageCount() || 1}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
|
onClick={() => table.nextPage()}
|
||||||
|
disabled={!table.getCanNextPage()}
|
||||||
|
>
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Next page</span>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
|
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
|
||||||
|
disabled={!table.getCanNextPage()}
|
||||||
|
>
|
||||||
|
<ChevronsRight className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Last page</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper component for sortable column headers
|
||||||
|
export function DataTableColumnHeader<TData, TValue>({
|
||||||
|
column,
|
||||||
|
title,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
column: any;
|
||||||
|
title: string;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
if (!column.getCanSort()) {
|
||||||
|
return <div className={cn("text-xs sm:text-sm", className)}>{title}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className={cn(
|
||||||
|
"data-[state=open]:bg-accent -ml-2 h-8 px-2 text-xs font-medium hover:bg-transparent sm:text-sm",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||||
|
>
|
||||||
|
<span className="mr-2">{title}</span>
|
||||||
|
{column.getIsSorted() === "desc" ? (
|
||||||
|
<ArrowUpDown className="h-3 w-3 rotate-180 sm:h-3.5 sm:w-3.5" />
|
||||||
|
) : column.getIsSorted() === "asc" ? (
|
||||||
|
<ArrowUpDown className="h-3 w-3 sm:h-3.5 sm:w-3.5" />
|
||||||
|
) : (
|
||||||
|
<ArrowUpDown className="text-muted-foreground/50 h-3 w-3 sm:h-3.5 sm:w-3.5" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export skeleton component for loading states
|
||||||
|
export function DataTableSkeleton({
|
||||||
|
columns = 5,
|
||||||
|
rows = 5,
|
||||||
|
}: {
|
||||||
|
columns?: number;
|
||||||
|
rows?: number;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Filter bar skeleton */}
|
||||||
|
<Card className="border-0 py-2 shadow-sm">
|
||||||
|
<CardContent className="px-3 py-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="bg-muted/30 h-9 w-full flex-1 animate-pulse rounded-md sm:max-w-sm"></div>
|
||||||
|
<div className="bg-muted/30 h-9 w-24 animate-pulse rounded-md"></div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Table skeleton */}
|
||||||
|
<Card className="overflow-hidden border-0 p-0 shadow-sm">
|
||||||
|
<div className="w-full overflow-x-auto">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow className="bg-muted/50 hover:bg-muted/50">
|
||||||
|
{Array.from({ length: columns }).map((_, i) => (
|
||||||
|
<TableHead
|
||||||
|
key={i}
|
||||||
|
className="h-9 px-3 text-left align-middle sm:h-10 sm:px-4"
|
||||||
|
>
|
||||||
|
<div className="bg-muted/30 h-4 w-16 animate-pulse rounded sm:w-20"></div>
|
||||||
|
</TableHead>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{Array.from({ length: rows }).map((_, i) => (
|
||||||
|
<TableRow key={i} className="border-b">
|
||||||
|
{Array.from({ length: columns }).map((_, j) => (
|
||||||
|
<TableCell
|
||||||
|
key={j}
|
||||||
|
className="px-3 py-1.5 align-middle sm:px-4 sm:py-2"
|
||||||
|
>
|
||||||
|
<div className="bg-muted/30 h-4 w-full animate-pulse rounded"></div>
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Pagination skeleton */}
|
||||||
|
<Card className="border-0 py-2 shadow-sm">
|
||||||
|
<CardContent className="px-3 py-0">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="bg-muted/30 h-4 w-20 animate-pulse rounded text-xs sm:w-32 sm:text-sm"></div>
|
||||||
|
<div className="bg-muted/30 h-8 w-[70px] animate-pulse rounded"></div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="bg-muted/30 h-8 w-8 animate-pulse rounded"
|
||||||
|
></div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,28 +1,28 @@
|
|||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import { format } from "date-fns"
|
import { format } from "date-fns";
|
||||||
import { Calendar as CalendarIcon } from "lucide-react"
|
import { Calendar as CalendarIcon } from "lucide-react";
|
||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
|
|
||||||
import { Button } from "~/components/ui/button"
|
import { Button } from "~/components/ui/button";
|
||||||
import { Calendar } from "~/components/ui/calendar"
|
import { Calendar } from "~/components/ui/calendar";
|
||||||
import { Label } from "~/components/ui/label"
|
import { Label } from "~/components/ui/label";
|
||||||
import {
|
import {
|
||||||
Popover,
|
Popover,
|
||||||
PopoverContent,
|
PopoverContent,
|
||||||
PopoverTrigger,
|
PopoverTrigger,
|
||||||
} from "~/components/ui/popover"
|
} from "~/components/ui/popover";
|
||||||
import { cn } from "~/lib/utils"
|
import { cn } from "~/lib/utils";
|
||||||
|
|
||||||
interface DatePickerProps {
|
interface DatePickerProps {
|
||||||
date?: Date
|
date?: Date;
|
||||||
onDateChange: (date: Date | undefined) => void
|
onDateChange: (date: Date | undefined) => void;
|
||||||
label?: string
|
label?: string;
|
||||||
placeholder?: string
|
placeholder?: string;
|
||||||
className?: string
|
className?: string;
|
||||||
disabled?: boolean
|
disabled?: boolean;
|
||||||
required?: boolean
|
required?: boolean;
|
||||||
id?: string
|
id?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DatePicker({
|
export function DatePicker({
|
||||||
@@ -33,16 +33,16 @@ export function DatePicker({
|
|||||||
className,
|
className,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
required = false,
|
required = false,
|
||||||
id
|
id,
|
||||||
}: DatePickerProps) {
|
}: DatePickerProps) {
|
||||||
const [open, setOpen] = React.useState(false)
|
const [open, setOpen] = React.useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("flex flex-col gap-2", className)}>
|
<div className={cn("flex flex-col gap-2", className)}>
|
||||||
{label && (
|
{label && (
|
||||||
<Label htmlFor={id} className="text-sm font-medium text-gray-700">
|
<Label htmlFor={id} className="text-sm font-medium">
|
||||||
{label}
|
{label}
|
||||||
{required && <span className="text-red-500 ml-1">*</span>}
|
{required && <span className="text-destructive ml-1">*</span>}
|
||||||
</Label>
|
</Label>
|
||||||
)}
|
)}
|
||||||
<Popover open={open} onOpenChange={setOpen}>
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
@@ -52,12 +52,12 @@ export function DatePicker({
|
|||||||
id={id}
|
id={id}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full justify-between font-normal h-10 border-gray-200 focus:border-emerald-500 focus:ring-emerald-500 text-sm",
|
"h-10 w-full justify-between text-sm font-normal",
|
||||||
!date && "text-gray-500"
|
!date && "text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{date ? format(date, "PPP") : placeholder}
|
{date ? format(date, "PPP") : placeholder}
|
||||||
<CalendarIcon className="h-4 w-4 text-gray-400" />
|
<CalendarIcon className="text-muted-foreground h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="w-auto overflow-hidden p-0" align="start">
|
<PopoverContent className="w-auto overflow-hidden p-0" align="start">
|
||||||
@@ -66,12 +66,12 @@ export function DatePicker({
|
|||||||
selected={date}
|
selected={date}
|
||||||
captionLayout="dropdown"
|
captionLayout="dropdown"
|
||||||
onSelect={(selectedDate: Date | undefined) => {
|
onSelect={(selectedDate: Date | undefined) => {
|
||||||
onDateChange(selectedDate)
|
onDateChange(selectedDate);
|
||||||
setOpen(false)
|
setOpen(false);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
||||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
|
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
|
||||||
|
|
||||||
import { cn } from "~/lib/utils"
|
import { cn } from "~/lib/utils";
|
||||||
|
|
||||||
function DropdownMenu({
|
function DropdownMenu({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuPortal({
|
function DropdownMenuPortal({
|
||||||
@@ -17,7 +17,7 @@ function DropdownMenuPortal({
|
|||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||||
return (
|
return (
|
||||||
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuTrigger({
|
function DropdownMenuTrigger({
|
||||||
@@ -28,7 +28,7 @@ function DropdownMenuTrigger({
|
|||||||
data-slot="dropdown-menu-trigger"
|
data-slot="dropdown-menu-trigger"
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuContent({
|
function DropdownMenuContent({
|
||||||
@@ -42,13 +42,13 @@ function DropdownMenuContent({
|
|||||||
data-slot="dropdown-menu-content"
|
data-slot="dropdown-menu-content"
|
||||||
sideOffset={sideOffset}
|
sideOffset={sideOffset}
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border-0 shadow-md",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
</DropdownMenuPrimitive.Portal>
|
</DropdownMenuPrimitive.Portal>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuGroup({
|
function DropdownMenuGroup({
|
||||||
@@ -56,7 +56,7 @@ function DropdownMenuGroup({
|
|||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||||
return (
|
return (
|
||||||
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuItem({
|
function DropdownMenuItem({
|
||||||
@@ -65,8 +65,8 @@ function DropdownMenuItem({
|
|||||||
variant = "default",
|
variant = "default",
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||||
inset?: boolean
|
inset?: boolean;
|
||||||
variant?: "default" | "destructive"
|
variant?: "default" | "destructive";
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<DropdownMenuPrimitive.Item
|
<DropdownMenuPrimitive.Item
|
||||||
@@ -75,11 +75,11 @@ function DropdownMenuItem({
|
|||||||
data-variant={variant}
|
data-variant={variant}
|
||||||
className={cn(
|
className={cn(
|
||||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuCheckboxItem({
|
function DropdownMenuCheckboxItem({
|
||||||
@@ -93,7 +93,7 @@ function DropdownMenuCheckboxItem({
|
|||||||
data-slot="dropdown-menu-checkbox-item"
|
data-slot="dropdown-menu-checkbox-item"
|
||||||
className={cn(
|
className={cn(
|
||||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
checked={checked}
|
checked={checked}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -105,7 +105,7 @@ function DropdownMenuCheckboxItem({
|
|||||||
</span>
|
</span>
|
||||||
{children}
|
{children}
|
||||||
</DropdownMenuPrimitive.CheckboxItem>
|
</DropdownMenuPrimitive.CheckboxItem>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuRadioGroup({
|
function DropdownMenuRadioGroup({
|
||||||
@@ -116,7 +116,7 @@ function DropdownMenuRadioGroup({
|
|||||||
data-slot="dropdown-menu-radio-group"
|
data-slot="dropdown-menu-radio-group"
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuRadioItem({
|
function DropdownMenuRadioItem({
|
||||||
@@ -129,7 +129,7 @@ function DropdownMenuRadioItem({
|
|||||||
data-slot="dropdown-menu-radio-item"
|
data-slot="dropdown-menu-radio-item"
|
||||||
className={cn(
|
className={cn(
|
||||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
@@ -140,7 +140,7 @@ function DropdownMenuRadioItem({
|
|||||||
</span>
|
</span>
|
||||||
{children}
|
{children}
|
||||||
</DropdownMenuPrimitive.RadioItem>
|
</DropdownMenuPrimitive.RadioItem>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuLabel({
|
function DropdownMenuLabel({
|
||||||
@@ -148,7 +148,7 @@ function DropdownMenuLabel({
|
|||||||
inset,
|
inset,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||||
inset?: boolean
|
inset?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<DropdownMenuPrimitive.Label
|
<DropdownMenuPrimitive.Label
|
||||||
@@ -156,11 +156,11 @@ function DropdownMenuLabel({
|
|||||||
data-inset={inset}
|
data-inset={inset}
|
||||||
className={cn(
|
className={cn(
|
||||||
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuSeparator({
|
function DropdownMenuSeparator({
|
||||||
@@ -173,7 +173,7 @@ function DropdownMenuSeparator({
|
|||||||
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuShortcut({
|
function DropdownMenuShortcut({
|
||||||
@@ -185,17 +185,17 @@ function DropdownMenuShortcut({
|
|||||||
data-slot="dropdown-menu-shortcut"
|
data-slot="dropdown-menu-shortcut"
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuSub({
|
function DropdownMenuSub({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||||
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
|
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuSubTrigger({
|
function DropdownMenuSubTrigger({
|
||||||
@@ -204,7 +204,7 @@ function DropdownMenuSubTrigger({
|
|||||||
children,
|
children,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||||
inset?: boolean
|
inset?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<DropdownMenuPrimitive.SubTrigger
|
<DropdownMenuPrimitive.SubTrigger
|
||||||
@@ -212,14 +212,14 @@ function DropdownMenuSubTrigger({
|
|||||||
data-inset={inset}
|
data-inset={inset}
|
||||||
className={cn(
|
className={cn(
|
||||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
|
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
<ChevronRightIcon className="ml-auto size-4" />
|
<ChevronRightIcon className="ml-auto size-4" />
|
||||||
</DropdownMenuPrimitive.SubTrigger>
|
</DropdownMenuPrimitive.SubTrigger>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuSubContent({
|
function DropdownMenuSubContent({
|
||||||
@@ -230,12 +230,12 @@ function DropdownMenuSubContent({
|
|||||||
<DropdownMenuPrimitive.SubContent
|
<DropdownMenuPrimitive.SubContent
|
||||||
data-slot="dropdown-menu-sub-content"
|
data-slot="dropdown-menu-sub-content"
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border-0 shadow-lg",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
@@ -254,4 +254,4 @@ export {
|
|||||||
DropdownMenuSub,
|
DropdownMenuSub,
|
||||||
DropdownMenuSubTrigger,
|
DropdownMenuSubTrigger,
|
||||||
DropdownMenuSubContent,
|
DropdownMenuSubContent,
|
||||||
}
|
};
|
||||||
|
|||||||
105
src/components/ui/floating-action-bar.tsx
Normal file
105
src/components/ui/floating-action-bar.tsx
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { cn } from "~/lib/utils";
|
||||||
|
|
||||||
|
interface FloatingActionBarProps {
|
||||||
|
/** Ref to the element that triggers visibility when scrolled out of view */
|
||||||
|
triggerRef: React.RefObject<HTMLElement | null>;
|
||||||
|
/** Title text displayed on the left */
|
||||||
|
title: string;
|
||||||
|
/** Action buttons to display on the right */
|
||||||
|
children: React.ReactNode;
|
||||||
|
/** Additional className for styling */
|
||||||
|
className?: string;
|
||||||
|
/** Whether to show the floating bar (for manual control) */
|
||||||
|
show?: boolean;
|
||||||
|
/** Callback when visibility changes */
|
||||||
|
onVisibilityChange?: (visible: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FloatingActionBar({
|
||||||
|
triggerRef,
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
show,
|
||||||
|
onVisibilityChange,
|
||||||
|
}: FloatingActionBarProps) {
|
||||||
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
|
const floatingRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// If show prop is provided, use it instead of auto-detection
|
||||||
|
if (show !== undefined) {
|
||||||
|
setIsVisible(show);
|
||||||
|
onVisibilityChange?.(show);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleScroll = () => {
|
||||||
|
if (!triggerRef.current) return;
|
||||||
|
|
||||||
|
const rect = triggerRef.current.getBoundingClientRect();
|
||||||
|
const isInView = rect.top < window.innerHeight && rect.bottom >= 0;
|
||||||
|
|
||||||
|
// Show floating bar when trigger element is out of view
|
||||||
|
const shouldShow = !isInView;
|
||||||
|
|
||||||
|
if (shouldShow !== isVisible) {
|
||||||
|
setIsVisible(shouldShow);
|
||||||
|
onVisibilityChange?.(shouldShow);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Use ResizeObserver and IntersectionObserver for better detection
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
const entry = entries[0];
|
||||||
|
if (entry) {
|
||||||
|
const shouldShow = !entry.isIntersecting;
|
||||||
|
if (shouldShow !== isVisible) {
|
||||||
|
setIsVisible(shouldShow);
|
||||||
|
onVisibilityChange?.(shouldShow);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Trigger when element is completely out of view
|
||||||
|
threshold: 0,
|
||||||
|
rootMargin: "0px 0px -100% 0px",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Start observing when trigger element is available
|
||||||
|
if (triggerRef.current) {
|
||||||
|
observer.observe(triggerRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also add scroll listener as fallback
|
||||||
|
window.addEventListener("scroll", handleScroll, { passive: true });
|
||||||
|
|
||||||
|
// Check initial state
|
||||||
|
handleScroll();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
observer.disconnect();
|
||||||
|
window.removeEventListener("scroll", handleScroll);
|
||||||
|
};
|
||||||
|
}, [triggerRef, isVisible, show, onVisibilityChange]);
|
||||||
|
|
||||||
|
if (!isVisible) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={floatingRef}
|
||||||
|
className={cn(
|
||||||
|
"border-border/40 bg-background/60 animate-in slide-in-from-bottom-4 fixed right-3 bottom-3 left-3 z-20 flex items-center justify-between rounded-2xl border p-4 shadow-lg backdrop-blur-xl backdrop-saturate-150 duration-300 md:right-3 md:left-[279px]",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<p className="text-muted-foreground text-sm">{title}</p>
|
||||||
|
<div className="flex items-center gap-3">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
|
|
||||||
import { cn } from "~/lib/utils"
|
import { cn } from "~/lib/utils";
|
||||||
|
|
||||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||||
return (
|
return (
|
||||||
@@ -8,14 +8,15 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
|||||||
type={type}
|
type={type}
|
||||||
data-slot="input"
|
data-slot="input"
|
||||||
className={cn(
|
className={cn(
|
||||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground bg-background/50 text-foreground border-border/40 flex h-10 w-full min-w-0 rounded-md border px-3 py-2 text-sm shadow-sm backdrop-blur-sm transition-all duration-200 outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
"focus-visible:border-ring focus-visible:bg-background/80 focus-visible:ring-ring/20 focus-visible:ring-[3px]",
|
||||||
|
"hover:border-border/60 hover:bg-background/60",
|
||||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Input }
|
export { Input };
|
||||||
|
|||||||
179
src/components/ui/number-input.tsx
Normal file
179
src/components/ui/number-input.tsx
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import { Input } from "~/components/ui/input";
|
||||||
|
import { cn } from "~/lib/utils";
|
||||||
|
import { Minus, Plus } from "lucide-react";
|
||||||
|
|
||||||
|
interface NumberInputProps {
|
||||||
|
value: number;
|
||||||
|
onChange: (value: number) => void;
|
||||||
|
min?: number;
|
||||||
|
max?: number;
|
||||||
|
step?: number;
|
||||||
|
placeholder?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
className?: string;
|
||||||
|
prefix?: string;
|
||||||
|
suffix?: string;
|
||||||
|
id?: string;
|
||||||
|
name?: string;
|
||||||
|
"aria-label"?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NumberInput({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
min = 0,
|
||||||
|
max,
|
||||||
|
step = 1,
|
||||||
|
placeholder = "0",
|
||||||
|
disabled = false,
|
||||||
|
className,
|
||||||
|
prefix,
|
||||||
|
suffix,
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
"aria-label": ariaLabel,
|
||||||
|
}: NumberInputProps) {
|
||||||
|
const [inputValue, setInputValue] = React.useState(value.toString());
|
||||||
|
|
||||||
|
// Update input when external value changes
|
||||||
|
React.useEffect(() => {
|
||||||
|
setInputValue(value.toString());
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
const handleIncrement = () => {
|
||||||
|
const newValue = Math.min(value + step, max ?? Infinity);
|
||||||
|
onChange(newValue);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDecrement = () => {
|
||||||
|
const newValue = Math.max(value - step, min);
|
||||||
|
onChange(newValue);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const inputVal = e.target.value;
|
||||||
|
setInputValue(inputVal);
|
||||||
|
|
||||||
|
// Allow empty input for better UX
|
||||||
|
if (inputVal === "") {
|
||||||
|
onChange(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const numValue = parseFloat(inputVal);
|
||||||
|
if (!isNaN(numValue)) {
|
||||||
|
const clampedValue = Math.max(min, Math.min(numValue, max ?? Infinity));
|
||||||
|
onChange(clampedValue);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInputBlur = () => {
|
||||||
|
// Ensure the input shows the actual value on blur
|
||||||
|
setInputValue(value.toString());
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (e.key === "ArrowUp" && canIncrement) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleIncrement();
|
||||||
|
} else if (e.key === "ArrowDown" && canDecrement) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleDecrement();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const canDecrement = value > min;
|
||||||
|
const canIncrement = !max || value < max;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn("relative flex items-center", className)}
|
||||||
|
role="group"
|
||||||
|
aria-label={
|
||||||
|
ariaLabel || "Number input with increment and decrement buttons"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{/* Prefix */}
|
||||||
|
{prefix && (
|
||||||
|
<div className="text-muted-foreground pointer-events-none absolute left-10 z-10 flex items-center text-sm">
|
||||||
|
{prefix}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Decrement Button */}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={disabled || !canDecrement}
|
||||||
|
onClick={handleDecrement}
|
||||||
|
className={cn(
|
||||||
|
"h-8 w-8 rounded-r-none border-r-0 p-0 transition-all duration-150",
|
||||||
|
"hover:border-emerald-200 hover:bg-emerald-50 hover:text-emerald-700",
|
||||||
|
"dark:hover:border-emerald-700 dark:hover:bg-emerald-900/30",
|
||||||
|
"focus:z-10 focus:ring-2 focus:ring-emerald-500/20",
|
||||||
|
!canDecrement && "cursor-not-allowed opacity-40",
|
||||||
|
)}
|
||||||
|
aria-label="Decrease value"
|
||||||
|
tabIndex={disabled ? -1 : 0}
|
||||||
|
>
|
||||||
|
<Minus className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Input */}
|
||||||
|
<Input
|
||||||
|
id={id}
|
||||||
|
name={name}
|
||||||
|
type="number"
|
||||||
|
value={inputValue}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
onBlur={handleInputBlur}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder={placeholder}
|
||||||
|
disabled={disabled}
|
||||||
|
step={step}
|
||||||
|
min={min}
|
||||||
|
max={max}
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
className={cn(
|
||||||
|
"h-8 rounded-none border-x-0 text-center font-mono focus:z-10",
|
||||||
|
"focus:border-emerald-300 focus:ring-2 focus:ring-emerald-500/20",
|
||||||
|
"dark:focus:border-emerald-600",
|
||||||
|
prefix && "pl-12",
|
||||||
|
suffix && "pr-12",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Increment Button */}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={disabled || !canIncrement}
|
||||||
|
onClick={handleIncrement}
|
||||||
|
className={cn(
|
||||||
|
"h-8 w-8 rounded-l-none border-l-0 p-0 transition-all duration-150",
|
||||||
|
"hover:border-emerald-200 hover:bg-emerald-50 hover:text-emerald-700",
|
||||||
|
"dark:hover:border-emerald-700 dark:hover:bg-emerald-900/30",
|
||||||
|
"focus:z-10 focus:ring-2 focus:ring-emerald-500/20",
|
||||||
|
!canIncrement && "cursor-not-allowed opacity-40",
|
||||||
|
)}
|
||||||
|
aria-label="Increase value"
|
||||||
|
tabIndex={disabled ? -1 : 0}
|
||||||
|
>
|
||||||
|
<Plus className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Suffix */}
|
||||||
|
{suffix && (
|
||||||
|
<div className="text-muted-foreground pointer-events-none absolute right-10 z-10 flex items-center text-sm">
|
||||||
|
{suffix}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
148
src/components/ui/page-layout.tsx
Normal file
148
src/components/ui/page-layout.tsx
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { cn } from "~/lib/utils";
|
||||||
|
|
||||||
|
interface PageLayoutProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PageLayout({ children, className }: PageLayoutProps) {
|
||||||
|
return (
|
||||||
|
<div className={cn("min-h-screen", className)}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PageContentProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
spacing?: "default" | "compact" | "large";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PageContent({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
spacing = "default"
|
||||||
|
}: PageContentProps) {
|
||||||
|
const spacingClasses = {
|
||||||
|
default: "space-y-8",
|
||||||
|
compact: "space-y-4",
|
||||||
|
large: "space-y-12"
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn(spacingClasses[spacing], className)}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PageSectionProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
actions?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PageSection({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
actions
|
||||||
|
}: PageSectionProps) {
|
||||||
|
return (
|
||||||
|
<section className={cn("space-y-4", className)}>
|
||||||
|
{(title ?? description ?? actions) && (
|
||||||
|
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
||||||
|
<div>
|
||||||
|
{title && (
|
||||||
|
<h2 className="text-xl font-semibold text-foreground">{title}</h2>
|
||||||
|
)}
|
||||||
|
{description && (
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">{description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{actions && (
|
||||||
|
<div className="flex flex-shrink-0 gap-3">{actions}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{children}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PageGridProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
columns?: 1 | 2 | 3 | 4;
|
||||||
|
gap?: "default" | "compact" | "large";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PageGrid({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
columns = 3,
|
||||||
|
gap = "default"
|
||||||
|
}: PageGridProps) {
|
||||||
|
const columnClasses = {
|
||||||
|
1: "grid-cols-1",
|
||||||
|
2: "grid-cols-1 md:grid-cols-2",
|
||||||
|
3: "grid-cols-1 md:grid-cols-2 lg:grid-cols-3",
|
||||||
|
4: "grid-cols-1 md:grid-cols-2 lg:grid-cols-4"
|
||||||
|
};
|
||||||
|
|
||||||
|
const gapClasses = {
|
||||||
|
default: "gap-4",
|
||||||
|
compact: "gap-2",
|
||||||
|
large: "gap-6"
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn(
|
||||||
|
"grid",
|
||||||
|
columnClasses[columns],
|
||||||
|
gapClasses[gap],
|
||||||
|
className
|
||||||
|
)}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empty state component for consistent empty states across pages
|
||||||
|
interface EmptyStateProps {
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
action?: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EmptyState({
|
||||||
|
icon,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
action,
|
||||||
|
className
|
||||||
|
}: EmptyStateProps) {
|
||||||
|
return (
|
||||||
|
<div className={cn("py-12 text-center", className)}>
|
||||||
|
{icon && (
|
||||||
|
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-muted/50">
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<h3 className="mb-2 text-lg font-semibold">{title}</h3>
|
||||||
|
{description && (
|
||||||
|
<p className="text-muted-foreground mb-4 max-w-sm mx-auto">
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{action && <div className="mt-4">{action}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
112
src/components/ui/quick-action-card.tsx
Normal file
112
src/components/ui/quick-action-card.tsx
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { Card, CardContent } from "~/components/ui/card";
|
||||||
|
import { cn } from "~/lib/utils";
|
||||||
|
import type { LucideIcon } from "lucide-react";
|
||||||
|
|
||||||
|
interface QuickActionCardProps {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
icon: LucideIcon;
|
||||||
|
variant?: "default" | "success" | "info" | "warning" | "purple";
|
||||||
|
className?: string;
|
||||||
|
onClick?: () => void;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const variantStyles = {
|
||||||
|
default: {
|
||||||
|
icon: "text-foreground",
|
||||||
|
background: "bg-muted/50",
|
||||||
|
hoverBackground: "group-hover:bg-muted/70",
|
||||||
|
},
|
||||||
|
success: {
|
||||||
|
icon: "text-status-success",
|
||||||
|
background: "bg-status-success-muted",
|
||||||
|
hoverBackground: "group-hover:bg-status-success-muted/70",
|
||||||
|
},
|
||||||
|
info: {
|
||||||
|
icon: "text-status-info",
|
||||||
|
background: "bg-status-info-muted",
|
||||||
|
hoverBackground: "group-hover:bg-status-info-muted/70",
|
||||||
|
},
|
||||||
|
warning: {
|
||||||
|
icon: "text-status-warning",
|
||||||
|
background: "bg-status-warning-muted",
|
||||||
|
hoverBackground: "group-hover:bg-status-warning-muted/70",
|
||||||
|
},
|
||||||
|
purple: {
|
||||||
|
icon: "text-purple-600",
|
||||||
|
background: "bg-purple-100 dark:bg-purple-900/30",
|
||||||
|
hoverBackground:
|
||||||
|
"group-hover:bg-purple-200 dark:group-hover:bg-purple-900/50",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function QuickActionCard({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
icon: Icon,
|
||||||
|
variant = "default",
|
||||||
|
className,
|
||||||
|
onClick,
|
||||||
|
children,
|
||||||
|
}: QuickActionCardProps) {
|
||||||
|
const styles = variantStyles[variant];
|
||||||
|
|
||||||
|
const content = (
|
||||||
|
<CardContent className="p-6 text-center">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-full transition-colors",
|
||||||
|
styles.background,
|
||||||
|
styles.hoverBackground,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className={cn("h-6 w-6", styles.icon)} />
|
||||||
|
</div>
|
||||||
|
<h3 className="font-semibold">{title}</h3>
|
||||||
|
{description && (
|
||||||
|
<p className="text-muted-foreground mt-1 text-sm">{description}</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (children) {
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
className={cn(
|
||||||
|
"group cursor-pointer border-0 shadow-md transition-all hover:scale-[1.02] hover:shadow-lg",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
className={cn(
|
||||||
|
"group cursor-pointer border-0 shadow-md transition-all hover:scale-[1.02] hover:shadow-lg",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function QuickActionCardSkeleton() {
|
||||||
|
return (
|
||||||
|
<Card className="border-0 shadow-md">
|
||||||
|
<CardContent className="p-6">
|
||||||
|
<div className="animate-pulse">
|
||||||
|
<div className="bg-muted mx-auto mb-3 h-12 w-12 rounded-full"></div>
|
||||||
|
<div className="bg-muted mx-auto mb-2 h-4 w-2/3 rounded"></div>
|
||||||
|
<div className="bg-muted mx-auto h-3 w-1/2 rounded"></div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -42,7 +42,7 @@ function SelectTrigger({
|
|||||||
data-slot="select-trigger"
|
data-slot="select-trigger"
|
||||||
data-size={size}
|
data-size={size}
|
||||||
className={cn(
|
className={cn(
|
||||||
"data-[placeholder]:text-muted-foreground flex h-10 w-full items-center justify-between gap-2 rounded-md border border-gray-200 bg-gray-50 px-3 text-sm shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-emerald-500 focus-visible:ring-[3px] focus-visible:ring-emerald-500 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-600 dark:bg-gray-700 dark:text-white",
|
"data-[placeholder]:text-muted-foreground border-input bg-background text-foreground focus-visible:border-ring focus-visible:ring-ring/50 flex h-10 w-full items-center justify-between gap-2 rounded-md border px-3 py-2 text-sm shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -66,7 +66,7 @@ function SelectContent({
|
|||||||
<SelectPrimitive.Content
|
<SelectPrimitive.Content
|
||||||
data-slot="select-content"
|
data-slot="select-content"
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border-0 shadow-md",
|
||||||
position === "popper" &&
|
position === "popper" &&
|
||||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||||
className,
|
className,
|
||||||
@@ -210,7 +210,7 @@ function SelectContentWithSearch({
|
|||||||
<SelectPrimitive.Content
|
<SelectPrimitive.Content
|
||||||
data-slot="select-content"
|
data-slot="select-content"
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-96 min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-hidden rounded-md border shadow-md",
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-96 min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-hidden rounded-md border-0 shadow-md",
|
||||||
position === "popper" &&
|
position === "popper" &&
|
||||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||||
className,
|
className,
|
||||||
@@ -231,7 +231,7 @@ function SelectContentWithSearch({
|
|||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{onSearchChange && (
|
{onSearchChange && (
|
||||||
<div className="border-border flex items-center border-b px-3 py-2">
|
<div className="border-border/20 flex items-center border-b px-3 py-2">
|
||||||
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
<input
|
<input
|
||||||
ref={searchInputRef}
|
ref={searchInputRef}
|
||||||
@@ -282,10 +282,21 @@ interface SearchableSelectProps {
|
|||||||
value?: string;
|
value?: string;
|
||||||
onValueChange?: (value: string) => void;
|
onValueChange?: (value: string) => void;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
options: { value: string; label: string }[];
|
options: { value: string; label: string; disabled?: boolean }[];
|
||||||
searchPlaceholder?: string;
|
searchPlaceholder?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
renderOption?: (option: {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
}) => React.ReactNode;
|
||||||
|
isOptionDisabled?: (option: {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
}) => boolean;
|
||||||
|
id?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SearchableSelect({
|
function SearchableSelect({
|
||||||
@@ -296,15 +307,21 @@ function SearchableSelect({
|
|||||||
searchPlaceholder = "Search...",
|
searchPlaceholder = "Search...",
|
||||||
className,
|
className,
|
||||||
disabled,
|
disabled,
|
||||||
|
renderOption,
|
||||||
|
isOptionDisabled,
|
||||||
|
id,
|
||||||
}: SearchableSelectProps) {
|
}: SearchableSelectProps) {
|
||||||
const [searchValue, setSearchValue] = React.useState("");
|
const [searchValue, setSearchValue] = React.useState("");
|
||||||
const [isOpen, setIsOpen] = React.useState(false);
|
const [isOpen, setIsOpen] = React.useState(false);
|
||||||
|
|
||||||
const filteredOptions = React.useMemo(() => {
|
const filteredOptions = React.useMemo(() => {
|
||||||
if (!searchValue) return options;
|
if (!searchValue) return options;
|
||||||
return options.filter((option) =>
|
return options.filter((option) => {
|
||||||
option.label.toLowerCase().includes(searchValue.toLowerCase()),
|
// Don't filter out dividers, disabled options, or placeholder
|
||||||
);
|
if (option.value?.startsWith("divider-")) return true;
|
||||||
|
if (option.value === "__placeholder__") return true;
|
||||||
|
return option.label.toLowerCase().includes(searchValue.toLowerCase());
|
||||||
|
});
|
||||||
}, [options, searchValue]);
|
}, [options, searchValue]);
|
||||||
|
|
||||||
// Convert empty string to placeholder value for display
|
// Convert empty string to placeholder value for display
|
||||||
@@ -327,7 +344,7 @@ function SearchableSelect({
|
|||||||
open={isOpen}
|
open={isOpen}
|
||||||
onOpenChange={setIsOpen}
|
onOpenChange={setIsOpen}
|
||||||
>
|
>
|
||||||
<SelectTrigger className={cn("w-full", className)}>
|
<SelectTrigger className={cn("w-full", className)} id={id}>
|
||||||
<SelectValue
|
<SelectValue
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
// Always show placeholder if nothing is selected
|
// Always show placeholder if nothing is selected
|
||||||
@@ -341,11 +358,34 @@ function SearchableSelect({
|
|||||||
isOpen={isOpen}
|
isOpen={isOpen}
|
||||||
filteredOptions={filteredOptions}
|
filteredOptions={filteredOptions}
|
||||||
>
|
>
|
||||||
{filteredOptions.map((option) => (
|
{filteredOptions.map((option) => {
|
||||||
<SelectItem key={option.value} value={option.value}>
|
const isDisabled = isOptionDisabled
|
||||||
{option.label}
|
? isOptionDisabled(option)
|
||||||
</SelectItem>
|
: option.disabled;
|
||||||
))}
|
|
||||||
|
if (renderOption && option.value?.startsWith("divider-")) {
|
||||||
|
return (
|
||||||
|
<div key={option.value} className="pointer-events-none">
|
||||||
|
{renderOption(option)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip rendering items with empty string values
|
||||||
|
if (option.value === "") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SelectItem
|
||||||
|
key={option.value}
|
||||||
|
value={option.value}
|
||||||
|
disabled={isDisabled}
|
||||||
|
>
|
||||||
|
{renderOption ? renderOption(option) : option.label}
|
||||||
|
</SelectItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</SelectContentWithSearch>
|
</SelectContentWithSearch>
|
||||||
</Select>
|
</Select>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
import { cn } from "~/lib/utils";
|
import { cn } from "~/lib/utils";
|
||||||
|
import { Card, CardContent, CardHeader } from "~/components/ui/card";
|
||||||
|
|
||||||
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-slot="skeleton"
|
data-slot="skeleton"
|
||||||
className={cn(
|
className={cn("bg-muted/30 animate-pulse rounded-md", className)}
|
||||||
"bg-muted animate-pulse rounded-md dark:bg-gray-700",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -20,14 +18,14 @@ export function DashboardStatsSkeleton() {
|
|||||||
{Array.from({ length: 4 }).map((_, i) => (
|
{Array.from({ length: 4 }).map((_, i) => (
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
className="rounded-xl border-0 bg-white/80 p-6 shadow-xl backdrop-blur-sm dark:border-gray-700 dark:bg-gray-800/80"
|
className="border-border/40 bg-background/60 rounded-2xl border p-6 shadow-lg backdrop-blur-xl backdrop-saturate-150"
|
||||||
>
|
>
|
||||||
<div className="mb-4 flex items-center justify-between">
|
<div className="mb-4 flex items-center justify-between">
|
||||||
<Skeleton className="h-4 w-24 dark:bg-gray-600" />
|
<Skeleton className="bg-muted/20 h-4 w-24" />
|
||||||
<Skeleton className="h-8 w-8 rounded-lg dark:bg-gray-600" />
|
<Skeleton className="bg-muted/20 h-8 w-8 rounded-lg" />
|
||||||
</div>
|
</div>
|
||||||
<Skeleton className="mb-2 h-8 w-16 dark:bg-gray-600" />
|
<Skeleton className="bg-muted/20 mb-2 h-8 w-16" />
|
||||||
<Skeleton className="h-3 w-32 dark:bg-gray-600" />
|
<Skeleton className="bg-muted/20 h-3 w-32" />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -40,16 +38,16 @@ export function DashboardCardsSkeleton() {
|
|||||||
{Array.from({ length: 2 }).map((_, i) => (
|
{Array.from({ length: 2 }).map((_, i) => (
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
className="rounded-xl border-0 bg-white/80 p-6 shadow-xl backdrop-blur-sm dark:border-gray-700 dark:bg-gray-800/80"
|
className="border-border/40 bg-background/60 rounded-2xl border p-6 shadow-lg backdrop-blur-xl backdrop-saturate-150"
|
||||||
>
|
>
|
||||||
<div className="mb-4 flex items-center gap-2">
|
<div className="mb-4 flex items-center gap-2">
|
||||||
<Skeleton className="h-8 w-8 rounded-lg dark:bg-gray-600" />
|
<Skeleton className="bg-muted/20 h-8 w-8 rounded-lg" />
|
||||||
<Skeleton className="h-6 w-32 dark:bg-gray-600" />
|
<Skeleton className="bg-muted/20 h-6 w-32" />
|
||||||
</div>
|
</div>
|
||||||
<Skeleton className="mb-4 h-4 w-full dark:bg-gray-600" />
|
<Skeleton className="bg-muted/20 mb-4 h-4 w-full" />
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<Skeleton className="h-10 w-24 dark:bg-gray-600" />
|
<Skeleton className="bg-muted/20 h-10 w-24" />
|
||||||
<Skeleton className="h-10 w-32 dark:bg-gray-600" />
|
<Skeleton className="bg-muted/20 h-10 w-32" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -59,65 +57,124 @@ export function DashboardCardsSkeleton() {
|
|||||||
|
|
||||||
export function DashboardActivitySkeleton() {
|
export function DashboardActivitySkeleton() {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border-0 bg-white/80 p-6 shadow-xl backdrop-blur-sm dark:border-gray-700 dark:bg-gray-800/80">
|
<div className="border-border/40 bg-background/60 rounded-2xl border p-6 shadow-lg backdrop-blur-xl backdrop-saturate-150">
|
||||||
<Skeleton className="mb-6 h-6 w-32 dark:bg-gray-600" />
|
<Skeleton className="bg-muted/20 mb-6 h-6 w-32" />
|
||||||
<div className="py-12 text-center">
|
<div className="py-12 text-center">
|
||||||
<Skeleton className="mx-auto mb-4 h-20 w-20 rounded-full dark:bg-gray-600" />
|
<Skeleton className="bg-muted/20 mx-auto mb-4 h-20 w-20 rounded-full" />
|
||||||
<Skeleton className="mx-auto mb-2 h-6 w-48 dark:bg-gray-600" />
|
<Skeleton className="bg-muted/20 mx-auto mb-2 h-6 w-48" />
|
||||||
<Skeleton className="mx-auto h-4 w-64 dark:bg-gray-600" />
|
<Skeleton className="bg-muted/20 mx-auto h-4 w-64" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Table skeleton components
|
// Table skeleton components
|
||||||
export function TableSkeleton({ rows = 5 }: { rows?: number }) {
|
export function TableSkeleton({ rows = 8 }: { rows?: number }) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="w-full">
|
||||||
{/* Search and filters */}
|
{/* Controls - matches universal table controls */}
|
||||||
<div className="flex flex-col gap-4 sm:flex-row">
|
<div className="border-border/40 bg-background/60 mb-4 flex flex-wrap items-center gap-3 rounded-2xl border p-4 shadow-lg backdrop-blur-xl backdrop-saturate-150">
|
||||||
<Skeleton className="h-10 w-64 dark:bg-gray-600" />
|
{/* Left side - View controls and filters */}
|
||||||
<div className="flex gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Skeleton className="h-10 w-24 dark:bg-gray-600" />
|
<Skeleton className="bg-muted/20 h-10 w-10" />{" "}
|
||||||
<Skeleton className="h-10 w-24 dark:bg-gray-600" />
|
{/* Table view button */}
|
||||||
|
<Skeleton className="bg-muted/20 h-10 w-10" />{" "}
|
||||||
|
{/* Grid view button */}
|
||||||
|
<Skeleton className="bg-muted/20 h-10 w-10" /> {/* Filter button */}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right side - Search and batch actions */}
|
||||||
|
<div className="ml-auto flex flex-shrink-0 items-center gap-2">
|
||||||
|
<Skeleton className="bg-muted/20 h-10 w-48 sm:w-64" />{" "}
|
||||||
|
{/* Search input */}
|
||||||
|
<Skeleton className="bg-muted/20 h-10 w-10" /> {/* Search button */}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Table */}
|
{/* Table - matches universal table structure */}
|
||||||
<div className="rounded-lg border dark:border-gray-700 dark:bg-gray-800/90">
|
<div className="bg-background/60 border-border/40 overflow-hidden rounded-2xl border shadow-lg backdrop-blur-xl backdrop-saturate-150">
|
||||||
<div className="border-b p-4 dark:border-gray-700">
|
<div className="w-full">
|
||||||
<div className="flex items-center justify-between">
|
{/* Table header */}
|
||||||
<Skeleton className="h-4 w-32 dark:bg-gray-600" />
|
<div className="border-border/40 border-b">
|
||||||
<div className="flex gap-2">
|
<div className="flex items-center px-4 py-4">
|
||||||
<Skeleton className="h-8 w-8 dark:bg-gray-600" />
|
<div className="w-12 px-4">
|
||||||
<Skeleton className="h-8 w-8 dark:bg-gray-600" />
|
<Skeleton className="bg-muted/20 h-4 w-4" /> {/* Checkbox */}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 px-4">
|
||||||
|
<Skeleton className="bg-muted/20 h-4 w-16" /> {/* Header 1 */}
|
||||||
|
</div>
|
||||||
|
<div className="w-32 px-4">
|
||||||
|
<Skeleton className="bg-muted/20 h-4 w-20" /> {/* Header 2 */}
|
||||||
|
</div>
|
||||||
|
<div className="w-32 px-4">
|
||||||
|
<Skeleton className="bg-muted/20 h-4 w-16" /> {/* Header 3 */}
|
||||||
|
</div>
|
||||||
|
<div className="w-32 px-4">
|
||||||
|
<Skeleton className="bg-muted/20 h-4 w-20" /> {/* Header 4 */}
|
||||||
|
</div>
|
||||||
|
<div className="w-8 px-4">
|
||||||
|
<Skeleton className="bg-muted/20 h-4 w-4" /> {/* Actions */}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-4">
|
{/* Table body */}
|
||||||
<div className="space-y-3">
|
<div>
|
||||||
{Array.from({ length: rows }).map((_, i) => (
|
{Array.from({ length: rows }).map((_, i) => (
|
||||||
<div key={i} className="flex items-center gap-4">
|
<div
|
||||||
<Skeleton className="h-4 w-4 dark:bg-gray-600" />
|
key={i}
|
||||||
<Skeleton className="h-4 flex-1 dark:bg-gray-600" />
|
className="border-border/40 border-b last:border-b-0"
|
||||||
<Skeleton className="h-4 w-24 dark:bg-gray-600" />
|
>
|
||||||
<Skeleton className="h-4 w-24 dark:bg-gray-600" />
|
<div className="hover:bg-accent/30 flex items-center px-4 py-4 transition-colors">
|
||||||
<Skeleton className="h-4 w-20 dark:bg-gray-600" />
|
<div className="w-12 px-4">
|
||||||
<Skeleton className="h-8 w-16 dark:bg-gray-600" />
|
<Skeleton className="bg-muted/20 h-4 w-4" />{" "}
|
||||||
|
{/* Checkbox */}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 px-4">
|
||||||
|
<Skeleton className="bg-muted/20 h-4 w-full max-w-48" />{" "}
|
||||||
|
{/* Main content */}
|
||||||
|
</div>
|
||||||
|
<div className="w-32 px-4">
|
||||||
|
<Skeleton className="bg-muted/20 h-4 w-24" />{" "}
|
||||||
|
{/* Column 2 */}
|
||||||
|
</div>
|
||||||
|
<div className="w-32 px-4">
|
||||||
|
<Skeleton className="bg-muted/20 h-4 w-20" />{" "}
|
||||||
|
{/* Column 3 */}
|
||||||
|
</div>
|
||||||
|
<div className="w-32 px-4">
|
||||||
|
<Skeleton className="bg-muted/20 h-4 w-16" />{" "}
|
||||||
|
{/* Column 4 */}
|
||||||
|
</div>
|
||||||
|
<div className="w-8 px-4">
|
||||||
|
<Skeleton className="bg-muted/20 h-8 w-8 rounded" />{" "}
|
||||||
|
{/* Actions button */}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Pagination */}
|
{/* Pagination - matches universal table pagination */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="border-border/40 bg-background/60 mt-4 mb-4 flex flex-wrap items-center justify-between gap-3 rounded-2xl border p-4 shadow-lg backdrop-blur-xl backdrop-saturate-150">
|
||||||
<Skeleton className="h-4 w-32 dark:bg-gray-600" />
|
{/* Left side - Page info and items per page */}
|
||||||
<div className="flex gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Skeleton className="h-8 w-8 dark:bg-gray-600" />
|
<Skeleton className="bg-muted/20 h-4 w-40" /> {/* Page info text */}
|
||||||
<Skeleton className="h-8 w-8 dark:bg-gray-600" />
|
<Skeleton className="bg-muted/20 h-8 w-20" />{" "}
|
||||||
<Skeleton className="h-8 w-8 dark:bg-gray-600" />
|
{/* Items per page select */}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right side - Pagination controls */}
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Skeleton className="bg-muted/20 h-8 w-20" /> {/* Previous button */}
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Skeleton className="bg-muted/20 h-8 w-8" /> {/* Page 1 */}
|
||||||
|
<Skeleton className="bg-muted/20 h-8 w-8" /> {/* Page 2 */}
|
||||||
|
<Skeleton className="bg-muted/20 h-8 w-8" /> {/* Page 3 */}
|
||||||
|
</div>
|
||||||
|
<Skeleton className="bg-muted/20 h-8 w-16" /> {/* Next button */}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -127,36 +184,115 @@ export function TableSkeleton({ rows = 5 }: { rows?: number }) {
|
|||||||
// Form skeleton components
|
// Form skeleton components
|
||||||
export function FormSkeleton() {
|
export function FormSkeleton() {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="mx-auto max-w-6xl pb-24">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
{/* Basic Information Card */}
|
||||||
<Skeleton className="mb-2 h-4 w-20 dark:bg-gray-600" />
|
<Card>
|
||||||
<Skeleton className="h-10 w-full dark:bg-gray-600" />
|
<CardHeader>
|
||||||
</div>
|
<div className="flex items-center gap-3">
|
||||||
<div>
|
<Skeleton className="bg-muted/20 h-10 w-10 rounded-lg" />
|
||||||
<Skeleton className="mb-2 h-4 w-24 dark:bg-gray-600" />
|
<div className="space-y-2">
|
||||||
<Skeleton className="h-10 w-full dark:bg-gray-600" />
|
<Skeleton className="bg-muted/20 h-6 w-40" />
|
||||||
</div>
|
<Skeleton className="bg-muted/20 h-4 w-56" />
|
||||||
<div>
|
</div>
|
||||||
<Skeleton className="mb-2 h-4 w-16 dark:bg-gray-600" />
|
</div>
|
||||||
<Skeleton className="h-10 w-full dark:bg-gray-600" />
|
</CardHeader>
|
||||||
</div>
|
<CardContent className="space-y-4">
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="bg-muted/20 h-4 w-24" />
|
||||||
|
<Skeleton className="bg-muted/20 h-10 w-full" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="bg-muted/20 h-4 w-20" />
|
||||||
|
<Skeleton className="bg-muted/20 h-10 w-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Contact Information Card */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Skeleton className="bg-muted/20 h-10 w-10 rounded-lg" />
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="bg-muted/20 h-6 w-44" />
|
||||||
|
<Skeleton className="bg-muted/20 h-4 w-48" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="bg-muted/20 h-4 w-16" />
|
||||||
|
<Skeleton className="bg-muted/20 h-10 w-full" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="bg-muted/20 h-4 w-16" />
|
||||||
|
<Skeleton className="bg-muted/20 h-10 w-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="bg-muted/20 h-4 w-20" />
|
||||||
|
<Skeleton className="bg-muted/20 h-10 w-full" />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Address Card */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Skeleton className="bg-muted/20 h-10 w-10 rounded-lg" />
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="bg-muted/20 h-6 w-20" />
|
||||||
|
<Skeleton className="bg-muted/20 h-4 w-40" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="bg-muted/20 h-4 w-28" />
|
||||||
|
<Skeleton className="bg-muted/20 h-10 w-full" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="bg-muted/20 h-4 w-28" />
|
||||||
|
<Skeleton className="bg-muted/20 h-10 w-full" />
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="bg-muted/20 h-4 w-12" />
|
||||||
|
<Skeleton className="bg-muted/20 h-10 w-full" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="bg-muted/20 h-4 w-16" />
|
||||||
|
<Skeleton className="bg-muted/20 h-10 w-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="bg-muted/20 h-4 w-20" />
|
||||||
|
<Skeleton className="bg-muted/20 h-10 w-full" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="bg-muted/20 h-4 w-20" />
|
||||||
|
<Skeleton className="bg-muted/20 h-10 w-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
{/* Form Actions - styled like data table footer */}
|
||||||
<div>
|
<div className="border-border/40 bg-background/60 fixed right-3 bottom-3 left-3 z-20 flex items-center justify-between rounded-2xl border p-4 shadow-lg backdrop-blur-xl backdrop-saturate-150 md:right-3 md:left-[279px]">
|
||||||
<Skeleton className="mb-2 h-4 w-20 dark:bg-gray-600" />
|
<Skeleton className="bg-muted/20 h-4 w-40" />
|
||||||
<Skeleton className="h-10 w-full dark:bg-gray-600" />
|
<div className="flex items-center gap-3">
|
||||||
|
<Skeleton className="bg-muted/20 h-10 w-24" />
|
||||||
|
<Skeleton className="bg-muted/20 h-10 w-32" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<Skeleton className="mb-2 h-4 w-16 dark:bg-gray-600" />
|
|
||||||
<Skeleton className="h-10 w-full dark:bg-gray-600" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-3">
|
|
||||||
<Skeleton className="h-10 w-24 dark:bg-gray-600" />
|
|
||||||
<Skeleton className="h-10 w-24 dark:bg-gray-600" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -169,41 +305,41 @@ export function InvoiceViewSkeleton() {
|
|||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Skeleton className="h-8 w-48 dark:bg-gray-600" />
|
<Skeleton className="bg-muted/20 h-8 w-48" />
|
||||||
<Skeleton className="h-4 w-64 dark:bg-gray-600" />
|
<Skeleton className="bg-muted/20 h-4 w-64" />
|
||||||
</div>
|
</div>
|
||||||
<Skeleton className="h-10 w-32 dark:bg-gray-600" />
|
<Skeleton className="bg-muted/20 h-10 w-32" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Client info */}
|
{/* Client info */}
|
||||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Skeleton className="h-5 w-24 dark:bg-gray-600" />
|
<Skeleton className="bg-muted/20 h-5 w-24" />
|
||||||
<Skeleton className="h-4 w-full dark:bg-gray-600" />
|
<Skeleton className="bg-muted/20 h-4 w-full" />
|
||||||
<Skeleton className="h-4 w-3/4 dark:bg-gray-600" />
|
<Skeleton className="bg-muted/20 h-4 w-3/4" />
|
||||||
<Skeleton className="h-4 w-1/2 dark:bg-gray-600" />
|
<Skeleton className="bg-muted/20 h-4 w-1/2" />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Skeleton className="h-5 w-24 dark:bg-gray-600" />
|
<Skeleton className="bg-muted/20 h-5 w-24" />
|
||||||
<Skeleton className="h-4 w-full dark:bg-gray-600" />
|
<Skeleton className="bg-muted/20 h-4 w-full" />
|
||||||
<Skeleton className="h-4 w-3/4 dark:bg-gray-600" />
|
<Skeleton className="bg-muted/20 h-4 w-3/4" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Items table */}
|
{/* Items table */}
|
||||||
<div className="rounded-lg border dark:border-gray-700 dark:bg-gray-800/90">
|
<div className="border-border bg-card rounded-lg border">
|
||||||
<div className="border-b p-4 dark:border-gray-700">
|
<div className="border-border border-b p-4">
|
||||||
<Skeleton className="h-5 w-32 dark:bg-gray-600" />
|
<Skeleton className="bg-muted/20 h-5 w-32" />
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{Array.from({ length: 3 }).map((_, i) => (
|
{Array.from({ length: 3 }).map((_, i) => (
|
||||||
<div key={i} className="flex items-center gap-4">
|
<div key={i} className="flex items-center gap-4">
|
||||||
<Skeleton className="h-4 w-20 dark:bg-gray-600" />
|
<Skeleton className="bg-muted/20 h-4 w-20" />
|
||||||
<Skeleton className="h-4 flex-1 dark:bg-gray-600" />
|
<Skeleton className="bg-muted/20 h-4 flex-1" />
|
||||||
<Skeleton className="h-4 w-16 dark:bg-gray-600" />
|
<Skeleton className="bg-muted/20 h-4 w-16" />
|
||||||
<Skeleton className="h-4 w-20 dark:bg-gray-600" />
|
<Skeleton className="bg-muted/20 h-4 w-20" />
|
||||||
<Skeleton className="h-4 w-24 dark:bg-gray-600" />
|
<Skeleton className="bg-muted/20 h-4 w-24" />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -213,8 +349,8 @@ export function InvoiceViewSkeleton() {
|
|||||||
{/* Total */}
|
{/* Total */}
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Skeleton className="h-6 w-32 dark:bg-gray-600" />
|
<Skeleton className="bg-muted/20 h-6 w-32" />
|
||||||
<Skeleton className="h-8 w-40 dark:bg-gray-600" />
|
<Skeleton className="bg-muted/20 h-8 w-40" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
107
src/components/ui/stats-card.tsx
Normal file
107
src/components/ui/stats-card.tsx
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { Card, CardContent } from "~/components/ui/card";
|
||||||
|
import { cn } from "~/lib/utils";
|
||||||
|
import type { LucideIcon } from "lucide-react";
|
||||||
|
|
||||||
|
interface StatsCardProps {
|
||||||
|
title: string;
|
||||||
|
value: string | number;
|
||||||
|
description?: string;
|
||||||
|
icon?: LucideIcon;
|
||||||
|
trend?: {
|
||||||
|
value: number;
|
||||||
|
isPositive: boolean;
|
||||||
|
};
|
||||||
|
variant?: "default" | "success" | "warning" | "error" | "info";
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const variantStyles = {
|
||||||
|
default: {
|
||||||
|
icon: "text-foreground",
|
||||||
|
background: "bg-muted/50",
|
||||||
|
},
|
||||||
|
success: {
|
||||||
|
icon: "text-status-success",
|
||||||
|
background: "bg-status-success-muted",
|
||||||
|
},
|
||||||
|
warning: {
|
||||||
|
icon: "text-status-warning",
|
||||||
|
background: "bg-status-warning-muted",
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
icon: "text-status-error",
|
||||||
|
background: "bg-status-error-muted",
|
||||||
|
},
|
||||||
|
info: {
|
||||||
|
icon: "text-status-info",
|
||||||
|
background: "bg-status-info-muted",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function StatsCard({
|
||||||
|
title,
|
||||||
|
value,
|
||||||
|
description,
|
||||||
|
icon: Icon,
|
||||||
|
trend,
|
||||||
|
variant = "default",
|
||||||
|
className,
|
||||||
|
}: StatsCardProps) {
|
||||||
|
const styles = variantStyles[variant];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
className={cn(
|
||||||
|
"border-0 shadow-md transition-shadow hover:shadow-lg",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<CardContent className="p-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-muted-foreground text-sm font-medium">{title}</p>
|
||||||
|
<div className="flex items-baseline gap-2">
|
||||||
|
<p className="text-2xl font-bold">{value}</p>
|
||||||
|
{trend && (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"text-sm font-medium",
|
||||||
|
trend.isPositive
|
||||||
|
? "text-status-success"
|
||||||
|
: "text-status-error",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{trend.isPositive ? "+" : ""}
|
||||||
|
{trend.value}%
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{description && (
|
||||||
|
<p className="text-muted-foreground text-xs">{description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{Icon && (
|
||||||
|
<div className={cn("rounded-full p-3", styles.background)}>
|
||||||
|
<Icon className={cn("h-6 w-6", styles.icon)} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StatsCardSkeleton() {
|
||||||
|
return (
|
||||||
|
<Card className="border-0 shadow-md">
|
||||||
|
<CardContent className="p-6">
|
||||||
|
<div className="animate-pulse">
|
||||||
|
<div className="bg-muted mb-2 h-4 w-1/2 rounded"></div>
|
||||||
|
<div className="bg-muted mb-2 h-8 w-3/4 rounded"></div>
|
||||||
|
<div className="bg-muted h-3 w-1/3 rounded"></div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
45
src/components/ui/status-badge.tsx
Normal file
45
src/components/ui/status-badge.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { Badge, type badgeVariants } from "./badge";
|
||||||
|
import { type VariantProps } from "class-variance-authority";
|
||||||
|
|
||||||
|
type StatusType = "draft" | "sent" | "paid" | "overdue" | "success" | "warning" | "error" | "info";
|
||||||
|
|
||||||
|
interface StatusBadgeProps extends Omit<React.ComponentProps<typeof Badge>, "variant"> {
|
||||||
|
status: StatusType;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusVariantMap: Record<StatusType, VariantProps<typeof badgeVariants>["variant"]> = {
|
||||||
|
draft: "secondary",
|
||||||
|
sent: "info",
|
||||||
|
paid: "success",
|
||||||
|
overdue: "error",
|
||||||
|
success: "success",
|
||||||
|
warning: "warning",
|
||||||
|
error: "error",
|
||||||
|
info: "info",
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusLabelMap: Record<StatusType, string> = {
|
||||||
|
draft: "Draft",
|
||||||
|
sent: "Sent",
|
||||||
|
paid: "Paid",
|
||||||
|
overdue: "Overdue",
|
||||||
|
success: "Success",
|
||||||
|
warning: "Warning",
|
||||||
|
error: "Error",
|
||||||
|
info: "Info",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function StatusBadge({ status, children, ...props }: StatusBadgeProps) {
|
||||||
|
const variant = statusVariantMap[status];
|
||||||
|
const label = children || statusLabelMap[status];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Badge variant={variant} {...props}>
|
||||||
|
{label}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { type StatusType };
|
||||||
31
src/components/ui/switch.tsx
Normal file
31
src/components/ui/switch.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as SwitchPrimitive from "@radix-ui/react-switch";
|
||||||
|
|
||||||
|
import { cn } from "~/lib/utils";
|
||||||
|
|
||||||
|
function Switch({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SwitchPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<SwitchPrimitive.Root
|
||||||
|
data-slot="switch"
|
||||||
|
className={cn(
|
||||||
|
"peer data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 inline-flex h-5 w-9 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-emerald-600",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SwitchPrimitive.Thumb
|
||||||
|
data-slot="switch-thumb"
|
||||||
|
className={cn(
|
||||||
|
"bg-background pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=checked]:bg-white data-[state=unchecked]:translate-x-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SwitchPrimitive.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Switch };
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
|
|
||||||
import { cn } from "~/lib/utils"
|
import { cn } from "~/lib/utils";
|
||||||
|
|
||||||
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
||||||
return (
|
return (
|
||||||
@@ -16,7 +16,7 @@ function Table({ className, ...props }: React.ComponentProps<"table">) {
|
|||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
||||||
@@ -26,7 +26,7 @@ function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
|||||||
className={cn("[&_tr]:border-b", className)}
|
className={cn("[&_tr]:border-b", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
||||||
@@ -36,7 +36,7 @@ function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
|||||||
className={cn("[&_tr:last-child]:border-0", className)}
|
className={cn("[&_tr:last-child]:border-0", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
|
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
|
||||||
@@ -45,11 +45,11 @@ function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
|
|||||||
data-slot="table-footer"
|
data-slot="table-footer"
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-muted/50 border-t font-medium [&>tr]:last:border-b-0",
|
"bg-muted/50 border-t font-medium [&>tr]:last:border-b-0",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
||||||
@@ -58,11 +58,11 @@ function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
|||||||
data-slot="table-row"
|
data-slot="table-row"
|
||||||
className={cn(
|
className={cn(
|
||||||
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
|
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
||||||
@@ -70,12 +70,12 @@ function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
|||||||
<th
|
<th
|
||||||
data-slot="table-head"
|
data-slot="table-head"
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
"text-foreground h-9 px-3 text-left align-middle text-xs font-medium sm:h-10 sm:px-4 sm:text-sm [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
||||||
@@ -83,12 +83,12 @@ function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
|||||||
<td
|
<td
|
||||||
data-slot="table-cell"
|
data-slot="table-cell"
|
||||||
className={cn(
|
className={cn(
|
||||||
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
"px-3 py-1.5 align-middle text-xs sm:px-4 sm:py-2 sm:text-sm [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TableCaption({
|
function TableCaption({
|
||||||
@@ -101,7 +101,7 @@ function TableCaption({
|
|||||||
className={cn("text-muted-foreground mt-4 text-sm", className)}
|
className={cn("text-muted-foreground mt-4 text-sm", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
@@ -113,4 +113,4 @@ export {
|
|||||||
TableRow,
|
TableRow,
|
||||||
TableCell,
|
TableCell,
|
||||||
TableCaption,
|
TableCaption,
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
|
|
||||||
import { cn } from "~/lib/utils"
|
import { cn } from "~/lib/utils";
|
||||||
|
|
||||||
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||||
return (
|
return (
|
||||||
<textarea
|
<textarea
|
||||||
data-slot="textarea"
|
data-slot="textarea"
|
||||||
className={cn(
|
className={cn(
|
||||||
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive bg-background text-foreground flex field-sizing-content min-h-16 w-full resize-y rounded-md border px-3 py-2 text-sm shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Textarea }
|
export { Textarea };
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
388
src/lib/form-constants.ts
Normal file
388
src/lib/form-constants.ts
Normal file
@@ -0,0 +1,388 @@
|
|||||||
|
/**
|
||||||
|
* Shared form constants and utilities
|
||||||
|
*/
|
||||||
|
|
||||||
|
// US States
|
||||||
|
export const US_STATES = [
|
||||||
|
{ value: "AL", label: "Alabama" },
|
||||||
|
{ value: "AK", label: "Alaska" },
|
||||||
|
{ value: "AZ", label: "Arizona" },
|
||||||
|
{ value: "AR", label: "Arkansas" },
|
||||||
|
{ value: "CA", label: "California" },
|
||||||
|
{ value: "CO", label: "Colorado" },
|
||||||
|
{ value: "CT", label: "Connecticut" },
|
||||||
|
{ value: "DE", label: "Delaware" },
|
||||||
|
{ value: "FL", label: "Florida" },
|
||||||
|
{ value: "GA", label: "Georgia" },
|
||||||
|
{ value: "HI", label: "Hawaii" },
|
||||||
|
{ value: "ID", label: "Idaho" },
|
||||||
|
{ value: "IL", label: "Illinois" },
|
||||||
|
{ value: "IN", label: "Indiana" },
|
||||||
|
{ value: "IA", label: "Iowa" },
|
||||||
|
{ value: "KS", label: "Kansas" },
|
||||||
|
{ value: "KY", label: "Kentucky" },
|
||||||
|
{ value: "LA", label: "Louisiana" },
|
||||||
|
{ value: "ME", label: "Maine" },
|
||||||
|
{ value: "MD", label: "Maryland" },
|
||||||
|
{ value: "MA", label: "Massachusetts" },
|
||||||
|
{ value: "MI", label: "Michigan" },
|
||||||
|
{ value: "MN", label: "Minnesota" },
|
||||||
|
{ value: "MS", label: "Mississippi" },
|
||||||
|
{ value: "MO", label: "Missouri" },
|
||||||
|
{ value: "MT", label: "Montana" },
|
||||||
|
{ value: "NE", label: "Nebraska" },
|
||||||
|
{ value: "NV", label: "Nevada" },
|
||||||
|
{ value: "NH", label: "New Hampshire" },
|
||||||
|
{ value: "NJ", label: "New Jersey" },
|
||||||
|
{ value: "NM", label: "New Mexico" },
|
||||||
|
{ value: "NY", label: "New York" },
|
||||||
|
{ value: "NC", label: "North Carolina" },
|
||||||
|
{ value: "ND", label: "North Dakota" },
|
||||||
|
{ value: "OH", label: "Ohio" },
|
||||||
|
{ value: "OK", label: "Oklahoma" },
|
||||||
|
{ value: "OR", label: "Oregon" },
|
||||||
|
{ value: "PA", label: "Pennsylvania" },
|
||||||
|
{ value: "RI", label: "Rhode Island" },
|
||||||
|
{ value: "SC", label: "South Carolina" },
|
||||||
|
{ value: "SD", label: "South Dakota" },
|
||||||
|
{ value: "TN", label: "Tennessee" },
|
||||||
|
{ value: "TX", label: "Texas" },
|
||||||
|
{ value: "UT", label: "Utah" },
|
||||||
|
{ value: "VT", label: "Vermont" },
|
||||||
|
{ value: "VA", label: "Virginia" },
|
||||||
|
{ value: "WA", label: "Washington" },
|
||||||
|
{ value: "WV", label: "West Virginia" },
|
||||||
|
{ value: "WI", label: "Wisconsin" },
|
||||||
|
{ value: "WY", label: "Wyoming" },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Most commonly used countries
|
||||||
|
export const POPULAR_COUNTRIES = [
|
||||||
|
{ value: "United States", label: "United States" },
|
||||||
|
{ value: "United Kingdom", label: "United Kingdom" },
|
||||||
|
{ value: "Canada", label: "Canada" },
|
||||||
|
{ value: "Australia", label: "Australia" },
|
||||||
|
{ value: "Germany", label: "Germany" },
|
||||||
|
{ value: "France", label: "France" },
|
||||||
|
{ value: "India", label: "India" },
|
||||||
|
{ value: "Japan", label: "Japan" },
|
||||||
|
{ value: "Mexico", label: "Mexico" },
|
||||||
|
{ value: "Brazil", label: "Brazil" },
|
||||||
|
];
|
||||||
|
|
||||||
|
// All countries with ISO codes
|
||||||
|
export const ALL_COUNTRIES = [
|
||||||
|
{ value: "Afghanistan", label: "Afghanistan" },
|
||||||
|
{ value: "Albania", label: "Albania" },
|
||||||
|
{ value: "Algeria", label: "Algeria" },
|
||||||
|
{ value: "Andorra", label: "Andorra" },
|
||||||
|
{ value: "Angola", label: "Angola" },
|
||||||
|
{ value: "Antigua and Barbuda", label: "Antigua and Barbuda" },
|
||||||
|
{ value: "Argentina", label: "Argentina" },
|
||||||
|
{ value: "Armenia", label: "Armenia" },
|
||||||
|
{ value: "Australia", label: "Australia" },
|
||||||
|
{ value: "Austria", label: "Austria" },
|
||||||
|
{ value: "Azerbaijan", label: "Azerbaijan" },
|
||||||
|
{ value: "Bahamas", label: "Bahamas" },
|
||||||
|
{ value: "Bahrain", label: "Bahrain" },
|
||||||
|
{ value: "Bangladesh", label: "Bangladesh" },
|
||||||
|
{ value: "Barbados", label: "Barbados" },
|
||||||
|
{ value: "Belarus", label: "Belarus" },
|
||||||
|
{ value: "Belgium", label: "Belgium" },
|
||||||
|
{ value: "Belize", label: "Belize" },
|
||||||
|
{ value: "Benin", label: "Benin" },
|
||||||
|
{ value: "Bhutan", label: "Bhutan" },
|
||||||
|
{ value: "Bolivia", label: "Bolivia" },
|
||||||
|
{ value: "Bosnia and Herzegovina", label: "Bosnia and Herzegovina" },
|
||||||
|
{ value: "Botswana", label: "Botswana" },
|
||||||
|
{ value: "Brazil", label: "Brazil" },
|
||||||
|
{ value: "Brunei", label: "Brunei" },
|
||||||
|
{ value: "Bulgaria", label: "Bulgaria" },
|
||||||
|
{ value: "Burkina Faso", label: "Burkina Faso" },
|
||||||
|
{ value: "Burundi", label: "Burundi" },
|
||||||
|
{ value: "Cabo Verde", label: "Cabo Verde" },
|
||||||
|
{ value: "Cambodia", label: "Cambodia" },
|
||||||
|
{ value: "Cameroon", label: "Cameroon" },
|
||||||
|
{ value: "Canada", label: "Canada" },
|
||||||
|
{ value: "Central African Republic", label: "Central African Republic" },
|
||||||
|
{ value: "Chad", label: "Chad" },
|
||||||
|
{ value: "Chile", label: "Chile" },
|
||||||
|
{ value: "China", label: "China" },
|
||||||
|
{ value: "Colombia", label: "Colombia" },
|
||||||
|
{ value: "Comoros", label: "Comoros" },
|
||||||
|
{ value: "Congo", label: "Congo" },
|
||||||
|
{ value: "Costa Rica", label: "Costa Rica" },
|
||||||
|
{ value: "Croatia", label: "Croatia" },
|
||||||
|
{ value: "Cuba", label: "Cuba" },
|
||||||
|
{ value: "Cyprus", label: "Cyprus" },
|
||||||
|
{ value: "Czech Republic", label: "Czech Republic" },
|
||||||
|
{
|
||||||
|
value: "Democratic Republic of the Congo",
|
||||||
|
label: "Democratic Republic of the Congo",
|
||||||
|
},
|
||||||
|
{ value: "Denmark", label: "Denmark" },
|
||||||
|
{ value: "Djibouti", label: "Djibouti" },
|
||||||
|
{ value: "Dominica", label: "Dominica" },
|
||||||
|
{ value: "Dominican Republic", label: "Dominican Republic" },
|
||||||
|
{ value: "East Timor", label: "East Timor" },
|
||||||
|
{ value: "Ecuador", label: "Ecuador" },
|
||||||
|
{ value: "Egypt", label: "Egypt" },
|
||||||
|
{ value: "El Salvador", label: "El Salvador" },
|
||||||
|
{ value: "Equatorial Guinea", label: "Equatorial Guinea" },
|
||||||
|
{ value: "Eritrea", label: "Eritrea" },
|
||||||
|
{ value: "Estonia", label: "Estonia" },
|
||||||
|
{ value: "Eswatini", label: "Eswatini" },
|
||||||
|
{ value: "Ethiopia", label: "Ethiopia" },
|
||||||
|
{ value: "Fiji", label: "Fiji" },
|
||||||
|
{ value: "Finland", label: "Finland" },
|
||||||
|
{ value: "France", label: "France" },
|
||||||
|
{ value: "Gabon", label: "Gabon" },
|
||||||
|
{ value: "Gambia", label: "Gambia" },
|
||||||
|
{ value: "Georgia", label: "Georgia" },
|
||||||
|
{ value: "Germany", label: "Germany" },
|
||||||
|
{ value: "Ghana", label: "Ghana" },
|
||||||
|
{ value: "Greece", label: "Greece" },
|
||||||
|
{ value: "Grenada", label: "Grenada" },
|
||||||
|
{ value: "Guatemala", label: "Guatemala" },
|
||||||
|
{ value: "Guinea", label: "Guinea" },
|
||||||
|
{ value: "Guinea-Bissau", label: "Guinea-Bissau" },
|
||||||
|
{ value: "Guyana", label: "Guyana" },
|
||||||
|
{ value: "Haiti", label: "Haiti" },
|
||||||
|
{ value: "Honduras", label: "Honduras" },
|
||||||
|
{ value: "Hungary", label: "Hungary" },
|
||||||
|
{ value: "Iceland", label: "Iceland" },
|
||||||
|
{ value: "India", label: "India" },
|
||||||
|
{ value: "Indonesia", label: "Indonesia" },
|
||||||
|
{ value: "Iran", label: "Iran" },
|
||||||
|
{ value: "Iraq", label: "Iraq" },
|
||||||
|
{ value: "Ireland", label: "Ireland" },
|
||||||
|
{ value: "Israel", label: "Israel" },
|
||||||
|
{ value: "Italy", label: "Italy" },
|
||||||
|
{ value: "Ivory Coast", label: "Ivory Coast" },
|
||||||
|
{ value: "Jamaica", label: "Jamaica" },
|
||||||
|
{ value: "Japan", label: "Japan" },
|
||||||
|
{ value: "Jordan", label: "Jordan" },
|
||||||
|
{ value: "Kazakhstan", label: "Kazakhstan" },
|
||||||
|
{ value: "Kenya", label: "Kenya" },
|
||||||
|
{ value: "Kiribati", label: "Kiribati" },
|
||||||
|
{ value: "Kuwait", label: "Kuwait" },
|
||||||
|
{ value: "Kyrgyzstan", label: "Kyrgyzstan" },
|
||||||
|
{ value: "Laos", label: "Laos" },
|
||||||
|
{ value: "Latvia", label: "Latvia" },
|
||||||
|
{ value: "Lebanon", label: "Lebanon" },
|
||||||
|
{ value: "Lesotho", label: "Lesotho" },
|
||||||
|
{ value: "Liberia", label: "Liberia" },
|
||||||
|
{ value: "Libya", label: "Libya" },
|
||||||
|
{ value: "Liechtenstein", label: "Liechtenstein" },
|
||||||
|
{ value: "Lithuania", label: "Lithuania" },
|
||||||
|
{ value: "Luxembourg", label: "Luxembourg" },
|
||||||
|
{ value: "Madagascar", label: "Madagascar" },
|
||||||
|
{ value: "Malawi", label: "Malawi" },
|
||||||
|
{ value: "Malaysia", label: "Malaysia" },
|
||||||
|
{ value: "Maldives", label: "Maldives" },
|
||||||
|
{ value: "Mali", label: "Mali" },
|
||||||
|
{ value: "Malta", label: "Malta" },
|
||||||
|
{ value: "Marshall Islands", label: "Marshall Islands" },
|
||||||
|
{ value: "Mauritania", label: "Mauritania" },
|
||||||
|
{ value: "Mauritius", label: "Mauritius" },
|
||||||
|
{ value: "Mexico", label: "Mexico" },
|
||||||
|
{ value: "Micronesia", label: "Micronesia" },
|
||||||
|
{ value: "Moldova", label: "Moldova" },
|
||||||
|
{ value: "Monaco", label: "Monaco" },
|
||||||
|
{ value: "Mongolia", label: "Mongolia" },
|
||||||
|
{ value: "Montenegro", label: "Montenegro" },
|
||||||
|
{ value: "Morocco", label: "Morocco" },
|
||||||
|
{ value: "Mozambique", label: "Mozambique" },
|
||||||
|
{ value: "Myanmar", label: "Myanmar" },
|
||||||
|
{ value: "Namibia", label: "Namibia" },
|
||||||
|
{ value: "Nauru", label: "Nauru" },
|
||||||
|
{ value: "Nepal", label: "Nepal" },
|
||||||
|
{ value: "Netherlands", label: "Netherlands" },
|
||||||
|
{ value: "New Zealand", label: "New Zealand" },
|
||||||
|
{ value: "Nicaragua", label: "Nicaragua" },
|
||||||
|
{ value: "Niger", label: "Niger" },
|
||||||
|
{ value: "Nigeria", label: "Nigeria" },
|
||||||
|
{ value: "North Korea", label: "North Korea" },
|
||||||
|
{ value: "North Macedonia", label: "North Macedonia" },
|
||||||
|
{ value: "Norway", label: "Norway" },
|
||||||
|
{ value: "Oman", label: "Oman" },
|
||||||
|
{ value: "Pakistan", label: "Pakistan" },
|
||||||
|
{ value: "Palau", label: "Palau" },
|
||||||
|
{ value: "Palestine", label: "Palestine" },
|
||||||
|
{ value: "Panama", label: "Panama" },
|
||||||
|
{ value: "Papua New Guinea", label: "Papua New Guinea" },
|
||||||
|
{ value: "Paraguay", label: "Paraguay" },
|
||||||
|
{ value: "Peru", label: "Peru" },
|
||||||
|
{ value: "Philippines", label: "Philippines" },
|
||||||
|
{ value: "Poland", label: "Poland" },
|
||||||
|
{ value: "Portugal", label: "Portugal" },
|
||||||
|
{ value: "Qatar", label: "Qatar" },
|
||||||
|
{ value: "Romania", label: "Romania" },
|
||||||
|
{ value: "Russia", label: "Russia" },
|
||||||
|
{ value: "Rwanda", label: "Rwanda" },
|
||||||
|
{ value: "Saint Kitts and Nevis", label: "Saint Kitts and Nevis" },
|
||||||
|
{ value: "Saint Lucia", label: "Saint Lucia" },
|
||||||
|
{
|
||||||
|
value: "Saint Vincent and the Grenadines",
|
||||||
|
label: "Saint Vincent and the Grenadines",
|
||||||
|
},
|
||||||
|
{ value: "Samoa", label: "Samoa" },
|
||||||
|
{ value: "San Marino", label: "San Marino" },
|
||||||
|
{ value: "Sao Tome and Principe", label: "Sao Tome and Principe" },
|
||||||
|
{ value: "Saudi Arabia", label: "Saudi Arabia" },
|
||||||
|
{ value: "Senegal", label: "Senegal" },
|
||||||
|
{ value: "Serbia", label: "Serbia" },
|
||||||
|
{ value: "Seychelles", label: "Seychelles" },
|
||||||
|
{ value: "Sierra Leone", label: "Sierra Leone" },
|
||||||
|
{ value: "Singapore", label: "Singapore" },
|
||||||
|
{ value: "Slovakia", label: "Slovakia" },
|
||||||
|
{ value: "Slovenia", label: "Slovenia" },
|
||||||
|
{ value: "Solomon Islands", label: "Solomon Islands" },
|
||||||
|
{ value: "Somalia", label: "Somalia" },
|
||||||
|
{ value: "South Africa", label: "South Africa" },
|
||||||
|
{ value: "South Korea", label: "South Korea" },
|
||||||
|
{ value: "South Sudan", label: "South Sudan" },
|
||||||
|
{ value: "Spain", label: "Spain" },
|
||||||
|
{ value: "Sri Lanka", label: "Sri Lanka" },
|
||||||
|
{ value: "Sudan", label: "Sudan" },
|
||||||
|
{ value: "Suriname", label: "Suriname" },
|
||||||
|
{ value: "Sweden", label: "Sweden" },
|
||||||
|
{ value: "Switzerland", label: "Switzerland" },
|
||||||
|
{ value: "Syria", label: "Syria" },
|
||||||
|
{ value: "Taiwan", label: "Taiwan" },
|
||||||
|
{ value: "Tajikistan", label: "Tajikistan" },
|
||||||
|
{ value: "Tanzania", label: "Tanzania" },
|
||||||
|
{ value: "Thailand", label: "Thailand" },
|
||||||
|
{ value: "Togo", label: "Togo" },
|
||||||
|
{ value: "Tonga", label: "Tonga" },
|
||||||
|
{ value: "Trinidad and Tobago", label: "Trinidad and Tobago" },
|
||||||
|
{ value: "Tunisia", label: "Tunisia" },
|
||||||
|
{ value: "Turkey", label: "Turkey" },
|
||||||
|
{ value: "Turkmenistan", label: "Turkmenistan" },
|
||||||
|
{ value: "Tuvalu", label: "Tuvalu" },
|
||||||
|
{ value: "Uganda", label: "Uganda" },
|
||||||
|
{ value: "Ukraine", label: "Ukraine" },
|
||||||
|
{ value: "United Arab Emirates", label: "United Arab Emirates" },
|
||||||
|
{ value: "United Kingdom", label: "United Kingdom" },
|
||||||
|
{ value: "United States", label: "United States" },
|
||||||
|
{ value: "Uruguay", label: "Uruguay" },
|
||||||
|
{ value: "Uzbekistan", label: "Uzbekistan" },
|
||||||
|
{ value: "Vanuatu", label: "Vanuatu" },
|
||||||
|
{ value: "Vatican City", label: "Vatican City" },
|
||||||
|
{ value: "Venezuela", label: "Venezuela" },
|
||||||
|
{ value: "Vietnam", label: "Vietnam" },
|
||||||
|
{ value: "Yemen", label: "Yemen" },
|
||||||
|
{ value: "Zambia", label: "Zambia" },
|
||||||
|
{ value: "Zimbabwe", label: "Zimbabwe" },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Phone number formatting
|
||||||
|
export function formatPhoneNumber(value: string): string {
|
||||||
|
// Remove all non-numeric characters
|
||||||
|
const phoneNumber = value.replace(/\D/g, "");
|
||||||
|
|
||||||
|
// Format as US phone number
|
||||||
|
if (phoneNumber.length <= 3) {
|
||||||
|
return phoneNumber;
|
||||||
|
} else if (phoneNumber.length <= 6) {
|
||||||
|
return `(${phoneNumber.slice(0, 3)}) ${phoneNumber.slice(3)}`;
|
||||||
|
} else if (phoneNumber.length <= 10) {
|
||||||
|
return `(${phoneNumber.slice(0, 3)}) ${phoneNumber.slice(3, 6)}-${phoneNumber.slice(6, 10)}`;
|
||||||
|
} else {
|
||||||
|
// Handle international numbers
|
||||||
|
return `+${phoneNumber.slice(0, phoneNumber.length - 10)} (${phoneNumber.slice(-10, -7)}) ${phoneNumber.slice(-7, -4)}-${phoneNumber.slice(-4)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Email validation
|
||||||
|
export function isValidEmail(email: string): boolean {
|
||||||
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
return emailRegex.test(email);
|
||||||
|
}
|
||||||
|
|
||||||
|
// URL formatting
|
||||||
|
export function formatWebsiteUrl(url: string): string {
|
||||||
|
if (!url) return "";
|
||||||
|
|
||||||
|
// If URL doesn't start with http:// or https://, add https://
|
||||||
|
if (!url.match(/^https?:\/\//i)) {
|
||||||
|
return `https://${url}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Postal code formatting
|
||||||
|
export function formatPostalCode(
|
||||||
|
value: string,
|
||||||
|
country: string = "United States",
|
||||||
|
): string {
|
||||||
|
if (country === "United States") {
|
||||||
|
// Format as US ZIP code (12345 or 12345-6789)
|
||||||
|
const digits = value.replace(/\D/g, "");
|
||||||
|
if (digits.length <= 5) {
|
||||||
|
return digits;
|
||||||
|
} else {
|
||||||
|
return `${digits.slice(0, 5)}-${digits.slice(5, 9)}`;
|
||||||
|
}
|
||||||
|
} else if (country === "Canada") {
|
||||||
|
// Format as Canadian postal code (A1A 1A1)
|
||||||
|
const cleaned = value.toUpperCase().replace(/[^A-Z0-9]/g, "");
|
||||||
|
if (cleaned.length <= 3) {
|
||||||
|
return cleaned;
|
||||||
|
} else {
|
||||||
|
return `${cleaned.slice(0, 3)} ${cleaned.slice(3, 6)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return as-is for other countries
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tax ID formatting
|
||||||
|
export function formatTaxId(value: string, type: string = "EIN"): string {
|
||||||
|
const digits = value.replace(/\D/g, "");
|
||||||
|
|
||||||
|
if (type === "EIN") {
|
||||||
|
// Format as XX-XXXXXXX
|
||||||
|
if (digits.length <= 2) {
|
||||||
|
return digits;
|
||||||
|
} else {
|
||||||
|
return `${digits.slice(0, 2)}-${digits.slice(2, 9)}`;
|
||||||
|
}
|
||||||
|
} else if (type === "SSN") {
|
||||||
|
// Format as XXX-XX-XXXX
|
||||||
|
if (digits.length <= 3) {
|
||||||
|
return digits;
|
||||||
|
} else if (digits.length <= 5) {
|
||||||
|
return `${digits.slice(0, 3)}-${digits.slice(3)}`;
|
||||||
|
} else {
|
||||||
|
return `${digits.slice(0, 3)}-${digits.slice(3, 5)}-${digits.slice(5, 9)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Form validation messages
|
||||||
|
export const VALIDATION_MESSAGES = {
|
||||||
|
required: "This field is required",
|
||||||
|
email: "Please enter a valid email address",
|
||||||
|
phone: "Please enter a valid phone number",
|
||||||
|
url: "Please enter a valid URL",
|
||||||
|
postalCode: "Please enter a valid postal code",
|
||||||
|
taxId: "Please enter a valid tax ID",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Form field placeholders
|
||||||
|
export const PLACEHOLDERS = {
|
||||||
|
name: "Enter name",
|
||||||
|
email: "email@example.com",
|
||||||
|
phone: "(555) 123-4567",
|
||||||
|
addressLine1: "123 Main Street",
|
||||||
|
addressLine2: "Suite 100",
|
||||||
|
city: "San Francisco",
|
||||||
|
postalCode: "12345",
|
||||||
|
website: "www.example.com",
|
||||||
|
taxId: "12-3456789",
|
||||||
|
};
|
||||||
36
src/lib/navigation.ts
Normal file
36
src/lib/navigation.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import {
|
||||||
|
Settings,
|
||||||
|
LayoutDashboard,
|
||||||
|
Users,
|
||||||
|
FileText,
|
||||||
|
Building,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
export interface NavLink {
|
||||||
|
name: string;
|
||||||
|
href: string;
|
||||||
|
icon: React.ComponentType<{ className?: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NavSection {
|
||||||
|
title: string;
|
||||||
|
links: NavLink[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const navigationConfig: NavSection[] = [
|
||||||
|
{
|
||||||
|
title: "Main",
|
||||||
|
links: [
|
||||||
|
{ name: "Dashboard", href: "/dashboard", icon: LayoutDashboard },
|
||||||
|
{ name: "Clients", href: "/dashboard/clients", icon: Users },
|
||||||
|
{ name: "Businesses", href: "/dashboard/businesses", icon: Building },
|
||||||
|
{ name: "Invoices", href: "/dashboard/invoices", icon: FileText },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Account",
|
||||||
|
links: [
|
||||||
|
{ name: "Settings", href: "/dashboard/settings", icon: Settings },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
121
src/lib/pluralize.ts
Normal file
121
src/lib/pluralize.ts
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
/**
|
||||||
|
* Pluralization rules for common entities in the app
|
||||||
|
*/
|
||||||
|
const PLURALIZATION_RULES: Record<string, { singular: string; plural: string }> = {
|
||||||
|
business: { singular: "Business", plural: "Businesses" },
|
||||||
|
client: { singular: "Client", plural: "Clients" },
|
||||||
|
invoice: { singular: "Invoice", plural: "Invoices" },
|
||||||
|
setting: { singular: "Setting", plural: "Settings" },
|
||||||
|
user: { singular: "User", plural: "Users" },
|
||||||
|
payment: { singular: "Payment", plural: "Payments" },
|
||||||
|
item: { singular: "Item", plural: "Items" },
|
||||||
|
tax: { singular: "Tax", plural: "Taxes" },
|
||||||
|
category: { singular: "Category", plural: "Categories" },
|
||||||
|
company: { singular: "Company", plural: "Companies" },
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the plural form of a word
|
||||||
|
*/
|
||||||
|
export function pluralize(word: string, count?: number): string {
|
||||||
|
// If count is provided and is 1, return singular
|
||||||
|
if (count === 1) {
|
||||||
|
return word;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lowerWord = word.toLowerCase();
|
||||||
|
|
||||||
|
// Check if we have a specific rule for this word
|
||||||
|
if (PLURALIZATION_RULES[lowerWord]) {
|
||||||
|
return PLURALIZATION_RULES[lowerWord].plural;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply general pluralization rules
|
||||||
|
// Words ending in s, ss, sh, ch, x, z
|
||||||
|
if (/(?:s|ss|sh|ch|x|z)$/i.test(word)) {
|
||||||
|
return word + "es";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Words ending in consonant + y
|
||||||
|
if (/[^aeiou]y$/i.test(word)) {
|
||||||
|
return word.slice(0, -1) + "ies";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Words ending in f or fe
|
||||||
|
if (/(?:f|fe)$/i.test(word)) {
|
||||||
|
return word.replace(/(?:f|fe)$/i, "ves");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default: just add 's'
|
||||||
|
return word + "s";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the singular form of a word
|
||||||
|
*/
|
||||||
|
export function singularize(word: string): string {
|
||||||
|
const lowerWord = word.toLowerCase();
|
||||||
|
|
||||||
|
// Check if we have a specific rule for this word (search by plural)
|
||||||
|
const rule = Object.values(PLURALIZATION_RULES).find(
|
||||||
|
(r) => r.plural.toLowerCase() === lowerWord
|
||||||
|
);
|
||||||
|
|
||||||
|
if (rule) {
|
||||||
|
return rule.singular;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply general singularization rules
|
||||||
|
// Words ending in ies
|
||||||
|
if (/ies$/i.test(word)) {
|
||||||
|
return word.slice(0, -3) + "y";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Words ending in es
|
||||||
|
if (/(?:s|ss|sh|ch|x|z)es$/i.test(word)) {
|
||||||
|
return word.slice(0, -2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Words ending in ves
|
||||||
|
if (/ves$/i.test(word)) {
|
||||||
|
return word.slice(0, -3) + "f";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Words ending in s
|
||||||
|
if (/s$/i.test(word) && word.length > 1) {
|
||||||
|
return word.slice(0, -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default: return as is
|
||||||
|
return word;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Capitalize the first letter of a word
|
||||||
|
*/
|
||||||
|
export function capitalize(word: string): string {
|
||||||
|
if (!word) return word;
|
||||||
|
return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a properly formatted label for a route segment
|
||||||
|
*/
|
||||||
|
export function getRouteLabel(segment: string, isPlural: boolean = true): string {
|
||||||
|
// First, check if it's already in our rules
|
||||||
|
const rule = PLURALIZATION_RULES[segment.toLowerCase()];
|
||||||
|
if (rule) {
|
||||||
|
return isPlural ? rule.plural : rule.singular;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If not, try to find it by plural form
|
||||||
|
const singularForm = singularize(segment);
|
||||||
|
const singularRule = PLURALIZATION_RULES[singularForm.toLowerCase()];
|
||||||
|
if (singularRule) {
|
||||||
|
return isPlural ? singularRule.plural : singularRule.singular;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, just capitalize and optionally pluralize
|
||||||
|
const capitalized = capitalize(segment);
|
||||||
|
return isPlural ? pluralize(capitalized) : capitalized;
|
||||||
|
}
|
||||||
@@ -43,8 +43,8 @@ export const businessesRouter = createTRPCRouter({
|
|||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(businesses.id, input.id),
|
eq(businesses.id, input.id),
|
||||||
eq(businesses.createdById, ctx.session.user.id)
|
eq(businesses.createdById, ctx.session.user.id),
|
||||||
)
|
),
|
||||||
)
|
)
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
@@ -59,8 +59,8 @@ export const businessesRouter = createTRPCRouter({
|
|||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(businesses.createdById, ctx.session.user.id),
|
eq(businesses.createdById, ctx.session.user.id),
|
||||||
eq(businesses.isDefault, true)
|
eq(businesses.isDefault, true),
|
||||||
)
|
),
|
||||||
)
|
)
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
@@ -96,7 +96,7 @@ export const businessesRouter = createTRPCRouter({
|
|||||||
z.object({
|
z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
...businessSchema.shape,
|
...businessSchema.shape,
|
||||||
})
|
}),
|
||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const { id, ...updateData } = input;
|
const { id, ...updateData } = input;
|
||||||
@@ -106,12 +106,7 @@ export const businessesRouter = createTRPCRouter({
|
|||||||
await ctx.db
|
await ctx.db
|
||||||
.update(businesses)
|
.update(businesses)
|
||||||
.set({ isDefault: false })
|
.set({ isDefault: false })
|
||||||
.where(
|
.where(eq(businesses.createdById, ctx.session.user.id));
|
||||||
and(
|
|
||||||
eq(businesses.createdById, ctx.session.user.id),
|
|
||||||
eq(businesses.id, id)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const [updatedBusiness] = await ctx.db
|
const [updatedBusiness] = await ctx.db
|
||||||
@@ -123,13 +118,15 @@ export const businessesRouter = createTRPCRouter({
|
|||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(businesses.id, id),
|
eq(businesses.id, id),
|
||||||
eq(businesses.createdById, ctx.session.user.id)
|
eq(businesses.createdById, ctx.session.user.id),
|
||||||
)
|
),
|
||||||
)
|
)
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
if (!updatedBusiness) {
|
if (!updatedBusiness) {
|
||||||
throw new Error("Business not found or you don't have permission to update it");
|
throw new Error(
|
||||||
|
"Business not found or you don't have permission to update it",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return updatedBusiness;
|
return updatedBusiness;
|
||||||
@@ -146,13 +143,15 @@ export const businessesRouter = createTRPCRouter({
|
|||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(businesses.id, input.id),
|
eq(businesses.id, input.id),
|
||||||
eq(businesses.createdById, ctx.session.user.id)
|
eq(businesses.createdById, ctx.session.user.id),
|
||||||
)
|
),
|
||||||
)
|
)
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (!business[0]) {
|
if (!business[0]) {
|
||||||
throw new Error("Business not found or you don't have permission to delete it");
|
throw new Error(
|
||||||
|
"Business not found or you don't have permission to delete it",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if this business has any invoices
|
// Check if this business has any invoices
|
||||||
@@ -162,7 +161,9 @@ export const businessesRouter = createTRPCRouter({
|
|||||||
.where(eq(invoices.businessId, input.id));
|
.where(eq(invoices.businessId, input.id));
|
||||||
|
|
||||||
if (invoiceCount[0] && invoiceCount[0].count > 0) {
|
if (invoiceCount[0] && invoiceCount[0].count > 0) {
|
||||||
throw new Error("Cannot delete business that has invoices. Please delete all invoices first.");
|
throw new Error(
|
||||||
|
"Cannot delete business that has invoices. Please delete all invoices first.",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await ctx.db
|
await ctx.db
|
||||||
@@ -170,8 +171,8 @@ export const businessesRouter = createTRPCRouter({
|
|||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(businesses.id, input.id),
|
eq(businesses.id, input.id),
|
||||||
eq(businesses.createdById, ctx.session.user.id)
|
eq(businesses.createdById, ctx.session.user.id),
|
||||||
)
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
@@ -194,15 +195,17 @@ export const businessesRouter = createTRPCRouter({
|
|||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(businesses.id, input.id),
|
eq(businesses.id, input.id),
|
||||||
eq(businesses.createdById, ctx.session.user.id)
|
eq(businesses.createdById, ctx.session.user.id),
|
||||||
)
|
),
|
||||||
)
|
)
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
if (!updatedBusiness) {
|
if (!updatedBusiness) {
|
||||||
throw new Error("Business not found or you don't have permission to update it");
|
throw new Error(
|
||||||
|
"Business not found or you don't have permission to update it",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return updatedBusiness;
|
return updatedBusiness;
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,6 +5,9 @@
|
|||||||
--font-sans:
|
--font-sans:
|
||||||
var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif,
|
var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif,
|
||||||
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
|
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
|
||||||
|
--font-mono:
|
||||||
|
var(--font-azeret-mono), ui-monospace, SFMono-Regular, "SF Mono", Consolas,
|
||||||
|
"Liberation Mono", Menlo, monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
@@ -48,11 +51,11 @@
|
|||||||
|
|
||||||
:root {
|
:root {
|
||||||
--radius: 0.625rem;
|
--radius: 0.625rem;
|
||||||
--background: oklch(1 0 0);
|
--background: oklch(0.99 0.003 164.25);
|
||||||
--foreground: oklch(0.145 0 0);
|
--foreground: oklch(0.145 0 0);
|
||||||
--card: oklch(1 0 0);
|
--card: oklch(0.995 0.002 164.25);
|
||||||
--card-foreground: oklch(0.145 0 0);
|
--card-foreground: oklch(0.145 0 0);
|
||||||
--popover: oklch(1 0 0);
|
--popover: oklch(0.995 0.002 164.25);
|
||||||
--popover-foreground: oklch(0.145 0 0);
|
--popover-foreground: oklch(0.145 0 0);
|
||||||
--primary: oklch(0.205 0 0);
|
--primary: oklch(0.205 0 0);
|
||||||
--primary-foreground: oklch(0.985 0 0);
|
--primary-foreground: oklch(0.985 0 0);
|
||||||
@@ -80,6 +83,26 @@
|
|||||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||||
--sidebar-border: oklch(0.922 0 0);
|
--sidebar-border: oklch(0.922 0 0);
|
||||||
--sidebar-ring: oklch(0.708 0 0);
|
--sidebar-ring: oklch(0.708 0 0);
|
||||||
|
|
||||||
|
/* Brand colors */
|
||||||
|
--brand-primary: oklch(0.646 0.222 164.25);
|
||||||
|
--brand-primary-hover: oklch(0.576 0.222 164.25);
|
||||||
|
--brand-secondary: oklch(0.6 0.118 184.704);
|
||||||
|
--brand-secondary-hover: oklch(0.53 0.118 184.704);
|
||||||
|
|
||||||
|
/* Status colors */
|
||||||
|
--status-success: oklch(0.646 0.222 164.25);
|
||||||
|
--status-success-foreground: oklch(0.985 0 0);
|
||||||
|
--status-success-muted: oklch(0.97 0.02 164.25);
|
||||||
|
--status-warning: oklch(0.828 0.189 84.429);
|
||||||
|
--status-warning-foreground: oklch(0.145 0 0);
|
||||||
|
--status-warning-muted: oklch(0.985 0.02 84.429);
|
||||||
|
--status-error: oklch(0.577 0.245 27.325);
|
||||||
|
--status-error-foreground: oklch(0.985 0 0);
|
||||||
|
--status-error-muted: oklch(0.985 0.02 27.325);
|
||||||
|
--status-info: oklch(0.6 0.118 184.704);
|
||||||
|
--status-info-foreground: oklch(0.985 0 0);
|
||||||
|
--status-info-muted: oklch(0.97 0.02 184.704);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
@@ -116,6 +139,26 @@
|
|||||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||||
--sidebar-border: oklch(1 0 0 / 10%);
|
--sidebar-border: oklch(1 0 0 / 10%);
|
||||||
--sidebar-ring: oklch(0.556 0 0);
|
--sidebar-ring: oklch(0.556 0 0);
|
||||||
|
|
||||||
|
/* Brand colors - dark mode */
|
||||||
|
--brand-primary: oklch(0.696 0.17 162.48);
|
||||||
|
--brand-primary-hover: oklch(0.766 0.17 162.48);
|
||||||
|
--brand-secondary: oklch(0.696 0.17 162.48);
|
||||||
|
--brand-secondary-hover: oklch(0.766 0.17 162.48);
|
||||||
|
|
||||||
|
/* Status colors - dark mode */
|
||||||
|
--status-success: oklch(0.696 0.17 162.48);
|
||||||
|
--status-success-foreground: oklch(0.145 0 0);
|
||||||
|
--status-success-muted: oklch(0.269 0.05 162.48);
|
||||||
|
--status-warning: oklch(0.828 0.189 84.429);
|
||||||
|
--status-warning-foreground: oklch(0.145 0 0);
|
||||||
|
--status-warning-muted: oklch(0.269 0.05 84.429);
|
||||||
|
--status-error: oklch(0.704 0.191 22.216);
|
||||||
|
--status-error-foreground: oklch(0.985 0 0);
|
||||||
|
--status-error-muted: oklch(0.269 0.05 22.216);
|
||||||
|
--status-info: oklch(0.769 0.188 70.08);
|
||||||
|
--status-info-foreground: oklch(0.145 0 0);
|
||||||
|
--status-info-muted: oklch(0.269 0.05 70.08);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,7 +171,7 @@
|
|||||||
@apply bg-background text-foreground font-sans antialiased;
|
@apply bg-background text-foreground font-sans antialiased;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Improved form elements for dark mode */
|
/* Comprehensive form elements styling - consistent across all inputs */
|
||||||
input[type="text"],
|
input[type="text"],
|
||||||
input[type="email"],
|
input[type="email"],
|
||||||
input[type="password"],
|
input[type="password"],
|
||||||
@@ -136,21 +179,111 @@
|
|||||||
input[type="url"],
|
input[type="url"],
|
||||||
input[type="search"],
|
input[type="search"],
|
||||||
input[type="number"],
|
input[type="number"],
|
||||||
|
input[type="date"],
|
||||||
|
input[type="datetime-local"],
|
||||||
|
input[type="time"],
|
||||||
textarea,
|
textarea,
|
||||||
select {
|
select {
|
||||||
@apply bg-background text-foreground border-input;
|
@apply bg-background text-foreground border-input h-10 rounded-md px-3 py-2 text-sm shadow-xs transition-colors;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Textarea specific height override */
|
||||||
|
textarea {
|
||||||
|
@apply h-auto min-h-20 resize-y;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Placeholder styling */
|
||||||
input::placeholder,
|
input::placeholder,
|
||||||
textarea::placeholder {
|
textarea::placeholder {
|
||||||
@apply text-muted-foreground;
|
@apply text-muted-foreground;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Better focus states */
|
/* Better focus states with consistent ring */
|
||||||
input:focus,
|
input:focus,
|
||||||
textarea:focus,
|
textarea:focus,
|
||||||
select:focus {
|
select:focus {
|
||||||
@apply ring-ring border-ring;
|
@apply ring-ring/50 border-ring ring-[3px] outline-none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Disabled state styling */
|
||||||
|
input:disabled,
|
||||||
|
textarea:disabled,
|
||||||
|
select:disabled {
|
||||||
|
@apply cursor-not-allowed opacity-50;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form input icons - consistent positioning for left icons */
|
||||||
|
.form-input-icon-left {
|
||||||
|
@apply pl-10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input-icon-left + .form-icon {
|
||||||
|
@apply text-muted-foreground pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form section styling */
|
||||||
|
.form-section {
|
||||||
|
@apply space-y-6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-section-header {
|
||||||
|
@apply flex items-center space-x-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-section-icon {
|
||||||
|
@apply text-primary h-5 w-5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-section-title {
|
||||||
|
@apply text-foreground text-lg font-semibold;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form field groups */
|
||||||
|
.form-field-group {
|
||||||
|
@apply space-y-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field-label {
|
||||||
|
@apply text-foreground text-sm font-medium;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field-help {
|
||||||
|
@apply text-muted-foreground text-xs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form grid layouts */
|
||||||
|
.form-grid-1 {
|
||||||
|
@apply grid grid-cols-1 gap-6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid-2 {
|
||||||
|
@apply grid grid-cols-1 gap-6 md:grid-cols-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid-3 {
|
||||||
|
@apply grid grid-cols-1 gap-6 md:grid-cols-3;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form buttons */
|
||||||
|
.form-actions {
|
||||||
|
@apply mt-8 flex justify-end gap-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Select elements specific styling to match inputs */
|
||||||
|
select {
|
||||||
|
@apply bg-background appearance-none;
|
||||||
|
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='m6 8 4 4 4-4'/%3e%3c/svg%3e");
|
||||||
|
background-position: right 0.5rem center;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-size: 1.5em 1.5em;
|
||||||
|
padding-right: 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode select arrow */
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
select {
|
||||||
|
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%9ca3af' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='m6 8 4 4 4-4'/%3e%3c/svg%3e");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Selection styling */
|
/* Selection styling */
|
||||||
@@ -233,4 +366,141 @@
|
|||||||
input[type="radio"]:checked {
|
input[type="radio"]:checked {
|
||||||
@apply bg-primary border-primary;
|
@apply bg-primary border-primary;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Background gradient utilities that adapt to dark mode */
|
||||||
|
.bg-gradient-auth {
|
||||||
|
background: linear-gradient(135deg, #f0fdf4 0%, #d1fae5 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.bg-gradient-auth {
|
||||||
|
background: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
oklch(0.145 0 0) 0%,
|
||||||
|
oklch(0.185 0 0) 100%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-gradient-dashboard {
|
||||||
|
background: linear-gradient(135deg, #ecfdf5 0%, #ffffff 40%, #f0fdfa 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.bg-gradient-dashboard {
|
||||||
|
background: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
oklch(0.145 0 0) 0%,
|
||||||
|
oklch(0.185 0 0) 40%,
|
||||||
|
oklch(0.205 0 0) 100%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Radial overlay that adapts to dark mode */
|
||||||
|
.bg-radial-overlay::before {
|
||||||
|
content: "";
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
background: radial-gradient(
|
||||||
|
ellipse at 80% 0%,
|
||||||
|
oklch(0.646 0.222 164.25 / 0.1) 0%,
|
||||||
|
transparent 60%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.bg-radial-overlay::before {
|
||||||
|
background: radial-gradient(
|
||||||
|
ellipse at 80% 0%,
|
||||||
|
oklch(0.696 0.17 162.48 / 0.15) 0%,
|
||||||
|
transparent 60%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Brand utility classes */
|
||||||
|
.bg-brand-primary {
|
||||||
|
background-color: var(--brand-primary);
|
||||||
|
color: var(--status-success-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-brand-gradient {
|
||||||
|
background: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
var(--brand-primary),
|
||||||
|
var(--brand-secondary)
|
||||||
|
);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hover\:bg-brand-gradient:hover {
|
||||||
|
background: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
var(--brand-primary-hover),
|
||||||
|
var(--brand-secondary-hover)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-brand-primary {
|
||||||
|
color: var(--brand-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.border-brand-primary {
|
||||||
|
border-color: var(--brand-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status utility classes */
|
||||||
|
.bg-status-success-muted {
|
||||||
|
background-color: var(--status-success-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-status-warning-muted {
|
||||||
|
background-color: var(--status-warning-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-status-error-muted {
|
||||||
|
background-color: var(--status-error-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-status-info-muted {
|
||||||
|
background-color: var(--status-info-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-status-success {
|
||||||
|
color: var(--status-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-status-warning {
|
||||||
|
color: var(--status-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-status-error {
|
||||||
|
color: var(--status-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-status-info {
|
||||||
|
color: var(--status-info);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-status-success-foreground {
|
||||||
|
color: var(--status-success-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-status-warning-foreground {
|
||||||
|
color: var(--status-warning-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-status-error-foreground {
|
||||||
|
color: var(--status-error-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-status-info-foreground {
|
||||||
|
color: var(--status-info-foreground);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,6 +52,26 @@ export default {
|
|||||||
DEFAULT: "hsl(var(--card))",
|
DEFAULT: "hsl(var(--card))",
|
||||||
foreground: "hsl(var(--card-foreground))",
|
foreground: "hsl(var(--card-foreground))",
|
||||||
},
|
},
|
||||||
|
brand: {
|
||||||
|
primary: "hsl(var(--brand-primary))",
|
||||||
|
"primary-hover": "hsl(var(--brand-primary-hover))",
|
||||||
|
secondary: "hsl(var(--brand-secondary))",
|
||||||
|
"secondary-hover": "hsl(var(--brand-secondary-hover))",
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
success: "hsl(var(--status-success))",
|
||||||
|
"success-foreground": "hsl(var(--status-success-foreground))",
|
||||||
|
"success-muted": "hsl(var(--status-success-muted))",
|
||||||
|
warning: "hsl(var(--status-warning))",
|
||||||
|
"warning-foreground": "hsl(var(--status-warning-foreground))",
|
||||||
|
"warning-muted": "hsl(var(--status-warning-muted))",
|
||||||
|
error: "hsl(var(--status-error))",
|
||||||
|
"error-foreground": "hsl(var(--status-error-foreground))",
|
||||||
|
"error-muted": "hsl(var(--status-error-muted))",
|
||||||
|
info: "hsl(var(--status-info))",
|
||||||
|
"info-foreground": "hsl(var(--status-info-foreground))",
|
||||||
|
"info-muted": "hsl(var(--status-info-muted))",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
borderRadius: {
|
borderRadius: {
|
||||||
lg: "var(--radius)",
|
lg: "var(--radius)",
|
||||||
|
|||||||
Reference in New Issue
Block a user