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",
"@radix-ui/react-alert-dialog": "^1.1.14",
"@radix-ui/react-checkbox": "^1.3.2",
"@radix-ui/react-collapsible": "^1.1.11",
"@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-label": "^2.1.7",
@@ -20,10 +21,12 @@
"@radix-ui/react-select": "^2.2.5",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.5",
"@radix-ui/react-tabs": "^1.1.12",
"@react-pdf/renderer": "^4.3.0",
"@t3-oss/env-nextjs": "^0.12.0",
"@tanstack/react-query": "^5.69.0",
"@tanstack/react-table": "^8.21.3",
"@trpc/client": "^11.0.0",
"@trpc/react-query": "^11.0.0",
"@trpc/server": "^11.0.0",
@@ -37,7 +40,7 @@
"file-saver": "^2.0.5",
"lucide": "^0.525.0",
"lucide-react": "^0.525.0",
"next": "^15.2.3",
"next": "^15.4.1",
"next-auth": "5.0.0-beta.25",
"react": "^19.0.0",
"react-day-picker": "^9.8.0",
@@ -77,6 +80,8 @@
"trustedDependencies": [
"@tailwindcss/oxide",
"better-sqlite3",
"esbuild",
"sharp",
"unrs-resolver",
],
"packages": {
@@ -192,47 +197,49 @@
"@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=="],
@@ -272,25 +279,25 @@
"@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/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=="],
@@ -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-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-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-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-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=="],
"@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=="],
"@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-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/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=="],
"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-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=="],
"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=="],
@@ -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=="],
"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=="],
@@ -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=="],
"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.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",
"@radix-ui/react-alert-dialog": "^1.1.14",
"@radix-ui/react-checkbox": "^1.3.2",
"@radix-ui/react-collapsible": "^1.1.11",
"@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-label": "^2.1.7",
@@ -42,10 +43,12 @@
"@radix-ui/react-select": "^2.2.5",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.5",
"@radix-ui/react-tabs": "^1.1.12",
"@react-pdf/renderer": "^4.3.0",
"@t3-oss/env-nextjs": "^0.12.0",
"@tanstack/react-query": "^5.69.0",
"@tanstack/react-table": "^8.21.3",
"@trpc/client": "^11.0.0",
"@trpc/react-query": "^11.0.0",
"@trpc/server": "^11.0.0",
@@ -59,7 +62,7 @@
"file-saver": "^2.0.5",
"lucide": "^0.525.0",
"lucide-react": "^0.525.0",
"next": "^15.2.3",
"next": "^15.4.1",
"next-auth": "5.0.0-beta.25",
"react": "^19.0.0",
"react-day-picker": "^9.8.0",
@@ -101,6 +104,8 @@
"@tailwindcss/oxide",
"better-sqlite3",
"core-js",
"esbuild",
"sharp",
"unrs-resolver"
]
}

View File

