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:
2025-07-15 00:29:02 -04:00
parent 89de059501
commit f331136090
79 changed files with 9944 additions and 4223 deletions

View File

@@ -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=="],

View 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
View 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
View 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.

View 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

View 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
View 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

View File

@@ -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"
] ]
} }

View File

@@ -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>

View File

@@ -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&apos;t have an account?{" "} Don&apos;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>

View 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>
);
}

View 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>
);
}

View File

@@ -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" />

View File

@@ -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>
);
} }

View 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>
);
}

View File

@@ -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>
</>
);
}

View File

@@ -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} />;
} }

View File

@@ -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>
);
} }

View File

@@ -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> </>
); );
} }

View File

@@ -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>

View File

@@ -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) && (

View 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>
</>
);
}

View File

@@ -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} />;
} }

View File

@@ -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>

View File

@@ -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> </>
); );
} }

View File

@@ -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>
);
}

View 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} />;
}

View File

@@ -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>
);
}

View 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}
/>
);
}

View File

@@ -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" />;
}

View File

@@ -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>
); );

View File

@@ -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>
); );
} }

View File

@@ -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> </>
); );
} }

View File

@@ -2,21 +2,25 @@ 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>

View File

@@ -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&apos;s what&apos;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>
); );
} }

View File

@@ -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:

View 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>
);
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>
); );
} }

View File

@@ -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

View File

@@ -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>
); );
} }

View File

@@ -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 // Recalculate errors if issue date or due date was updated
if (updates.issueDate !== undefined || updates.dueDate !== undefined) { if (updates.issueDate !== undefined || updates.dueDate !== undefined) {
const newErrors = [...updatedFile.errors]; const newErrors = [...updatedFile.errors];
// Remove filename format error if a valid issue date is now set // 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")) { if (
const errorIndex = newErrors.indexOf("Filename must be in YYYY-MM-DD.csv format"); updatedFile.issueDate &&
if (errorIndex > -1) { newErrors.includes("Filename must be in YYYY-MM-DD.csv format")
newErrors.splice(errorIndex, 1); ) {
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
if (
updatedFile.issueDate &&
newErrors.includes("Invalid date in filename")
) {
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"),
);
} }
// Remove invalid date error if a valid issue date is now set return updatedFile;
if (updatedFile.issueDate && newErrors.includes("Invalid date in filename")) { }),
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")
);
}
return updatedFile;
}));
}; };
const openPreview = (index: number) => { const openPreview = (index: number) => {
@@ -277,7 +315,7 @@ export function CSVImportPage() {
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) {
@@ -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>

View File

@@ -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: &quot;media&quot;
</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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>

View File

@@ -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..."}
</> </>
) : ( ) : (

View File

@@ -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}>

View File

@@ -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 ? (
<> <>

View 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>
);
}

View 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>
);
}

View File

@@ -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 };

View File

@@ -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 };

View File

@@ -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,
} };

View 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 }

View 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>
);
}

View File

@@ -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>
) );
} }

View File

@@ -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,
} };

View 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>
);
}

View File

@@ -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 };

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -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>
); );

View File

@@ -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>

View 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>
);
}

View 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 };

View 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 };

View File

@@ -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,
} };

View File

@@ -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
View 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
View 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
View 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;
}

View File

@@ -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,13 +195,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),
) ),
) )
.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;

View File

@@ -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);
}
} }

View File

@@ -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)",