@@ -49,25 +49,25 @@ function RegisterForm() {
}
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">
{/* Logo and Welcome */}
<div className="space-y-4 text-center">
<Logo size="lg" className="mx-auto" />
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
<h1 className="text-foreground text-2xl font-bold">
Join beenvoice
</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
</p>
</div>
</div>
{/* Registration Form */}
<Card className="border-0 shadow-xl dark:bg-gray-800">
<Card className="border-0 shadow-xl">
<CardHeader className="space-y-1">
<CardTitle className="text-center text-xl dark:text-white">
<CardTitle className="text-center text-xl">
Create Account
</CardTitle>
</CardHeader>
@@ -77,7 +77,7 @@ function RegisterForm() {
<div className="space-y-2">
<Label htmlFor="firstName">First Name</Label>
<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
id="firstName"
type="text"
@@ -93,7 +93,7 @@ function RegisterForm() {
<div className="space-y-2">
<Label htmlFor="lastName">Last Name</Label>
<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
id="lastName"
type="text"
@@ -109,7 +109,7 @@ function RegisterForm() {
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<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
id="email"
type="email"
@@ -124,7 +124,7 @@ function RegisterForm() {
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<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
id="password"
type="password"
@@ -136,7 +136,7 @@ function RegisterForm() {
placeholder="Create a password"
/>
</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
</p>
</div>
@@ -152,7 +152,7 @@ function RegisterForm() {
</Button>
</form>
<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?{" "}
</span>
<Link
@@ -167,10 +167,10 @@ function RegisterForm() {
{/* Features */}
<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
</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> No credit card</span>
<span> Cancel anytime</span>
@@ -185,17 +185,15 @@ export default function RegisterPage() {
return (
<Suspense
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="space-y-4 text-center">
<Logo size="lg" className="mx-auto" />
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
<h1 className="text-foreground text-2xl font-bold">
Join beenvoice
</h1>
<p className="mt-2 text-gray-600 dark:text-gray-300">
Loading...
</p>
<p className="text-muted-foreground mt-2">Loading...</p>
</div>
</div>
</div>

View File

@@ -42,34 +42,30 @@ function SignInForm() {
}
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">
{/* Logo and Welcome */}
<div className="space-y-4 text-center">
<Logo size="lg" className="mx-auto" />
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
Welcome back
</h1>
<p className="mt-2 text-gray-600 dark:text-gray-300">
<h1 className="text-foreground text-2xl font-bold">Welcome back</h1>
<p className="text-muted-foreground mt-2">
Sign in to your beenvoice account
</p>
</div>
</div>
{/* Sign In Form */}
<Card className="border-0 shadow-xl dark:bg-gray-800">
<Card className="border-0 shadow-xl">
<CardHeader className="space-y-1">
<CardTitle className="text-center text-xl dark:text-white">
Sign In
</CardTitle>
<CardTitle className="text-center text-xl">Sign In</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSignIn} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<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
id="email"
type="email"
@@ -85,7 +81,7 @@ function SignInForm() {
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<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
id="password"
type="password"
@@ -109,7 +105,7 @@ function SignInForm() {
</Button>
</form>
<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?{" "}
</span>
<Link
@@ -124,10 +120,10 @@ function SignInForm() {
{/* Features */}
<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
</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> Professional invoices</span>
<span> Payment tracking</span>
@@ -142,17 +138,15 @@ export default function SignInPage() {
return (
<Suspense
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="space-y-4 text-center">
<Logo size="lg" className="mx-auto" />
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
<h1 className="text-foreground text-2xl font-bold">
Welcome back
</h1>
<p className="mt-2 text-gray-600 dark:text-gray-300">
Loading...
</p>
<p className="text-muted-foreground mt-2">Loading...</p>
</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 { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { Button } from "~/components/ui/button";
import { StatusBadge, type StatusType } from "~/components/ui/status-badge";
import {
Users,
FileText,
@@ -44,60 +45,60 @@ export function DashboardStats() {
return (
<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">
<CardTitle className="text-sm font-medium text-gray-700 dark:text-gray-300">
<CardTitle className="text-muted-foreground text-sm font-medium">
Total Clients
</CardTitle>
<div className="rounded-lg bg-emerald-100 p-2 dark:bg-emerald-900/30">
<Users className="h-4 w-4 text-emerald-600 dark:text-emerald-400" />
<div className="rounded-lg bg-emerald-100 p-2">
<Users className="h-4 w-4 text-emerald-600" />
</div>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold text-emerald-600 dark:text-emerald-400">
<div className="text-3xl font-bold text-emerald-600">
{totalClients}
</div>
<p className="text-xs text-gray-500 dark:text-gray-400">
<p className="text-muted-foreground text-xs">
{totalClients > lastMonthClients ? "+" : ""}
{totalClients - lastMonthClients} from last month
</p>
</CardContent>
</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">
<CardTitle className="text-sm font-medium text-gray-700 dark:text-gray-300">
<CardTitle className="text-muted-foreground text-sm font-medium">
Total Invoices
</CardTitle>
<div className="rounded-lg bg-blue-100 p-2 dark:bg-blue-900/30">
<FileText className="h-4 w-4 text-blue-600 dark:text-blue-400" />
<div className="rounded-lg bg-blue-100 p-2">
<FileText className="h-4 w-4 text-blue-600" />
</div>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold text-blue-600 dark:text-blue-400">
<div className="text-3xl font-bold text-blue-600">
{totalInvoices}
</div>
<p className="text-xs text-gray-500 dark:text-gray-400">
<p className="text-muted-foreground text-xs">
{totalInvoices > lastMonthInvoices ? "+" : ""}
{totalInvoices - lastMonthInvoices} from last month
</p>
</CardContent>
</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">
<CardTitle className="text-sm font-medium text-gray-700 dark:text-gray-300">
<CardTitle className="text-muted-foreground text-sm font-medium">
Revenue
</CardTitle>
<div className="rounded-lg bg-teal-100 p-2 dark:bg-teal-900/30">
<TrendingUp className="h-4 w-4 text-teal-600 dark:text-teal-400" />
<div className="rounded-lg bg-teal-100 p-2">
<TrendingUp className="h-4 w-4 text-teal-600" />
</div>
</CardHeader>
<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)}
</div>
<p className="text-xs text-gray-500 dark:text-gray-400">
<p className="text-muted-foreground text-xs">
{totalRevenue > lastMonthRevenue ? "+" : ""}
{(
((totalRevenue - lastMonthRevenue) / (lastMonthRevenue || 1)) *
@@ -108,22 +109,20 @@ export function DashboardStats() {
</CardContent>
</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">
<CardTitle className="text-sm font-medium text-gray-700 dark:text-gray-300">
<CardTitle className="text-muted-foreground text-sm font-medium">
Pending Invoices
</CardTitle>
<div className="rounded-lg bg-orange-100 p-2 dark:bg-orange-900/30">
<Calendar className="h-4 w-4 text-orange-600 dark:text-orange-400" />
<div className="rounded-lg bg-orange-100 p-2">
<Calendar className="h-4 w-4 text-orange-600" />
</div>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold text-orange-600 dark:text-orange-400">
<div className="text-3xl font-bold text-orange-600">
{pendingInvoices}
</div>
<p className="text-xs text-gray-500 dark:text-gray-400">
Due this month
</p>
<p className="text-muted-foreground text-xs">Due this month</p>
</CardContent>
</Card>
</div>
@@ -134,34 +133,27 @@ export function DashboardStats() {
export function DashboardCards() {
return (
<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>
<CardTitle className="flex items-center gap-2 text-emerald-700 dark:text-emerald-400">
<div className="rounded-lg bg-emerald-100 p-2 dark:bg-emerald-900/30">
<CardTitle className="flex items-center gap-2 text-emerald-700">
<div className="rounded-lg bg-emerald-100 p-2">
<Users className="h-5 w-5" />
</div>
Manage Clients
</CardTitle>
</CardHeader>
<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.
</p>
<div className="flex gap-3">
<Button
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"
>
<Button asChild variant="brand">
<Link href="/dashboard/clients/new">
<Plus className="mr-2 h-4 w-4" />
Add Client
</Link>
</Button>
<Button
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"
>
<Button variant="outline" asChild className="font-medium">
<Link href="/dashboard/clients">
View All Clients
<ArrowRight className="ml-2 h-4 w-4" />
@@ -171,34 +163,27 @@ export function DashboardCards() {
</CardContent>
</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>
<CardTitle className="flex items-center gap-2 text-emerald-700 dark:text-emerald-400">
<div className="rounded-lg bg-emerald-100 p-2 dark:bg-emerald-900/30">
<CardTitle className="flex items-center gap-2 text-emerald-700">
<div className="rounded-lg bg-emerald-100 p-2">
<FileText className="h-5 w-5" />
</div>
Create Invoices
</CardTitle>
</CardHeader>
<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.
</p>
<div className="flex gap-3">
<Button
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"
>
<Button asChild variant="brand">
<Link href="/dashboard/invoices/new">
<Plus className="mr-2 h-4 w-4" />
New Invoice
</Link>
</Button>
<Button
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"
>
<Button variant="outline" asChild className="font-medium">
<Link href="/dashboard/invoices">
View All Invoices
<ArrowRight className="ml-2 h-4 w-4" />
@@ -222,22 +207,20 @@ export function DashboardActivity() {
const recentInvoices = invoices?.slice(0, 5) ?? [];
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>
<CardTitle className="text-emerald-700 dark:text-emerald-400">
Recent Activity
</CardTitle>
<CardTitle className="text-emerald-700">Recent Activity</CardTitle>
</CardHeader>
<CardContent>
{recentInvoices.length === 0 ? (
<div className="py-12 text-center text-gray-500 dark:text-gray-400">
<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">
<FileText className="h-8 w-8 text-gray-400 dark:text-gray-500" />
<div className="text-muted-foreground py-12 text-center">
<div className="bg-muted mx-auto mb-4 flex h-20 w-20 items-center justify-center rounded-full p-4">
<FileText className="text-muted-foreground h-8 w-8" />
</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
</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
</p>
</div>
@@ -246,37 +229,24 @@ export function DashboardActivity() {
{recentInvoices.map((invoice) => (
<div
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="rounded-lg bg-emerald-100 p-2 dark:bg-emerald-900/30">
<FileText className="h-4 w-4 text-emerald-600 dark:text-emerald-400" />
<div className="rounded-lg bg-emerald-100 p-2">
<FileText className="h-4 w-4 text-emerald-600" />
</div>
<div>
<p className="font-medium text-gray-900 dark:text-white">
<p className="text-foreground font-medium">
Invoice #{invoice.invoiceNumber}
</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.totalAmount.toFixed(2)}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<span
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>
<StatusBadge status={invoice.status as StatusType} />
<Button variant="ghost" size="sm" asChild>
<Link href={`/dashboard/invoices/${invoice.id}`}>
<ArrowRight className="h-4 w-4" />

View File

@@ -1,11 +1,24 @@
"use client";
import { BusinessForm } from "~/components/business-form";
import Link from "next/link";
import { useParams } from "next/navigation";
import { BusinessForm } from "~/components/business-form";
import { PageHeader } from "~/components/page-header";
export default function EditBusinessPage() {
const params = useParams();
const businessId = Array.isArray(params?.id) ? params.id[0] : params?.id;
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";
import { api } from "~/trpc/react";
import { UniversalTable } from "~/components/ui/universal-table";
import { TableSkeleton } from "~/components/ui/skeleton";
import { DataTableSkeleton } from "~/components/ui/data-table";
import { BusinessesDataTable } from "./businesses-data-table";
export function BusinessesTable() {
const { isLoading } = api.businesses.getAll.useQuery();
const { data: businesses, isLoading } = api.businesses.getAll.useQuery();
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 { PageHeader } from "~/components/page-header";
import { HydrateClient } from "~/trpc/server";
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 { api, HydrateClient } from "~/trpc/server";
import { HydrateClient } from "~/trpc/server";
import { Button } from "~/components/ui/button";
import { Plus } from "lucide-react";
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() {
return (
<div>
<div className="mb-8 flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div>
<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">
Businesses
</h1>
<p className="mt-1 text-lg text-gray-600 dark:text-gray-300">
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"
<>
<PageHeader
title="Businesses"
description="Manage your businesses and their information."
variant="gradient"
>
<Button asChild variant="brand">
<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>
</Button>
</div>
</PageHeader>
<HydrateClient>
<BusinessesTable />
</HydrateClient>
</div>
</>
);
}

View File

@@ -1,5 +1,7 @@
import Link from "next/link";
import { HydrateClient } from "~/trpc/server";
import { ClientForm } from "~/components/client-form";
import { PageHeader } from "~/components/page-header";
interface EditClientPageProps {
params: Promise<{ id: string }>;
@@ -10,14 +12,11 @@ export default async function EditClientPage({ params }: EditClientPageProps) {
return (
<div>
<div className="mb-8">
<h1 className="bg-gradient-to-r from-emerald-600 to-teal-600 bg-clip-text text-3xl font-bold text-transparent">
Edit Client
</h1>
<p className="mt-1 text-lg text-gray-600">
Update client information below.
</p>
</div>
<PageHeader
title="Edit Client"
description="Update client information below."
variant="gradient"
/>
<HydrateClient>
<ClientForm mode="edit" clientId={id} />
</HydrateClient>

View File

@@ -3,6 +3,7 @@ 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,
@@ -53,32 +54,27 @@ export default async function ClientDetailPage({
client.invoices?.filter((invoice) => invoice.status === "sent").length || 0;
return (
<div className="p-4 md:mr-4 md:ml-72 md:p-6">
<div className="mx-auto max-w-4xl space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="bg-gradient-to-r from-emerald-600 to-teal-600 bg-clip-text text-3xl font-bold text-transparent">
{client.name}
</h1>
<p className="text-muted-foreground dark:text-gray-300">
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">
<div className="mx-auto max-w-4xl space-y-6">
<PageHeader
title={client.name}
description="Client Details"
variant="gradient"
>
<Link href={`/dashboard/clients/${client.id}/edit`}>
<Button variant="brand">
<Edit className="mr-2 h-4 w-4" />
Edit Client
</Button>
</Link>
</div>
</PageHeader>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
{/* Client Information Card */}
<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>
<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" />
<span>Contact Information</span>
</CardTitle>
@@ -92,10 +88,10 @@ export default async function ClientDetailPage({
<Mail className="h-4 w-4 text-emerald-600" />
</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
</p>
<p className="text-sm dark:text-gray-300">
<p className="text-foreground text-sm">
{client.email}
</p>
</div>
@@ -108,10 +104,10 @@ export default async function ClientDetailPage({
<Phone className="h-4 w-4 text-emerald-600" />
</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
</p>
<p className="text-sm dark:text-gray-300">
<p className="text-foreground text-sm">
{client.phone}
</p>
</div>
@@ -127,12 +123,12 @@ export default async function ClientDetailPage({
<MapPin className="h-4 w-4 text-emerald-600" />
</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
</p>
</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.addressLine2 && <p>{client.addressLine2}</p>}
{(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";
import { api } from "~/trpc/react";
import { UniversalTable } from "~/components/ui/universal-table";
import { TableSkeleton } from "~/components/ui/skeleton";
import { DataTableSkeleton } from "~/components/ui/data-table";
import { ClientsDataTable } from "./clients-data-table";
export function ClientsTable() {
const { isLoading } = api.clients.getAll.useQuery();
const { data: clients, isLoading } = api.clients.getAll.useQuery();
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 { ClientForm } from "~/components/client-form";
import { PageHeader } from "~/components/page-header";
export default async function NewClientPage() {
return (
<div>
<div className="mb-8">
<h1 className="bg-gradient-to-r from-emerald-600 to-teal-600 bg-clip-text text-3xl font-bold text-transparent">
Add Client
</h1>
<p className="mt-1 text-lg text-gray-600">
Enter client details below to add a new client.
</p>
</div>
<PageHeader
title="Add Client"
description="Enter client details below to add a new client."
variant="gradient"
/>
<HydrateClient>
<ClientForm mode="create" />
</HydrateClient>

View File

@@ -1,35 +1,30 @@
import Link from "next/link";
import { api, HydrateClient } from "~/trpc/server";
import { HydrateClient } from "~/trpc/server";
import { Button } from "~/components/ui/button";
import { Plus } from "lucide-react";
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() {
return (
<div>
<div className="mb-8 flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div>
<h1 className="bg-gradient-to-r from-emerald-600 to-teal-600 bg-clip-text text-3xl font-bold text-transparent">
Clients
</h1>
<p className="mt-1 text-lg text-gray-600">
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"
<>
<PageHeader
title="Clients"
description="Manage your clients and their information."
variant="gradient"
>
<Button asChild variant="brand">
<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>
</Button>
</div>
</PageHeader>
<HydrateClient>
<ClientsTable />
</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 { InvoiceView } from "~/components/invoice-view";
import { InvoiceForm } from "~/components/invoice-form";
import Link from "next/link";
import { Suspense } from "react";
import { notFound } from "next/navigation";
import { Edit, Eye, ArrowLeft } from "lucide-react";
import { UnifiedInvoicePage } from "./_components/unified-invoice-page";
import Link from "next/link";
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 {
params: Promise<{ id: string }>;
searchParams: Promise<{ mode?: string }>;
}
export default async function InvoicePage({
params,
searchParams,
}: InvoicePageProps) {
const { id } = await params;
const { mode = "view" } = await searchParams;
function InvoiceStatusBadge({
status,
dueDate,
}: {
status: string;
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 (
<StatusBadge status={actualStatus} className="flex items-center gap-1">
<Icon className="h-3 w-3" />
{actualStatus.charAt(0).toUpperCase() + actualStatus.slice(1)}
</StatusBadge>
);
}
async function InvoiceDetails({ invoiceId }: { invoiceId: string }) {
const invoice = await api.invoices.getById({ id: invoiceId });
if (!invoice) {
notFound();
}
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>
<div className="mb-6">
<div className="mb-4 flex items-center justify-between">
<div>
<h1 className="bg-gradient-to-r from-emerald-600 to-teal-600 bg-clip-text text-3xl font-bold text-transparent">
Invoice Details
<h1 className="text-2xl font-bold text-emerald-700 dark:text-emerald-400">
{invoice.invoiceNumber}
</h1>
<p className="mt-1 text-lg text-gray-600 dark:text-gray-300">
View and manage invoice information.
<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 className="relative flex rounded-lg border border-gray-200 bg-gray-100 p-1 dark:border-gray-700 dark:bg-gray-800">
<div
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"
}`}
</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}
/>
<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>
</div>
<div className="mt-4">
<HydrateClient>
<UnifiedInvoicePage invoiceId={id} mode={mode} />
</HydrateClient>
{/* 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>
);
}
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 { 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",
},
];
export default async function ImportPage() {
return (
<div>
<div className="mb-8">
<h1 className="bg-gradient-to-r from-emerald-600 to-teal-600 bg-clip-text text-3xl font-bold text-transparent">
Import Invoices
</h1>
<p className="mt-1 text-lg text-gray-600">
Upload CSV files to create invoices in batch.
<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() {
return (
<div className="space-y-8">
<PageHeader
title="Import Invoices"
description="Upload CSV files to create invoices in batch"
variant="gradient"
>
<Link href="/dashboard/invoices">
<Button variant="outline" size="lg">
<ArrowLeft className="mr-2 h-5 w-5" />
Back to Invoices
</Button>
</Link>
</PageHeader>
<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>
</div>
);

View File

@@ -1,20 +1,740 @@
import { HydrateClient } from "~/trpc/server";
import { InvoiceForm } from "~/components/invoice-form";
"use client";
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 (
<div>
<div className="mb-8">
<h1 className="bg-gradient-to-r from-emerald-600 to-teal-600 bg-clip-text text-3xl font-bold text-transparent">
Create Invoice
</h1>
<p className="mt-1 text-lg text-gray-600">
Fill out the details below to create a new invoice.
</p>
<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>
<HydrateClient>
<InvoiceForm />
</HydrateClient>
{/* 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>
);
}
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>
);
}

View File

@@ -1,47 +1,49 @@
import Link from "next/link";
import { Suspense } from "react";
import { api, HydrateClient } from "~/trpc/server";
import { Button } from "~/components/ui/button";
import { PageHeader } from "~/components/page-header";
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() {
return (
<div>
<div className="mb-8 flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div>
<h1 className="bg-gradient-to-r from-emerald-600 to-teal-600 bg-clip-text text-3xl font-bold text-transparent">
Invoices
</h1>
<p className="mt-1 text-lg text-gray-600">
Manage your invoices and payments.
</p>
</div>
<div className="flex gap-3">
<Button
asChild
variant="outline"
size="lg"
className="border-gray-200 bg-white/80 font-medium text-gray-700 shadow-lg hover:bg-gray-50 hover:shadow-xl"
<>
<PageHeader
title="Invoices"
description="Manage your invoices and track payments"
variant="gradient"
>
<Button asChild variant="outline" className="shadow-sm">
<Link href="/dashboard/invoices/import">
<Upload className="mr-2 h-5 w-5" /> Import CSV
<Upload className="mr-2 h-5 w-5" />
<span>Import CSV</span>
</Link>
</Button>
<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"
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"
>
<Link href="/dashboard/invoices/new">
<Plus className="mr-2 h-5 w-5" /> Add Invoice
<Plus className="mr-2 h-5 w-5" />
<span>Create Invoice</span>
</Link>
</Button>
</div>
</div>
</PageHeader>
<HydrateClient>
<Suspense fallback={<DataTableSkeleton columns={7} rows={5} />}>
<InvoicesTable />
</Suspense>
</HydrateClient>
</div>
</>
);
}

View File

@@ -2,21 +2,25 @@ import { Navbar } from "~/components/Navbar";
import { Sidebar } from "~/components/Sidebar";
import { DashboardBreadcrumbs } from "~/components/dashboard-breadcrumbs";
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
export default function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<>
<Navbar />
<Sidebar />
{/* Mobile layout - no left margin */}
<main className="min-h-screen pt-24 md:hidden">
<div className="px-4 sm:px-6 pt-4 pb-6">
<main className="min-h-screen pt-20 md:hidden">
<div className="px-4 pt-4 pb-6 sm:px-6">
<DashboardBreadcrumbs />
{children}
</div>
</main>
{/* Desktop layout - with sidebar margin */}
<main className="min-h-screen pt-24 hidden md:block ml-70">
<div className="px-8 pt-6 pb-6">
<main className="hidden min-h-screen pt-20 md:ml-[276px] md:block">
<div className="px-6 pt-6 pb-6">
<DashboardBreadcrumbs />
{children}
</div>

View File

@@ -1,31 +1,36 @@
import { auth } from "~/server/auth";
import { api, HydrateClient } from "~/trpc/server";
import { HydrateClient } from "~/trpc/server";
import {
DashboardStats,
DashboardCards,
DashboardActivity,
} from "./_components/dashboard-components";
import { DashboardPageHeader } from "~/components/page-header";
import { PageContent, PageSection } from "~/components/ui/page-layout";
export default async function DashboardPage() {
const session = await auth();
return (
<div>
{/* Header */}
<div className="mb-8">
<h1 className="bg-gradient-to-r from-emerald-600 to-teal-600 bg-clip-text text-4xl font-bold text-transparent">
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>
<PageContent>
<DashboardPageHeader
title={`Welcome back, ${session?.user?.name?.split(" ")[0] ?? "User"}!`}
description="Here's what's happening with your invoicing business"
/>
<HydrateClient>
<PageSection>
<DashboardStats />
</PageSection>
<PageSection>
<DashboardCards />
</PageSection>
<PageSection>
<DashboardActivity />
</PageSection>
</HydrateClient>
</div>
</PageContent>
);
}

View File

@@ -46,6 +46,7 @@ import {
DialogTrigger,
} from "~/components/ui/dialog";
import { Textarea } from "~/components/ui/textarea";
import { PageHeader } from "~/components/page-header";
export default function SettingsPage() {
const { data: session } = useSession();
@@ -230,34 +231,26 @@ export default function SettingsPage() {
return (
<div className="space-y-8">
{/* Header */}
<div>
<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">
Settings
</h1>
<p className="mt-2 text-lg text-gray-600 dark:text-gray-300">
Manage your account and data preferences
</p>
</div>
<PageHeader
title="Settings"
description="Manage your account and data preferences"
variant="large-gradient"
/>
<div className="grid gap-8 lg:grid-cols-2">
{/* Profile Section */}
<Card className="dark:border-gray-700 dark:bg-gray-800/80">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 dark:text-white">
<User className="h-5 w-5 dark:text-emerald-400" />
<CardTitle className="flex items-center gap-2">
<User className="h-5 w-5 text-emerald-600" />
Profile
</CardTitle>
<CardDescription className="dark:text-gray-300">
Update your personal information
</CardDescription>
<CardDescription>Update your personal information</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<form onSubmit={handleUpdateProfile} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name" className="dark:text-gray-300">
Name
</Label>
<Label htmlFor="name">Name</Label>
<Input
id="name"
value={name}
@@ -266,16 +259,14 @@ export default function SettingsPage() {
/>
</div>
<div className="space-y-2">
<Label htmlFor="email" className="dark:text-gray-300">
Email
</Label>
<Label htmlFor="email">Email</Label>
<Input
id="email"
value={session?.user?.email ?? ""}
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
</p>
</div>
@@ -293,41 +284,33 @@ export default function SettingsPage() {
</Card>
{/* Data Statistics */}
<Card className="dark:border-gray-700 dark:bg-gray-800/80">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 dark:text-white">
<Database className="h-5 w-5 dark:text-emerald-400" />
<CardTitle className="flex items-center gap-2">
<Database className="h-5 w-5 text-emerald-600" />
Your Data
</CardTitle>
<CardDescription className="dark:text-gray-300">
Overview of your account data
</CardDescription>
<CardDescription>Overview of your account data</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-3 gap-4 text-center">
<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}
</div>
<div className="text-sm text-gray-500 dark:text-gray-400">
Clients
</div>
<div className="text-muted-foreground text-sm">Clients</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}
</div>
<div className="text-sm text-gray-500 dark:text-gray-400">
Businesses
</div>
<div className="text-muted-foreground text-sm">Businesses</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}
</div>
<div className="text-sm text-gray-500 dark:text-gray-400">
Invoices
</div>
<div className="text-muted-foreground text-sm">Invoices</div>
</div>
</div>
</CardContent>
@@ -335,13 +318,13 @@ export default function SettingsPage() {
</div>
{/* Backup & Restore Section */}
<Card className="dark:border-gray-700 dark:bg-gray-800/80">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 dark:text-white">
<Shield className="h-5 w-5 dark:text-emerald-400" />
<CardTitle className="flex items-center gap-2">
<Shield className="h-5 w-5 text-emerald-600" />
Backup & Restore
</CardTitle>
<CardDescription className="dark:text-gray-300">
<CardDescription>
Export your data for backup or import from a previous backup
</CardDescription>
</CardHeader>
@@ -349,8 +332,8 @@ export default function SettingsPage() {
<div className="grid gap-4 md:grid-cols-2">
{/* Export Data */}
<div className="space-y-3">
<h3 className="font-semibold dark:text-white">Export Data</h3>
<p className="text-sm text-gray-600 dark:text-gray-300">
<h3 className="font-semibold">Export Data</h3>
<p className="text-muted-foreground text-sm">
Download all your clients, businesses, and invoices as a JSON
backup file.
</p>
@@ -367,8 +350,8 @@ export default function SettingsPage() {
{/* Import Data */}
<div className="space-y-3">
<h3 className="font-semibold dark:text-white">Import Data</h3>
<p className="text-sm text-gray-600 dark:text-gray-300">
<h3 className="font-semibold">Import Data</h3>
<p className="text-muted-foreground text-sm">
Restore your data from a previous backup file.
</p>
<Dialog
@@ -381,12 +364,10 @@ export default function SettingsPage() {
Import Data
</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl dark:border-gray-700 dark:bg-gray-800">
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle className="dark:text-white">
Import Backup Data
</DialogTitle>
<DialogDescription className="dark:text-gray-300">
<DialogTitle>Import Backup Data</DialogTitle>
<DialogDescription>
Paste the contents of your backup JSON file below. This
will add the data to your existing account.
</DialogDescription>
@@ -424,11 +405,9 @@ export default function SettingsPage() {
</div>
</div>
<div className="rounded-lg bg-blue-50 p-4 dark:border dark:border-blue-800/30 dark:bg-blue-900/20">
<h4 className="font-medium text-blue-900 dark:text-blue-300">
Backup Tips
</h4>
<ul className="mt-2 space-y-1 text-sm text-blue-800 dark:text-blue-200">
<div className="rounded-lg border border-blue-200 bg-blue-50 p-4">
<h4 className="font-medium text-blue-900">Backup Tips</h4>
<ul className="mt-2 space-y-1 text-sm text-blue-800">
<li> Regular backups help protect your data</li>
<li>
Backup files contain all your business data in JSON format
@@ -443,23 +422,21 @@ export default function SettingsPage() {
</Card>
{/* Danger Zone */}
<Card className="border-red-200 dark:border-red-800/50 dark:bg-gray-800/80">
<Card className="border-red-200">
<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" />
Danger Zone
</CardTitle>
<CardDescription className="dark:text-gray-300">
<CardDescription>
Irreversible actions for your account data
</CardDescription>
</CardHeader>
<CardContent>
<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">
<h4 className="font-medium text-red-900 dark:text-red-300">
Delete All Data
</h4>
<p className="mt-1 text-sm text-red-800 dark:text-red-200">
<div className="rounded-lg border border-red-200 bg-red-50 p-4">
<h4 className="font-medium text-red-900">Delete All Data</h4>
<p className="mt-1 text-sm text-red-800">
This will permanently delete all your clients, businesses,
invoices, and related data. This action cannot be undone.
</p>
@@ -469,12 +446,10 @@ export default function SettingsPage() {
<AlertDialogTrigger asChild>
<Button variant="destructive">Delete All Data</Button>
</AlertDialogTrigger>
<AlertDialogContent className="dark:border-gray-700 dark:bg-gray-800">
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="dark:text-white">
Are you absolutely sure?
</AlertDialogTitle>
<AlertDialogDescription className="space-y-2 dark:text-gray-300">
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
<AlertDialogDescription className="space-y-2">
<p>
This action cannot be undone. This will permanently delete
all your:
@@ -487,7 +462,7 @@ export default function SettingsPage() {
</ul>
<p className="font-medium">
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
</span>{" "}
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 { type Metadata } from "next";
import { Geist } from "next/font/google";
import { Geist, Azeret_Mono } from "next/font/google";
import { TRPCReactProvider } from "~/trpc/react";
import { Toaster } from "~/components/ui/toaster";
@@ -18,12 +18,18 @@ const geist = Geist({
variable: "--font-geist-sans",
});
const azeretMono = Azeret_Mono({
subsets: ["latin"],
variable: "--font-azeret-mono",
display: "swap",
});
export default function RootLayout({
children,
}: Readonly<{ children: React.ReactNode }>) {
return (
<html lang="en" className={`${geist.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%)]">
<html lang="en" className={`${geist.variable} ${azeretMono.variable}`}>
<body className="bg-gradient-dashboard bg-radial-overlay relative min-h-screen overflow-x-hidden font-sans antialiased">
<TRPCReactProvider>{children}</TRPCReactProvider>
<Toaster />
</body>

View File

@@ -23,26 +23,19 @@ import {
export default function HomePage() {
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 />
{/* 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="flex items-center justify-between">
<Logo />
<div className="flex items-center space-x-4">
<Link href="/auth/signin">
<Button
variant="ghost"
className="dark:text-gray-300 dark:hover:bg-gray-800"
>
Sign In
</Button>
<Button variant="ghost">Sign In</Button>
</Link>
<Link href="/auth/register">
<Button className="dark:bg-green-600 dark:hover:bg-green-700">
Get Started
</Button>
<Button>Get Started</Button>
</Link>
</div>
</div>
@@ -52,33 +45,23 @@ export default function HomePage() {
{/* Hero Section */}
<section className="px-4 py-20">
<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
<span className="text-green-600 dark:text-green-400">
{" "}
Freelancers
</span>
<span className="text-green-600"> Freelancers</span>
</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
with beenvoice. The invoicing app that works as hard as you do.
</p>
<div className="flex flex-col justify-center gap-4 sm:flex-row">
<Link href="/auth/register">
<Button
size="lg"
className="px-8 py-6 text-lg dark:bg-green-600 dark:hover:bg-green-700"
>
<Button size="lg" className="px-8 py-6 text-lg">
Start Free Trial
<ArrowRight className="ml-2 h-5 w-5" />
</Button>
</Link>
<Link href="#features">
<Button
variant="outline"
size="lg"
className="px-8 py-6 text-lg dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-800"
>
<Button variant="outline" size="lg" className="px-8 py-6 text-lg">
See How It Works
</Button>
</Link>
@@ -87,30 +70,28 @@ export default function HomePage() {
</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="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
</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
</p>
</div>
<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>
<Users className="mb-4 h-12 w-12 text-green-600 dark:text-green-400" />
<CardTitle className="dark:text-white">
Client Management
</CardTitle>
<CardDescription className="dark:text-gray-300">
<Users className="mb-4 h-12 w-12 text-green-600" />
<CardTitle>Client Management</CardTitle>
<CardDescription>
Keep all your client information organized in one place
</CardDescription>
</CardHeader>
<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">
<CheckCircle className="mr-2 h-4 w-4 text-green-500" />
Store contact details and addresses
@@ -127,18 +108,16 @@ export default function HomePage() {
</CardContent>
</Card>
<Card className="border-0 shadow-lg dark:bg-gray-700">
<Card className="border-0 shadow-lg">
<CardHeader>
<FileText className="mb-4 h-12 w-12 text-green-600 dark:text-green-400" />
<CardTitle className="dark:text-white">
Professional Invoices
</CardTitle>
<CardDescription className="dark:text-gray-300">
<FileText className="mb-4 h-12 w-12 text-green-600" />
<CardTitle>Professional Invoices</CardTitle>
<CardDescription>
Create beautiful, detailed invoices with line items
</CardDescription>
</CardHeader>
<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">
<CheckCircle className="mr-2 h-4 w-4 text-green-500" />
Add multiple line items with dates
@@ -155,18 +134,16 @@ export default function HomePage() {
</CardContent>
</Card>
<Card className="border-0 shadow-lg dark:bg-gray-700">
<Card className="border-0 shadow-lg">
<CardHeader>
<DollarSign className="mb-4 h-12 w-12 text-green-600 dark:text-green-400" />
<CardTitle className="dark:text-white">
Payment Tracking
</CardTitle>
<CardDescription className="dark:text-gray-300">
<DollarSign className="mb-4 h-12 w-12 text-green-600" />
<CardTitle>Payment Tracking</CardTitle>
<CardDescription>
Monitor invoice status and track payments
</CardDescription>
</CardHeader>
<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">
<CheckCircle className="mr-2 h-4 w-4 text-green-500" />
Track draft, sent, paid, and overdue status
@@ -187,21 +164,21 @@ export default function HomePage() {
</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">
<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?
</h2>
<div className="grid gap-12 md:grid-cols-2">
<div className="space-y-6">
<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">
<h3 className="mb-2 text-xl font-semibold dark:text-white">
<h3 className="text-foreground mb-2 text-xl font-semibold">
Lightning Fast
</h3>
<p className="text-gray-600 dark:text-gray-300">
<p className="text-muted-foreground">
Create invoices in seconds, not minutes. Our streamlined
interface gets you back to work faster.
</p>
@@ -209,12 +186,12 @@ export default function HomePage() {
</div>
<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">
<h3 className="mb-2 text-xl font-semibold dark:text-white">
<h3 className="text-foreground mb-2 text-xl font-semibold">
Secure & Private
</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
information with third parties.
</p>
@@ -224,12 +201,12 @@ export default function HomePage() {
<div className="space-y-6">
<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">
<h3 className="mb-2 text-xl font-semibold dark:text-white">
<h3 className="text-foreground mb-2 text-xl font-semibold">
Professional Quality
</h3>
<p className="text-gray-600 dark:text-gray-300">
<p className="text-muted-foreground">
Generate invoices that look professional and build trust
with your clients.
</p>
@@ -237,12 +214,12 @@ export default function HomePage() {
</div>
<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">
<h3 className="mb-2 text-xl font-semibold dark:text-white">
<h3 className="text-foreground mb-2 text-xl font-semibold">
Save Time
</h3>
<p className="text-gray-600 dark:text-gray-300">
<p className="text-muted-foreground">
Automated calculations, templates, and client management
save you hours every month.
</p>
@@ -254,48 +231,44 @@ export default function HomePage() {
</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">
<h2 className="mb-4 text-4xl font-bold text-white">
Ready to get started?
</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
invoicing needs.
</p>
<Link href="/auth/register">
<Button
size="lg"
variant="secondary"
className="px-8 py-6 text-lg dark:bg-white dark:text-green-700 dark:hover:bg-gray-100"
>
<Button size="lg" variant="secondary" className="px-8 py-6 text-lg">
Start Your Free Trial
<ArrowRight className="ml-2 h-5 w-5" />
</Button>
</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
</p>
</div>
</section>
{/* 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">
<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
</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
href="/auth/signin"
className="hover:text-white dark:hover:text-gray-300"
className="hover:text-foreground transition-colors"
>
Sign In
</Link>
<Link
href="/auth/register"
className="hover:text-white dark:hover:text-gray-300"
className="hover:text-foreground transition-colors"
>
Register
</Link>

View File

@@ -1,33 +1,45 @@
"use client";
import Link from "next/link";
import { useSession, signOut } from "next-auth/react";
import { useState } from "react";
import { Button } from "~/components/ui/button";
import { Skeleton } from "~/components/ui/skeleton";
import { Logo } from "./logo";
import { SidebarTrigger } from "./SidebarTrigger";
export function Navbar() {
const { data: session } = useSession();
const { data: session, status } = useSession();
const [isMobileNavOpen, setIsMobileNavOpen] = useState(false);
return (
<header className="fixed top-4 right-4 left-4 z-30 md:top-6 md:right-6 md:left-6">
<div className="rounded-xl border-0 bg-white/60 shadow-2xl backdrop-blur-md dark:bg-gray-900/60">
<header className="fixed top-2 right-2 left-2 z-30 md:top-3 md:right-3 md:left-3">
<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 items-center gap-4 md:gap-6">
<SidebarTrigger />
<SidebarTrigger
isOpen={isMobileNavOpen}
onToggle={() => setIsMobileNavOpen(!isMobileNavOpen)}
/>
<Link href="/dashboard" className="flex items-center gap-2">
<Logo size="md" />
</Link>
</div>
<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}
</span>
<Button
variant="outline"
size="sm"
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
</Button>
@@ -38,7 +50,7 @@ export function Navbar() {
<Button
variant="ghost"
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
</Button>
@@ -46,7 +58,7 @@ export function Navbar() {
<Link href="/auth/register">
<Button
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
</Button>

View File

@@ -2,66 +2,62 @@
import Link from "next/link";
import { usePathname } from "next/navigation";
import {
Settings,
LayoutDashboard,
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 },
];
import { useSession } from "next-auth/react";
import { Skeleton } from "~/components/ui/skeleton";
import { navigationConfig } from "~/lib/navigation";
export function Sidebar() {
const pathname = usePathname();
const { status } = useSession();
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">
<nav className="flex flex-col gap-1">
<div className="mb-2 text-xs font-semibold tracking-wider text-gray-400 uppercase dark:text-gray-500">
Main
<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">
{navigationConfig.map((section, sectionIndex) => (
<div key={section.title} className={sectionIndex > 0 ? "mt-6" : ""}>
{sectionIndex > 0 && (
<div className="border-border/40 my-4 border-t" />
)}
<div className="text-muted-foreground mb-3 text-xs font-semibold tracking-wider uppercase">
{section.title}
</div>
{navLinks.map((link) => {
<div className="flex flex-col gap-0.5">
{status === "loading" ? (
<>
{Array.from({ length: section.links.length }).map((_, i) => (
<div
key={i}
className="flex items-center gap-3 rounded-lg px-3 py-2.5"
>
<Skeleton className="bg-muted/20 h-4 w-4" />
<Skeleton className="bg-muted/20 h-4 w-20" />
</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-4 py-2 text-base font-medium transition-all duration-200 ${
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-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"
? "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-5 w-5" />
<Icon className="h-4 w-4" />
{link.name}
</Link>
);
})}
})
)}
</div>
</div>
))}
</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>
);
}

View File

@@ -1,97 +1,95 @@
"use client";
import {
Sheet,
SheetContent,
SheetTrigger,
SheetHeader,
SheetTitle,
} from "~/components/ui/sheet";
import { Button } from "~/components/ui/button";
import {
MenuIcon,
Settings,
LayoutDashboard,
Users,
FileText,
} from "lucide-react";
import { Skeleton } from "~/components/ui/skeleton";
import { MenuIcon, X } from "lucide-react";
import { useState } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useSession } from "next-auth/react";
import { navigationConfig } from "~/lib/navigation";
const navLinks = [
{ name: "Dashboard", href: "/dashboard", icon: LayoutDashboard },
{ name: "Clients", href: "/dashboard/clients", icon: Users },
{ name: "Invoices", href: "/dashboard/invoices", icon: FileText },
];
interface SidebarTriggerProps {
isOpen: boolean;
onToggle: () => void;
}
export function SidebarTrigger() {
export function SidebarTrigger({ isOpen, onToggle }: SidebarTriggerProps) {
const pathname = usePathname();
const [open, setOpen] = useState(false);
const { status } = useSession();
return (
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild>
<>
<Button
variant="outline"
size="icon"
aria-label="Open sidebar"
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"
aria-label="Toggle navigation"
onClick={onToggle}
className="bg-card/80 h-8 w-8 shadow-lg backdrop-blur-sm md:hidden"
>
<MenuIcon className="h-4 w-4" />
{isOpen ? <X className="h-4 w-4" /> : <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">
<SheetTitle className="dark:text-white">Navigation</SheetTitle>
</SheetHeader>
{/* Navigation */}
<nav className="flex flex-1 flex-col gap-1 p-4">
<div className="mb-2 text-xs font-semibold tracking-wider text-gray-400 uppercase dark:text-gray-500">
Main
{/* Mobile dropdown navigation */}
{isOpen && (
<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">
{/* Navigation content */}
<nav className="flex flex-col p-4">
{navigationConfig.map((section, sectionIndex) => (
<div
key={section.title}
className={sectionIndex > 0 ? "mt-4" : ""}
>
{sectionIndex > 0 && (
<div className="border-border/40 my-3 border-t" />
)}
<div className="text-muted-foreground mb-2 text-xs font-semibold tracking-wider uppercase">
{section.title}
</div>
{navLinks.map((link) => {
<div className="flex flex-col gap-0.5">
{status === "loading" ? (
<>
{Array.from({ length: section.links.length }).map(
(_, i) => (
<div
key={i}
className="flex items-center gap-3 rounded-lg px-3 py-2.5"
>
<Skeleton className="bg-muted/20 h-4 w-4" />
<Skeleton className="bg-muted/20 h-4 w-20" />
</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-3 text-base font-medium transition-all duration-200 ${
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-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"
? "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={() => setOpen(false)}
onClick={onToggle}
>
<Icon className="h-5 w-5" />
<Icon className="h-4 w-4" />
{link.name}
</Link>
);
})}
<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-3 py-3 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"
}`}
onClick={() => setOpen(false)}
>
<Settings className="h-5 w-5" />
Settings
</Link>
</div>
))}
</nav>
</SheetContent>
</Sheet>
</div>
)}
</>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,25 +1,54 @@
"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 { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { useEffect, useState, useRef } from "react";
import { toast } from "sonner";
import { Button } from "~/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
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 {
formatPhoneNumber,
isValidEmail,
VALIDATION_MESSAGES,
PLACEHOLDERS,
} from "~/lib/form-constants";
interface ClientFormProps {
clientId?: string;
mode: "create" | "edit";
}
export function ClientForm({ clientId, mode }: ClientFormProps) {
const router = useRouter();
const [formData, setFormData] = useState({
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: "",
@@ -28,9 +57,16 @@ export function ClientForm({ clientId, mode }: ClientFormProps) {
city: "",
state: "",
postalCode: "",
country: "",
});
const [loading, setLoading] = useState(false);
country: "United States",
};
export function ClientForm({ clientId, mode }: ClientFormProps) {
const router = useRouter();
const [formData, setFormData] = useState<FormData>(initialFormData);
const [errors, setErrors] = useState<FormErrors>({});
const [isSubmitting, setIsSubmitting] = useState(false);
const [isDirty, setIsDirty] = useState(false);
const footerRef = useRef<HTMLDivElement>(null);
// Fetch client data if editing
const { data: client, isLoading: isLoadingClient } =
@@ -71,14 +107,80 @@ export function ClientForm({ clientId, mode }: ClientFormProps) {
city: client.city ?? "",
state: client.state ?? "",
postalCode: client.postalCode ?? "",
country: client.country ?? "",
country: client.country ?? "United States",
});
}
}, [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) => {
e.preventDefault();
setLoading(true);
if (!validateForm()) {
toast.error("Please correct the errors in the form");
return;
}
setIsSubmitting(true);
try {
if (mode === "create") {
@@ -90,551 +192,233 @@ export function ClientForm({ clientId, mode }: ClientFormProps) {
});
}
} finally {
setLoading(false);
setIsSubmitting(false);
}
};
const handleInputChange = (field: string, value: string) => {
setFormData((prev) => ({ ...prev, [field]: value }));
};
// Phone number formatting
const formatPhoneNumber = (value: string) => {
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)}`;
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/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) {
return (
<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 <FormSkeleton />;
}
return (
<Card className="my-8 w-full border-0 bg-white/80 px-0 shadow-xl backdrop-blur-sm dark:bg-gray-800/80">
<CardContent>
<form onSubmit={handleSubmit} className="space-y-8">
{/* Basic Information Section */}
<div className="space-y-6">
<div className="flex items-center space-x-2 text-emerald-700 dark:text-emerald-400">
<Building className="h-5 w-5" />
<h3 className="text-lg font-semibold dark:text-white">
Business Information
</h3>
<div className="mx-auto max-w-6xl">
<form onSubmit={handleSubmit} className="space-y-6">
{/* Main Form Container - styled like data table */}
<div className="space-y-4">
{/* Basic Information */}
<Card>
<CardHeader>
<div className="flex items-center gap-3">
<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">
<UserPlus className="h-5 w-5 text-emerald-700 dark:text-emerald-400" />
</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">
<Label
htmlFor="name"
className="text-sm font-medium text-gray-700 dark:text-gray-300"
>
Business Name / Full Name *
<Label htmlFor="name" className="text-sm font-medium">
Client Name<span className="text-destructive ml-1">*</span>
</Label>
<Input
id="name"
value={formData.name}
onChange={(e) => handleInputChange("name", e.target.value)}
required
placeholder="Enter business name or full name"
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"
placeholder={PLACEHOLDERS.name}
className={`${errors.name ? "border-destructive" : ""}`}
disabled={isSubmitting}
/>
{errors.name && (
<p className="text-destructive text-sm">{errors.name}</p>
)}
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label
htmlFor="email"
className="text-sm font-medium text-gray-700 dark:text-gray-300"
>
Email Address
<Label htmlFor="email" className="text-sm font-medium">
Email
<span className="text-muted-foreground ml-1 text-xs font-normal">
(Optional)
</span>
</Label>
<div className="relative">
<Mail className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 transform text-gray-400 dark:text-gray-500" />
<Input
id="email"
type="email"
value={formData.email}
onChange={(e) => handleInputChange("email", e.target.value)}
placeholder="business@example.com"
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"
placeholder={PLACEHOLDERS.email}
className={`${errors.email ? "border-destructive" : ""}`}
disabled={isSubmitting}
/>
</div>
</div>
</div>
{errors.email && (
<p className="text-destructive text-sm">{errors.email}</p>
)}
</div>
{/* Contact Information Section */}
<div className="space-y-6">
<div className="flex items-center space-x-2 text-emerald-700 dark:text-emerald-400">
<Phone className="h-5 w-5" />
<h3 className="text-lg font-semibold dark:text-white">
Contact Information
</h3>
</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 htmlFor="phone" className="text-sm font-medium">
Phone
<span className="text-muted-foreground ml-1 text-xs font-normal">
(Optional)
</span>
</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
id="phone"
type="tel"
value={formData.phone}
onChange={(e) => handlePhoneChange(e.target.value)}
placeholder="(555) 123-4567"
maxLength={14}
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"
placeholder={PLACEHOLDERS.phone}
className={`${errors.phone ? "border-destructive" : ""}`}
disabled={isSubmitting}
/>
{errors.phone && (
<p className="text-destructive text-sm">{errors.phone}</p>
)}
</div>
<p className="text-xs text-gray-500 dark:text-gray-400">
Format: (555) 123-4567
</div>
</CardContent>
</Card>
{/* Address */}
<Card>
<CardHeader>
<div className="flex items-center gap-3">
<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">
<svg
className="h-5 w-5 text-emerald-700 dark:text-emerald-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
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"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
</div>
<div>
<CardTitle>Address</CardTitle>
<p className="text-muted-foreground mt-1 text-sm">
Client's physical location
</p>
</div>
</div>
</CardHeader>
<CardContent>
<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}
/>
</CardContent>
</Card>
</div>
{/* Address Section */}
<div className="space-y-6">
<div className="flex items-center space-x-2 text-emerald-700 dark:text-emerald-400">
<MapPin className="h-5 w-5" />
<h3 className="text-lg font-semibold dark:text-white">
Address Information
</h3>
</div>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<div className="space-y-2">
<Label
htmlFor="addressLine1"
className="text-sm font-medium text-gray-700 dark:text-gray-300"
{/* 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"
>
Address Line 1
</Label>
<Input
id="addressLine1"
value={formData.addressLine1}
onChange={(e) =>
handleInputChange("addressLine1", e.target.value)
}
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"
/>
</div>
<div className="space-y-2">
<Label
htmlFor="addressLine2"
className="text-sm font-medium text-gray-700 dark:text-gray-300"
<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"
>
Address Line 2
</Label>
<Input
id="addressLine2"
value={formData.addressLine2}
onChange={(e) =>
handleInputChange("addressLine2", e.target.value)
}
placeholder="Suite 100"
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="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 */}
<div className="flex gap-3 pt-6">
<ArrowLeft className="mr-2 h-4 w-4" />
Cancel
</Button>
<Button
type="submit"
disabled={loading}
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"
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"
>
{loading ? (
{isSubmitting ? (
<>
<div className="mr-2 h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent" />
{mode === "create" ? "Creating..." : "Updating..."}
<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" : "Update Client"}
{mode === "create" ? "Create Client" : "Save Changes"}
</>
)}
</Button>
</div>
</div>
</form>
<FloatingActionBar
triggerRef={footerRef}
title={
mode === "create" ? "Creating a new client" : "Editing client details"
}
>
<Button
type="button"
variant="outline"
onClick={() => router.push("/dashboard/clients")}
className="border-gray-300 font-medium text-gray-700 hover:bg-gray-50"
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>
</form>
</CardContent>
</Card>
);
}

View File

@@ -1,13 +1,29 @@
"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 { toast } from "sonner";
import { Badge } from "~/components/ui/badge";
import { Button } from "~/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
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 { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
@@ -47,12 +63,15 @@ export function CSVImportPage() {
const [files, setFiles] = useState<FileData[]>([]);
const [globalClientId, setGlobalClientId] = useState("");
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 [progressCount, setProgressCount] = useState(0);
// 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({
onSuccess: () => {
@@ -65,7 +84,7 @@ export function CSVImportPage() {
const parseCSVLine = (line: string): string[] => {
const result: string[] = [];
let current = '';
let current = "";
let inQuotes = false;
let i = 0;
@@ -83,10 +102,10 @@ export function CSVImportPage() {
inQuotes = !inQuotes;
i++;
}
} else if (char === ',' && !inQuotes) {
} else if (char === "," && !inQuotes) {
// End of field
result.push(current.trim());
current = '';
current = "";
i++;
} else {
// Regular character
@@ -101,39 +120,40 @@ export function CSVImportPage() {
};
const parseCSV = (csvText: string): CSVRow[] => {
const lines = csvText.split('\n');
const headers = parseCSVLine(lines[0] ?? '');
const lines = csvText.split("\n");
const headers = parseCSVLine(lines[0] ?? "");
// Validate headers
const requiredHeaders = ['DATE', 'DESCRIPTION', 'HOURS', 'RATE', 'AMOUNT'];
const missingHeaders = requiredHeaders.filter(h => !headers?.includes(h));
const requiredHeaders = ["DATE", "DESCRIPTION", "HOURS", "RATE", "AMOUNT"];
const missingHeaders = requiredHeaders.filter((h) => !headers?.includes(h));
if (missingHeaders.length > 0) {
throw new Error(`Missing required headers: ${missingHeaders.join(', ')}`);
throw new Error(`Missing required headers: ${missingHeaders.join(", ")}`);
}
return lines.slice(1)
.filter(line => line.trim())
.map(line => {
return lines
.slice(1)
.filter((line) => line.trim())
.map((line) => {
const values = parseCSVLine(line);
return {
DATE: values[0] ?? '',
DESCRIPTION: values[1] ?? '',
HOURS: parseFloat(values[2] ?? '0') || 0,
RATE: parseFloat(values[3] ?? '0') || 0,
AMOUNT: parseFloat(values[4] ?? '0') || 0,
DATE: values[0] ?? "",
DESCRIPTION: values[1] ?? "",
HOURS: parseFloat(values[2] ?? "0") || 0,
RATE: parseFloat(values[3] ?? "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 => {
// Handle m/dd/yy format
const parts = dateStr.split('/');
const parts = dateStr.split("/");
if (parts.length === 3) {
const month = parseInt(parts[0] ?? '1') - 1; // 0-based month
const day = parseInt(parts[1] ?? '1');
const year = parseInt(parts[2] ?? '2000') + 2000; // Assume 20xx
const month = parseInt(parts[0] ?? "1") - 1; // 0-based month
const day = parseInt(parts[1] ?? "1");
const year = parseInt(parts[2] ?? "2000") + 2000; // Assume 20xx
return new Date(year, month, day);
}
// Fallback to standard date parsing
@@ -169,7 +189,7 @@ export function CSVImportPage() {
const csvData = parseCSV(text);
// Parse items for invoice creation
const items = csvData.map(row => ({
const items = csvData.map((row) => ({
date: parseDate(row.DATE),
description: row.DESCRIPTION,
hours: row.HOURS,
@@ -181,24 +201,29 @@ export function CSVImportPage() {
file,
parsedItems: items,
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
issueDate,
dueDate,
status: errors.length > 0 ? "error" : "pending",
errors,
hasDateError
hasDateError,
};
setFiles(prev => [...prev, fileData]);
setFiles((prev) => [...prev, fileData]);
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 {
toast.success(`Parsed ${items.length} items from ${file.name}`);
}
} 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 = {
file,
parsedItems: [],
@@ -209,28 +234,31 @@ export function CSVImportPage() {
dueDate: null,
status: "error",
errors: [`Error parsing CSV: ${errorMessage}`],
hasDateError: true
hasDateError: true,
};
setFiles(prev => [...prev, fileData]);
setFiles((prev) => [...prev, fileData]);
toast.error(`Error parsing ${file.name}: ${errorMessage}`);
}
}
};
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
const applyGlobalClient = (clientId: string) => {
setFiles(prev => prev.map(file => ({
setFiles((prev) =>
prev.map((file) => ({
...file,
clientId: file.clientId || clientId // Only apply if no client is already selected
})));
clientId: file.clientId || clientId, // Only apply if no client is already selected
})),
);
};
const updateFileData = (index: number, updates: Partial<FileData>) => {
setFiles(prev => prev.map((file, i) => {
setFiles((prev) =>
prev.map((file, i) => {
if (i !== index) return file;
const updatedFile = { ...file, ...updates };
@@ -240,15 +268,23 @@ export function CSVImportPage() {
const newErrors = [...updatedFile.errors];
// Remove filename format error if a valid issue date is now set
if (updatedFile.issueDate && newErrors.includes("Filename must be in YYYY-MM-DD.csv format")) {
const errorIndex = newErrors.indexOf("Filename must be in YYYY-MM-DD.csv format");
if (
updatedFile.issueDate &&
newErrors.includes("Filename must be in YYYY-MM-DD.csv format")
) {
const errorIndex = newErrors.indexOf(
"Filename must be in YYYY-MM-DD.csv format",
);
if (errorIndex > -1) {
newErrors.splice(errorIndex, 1);
}
}
// Remove invalid date error if a valid issue date is now set
if (updatedFile.issueDate && newErrors.includes("Invalid date in filename")) {
if (
updatedFile.issueDate &&
newErrors.includes("Invalid date in filename")
) {
const errorIndex = newErrors.indexOf("Invalid date in filename");
if (errorIndex > -1) {
newErrors.splice(errorIndex, 1);
@@ -257,13 +293,15 @@ export function CSVImportPage() {
updatedFile.errors = newErrors;
updatedFile.status = newErrors.length > 0 ? "error" : "pending";
updatedFile.hasDateError = newErrors.some(error =>
error.includes("Filename") || error.includes("Invalid date")
updatedFile.hasDateError = newErrors.some(
(error) =>
error.includes("Filename") || error.includes("Invalid date"),
);
}
return updatedFile;
}));
}),
);
};
const openPreview = (index: number) => {
@@ -277,7 +315,7 @@ export function CSVImportPage() {
files.forEach((fileData) => {
// Check for existing errors
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) {
@@ -300,7 +338,7 @@ export function CSVImportPage() {
const processBatch = async () => {
const errors = validateFiles();
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;
}
@@ -336,7 +374,7 @@ export function CSVImportPage() {
dueDate: fileData.dueDate,
status: "draft" as const,
notes: `Imported from CSV: ${fileData.file.name}`,
items: fileData.parsedItems.map(item => ({
items: fileData.parsedItems.map((item) => ({
date: item.date,
description: item.description,
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);
console.log('Invoice created successfully');
console.log("Invoice created successfully");
successCount++;
} catch (error) {
errorCount++;
console.error(`Failed to create invoice for ${fileData.file.name}:`, error);
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
toast.error(`Failed to create invoice for ${fileData.file.name}: ${errorMessage}`);
console.error(
`Failed to create invoice for ${fileData.file.name}:`,
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);
if (successCount > 0) {
toast.success(`Successfully created ${successCount} invoice${successCount > 1 ? 's' : ''}`);
toast.success(
`Successfully created ${successCount} invoice${successCount > 1 ? "s" : ""}`,
);
}
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) {
@@ -373,19 +421,24 @@ export function CSVImportPage() {
};
const totalFiles = files.length;
const readyFiles = files.filter(f =>
const readyFiles = files.filter(
(f) =>
f.errors.length === 0 &&
(f.clientId || globalClientId) &&
f.issueDate &&
f.dueDate
f.dueDate,
).length;
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 (
<div className="space-y-6">
{/* 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>
<CardTitle className="flex items-center gap-2 text-emerald-800">
<Users className="h-5 w-5" />
@@ -394,7 +447,7 @@ export function CSVImportPage() {
</CardHeader>
<CardContent>
<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)
</Label>
<select
@@ -411,19 +464,22 @@ export function CSVImportPage() {
disabled={loadingClients}
>
<option value="">No default client (select individually)</option>
{clients?.map(client => (
<option key={client.id} value={client.id}>{client.name}</option>
{clients?.map((client) => (
<option key={client.id} value={client.id}>
{client.name}
</option>
))}
</select>
<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>
</div>
</CardContent>
</Card>
{/* 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>
<CardTitle className="flex items-center gap-2 text-emerald-800">
<Upload className="h-5 w-5" />
@@ -442,24 +498,33 @@ export function CSVImportPage() {
{/* Summary Stats */}
{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-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-xs text-gray-500">of 50 max</div>
</div>
<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>
<div className="text-center">
<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 className="text-sm text-gray-600">Total Amount</div>
</div>
<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>
</div>
@@ -469,21 +534,30 @@ export function CSVImportPage() {
{/* File List */}
{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>
<CardTitle className="text-emerald-800">Uploaded Files</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{files.map((fileData, index) => (
<div key={index} className="border border-gray-200 rounded-lg p-4 bg-white">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-4">
<div
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">
<FileText className="h-5 w-5 text-emerald-600" />
<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">
{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>
</div>
</div>
@@ -508,46 +582,60 @@ export function CSVImportPage() {
</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">
<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
value={fileData.invoiceNumber}
className="h-9 text-sm bg-gray-50"
className="h-9 text-sm"
placeholder="Auto-generated"
readOnly
/>
</div>
<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
value={fileData.clientId}
onChange={(e) => updateFileData(index, { clientId: e.target.value })}
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"
onChange={(e) =>
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>
{clients?.map(client => (
<option key={client.id} value={client.id}>{client.name}</option>
{clients?.map((client) => (
<option key={client.id} value={client.id}>
{client.name}
</option>
))}
</select>
</div>
<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
date={fileData.issueDate ?? undefined}
onDateChange={(date) => updateFileData(index, { issueDate: date ?? null })}
onDateChange={(date) =>
updateFileData(index, { issueDate: date ?? null })
}
placeholder="Select issue date"
className="h-9"
/>
</div>
<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
date={fileData.dueDate ?? undefined}
onDateChange={(date) => updateFileData(index, { dueDate: date ?? null })}
onDateChange={(date) =>
updateFileData(index, { dueDate: date ?? null })
}
placeholder="Select due date"
className="h-9"
/>
@@ -556,14 +644,19 @@ export function CSVImportPage() {
{/* Error Display */}
{fileData.errors.length > 0 && (
<div className="mt-4 p-3 bg-red-50 border border-red-200 rounded-lg">
<div className="flex items-center gap-2 mb-2">
<div className="mt-4 rounded-lg border border-red-200 bg-red-50 p-3">
<div className="mb-2 flex items-center gap-2">
<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>
<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) => (
<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>{error}</span>
</li>
@@ -574,20 +667,39 @@ export function CSVImportPage() {
<div className="mt-4 flex items-center justify-between">
<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 className="flex items-center gap-2">
{fileData.errors.length > 0 && (
<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 variant={
fileData.errors.length > 0 ? "destructive" :
(fileData.clientId || globalClientId) && fileData.issueDate && fileData.dueDate ? "default" : "secondary"
}>
{fileData.errors.length > 0 ? "Has Errors" :
(fileData.clientId || globalClientId) && fileData.issueDate && fileData.dueDate ? "Ready" : "Pending"}
<Badge
variant={
fileData.errors.length > 0
? "destructive"
: (fileData.clientId || globalClientId) &&
fileData.issueDate &&
fileData.dueDate
? "default"
: "secondary"
}
>
{fileData.errors.length > 0
? "Has Errors"
: (fileData.clientId || globalClientId) &&
fileData.issueDate &&
fileData.dueDate
? "Ready"
: "Pending"}
</Badge>
</div>
</div>
@@ -600,25 +712,31 @@ export function CSVImportPage() {
{/* Batch Actions */}
{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>
<div className="flex flex-col gap-4">
{isProcessing && (
<div className="w-full flex flex-col gap-2">
<span className="text-xs text-gray-500">Uploading invoices...</span>
<Progress value={Math.round((progressCount / totalFiles) * 100)} />
<div className="flex w-full flex-col gap-2">
<span className="text-xs text-gray-500">
Uploading invoices...
</span>
<Progress
value={Math.round((progressCount / totalFiles) * 100)}
/>
</div>
)}
<div className="flex justify-between items-center">
<div className="flex items-center justify-between">
<div className="text-sm text-gray-600">
{readyFiles} of {totalFiles} files ready for import
</div>
<Button
onClick={processBatch}
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>
</div>
</div>
@@ -628,11 +746,12 @@ export function CSVImportPage() {
{/* Preview Modal */}
<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">
<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" />
{selectedFileIndex !== null && files[selectedFileIndex]?.file.name}
{selectedFileIndex !== null &&
files[selectedFileIndex]?.file.name}
</DialogTitle>
<DialogDescription className="text-gray-600">
Preview of parsed CSV data
@@ -640,49 +759,90 @@ export function CSVImportPage() {
</DialogHeader>
{selectedFileIndex !== null && files[selectedFileIndex] && (
<div className="flex-1 flex flex-col min-h-0 space-y-4">
<div className="flex-shrink-0 grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="flex min-h-0 flex-1 flex-col space-y-4">
<div className="grid flex-shrink-0 grid-cols-1 gap-4 md:grid-cols-3">
<div className="flex items-center gap-2">
<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 className="flex items-center gap-2">
<Clock className="h-4 w-4 text-emerald-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>
</div>
<div className="flex items-center gap-2">
<DollarSign className="h-4 w-4 text-emerald-600" />
<span className="text-sm text-gray-600 font-medium">
{files[selectedFileIndex].parsedItems.reduce((sum, item) => sum + item.amount, 0).toLocaleString('en-US', { style: 'currency', currency: 'USD' })}
<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",
})}
</span>
</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="overflow-x-auto">
<table className="w-full text-sm min-w-[600px]">
<thead className="bg-gray-50 sticky top-0">
<table className="w-full min-w-[600px] text-sm">
<thead className="sticky top-0 bg-gray-50">
<tr>
<th className="text-left p-2 font-medium text-gray-700 whitespace-nowrap">Date</th>
<th className="text-left p-2 font-medium text-gray-700">Description</th>
<th className="text-right p-2 font-medium text-gray-700 whitespace-nowrap">Hours</th>
<th className="text-right p-2 font-medium text-gray-700 whitespace-nowrap">Rate</th>
<th className="text-right p-2 font-medium text-gray-700 whitespace-nowrap">Amount</th>
<th className="p-2 text-left font-medium whitespace-nowrap text-gray-700">
Date
</th>
<th className="p-2 text-left font-medium text-gray-700">
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>
</thead>
<tbody>
{files[selectedFileIndex].parsedItems.map((item, index) => (
<tr key={index} className="border-b border-gray-100">
<td className="p-2 text-gray-600 whitespace-nowrap">{item.date.toLocaleDateString()}</td>
<td className="p-2 text-gray-600 max-w-xs truncate">{item.description}</td>
<td className="p-2 text-gray-600 text-right whitespace-nowrap">{item.hours}</td>
<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>
{files[selectedFileIndex].parsedItems.map(
(item, index) => (
<tr
key={index}
className="border-b border-gray-100"
>
<td className="p-2 whitespace-nowrap text-gray-600">
{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>
</table>
</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 { format } from "date-fns";
import { Skeleton } from "~/components/ui/skeleton";
import { getRouteLabel, capitalize } from "~/lib/pluralize";
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(
@@ -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() {
const pathname = usePathname();
const segments = pathname.split("/").filter(Boolean);
// Find clientId if present
let clientId: string | undefined = undefined;
if (segments[1] === "clients" && segments[2] && isUUID(segments[2])) {
clientId = segments[2];
}
// Determine resource type and ID from path
const resourceType = segments[1]; // e.g., 'clients', 'invoices', 'businesses'
const resourceId =
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 } =
api.clients.getById.useQuery(
{ id: clientId ?? "" },
{ enabled: !!clientId },
{ id: resourceId ?? "" },
{ enabled: resourceType === "clients" && !!resourceId },
);
// Find invoiceId if present
let invoiceId: string | undefined = undefined;
if (segments[1] === "invoices" && segments[2] && isUUID(segments[2])) {
invoiceId = segments[2];
}
// Fetch invoice data if needed
const { data: invoice, isLoading: invoiceLoading } =
api.invoices.getById.useQuery(
{ id: invoiceId ?? "" },
{ enabled: !!invoiceId },
{ id: resourceId ?? "" },
{ 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
const breadcrumbs = React.useMemo(() => {
const items = [];
for (let i = 0; i < segments.length; i++) {
const segment = segments[i];
const path = `/${segments.slice(0, i + 1).join("/")}`;
// Skip dashboard segment as it's always shown as root
if (segment === "dashboard") continue;
let label: string | React.ReactElement = segment ?? "";
if (segment === "clients") label = "Clients";
if (isUUID(segment ?? "") && clientLoading)
let label: string | React.ReactElement = "";
let shouldShow = true;
// Handle UUID segments
if (segment && isUUID(segment)) {
// Determine which resource we're looking at
const prevSegment = segments[i - 1];
if (prevSegment === "clients") {
if (clientLoading) {
label = <Skeleton className="inline-block h-5 w-24 align-middle" />;
else if (isUUID(segment ?? "") && client) label = client.name ?? "";
if (isUUID(segment ?? "") && invoiceLoading)
label = <Skeleton className="inline-block h-5 w-24 align-middle" />;
else if (isUUID(segment ?? "") && invoice) {
const issueDate = new Date(invoice.issueDate);
label = format(issueDate, "MMM dd, yyyy");
} else if (client) {
label = client.name;
}
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";
} 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 ||
(segment === "edit" && i === segments.length - 1 && client),
isLast: i === segments.length - 1,
});
}
}
return items;
}, [segments, client, invoice, clientLoading, invoiceLoading]);
}, [
segments,
client,
invoice,
business,
clientLoading,
invoiceLoading,
businessLoading,
resourceId,
]);
if (breadcrumbs.length === 0) return null;
@@ -100,8 +159,8 @@ export function DashboardBreadcrumbs() {
</Link>
</BreadcrumbLink>
</BreadcrumbItem>
{breadcrumbs.map((crumb) => (
<React.Fragment key={crumb.href}>
{breadcrumbs.map((crumb, index) => (
<React.Fragment key={`${crumb.href}-${index}`}>
<BreadcrumbSeparator>
<ChevronRight className="h-3 w-3 sm:h-4 sm:w-4" />
</BreadcrumbSeparator>

View File

@@ -42,23 +42,18 @@ const STATUS_OPTIONS = [
{
value: "draft",
label: "Draft",
color: "bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300",
},
{
value: "sent",
label: "Sent",
color: "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400",
},
{
value: "paid",
label: "Paid",
color:
"bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400",
},
{
value: "overdue",
label: "Overdue",
color: "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400",
},
] as const;
@@ -438,26 +433,20 @@ export function InvoiceForm({ invoiceId }: InvoiceFormProps) {
<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="space-y-2">
<Label
htmlFor="invoiceNumber"
className="text-sm font-medium text-gray-700 dark:text-gray-300"
>
<Label htmlFor="invoiceNumber" className="text-sm font-medium">
Invoice Number
</Label>
<Input
id="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"
readOnly
/>
</div>
<div className="space-y-2">
<Label
htmlFor="businessId"
className="text-sm font-medium text-gray-700 dark:text-gray-300"
>
<Label htmlFor="businessId" className="text-sm font-medium">
Business *
</Label>
<SearchableSelect
@@ -478,10 +467,7 @@ export function InvoiceForm({ invoiceId }: InvoiceFormProps) {
</div>
<div className="space-y-2">
<Label
htmlFor="clientId"
className="text-sm font-medium text-gray-700 dark:text-gray-300"
>
<Label htmlFor="clientId" className="text-sm font-medium">
Client *
</Label>
<SearchableSelect
@@ -502,10 +488,7 @@ export function InvoiceForm({ invoiceId }: InvoiceFormProps) {
</div>
<div className="space-y-2">
<Label
htmlFor="status"
className="text-sm font-medium text-gray-700 dark:text-gray-300"
>
<Label htmlFor="status" className="text-sm font-medium">
Status
</Label>
<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" />
</SelectTrigger>
<SelectContent>
@@ -530,10 +513,7 @@ export function InvoiceForm({ invoiceId }: InvoiceFormProps) {
</div>
<div className="space-y-2">
<Label
htmlFor="issueDate"
className="text-sm font-medium text-gray-700 dark:text-gray-300"
>
<Label htmlFor="issueDate" className="text-sm font-medium">
Issue Date *
</Label>
<DatePicker
@@ -547,10 +527,7 @@ export function InvoiceForm({ invoiceId }: InvoiceFormProps) {
</div>
<div className="space-y-2">
<Label
htmlFor="dueDate"
className="text-sm font-medium text-gray-700 dark:text-gray-300"
>
<Label htmlFor="dueDate" className="text-sm font-medium">
Due Date *
</Label>
<DatePicker
@@ -564,10 +541,7 @@ export function InvoiceForm({ invoiceId }: InvoiceFormProps) {
</div>
<div className="space-y-2">
<Label
htmlFor="defaultRate"
className="text-sm font-medium text-gray-700 dark:text-gray-300"
>
<Label htmlFor="defaultRate" className="text-sm font-medium">
Default Rate ($/hr)
</Label>
<div className="flex gap-2">
@@ -580,14 +554,14 @@ export function InvoiceForm({ invoiceId }: InvoiceFormProps) {
setDefaultRate(parseFloat(e.target.value) || 0)
}
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
type="button"
onClick={applyDefaultRate}
variant="outline"
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
</Button>
@@ -595,10 +569,7 @@ export function InvoiceForm({ invoiceId }: InvoiceFormProps) {
</div>
<div className="space-y-2">
<Label
htmlFor="taxRate"
className="text-sm font-medium text-gray-700 dark:text-gray-300"
>
<Label htmlFor="taxRate" className="text-sm font-medium">
Tax Rate (%)
</Label>
<Input
@@ -615,18 +586,18 @@ export function InvoiceForm({ invoiceId }: InvoiceFormProps) {
}))
}
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>
{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="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" />
<span className="font-medium">Business Information</span>
</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>
{selectedBusiness.email && <p>{selectedBusiness.email}</p>}
{selectedBusiness.phone && <p>{selectedBusiness.phone}</p>}
@@ -652,11 +623,11 @@ export function InvoiceForm({ invoiceId }: InvoiceFormProps) {
{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="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" />
<span className="font-medium">Client Information</span>
</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>
{selectedClient.email && <p>{selectedClient.email}</p>}
{selectedClient.phone && <p>{selectedClient.phone}</p>}
@@ -665,10 +636,7 @@ export function InvoiceForm({ invoiceId }: InvoiceFormProps) {
)}
<div className="space-y-2">
<Label
htmlFor="notes"
className="text-sm font-medium text-gray-700 dark:text-gray-300"
>
<Label htmlFor="notes" className="text-sm font-medium">
Notes
</Label>
<textarea
@@ -705,7 +673,7 @@ export function InvoiceForm({ invoiceId }: InvoiceFormProps) {
</CardHeader>
<CardContent className="space-y-4">
{/* 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-2">Date</div>
<div className="col-span-4">Description</div>
@@ -761,7 +729,7 @@ export function InvoiceForm({ invoiceId }: InvoiceFormProps) {
</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
</div>
<div className="text-3xl font-bold text-emerald-600 dark:text-emerald-400">
@@ -801,7 +769,7 @@ export function InvoiceForm({ invoiceId }: InvoiceFormProps) {
type="button"
variant="outline"
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
</Button>
@@ -812,7 +780,7 @@ export function InvoiceForm({ invoiceId }: InvoiceFormProps) {
>
{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..."}
</>
) : (

View File

@@ -3,27 +3,36 @@
import { useState } from "react";
import Link from "next/link";
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 { Input } from "~/components/ui/input";
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 { FileText, Calendar, DollarSign, Edit, Trash2, Eye, Plus, User } from "lucide-react";
const statusColors = {
draft: "bg-gray-100 text-gray-800",
sent: "bg-blue-100 text-blue-800",
paid: "bg-green-100 text-green-800",
overdue: "bg-red-100 text-red-800",
};
const statusLabels = {
draft: "Draft",
sent: "Sent",
paid: "Paid",
overdue: "Overdue",
};
import {
FileText,
Calendar,
DollarSign,
Edit,
Trash2,
Eye,
Plus,
User,
} from "lucide-react";
export function InvoiceList() {
const [searchTerm, setSearchTerm] = useState("");
@@ -43,9 +52,13 @@ export function InvoiceList() {
},
});
const filteredInvoices = invoices?.filter(invoice =>
invoice.invoiceNumber.toLowerCase().includes(searchTerm.toLowerCase()) ||
invoice.client.name.toLowerCase().includes(searchTerm.toLowerCase())
const filteredInvoices =
invoices?.filter(
(invoice) =>
invoice.invoiceNumber
.toLowerCase()
.includes(searchTerm.toLowerCase()) ||
invoice.client.name.toLowerCase().includes(searchTerm.toLowerCase()),
) || [];
const handleDelete = (invoiceId: string) => {
@@ -64,9 +77,9 @@ export function InvoiceList() {
};
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(amount);
};
@@ -76,12 +89,12 @@ export function InvoiceList() {
{[...Array(3)].map((_, i) => (
<Card key={i}>
<CardHeader>
<div className="h-4 bg-muted rounded animate-pulse" />
<div className="bg-muted h-4 animate-pulse rounded" />
</CardHeader>
<CardContent>
<div className="space-y-2">
<div className="h-3 bg-muted rounded animate-pulse" />
<div className="h-3 bg-muted rounded w-2/3 animate-pulse" />
<div className="bg-muted h-3 animate-pulse rounded" />
<div className="bg-muted h-3 w-2/3 animate-pulse rounded" />
</div>
</CardContent>
</Card>
@@ -158,26 +171,25 @@ export function InvoiceList() {
</div>
</CardTitle>
<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]}`}>
{statusLabels[invoice.status as keyof typeof statusLabels]}
</span>
<StatusBadge status={invoice.status as StatusType} />
<span className="text-lg font-bold text-green-600">
{formatCurrency(invoice.totalAmount)}
</span>
</div>
</CardHeader>
<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" />
{invoice.client.name}
</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" />
Due: {formatDate(invoice.dueDate)}
</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" />
{invoice.items.length} item{invoice.items.length !== 1 ? 's' : ''}
{invoice.items.length} item
{invoice.items.length !== 1 ? "s" : ""}
</div>
</CardContent>
</Card>
@@ -189,11 +201,15 @@ export function InvoiceList() {
<DialogHeader>
<DialogTitle>Delete Invoice</DialogTitle>
<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>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteDialogOpen(false)}>
<Button
variant="outline"
onClick={() => setDeleteDialogOpen(false)}
>
Cancel
</Button>
<Button variant="destructive" onClick={confirmDelete}>

View File

@@ -1,10 +1,12 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { api } from "~/trpc/react";
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 {
Dialog,
@@ -15,7 +17,6 @@ import {
DialogTitle,
} from "~/components/ui/dialog";
import { toast } from "sonner";
import { useRouter } from "next/navigation";
import { format } from "date-fns";
import {
Calendar,
@@ -41,28 +42,11 @@ interface InvoiceViewProps {
invoiceId: string;
}
const statusConfig = {
draft: {
label: "Draft",
color: "bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300",
icon: FileText,
},
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,
},
const statusIconConfig = {
draft: FileText,
sent: Send,
paid: DollarSign,
overdue: AlertCircle,
} as const;
export function InvoiceView({ invoiceId }: InvoiceViewProps) {
@@ -168,7 +152,7 @@ export function InvoiceView({ invoiceId }: InvoiceViewProps) {
}
const StatusIcon =
statusConfig[invoice.status as keyof typeof statusConfig].icon;
statusIconConfig[invoice.status as keyof typeof statusIconConfig];
return (
<div className="space-y-6">
@@ -227,22 +211,20 @@ export function InvoiceView({ invoiceId }: InvoiceViewProps) {
</div>
<div className="space-y-3 text-right">
<Badge
className={`${statusConfig[invoice.status as keyof typeof statusConfig].color} px-3 py-1 text-sm font-medium`}
<StatusBadge
status={invoice.status as StatusType}
className="px-3 py-1 text-sm font-medium"
>
<StatusIcon className="mr-1 h-3 w-3" />
{
statusConfig[invoice.status as keyof typeof statusConfig]
.label
}
</Badge>
</StatusBadge>
<div className="text-3xl font-bold text-emerald-600 dark:text-emerald-400">
{formatCurrency(invoice.totalAmount)}
</div>
<Button
onClick={handlePDFExport}
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 ? (
<>

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 { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "~/lib/utils"
import { cn } from "~/lib/utils";
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",
@@ -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",
outline:
"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: {
variant: "default",
},
}
)
},
);
function Badge({
className,
@@ -32,7 +36,7 @@ function Badge({
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span"
const Comp = asChild ? Slot : "span";
return (
<Comp
@@ -40,7 +44,7 @@ function Badge({
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
);
}
export { Badge, badgeVariants }
export { Badge, badgeVariants };

View File

@@ -1,8 +1,8 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "~/lib/utils"
import { cn } from "~/lib/utils";
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",
@@ -11,10 +11,12 @@ const buttonVariants = cva(
variant: {
default:
"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:
"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:
"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:
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
ghost:
@@ -32,8 +34,8 @@ const buttonVariants = cva(
variant: "default",
size: "default",
},
}
)
},
);
function Button({
className,
@@ -43,9 +45,9 @@ function Button({
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
asChild?: boolean;
}) {
const Comp = asChild ? Slot : "button"
const Comp = asChild ? Slot : "button";
return (
<Comp
@@ -53,7 +55,7 @@ function Button({
className={cn(buttonVariants({ variant, size, className }))}
{...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">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
"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,
)}
{...props}
/>
)
);
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
@@ -21,21 +21,21 @@ function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
data-slot="card-header"
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",
className
className,
)}
{...props}
/>
)
);
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
)
);
}
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)}
{...props}
/>
)
);
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
@@ -54,11 +54,11 @@ function CardAction({ className, ...props }: React.ComponentProps<"div">) {
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
className,
)}
{...props}
/>
)
);
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
@@ -68,7 +68,7 @@ function CardContent({ className, ...props }: React.ComponentProps<"div">) {
className={cn("px-6", className)}
{...props}
/>
)
);
}
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)}
{...props}
/>
)
);
}
export {
@@ -89,4 +89,4 @@ export {
CardAction,
CardDescription,
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 { Calendar as CalendarIcon } from "lucide-react"
import * as React from "react"
import { format } from "date-fns";
import { Calendar as CalendarIcon } from "lucide-react";
import * as React from "react";
import { Button } from "~/components/ui/button"
import { Calendar } from "~/components/ui/calendar"
import { Label } from "~/components/ui/label"
import { Button } from "~/components/ui/button";
import { Calendar } from "~/components/ui/calendar";
import { Label } from "~/components/ui/label";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "~/components/ui/popover"
import { cn } from "~/lib/utils"
} from "~/components/ui/popover";
import { cn } from "~/lib/utils";
interface DatePickerProps {
date?: Date
onDateChange: (date: Date | undefined) => void
label?: string
placeholder?: string
className?: string
disabled?: boolean
required?: boolean
id?: string
date?: Date;
onDateChange: (date: Date | undefined) => void;
label?: string;
placeholder?: string;
className?: string;
disabled?: boolean;
required?: boolean;
id?: string;
}
export function DatePicker({
@@ -33,16 +33,16 @@ export function DatePicker({
className,
disabled = false,
required = false,
id
id,
}: DatePickerProps) {
const [open, setOpen] = React.useState(false)
const [open, setOpen] = React.useState(false);
return (
<div className={cn("flex flex-col gap-2", className)}>
{label && (
<Label htmlFor={id} className="text-sm font-medium text-gray-700">
<Label htmlFor={id} className="text-sm font-medium">
{label}
{required && <span className="text-red-500 ml-1">*</span>}
{required && <span className="text-destructive ml-1">*</span>}
</Label>
)}
<Popover open={open} onOpenChange={setOpen}>
@@ -52,12 +52,12 @@ export function DatePicker({
id={id}
disabled={disabled}
className={cn(
"w-full justify-between font-normal h-10 border-gray-200 focus:border-emerald-500 focus:ring-emerald-500 text-sm",
!date && "text-gray-500"
"h-10 w-full justify-between text-sm font-normal",
!date && "text-muted-foreground",
)}
>
{date ? format(date, "PPP") : placeholder}
<CalendarIcon className="h-4 w-4 text-gray-400" />
<CalendarIcon className="text-muted-foreground h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto overflow-hidden p-0" align="start">
@@ -66,12 +66,12 @@ export function DatePicker({
selected={date}
captionLayout="dropdown"
onSelect={(selectedDate: Date | undefined) => {
onDateChange(selectedDate)
setOpen(false)
onDateChange(selectedDate);
setOpen(false);
}}
/>
</PopoverContent>
</Popover>
</div>
)
);
}

View File

@@ -1,15 +1,15 @@
"use client"
"use client";
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import * as React from "react";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
import { cn } from "~/lib/utils"
import { cn } from "~/lib/utils";
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
}
function DropdownMenuPortal({
@@ -17,7 +17,7 @@ function DropdownMenuPortal({
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
);
}
function DropdownMenuTrigger({
@@ -28,7 +28,7 @@ function DropdownMenuTrigger({
data-slot="dropdown-menu-trigger"
{...props}
/>
)
);
}
function DropdownMenuContent({
@@ -42,13 +42,13 @@ function DropdownMenuContent({
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
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",
className
"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,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
);
}
function DropdownMenuGroup({
@@ -56,7 +56,7 @@ function DropdownMenuGroup({
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
);
}
function DropdownMenuItem({
@@ -65,8 +65,8 @@ function DropdownMenuItem({
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
inset?: boolean;
variant?: "default" | "destructive";
}) {
return (
<DropdownMenuPrimitive.Item
@@ -75,11 +75,11 @@ function DropdownMenuItem({
data-variant={variant}
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",
className
className,
)}
{...props}
/>
)
);
}
function DropdownMenuCheckboxItem({
@@ -93,7 +93,7 @@ function DropdownMenuCheckboxItem({
data-slot="dropdown-menu-checkbox-item"
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",
className
className,
)}
checked={checked}
{...props}
@@ -105,7 +105,7 @@ function DropdownMenuCheckboxItem({
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
);
}
function DropdownMenuRadioGroup({
@@ -116,7 +116,7 @@ function DropdownMenuRadioGroup({
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
);
}
function DropdownMenuRadioItem({
@@ -129,7 +129,7 @@ function DropdownMenuRadioItem({
data-slot="dropdown-menu-radio-item"
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",
className
className,
)}
{...props}
>
@@ -140,7 +140,7 @@ function DropdownMenuRadioItem({
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
);
}
function DropdownMenuLabel({
@@ -148,7 +148,7 @@ function DropdownMenuLabel({
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.Label
@@ -156,11 +156,11 @@ function DropdownMenuLabel({
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
className,
)}
{...props}
/>
)
);
}
function DropdownMenuSeparator({
@@ -173,7 +173,7 @@ function DropdownMenuSeparator({
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
)
);
}
function DropdownMenuShortcut({
@@ -185,17 +185,17 @@ function DropdownMenuShortcut({
data-slot="dropdown-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
className,
)}
{...props}
/>
)
);
}
function DropdownMenuSub({
...props
}: 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({
@@ -204,7 +204,7 @@ function DropdownMenuSubTrigger({
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.SubTrigger
@@ -212,14 +212,14 @@ function DropdownMenuSubTrigger({
data-inset={inset}
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",
className
className,
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
)
);
}
function DropdownMenuSubContent({
@@ -230,12 +230,12 @@ function DropdownMenuSubContent({
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
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",
className
"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,
)}
{...props}
/>
)
);
}
export {
@@ -254,4 +254,4 @@ export {
DropdownMenuSub,
DropdownMenuSubTrigger,
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">) {
return (
@@ -8,14 +8,15 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
type={type}
data-slot="input"
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",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"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: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",
className
className,
)}
{...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-size={size}
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,
)}
{...props}
@@ -66,7 +66,7 @@ function SelectContent({
<SelectPrimitive.Content
data-slot="select-content"
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" &&
"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,
@@ -210,7 +210,7 @@ function SelectContentWithSearch({
<SelectPrimitive.Content
data-slot="select-content"
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" &&
"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,
@@ -231,7 +231,7 @@ function SelectContentWithSearch({
{...props}
>
{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" />
<input
ref={searchInputRef}
@@ -282,10 +282,21 @@ interface SearchableSelectProps {
value?: string;
onValueChange?: (value: string) => void;
placeholder?: string;
options: { value: string; label: string }[];
options: { value: string; label: string; disabled?: boolean }[];
searchPlaceholder?: string;
className?: string;
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({
@@ -296,15 +307,21 @@ function SearchableSelect({
searchPlaceholder = "Search...",
className,
disabled,
renderOption,
isOptionDisabled,
id,
}: SearchableSelectProps) {
const [searchValue, setSearchValue] = React.useState("");
const [isOpen, setIsOpen] = React.useState(false);
const filteredOptions = React.useMemo(() => {
if (!searchValue) return options;
return options.filter((option) =>
option.label.toLowerCase().includes(searchValue.toLowerCase()),
);
return options.filter((option) => {
// 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]);
// Convert empty string to placeholder value for display
@@ -327,7 +344,7 @@ function SearchableSelect({
open={isOpen}
onOpenChange={setIsOpen}
>
<SelectTrigger className={cn("w-full", className)}>
<SelectTrigger className={cn("w-full", className)} id={id}>
<SelectValue
placeholder={placeholder}
// Always show placeholder if nothing is selected
@@ -341,11 +358,34 @@ function SearchableSelect({
isOpen={isOpen}
filteredOptions={filteredOptions}
>
{filteredOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
{filteredOptions.map((option) => {
const isDisabled = isOptionDisabled
? isOptionDisabled(option)
: 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>
</Select>
);

View File

@@ -1,13 +1,11 @@
import { cn } from "~/lib/utils";
import { Card, CardContent, CardHeader } from "~/components/ui/card";
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn(
"bg-muted animate-pulse rounded-md dark:bg-gray-700",
className,
)}
className={cn("bg-muted/30 animate-pulse rounded-md", className)}
{...props}
/>
);
@@ -20,14 +18,14 @@ export function DashboardStatsSkeleton() {
{Array.from({ length: 4 }).map((_, i) => (
<div
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">
<Skeleton className="h-4 w-24 dark:bg-gray-600" />
<Skeleton className="h-8 w-8 rounded-lg dark:bg-gray-600" />
<Skeleton className="bg-muted/20 h-4 w-24" />
<Skeleton className="bg-muted/20 h-8 w-8 rounded-lg" />
</div>
<Skeleton className="mb-2 h-8 w-16 dark:bg-gray-600" />
<Skeleton className="h-3 w-32 dark:bg-gray-600" />
<Skeleton className="bg-muted/20 mb-2 h-8 w-16" />
<Skeleton className="bg-muted/20 h-3 w-32" />
</div>
))}
</div>
@@ -40,16 +38,16 @@ export function DashboardCardsSkeleton() {
{Array.from({ length: 2 }).map((_, i) => (
<div
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">
<Skeleton className="h-8 w-8 rounded-lg dark:bg-gray-600" />
<Skeleton className="h-6 w-32 dark:bg-gray-600" />
<Skeleton className="bg-muted/20 h-8 w-8 rounded-lg" />
<Skeleton className="bg-muted/20 h-6 w-32" />
</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">
<Skeleton className="h-10 w-24 dark:bg-gray-600" />
<Skeleton className="h-10 w-32 dark:bg-gray-600" />
<Skeleton className="bg-muted/20 h-10 w-24" />
<Skeleton className="bg-muted/20 h-10 w-32" />
</div>
</div>
))}
@@ -59,65 +57,124 @@ export function DashboardCardsSkeleton() {
export function DashboardActivitySkeleton() {
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">
<Skeleton className="mb-6 h-6 w-32 dark:bg-gray-600" />
<div className="border-border/40 bg-background/60 rounded-2xl border p-6 shadow-lg backdrop-blur-xl backdrop-saturate-150">
<Skeleton className="bg-muted/20 mb-6 h-6 w-32" />
<div className="py-12 text-center">
<Skeleton className="mx-auto mb-4 h-20 w-20 rounded-full dark:bg-gray-600" />
<Skeleton className="mx-auto mb-2 h-6 w-48 dark:bg-gray-600" />
<Skeleton className="mx-auto h-4 w-64 dark:bg-gray-600" />
<Skeleton className="bg-muted/20 mx-auto mb-4 h-20 w-20 rounded-full" />
<Skeleton className="bg-muted/20 mx-auto mb-2 h-6 w-48" />
<Skeleton className="bg-muted/20 mx-auto h-4 w-64" />
</div>
</div>
);
}
// Table skeleton components
export function TableSkeleton({ rows = 5 }: { rows?: number }) {
export function TableSkeleton({ rows = 8 }: { rows?: number }) {
return (
<div className="space-y-4">
{/* Search and filters */}
<div className="flex flex-col gap-4 sm:flex-row">
<Skeleton className="h-10 w-64 dark:bg-gray-600" />
<div className="flex gap-2">
<Skeleton className="h-10 w-24 dark:bg-gray-600" />
<Skeleton className="h-10 w-24 dark:bg-gray-600" />
<div className="w-full">
{/* Controls - matches universal table controls */}
<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">
{/* Left side - View controls and filters */}
<div className="flex items-center gap-2">
<Skeleton className="bg-muted/20 h-10 w-10" />{" "}
{/* 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>
{/* Table */}
<div className="rounded-lg border dark:border-gray-700 dark:bg-gray-800/90">
<div className="border-b p-4 dark:border-gray-700">
<div className="flex items-center justify-between">
<Skeleton className="h-4 w-32 dark:bg-gray-600" />
<div className="flex gap-2">
<Skeleton className="h-8 w-8 dark:bg-gray-600" />
<Skeleton className="h-8 w-8 dark:bg-gray-600" />
{/* Table - matches universal table structure */}
<div className="bg-background/60 border-border/40 overflow-hidden rounded-2xl border shadow-lg backdrop-blur-xl backdrop-saturate-150">
<div className="w-full">
{/* Table header */}
<div className="border-border/40 border-b">
<div className="flex items-center px-4 py-4">
<div className="w-12 px-4">
<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 className="p-4">
<div className="space-y-3">
{/* Table body */}
<div>
{Array.from({ length: rows }).map((_, i) => (
<div key={i} className="flex items-center gap-4">
<Skeleton className="h-4 w-4 dark:bg-gray-600" />
<Skeleton className="h-4 flex-1 dark:bg-gray-600" />
<Skeleton className="h-4 w-24 dark:bg-gray-600" />
<Skeleton className="h-4 w-24 dark:bg-gray-600" />
<Skeleton className="h-4 w-20 dark:bg-gray-600" />
<Skeleton className="h-8 w-16 dark:bg-gray-600" />
<div
key={i}
className="border-border/40 border-b last:border-b-0"
>
<div className="hover:bg-accent/30 flex items-center px-4 py-4 transition-colors">
<div className="w-12 px-4">
<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>
{/* Pagination */}
<div className="flex items-center justify-between">
<Skeleton className="h-4 w-32 dark:bg-gray-600" />
<div className="flex gap-2">
<Skeleton className="h-8 w-8 dark:bg-gray-600" />
<Skeleton className="h-8 w-8 dark:bg-gray-600" />
<Skeleton className="h-8 w-8 dark:bg-gray-600" />
{/* Pagination - matches universal table pagination */}
<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">
{/* Left side - Page info and items per page */}
<div className="flex items-center gap-2">
<Skeleton className="bg-muted/20 h-4 w-40" /> {/* Page info text */}
<Skeleton className="bg-muted/20 h-8 w-20" />{" "}
{/* 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>
@@ -127,36 +184,115 @@ export function TableSkeleton({ rows = 5 }: { rows?: number }) {
// Form skeleton components
export function FormSkeleton() {
return (
<div className="space-y-6">
<div className="mx-auto max-w-6xl pb-24">
<div className="space-y-4">
<div>
<Skeleton className="mb-2 h-4 w-20 dark:bg-gray-600" />
<Skeleton className="h-10 w-full dark:bg-gray-600" />
{/* Basic 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-40" />
<Skeleton className="bg-muted/20 h-4 w-56" />
</div>
<div>
<Skeleton className="mb-2 h-4 w-24 dark:bg-gray-600" />
<Skeleton className="h-10 w-full dark:bg-gray-600" />
</div>
<div>
<Skeleton className="mb-2 h-4 w-16 dark:bg-gray-600" />
<Skeleton className="h-10 w-full dark:bg-gray-600" />
</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-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 className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<Skeleton className="mb-2 h-4 w-20 dark:bg-gray-600" />
<Skeleton className="h-10 w-full dark:bg-gray-600" />
{/* Form Actions - styled like data table footer */}
<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="bg-muted/20 h-4 w-40" />
<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>
<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>
);
@@ -169,41 +305,41 @@ export function InvoiceViewSkeleton() {
{/* Header */}
<div className="flex items-start justify-between">
<div className="space-y-2">
<Skeleton className="h-8 w-48 dark:bg-gray-600" />
<Skeleton className="h-4 w-64 dark:bg-gray-600" />
<Skeleton className="bg-muted/20 h-8 w-48" />
<Skeleton className="bg-muted/20 h-4 w-64" />
</div>
<Skeleton className="h-10 w-32 dark:bg-gray-600" />
<Skeleton className="bg-muted/20 h-10 w-32" />
</div>
{/* Client info */}
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<div className="space-y-3">
<Skeleton className="h-5 w-24 dark:bg-gray-600" />
<Skeleton className="h-4 w-full dark:bg-gray-600" />
<Skeleton className="h-4 w-3/4 dark:bg-gray-600" />
<Skeleton className="h-4 w-1/2 dark:bg-gray-600" />
<Skeleton className="bg-muted/20 h-5 w-24" />
<Skeleton className="bg-muted/20 h-4 w-full" />
<Skeleton className="bg-muted/20 h-4 w-3/4" />
<Skeleton className="bg-muted/20 h-4 w-1/2" />
</div>
<div className="space-y-3">
<Skeleton className="h-5 w-24 dark:bg-gray-600" />
<Skeleton className="h-4 w-full dark:bg-gray-600" />
<Skeleton className="h-4 w-3/4 dark:bg-gray-600" />
<Skeleton className="bg-muted/20 h-5 w-24" />
<Skeleton className="bg-muted/20 h-4 w-full" />
<Skeleton className="bg-muted/20 h-4 w-3/4" />
</div>
</div>
{/* Items table */}
<div className="rounded-lg border dark:border-gray-700 dark:bg-gray-800/90">
<div className="border-b p-4 dark:border-gray-700">
<Skeleton className="h-5 w-32 dark:bg-gray-600" />
<div className="border-border bg-card rounded-lg border">
<div className="border-border border-b p-4">
<Skeleton className="bg-muted/20 h-5 w-32" />
</div>
<div className="p-4">
<div className="space-y-3">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="flex items-center gap-4">
<Skeleton className="h-4 w-20 dark:bg-gray-600" />
<Skeleton className="h-4 flex-1 dark:bg-gray-600" />
<Skeleton className="h-4 w-16 dark:bg-gray-600" />
<Skeleton className="h-4 w-20 dark:bg-gray-600" />
<Skeleton className="h-4 w-24 dark:bg-gray-600" />
<Skeleton className="bg-muted/20 h-4 w-20" />
<Skeleton className="bg-muted/20 h-4 flex-1" />
<Skeleton className="bg-muted/20 h-4 w-16" />
<Skeleton className="bg-muted/20 h-4 w-20" />
<Skeleton className="bg-muted/20 h-4 w-24" />
</div>
))}
</div>
@@ -213,8 +349,8 @@ export function InvoiceViewSkeleton() {
{/* Total */}
<div className="flex justify-end">
<div className="space-y-2">
<Skeleton className="h-6 w-32 dark:bg-gray-600" />
<Skeleton className="h-8 w-40 dark:bg-gray-600" />
<Skeleton className="bg-muted/20 h-6 w-32" />
<Skeleton className="bg-muted/20 h-8 w-40" />
</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">) {
return (
@@ -16,7 +16,7 @@ function Table({ className, ...props }: React.ComponentProps<"table">) {
{...props}
/>
</div>
)
);
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
@@ -26,7 +26,7 @@ function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
className={cn("[&_tr]:border-b", className)}
{...props}
/>
)
);
}
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)}
{...props}
/>
)
);
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
@@ -45,11 +45,11 @@ function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
data-slot="table-footer"
className={cn(
"bg-muted/50 border-t font-medium [&>tr]:last:border-b-0",
className
className,
)}
{...props}
/>
)
);
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
@@ -58,11 +58,11 @@ function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
data-slot="table-row"
className={cn(
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
className
className,
)}
{...props}
/>
)
);
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
@@ -70,12 +70,12 @@ function TableHead({ className, ...props }: React.ComponentProps<"th">) {
<th
data-slot="table-head"
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]",
className
"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,
)}
{...props}
/>
)
);
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
@@ -83,12 +83,12 @@ function TableCell({ className, ...props }: React.ComponentProps<"td">) {
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
"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,
)}
{...props}
/>
)
);
}
function TableCaption({
@@ -101,7 +101,7 @@ function TableCaption({
className={cn("text-muted-foreground mt-4 text-sm", className)}
{...props}
/>
)
);
}
export {
@@ -113,4 +113,4 @@ export {
TableRow,
TableCell,
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">) {
return (
<textarea
data-slot="textarea"
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",
className
"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,
)}
{...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(
and(
eq(businesses.id, input.id),
eq(businesses.createdById, ctx.session.user.id)
)
eq(businesses.createdById, ctx.session.user.id),
),
)
.limit(1);
@@ -59,8 +59,8 @@ export const businessesRouter = createTRPCRouter({
.where(
and(
eq(businesses.createdById, ctx.session.user.id),
eq(businesses.isDefault, true)
)
eq(businesses.isDefault, true),
),
)
.limit(1);
@@ -96,7 +96,7 @@ export const businessesRouter = createTRPCRouter({
z.object({
id: z.string(),
...businessSchema.shape,
})
}),
)
.mutation(async ({ ctx, input }) => {
const { id, ...updateData } = input;
@@ -106,12 +106,7 @@ export const businessesRouter = createTRPCRouter({
await ctx.db
.update(businesses)
.set({ isDefault: false })
.where(
and(
eq(businesses.createdById, ctx.session.user.id),
eq(businesses.id, id)
)
);
.where(eq(businesses.createdById, ctx.session.user.id));
}
const [updatedBusiness] = await ctx.db
@@ -123,13 +118,15 @@ export const businessesRouter = createTRPCRouter({
.where(
and(
eq(businesses.id, id),
eq(businesses.createdById, ctx.session.user.id)
)
eq(businesses.createdById, ctx.session.user.id),
),
)
.returning();
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;
@@ -146,13 +143,15 @@ export const businessesRouter = createTRPCRouter({
.where(
and(
eq(businesses.id, input.id),
eq(businesses.createdById, ctx.session.user.id)
)
eq(businesses.createdById, ctx.session.user.id),
),
)
.limit(1);
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
@@ -162,7 +161,9 @@ export const businessesRouter = createTRPCRouter({
.where(eq(invoices.businessId, input.id));
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
@@ -170,8 +171,8 @@ export const businessesRouter = createTRPCRouter({
.where(
and(
eq(businesses.id, input.id),
eq(businesses.createdById, ctx.session.user.id)
)
eq(businesses.createdById, ctx.session.user.id),
),
);
return { success: true };
@@ -194,13 +195,15 @@ export const businessesRouter = createTRPCRouter({
.where(
and(
eq(businesses.id, input.id),
eq(businesses.createdById, ctx.session.user.id)
)
eq(businesses.createdById, ctx.session.user.id),
),
)
.returning();
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;

View File

@@ -5,6 +5,9 @@
--font-sans:
var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif,
"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 {
@@ -48,11 +51,11 @@
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--background: oklch(0.99 0.003 164.25);
--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);
--popover: oklch(1 0 0);
--popover: oklch(0.995 0.002 164.25);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
@@ -80,6 +83,26 @@
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 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) {
@@ -116,6 +139,26 @@
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--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;
}
/* Improved form elements for dark mode */
/* Comprehensive form elements styling - consistent across all inputs */
input[type="text"],
input[type="email"],
input[type="password"],
@@ -136,21 +179,111 @@
input[type="url"],
input[type="search"],
input[type="number"],
input[type="date"],
input[type="datetime-local"],
input[type="time"],
textarea,
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,
textarea::placeholder {
@apply text-muted-foreground;
}
/* Better focus states */
/* Better focus states with consistent ring */
input:focus,
textarea: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 */
@@ -233,4 +366,141 @@
input[type="radio"]:checked {
@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))",
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: {
lg: "var(--radius)",