From 0e46fdafb280eb1d4ce4701b51645c261cf73379 Mon Sep 17 00:00:00 2001 From: Sean O'Connor Date: Thu, 30 Apr 2026 10:50:50 -0400 Subject: [PATCH] feat: add administration page and account role management - Implemented `AdministrationContent` component for managing account roles. - Created `AdministrationPage` to serve as the main entry point for administration tasks. - Added PDF preview functionality with `PdfPreviewFrame` component for invoice generation. - Introduced `InputColor` component for advanced color selection with various formats. - Established color conversion utilities in `color-converter.ts` for handling color formats. - Defined appearance-related schemas and types in `appearance.ts` for consistent theme management. --- bun.lock | 27 +- next.config.js | 1 + package.json | 20 +- src/app/(legal)/terms/page.tsx | 7 +- src/app/auth/reset-password/page.tsx | 5 +- .../_components/invoice-status-chart.tsx | 93 +- .../_components/monthly-metrics-chart.tsx | 86 +- .../dashboard/_components/revenue-chart.tsx | 8 +- .../dashboard/_components/status-manager.tsx | 4 +- .../_components/administration-content.tsx | 101 ++ src/app/dashboard/administration/page.tsx | 23 + src/app/dashboard/expenses/page.tsx | 297 ++++-- .../[id]/_components/invoice-items-table.tsx | 7 +- src/app/dashboard/invoices/[id]/page.tsx | 12 +- .../_components/invoices-data-table.tsx | 2 +- src/app/dashboard/invoices/import/page.tsx | 14 +- src/app/dashboard/page.tsx | 19 +- .../_components/pdf-preview-frame.tsx | 124 +++ .../settings/_components/settings-content.tsx | 388 ++++---- src/app/dashboard/settings/page.tsx | 15 +- src/app/layout.tsx | 43 +- src/app/page.tsx | 332 ++----- src/components/analytics/umami-script.tsx | 28 +- .../branding/address-autocomplete.tsx | 2 +- src/components/branding/logo.tsx | 35 +- src/components/csv-import-page.tsx | 15 +- src/components/data/data-table.tsx | 77 +- .../data/editable-invoice-items.tsx | 14 +- src/components/data/stats-card.tsx | 2 +- src/components/forms/business-form.tsx | 1 + src/components/forms/client-form.tsx | 1 + src/components/forms/file-upload.tsx | 8 +- .../forms/invoice-calendar-view.tsx | 862 ++++++++++-------- src/components/forms/invoice-form.tsx | 22 +- .../forms/invoice/invoice-meta-sidebar.tsx | 382 ++++---- src/components/layout/dashboard-shell.tsx | 128 +-- src/components/layout/motion-background.tsx | 42 +- src/components/layout/page-header.tsx | 18 +- src/components/layout/page-layout.tsx | 47 +- src/components/layout/quick-action-card.tsx | 4 +- src/components/layout/sidebar-provider.tsx | 78 +- src/components/layout/sidebar.tsx | 141 ++- src/components/navigation/breadcrumbs.tsx | 9 +- src/components/navigation/sidebar-trigger.tsx | 11 +- .../animation-preferences-provider.tsx | 27 +- .../providers/appearance-provider.tsx | 211 +++-- src/components/ui/alert-dialog.tsx | 42 +- src/components/ui/avatar.tsx | 76 +- src/components/ui/breadcrumb.tsx | 30 +- src/components/ui/button.tsx | 2 +- src/components/ui/calendar.tsx | 92 +- src/components/ui/card.tsx | 2 +- src/components/ui/checkbox.tsx | 16 +- src/components/ui/collapsible.tsx | 12 +- src/components/ui/count-up.tsx | 25 +- src/components/ui/date-picker.tsx | 19 +- src/components/ui/dialog.tsx | 42 +- src/components/ui/dropdown-menu.tsx | 12 +- src/components/ui/image-with-skeleton.tsx | 50 +- src/components/ui/input-color.tsx | 568 ++++++++++++ src/components/ui/label.tsx | 14 +- src/components/ui/navigation-menu.tsx | 54 +- src/components/ui/popover.tsx | 22 +- src/components/ui/progress.tsx | 2 +- src/components/ui/separator.tsx | 22 +- src/components/ui/sheet.tsx | 38 +- src/components/ui/skeleton.tsx | 31 +- src/components/ui/slider.tsx | 1 + src/components/ui/tabs.tsx | 8 +- src/components/ui/tooltip.tsx | 20 +- src/env.js | 15 +- src/hooks/useCountUp.ts | 1 + src/lib/appearance.ts | 126 +++ src/lib/auth-client.ts | 4 +- src/lib/branding.ts | 159 ++-- src/lib/color-converter.ts | 113 +++ src/lib/color-utils.ts | 25 +- src/lib/gravatar.ts | 6 +- src/lib/navigation.ts | 6 + src/lib/pdf-export.tsx | 755 +++++++++++---- src/lib/utils.ts | 6 +- src/server/api/routers/dashboard.ts | 213 ++--- src/server/api/routers/expenses.ts | 33 +- src/server/api/routers/invoiceTemplates.ts | 10 +- src/server/api/routers/settings.ts | 98 +- src/server/umami.ts | 112 +-- src/styles/globals.css | 316 ++++++- 87 files changed, 4566 insertions(+), 2425 deletions(-) create mode 100644 src/app/dashboard/administration/_components/administration-content.tsx create mode 100644 src/app/dashboard/administration/page.tsx create mode 100644 src/app/dashboard/settings/_components/pdf-preview-frame.tsx create mode 100644 src/components/ui/input-color.tsx create mode 100644 src/lib/appearance.ts create mode 100644 src/lib/color-converter.ts diff --git a/bun.lock b/bun.lock index cd884a0..58e7e7e 100644 --- a/bun.lock +++ b/bun.lock @@ -10,6 +10,7 @@ "@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", + "@fontsource-variable/playfair-display": "^5.2.8", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-avatar": "^1.1.11", "@radix-ui/react-checkbox": "^1.3.3", @@ -50,11 +51,12 @@ "file-saver": "^2.0.5", "framer-motion": "^12.23.26", "lucide-react": "^0.525.0", - "next": "^16.2.2", + "next": "^16.2.4", "pg": "8.13.1", - "react": "^19.2.4", + "react": "^19.2.5", + "react-colorful": "^5.6.1", "react-day-picker": "^9.12.0", - "react-dom": "^19.2.4", + "react-dom": "^19.2.5", "react-dropzone": "^14.3.8", "recharts": "^3.5.1", "resend": "^4.8.0", @@ -71,12 +73,13 @@ "@types/node": "^20.19.26", "@types/pg": "^8.16.0", "@types/raf": "^3.4.3", - "@types/react": "^19.2.7", + "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", - "baseline-browser-mapping": "^2.9.6", + "babel-plugin-react-compiler": "^1.0.0", + "baseline-browser-mapping": "^2.10.24", "drizzle-kit": "^0.30.6", "eslint": "^9.39.1", - "eslint-config-next": "^16.0.10", + "eslint-config-next": "^16.2.4", "eslint-plugin-drizzle": "^0.2.3", "postcss": "^8.5.6", "prettier": "3.6.2", @@ -250,6 +253,8 @@ "@floating-ui/utils": ["@floating-ui/utils@0.2.11", "", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="], + "@fontsource-variable/playfair-display": ["@fontsource-variable/playfair-display@5.2.8", "", {}, "sha512-ZzVIXPOrL85yyOvZYoBzUszIJM+xKkHqni4IYn2CVLaGQQdJR8sBeC8yFNgjxSJ7ludTwta8qpULeOFuk5X75A=="], + "@humanfs/core": ["@humanfs/core@0.19.2", "", { "dependencies": { "@humanfs/types": "^0.15.0" } }, "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA=="], "@humanfs/node": ["@humanfs/node@0.16.8", "", { "dependencies": { "@humanfs/core": "^0.19.2", "@humanfs/types": "^0.15.0", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ=="], @@ -750,11 +755,13 @@ "axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="], + "babel-plugin-react-compiler": ["babel-plugin-react-compiler@1.0.0", "", { "dependencies": { "@babel/types": "^7.26.0" } }, "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw=="], + "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], "base64-js": ["base64-js@0.0.8", "", {}, "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw=="], - "baseline-browser-mapping": ["baseline-browser-mapping@2.10.23", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-xwVXGqevyKPsiuQdLj+dZMVjidjJV508TBqexND5HrF89cGdCYCJFB3qhcxRHSeMctdCfbR1jrxBajhDy7o29g=="], + "baseline-browser-mapping": ["baseline-browser-mapping@2.10.24", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-I2NkZOOrj2XuguvWCK6OVh9GavsNjZjK908Rq3mIBK25+GD8vPX5w2WdxVqnQ7xx3SrZJiCiZFu+/Oz50oSYSA=="], "bcryptjs": ["bcryptjs@3.0.3", "", { "bin": { "bcrypt": "bin/bcrypt" } }, "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g=="], @@ -1376,6 +1383,8 @@ "react": ["react@19.2.5", "", {}, "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA=="], + "react-colorful": ["react-colorful@5.6.1", "", { "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-1exovf0uGTGyq5mXQT0zgQ80uvj2PCwvF8zY1RN9/vbJVSjSo3fsB/4L3ObbF7u70NduSiK4xu4Y6q1MHoUGEw=="], + "react-day-picker": ["react-day-picker@9.14.0", "", { "dependencies": { "@date-fns/tz": "^1.4.1", "@tabby_ai/hijri-converter": "1.0.5", "date-fns": "^4.1.0", "date-fns-jalali": "4.1.0-0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-tBaoDWjPwe0M5pGrum4H0SR6Lyk+BO9oHnp9JbKpGKW2mlraNPgP9BMfsg5pWpwrssARmeqk7YBl2oXutZTaHA=="], "react-dom": ["react-dom@19.2.5", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.5" } }, "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag=="], @@ -1692,6 +1701,8 @@ "brotli/base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], + "browserslist/baseline-browser-mapping": ["baseline-browser-mapping@2.10.23", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-xwVXGqevyKPsiuQdLj+dZMVjidjJV508TBqexND5HrF89cGdCYCJFB3qhcxRHSeMctdCfbR1jrxBajhDy7o29g=="], + "color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], "cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], @@ -1714,6 +1725,8 @@ "micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], + "next/baseline-browser-mapping": ["baseline-browser-mapping@2.10.23", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-xwVXGqevyKPsiuQdLj+dZMVjidjJV508TBqexND5HrF89cGdCYCJFB3qhcxRHSeMctdCfbR1jrxBajhDy7o29g=="], + "next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="], "node-exports-info/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], diff --git a/next.config.js b/next.config.js index cc7861b..88f92be 100644 --- a/next.config.js +++ b/next.config.js @@ -7,6 +7,7 @@ import "./src/env.js"; /** @type {import("next").NextConfig} */ const config = { output: "standalone", + reactCompiler: true, serverExternalPackages: ["pg"], }; diff --git a/package.json b/package.json index 74daf61..7844dcf 100644 --- a/package.json +++ b/package.json @@ -11,9 +11,8 @@ "db:push": "drizzle-kit push", "db:studio": "drizzle-kit studio", "db:clone": "./scripts/clone-local.sh", - "docker:up": "colima start && docker compose up -d", - "docker:dev:up": "colima start && docker compose -f docker-compose.dev.yml up -d", - "docker:down": "docker compose down && colima stop", + "docker:up": "colima start && docker compose -f docker-compose.dev.yml up -d", + "docker:down": "docker compose -f docker-compose.dev.yml down && colima stop", "docker:dev:down": "docker compose -f docker-compose.dev.yml down && colima stop", "deploy": "drizzle-kit push && next build", "dev": "next dev --turbo", @@ -31,6 +30,7 @@ "@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", + "@fontsource-variable/playfair-display": "^5.2.8", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-avatar": "^1.1.11", "@radix-ui/react-checkbox": "^1.3.3", @@ -71,11 +71,12 @@ "file-saver": "^2.0.5", "framer-motion": "^12.23.26", "lucide-react": "^0.525.0", - "next": "^16.2.2", + "next": "^16.2.4", "pg": "8.13.1", - "react": "^19.2.4", + "react": "^19.2.5", + "react-colorful": "^5.6.1", "react-day-picker": "^9.12.0", - "react-dom": "^19.2.4", + "react-dom": "^19.2.5", "react-dropzone": "^14.3.8", "recharts": "^3.5.1", "resend": "^4.8.0", @@ -92,12 +93,13 @@ "@types/node": "^20.19.26", "@types/pg": "^8.16.0", "@types/raf": "^3.4.3", - "@types/react": "^19.2.7", + "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", - "baseline-browser-mapping": "^2.9.6", + "babel-plugin-react-compiler": "^1.0.0", + "baseline-browser-mapping": "^2.10.24", "drizzle-kit": "^0.30.6", "eslint": "^9.39.1", - "eslint-config-next": "^16.0.10", + "eslint-config-next": "^16.2.4", "eslint-plugin-drizzle": "^0.2.3", "postcss": "^8.5.6", "prettier": "3.6.2", diff --git a/src/app/(legal)/terms/page.tsx b/src/app/(legal)/terms/page.tsx index e1786e7..533506f 100644 --- a/src/app/(legal)/terms/page.tsx +++ b/src/app/(legal)/terms/page.tsx @@ -35,9 +35,10 @@ export default function TermsOfServicePage() {

- These Terms of Service ("Terms") govern your use of the - beenvoice platform and services (the "Service") operated by - beenvoice ("us", "we", or "our"). + These Terms of Service ("Terms") govern your use of + the beenvoice platform and services (the "Service") + operated by beenvoice ("us", "we", or + "our").

By accessing or using our Service, you agree to be bound by diff --git a/src/app/auth/reset-password/page.tsx b/src/app/auth/reset-password/page.tsx index 74614c8..9c0eee1 100644 --- a/src/app/auth/reset-password/page.tsx +++ b/src/app/auth/reset-password/page.tsx @@ -29,11 +29,12 @@ function ResetPasswordForm() { const [success, setSuccess] = useState(false); const [showPassword, setShowPassword] = useState(false); const [showConfirmPassword, setShowConfirmPassword] = useState(false); - const [tokenValid, setTokenValid] = useState(null); + const [tokenValid, setTokenValid] = useState(() => + token ? null : false, + ); useEffect(() => { if (!token) { - setTokenValid(false); return; } diff --git a/src/app/dashboard/_components/invoice-status-chart.tsx b/src/app/dashboard/_components/invoice-status-chart.tsx index 1c03448..b957187 100644 --- a/src/app/dashboard/_components/invoice-status-chart.tsx +++ b/src/app/dashboard/_components/invoice-status-chart.tsx @@ -16,6 +16,47 @@ interface InvoiceStatusChartProps { invoices: Invoice[]; } +const STATUS_COLORS = { + draft: "hsl(0, 0%, 60%)", + sent: "hsl(217, 91%, 60%)", + pending: "hsl(217, 91%, 60%)", + paid: "hsl(142, 71%, 45%)", + overdue: "hsl(var(--destructive))", +} as const; + +const formatChartCurrency = (value: number) => { + return new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + minimumFractionDigits: 0, + maximumFractionDigits: 0, + }).format(value); +}; + +function StatusTooltip({ + active, + payload, +}: { + active?: boolean; + payload?: Array<{ + payload: { name: string; count: number; value: number }; + }>; +}) { + if (active && payload?.length) { + const data = payload[0]!.payload; + return ( +

+

{data.name}

+

+ {data.count} invoice{data.count !== 1 ? "s" : ""} +

+

{formatChartCurrency(data.value)}

+
+ ); + } + return null; +} + export function InvoiceStatusChart({ invoices }: InvoiceStatusChartProps) { // Process invoice data to create status breakdown const statusData = invoices.reduce( @@ -44,14 +85,6 @@ export function InvoiceStatusChart({ invoices }: InvoiceStatusChartProps) { name: item.status.charAt(0).toUpperCase() + item.status.slice(1), })); - // Use theme-aware colors - const COLORS = { - draft: "hsl(0, 0%, 60%)", // neutral grey - matches monthly metrics chart - sent: "hsl(217, 91%, 60%)", // vibrant blue - pending: "hsl(217, 91%, 60%)", // blue - paid: "hsl(142, 71%, 45%)", // vibrant green - overdue: "hsl(var(--destructive))", // red - }; // Animation / motion preferences const { prefersReducedMotion, animationSpeedMultiplier } = useAnimationPreferences(); @@ -59,39 +92,6 @@ export function InvoiceStatusChart({ invoices }: InvoiceStatusChartProps) { 600 / (animationSpeedMultiplier || 1), ); - const formatCurrency = (value: number) => { - return new Intl.NumberFormat("en-US", { - style: "currency", - currency: "USD", - minimumFractionDigits: 0, - maximumFractionDigits: 0, - }).format(value); - }; - - const CustomTooltip = ({ - active, - payload, - }: { - active?: boolean; - payload?: Array<{ - payload: { name: string; count: number; value: number }; - }>; - }) => { - if (active && payload?.length) { - const data = payload[0]!.payload; - return ( -
-

{data.name}

-

- {data.count} invoice{data.count !== 1 ? "s" : ""} -

-

{formatCurrency(data.value)}

-
- ); - } - return null; - }; - if (chartData.length === 0) { return (
@@ -127,11 +127,13 @@ export function InvoiceStatusChart({ invoices }: InvoiceStatusChartProps) { {chartData.map((entry, index) => ( ))} - } /> + } />
@@ -144,7 +146,8 @@ export function InvoiceStatusChart({ invoices }: InvoiceStatusChartProps) {
{item.name} @@ -152,7 +155,7 @@ export function InvoiceStatusChart({ invoices }: InvoiceStatusChartProps) {

{item.count}

- {formatCurrency(item.value)} + {formatChartCurrency(item.value)}

diff --git a/src/app/dashboard/_components/monthly-metrics-chart.tsx b/src/app/dashboard/_components/monthly-metrics-chart.tsx index e5a7768..437f890 100644 --- a/src/app/dashboard/_components/monthly-metrics-chart.tsx +++ b/src/app/dashboard/_components/monthly-metrics-chart.tsx @@ -24,6 +24,43 @@ interface MonthlyMetricsChartProps { invoices: Invoice[]; } +function MonthlyMetricsTooltip({ + active, + payload, + label, +}: { + active?: boolean; + payload?: Array<{ + payload: { + paidInvoices: number; + pendingInvoices: number; + overdueInvoices: number; + draftInvoices: number; + totalInvoices: number; + }; + }>; + label?: string; +}) { + if (active && payload?.length) { + const data = payload[0]!.payload; + return ( +
+

{label}

+
+

Paid: {data.paidInvoices}

+

Pending: {data.pendingInvoices}

+

Overdue: {data.overdueInvoices}

+

Draft: {data.draftInvoices}

+

+ Total: {data.totalInvoices} +

+
+
+ ); + } + return null; +} + export function MonthlyMetricsChart({ invoices }: MonthlyMetricsChartProps) { // Process invoice data to create monthly metrics const monthlyData = invoices.reduce( @@ -95,49 +132,6 @@ export function MonthlyMetricsChart({ invoices }: MonthlyMetricsChartProps) { 500 / (animationSpeedMultiplier || 1), ); - const CustomTooltip = ({ - active, - payload, - label, - }: { - active?: boolean; - payload?: Array<{ - payload: { - paidInvoices: number; - pendingInvoices: number; - overdueInvoices: number; - draftInvoices: number; - totalInvoices: number; - }; - }>; - label?: string; - }) => { - if (active && payload?.length) { - const data = payload[0]!.payload; - return ( -
-

{label}

-
-

Paid: {data.paidInvoices}

-

- Pending: {data.pendingInvoices} -

-

- Overdue: {data.overdueInvoices} -

-

- Draft: {data.draftInvoices} -

-

- Total: {data.totalInvoices} -

-
-
- ); - } - return null; - }; - if (chartData.length === 0) { return (
@@ -169,7 +163,7 @@ export function MonthlyMetricsChart({ invoices }: MonthlyMetricsChartProps) { tickLine={false} tick={{ fontSize: 12, fill: "var(--muted-foreground)" }} /> - } /> + } /> Pending
-
+
Overdue
diff --git a/src/app/dashboard/_components/revenue-chart.tsx b/src/app/dashboard/_components/revenue-chart.tsx index 87aafa0..dbdba35 100644 --- a/src/app/dashboard/_components/revenue-chart.tsx +++ b/src/app/dashboard/_components/revenue-chart.tsx @@ -10,8 +10,6 @@ import { } from "recharts"; import { useAnimationPreferences } from "~/components/providers/animation-preferences-provider"; - - interface RevenueChartProps { data: { month: string; @@ -91,7 +89,11 @@ export function RevenueChart({ data }: RevenueChartProps) { - + +
{daysPastDue} day{daysPastDue !== 1 ? "s" : ""} overdue @@ -325,7 +325,7 @@ export function StatusManager({ {/* No Email Warning */} {!clientEmail && effectiveStatus !== "paid" && ( -
+
diff --git a/src/app/dashboard/administration/_components/administration-content.tsx b/src/app/dashboard/administration/_components/administration-content.tsx new file mode 100644 index 0000000..d630c7c --- /dev/null +++ b/src/app/dashboard/administration/_components/administration-content.tsx @@ -0,0 +1,101 @@ +"use client"; + +import { Shield } from "lucide-react"; +import { toast } from "sonner"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "~/components/ui/card"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "~/components/ui/select"; +import { api } from "~/trpc/react"; + +export function AdministrationContent() { + const { + data: accounts = [], + refetch, + error, + } = api.settings.listAccounts.useQuery(); + const updateAccountRoleMutation = api.settings.updateAccountRole.useMutation({ + onSuccess: () => { + toast.success("Account role updated"); + void refetch(); + }, + onError: (mutationError: { message: string }) => { + toast.error(`Failed to update role: ${mutationError.message}`); + }, + }); + + if (error) { + return ( + + + + + Administration + + + Administrative access is required for this page. + + + + ); + } + + return ( + + + + + Accounts + + + Manage account access and roles without opening customer data. + + + + {accounts.map((account) => ( +
+
+

{account.name}

+

+ {account.email} +

+

+ Created {new Date(account.createdAt).toLocaleDateString()} +

+
+ +
+ ))} +
+
+ ); +} diff --git a/src/app/dashboard/administration/page.tsx b/src/app/dashboard/administration/page.tsx new file mode 100644 index 0000000..0751c6a --- /dev/null +++ b/src/app/dashboard/administration/page.tsx @@ -0,0 +1,23 @@ +import { Suspense } from "react"; +import { DataTableSkeleton } from "~/components/data/data-table"; +import { PageHeader } from "~/components/layout/page-header"; +import { HydrateClient } from "~/trpc/server"; +import { AdministrationContent } from "./_components/administration-content"; + +export default async function AdministrationPage() { + return ( +
+ + + + }> + + + +
+ ); +} diff --git a/src/app/dashboard/expenses/page.tsx b/src/app/dashboard/expenses/page.tsx index caf3304..661aae6 100644 --- a/src/app/dashboard/expenses/page.tsx +++ b/src/app/dashboard/expenses/page.tsx @@ -68,20 +68,39 @@ export default function ExpensesPage() { const { data: clients = [] } = api.clients.getAll.useQuery(); const create = api.expenses.create.useMutation({ - onSuccess: () => { toast.success("Expense added"); void utils.expenses.getAll.invalidate(); setOpen(false); setForm(defaultForm); }, + onSuccess: () => { + toast.success("Expense added"); + void utils.expenses.getAll.invalidate(); + setOpen(false); + setForm(defaultForm); + }, onError: (e) => toast.error(e.message), }); const update = api.expenses.update.useMutation({ - onSuccess: () => { toast.success("Expense updated"); void utils.expenses.getAll.invalidate(); setOpen(false); setEditId(null); setForm(defaultForm); }, + onSuccess: () => { + toast.success("Expense updated"); + void utils.expenses.getAll.invalidate(); + setOpen(false); + setEditId(null); + setForm(defaultForm); + }, onError: (e) => toast.error(e.message), }); const del = api.expenses.delete.useMutation({ - onSuccess: () => { toast.success("Expense deleted"); void utils.expenses.getAll.invalidate(); setDeleteId(null); }, + onSuccess: () => { + toast.success("Expense deleted"); + void utils.expenses.getAll.invalidate(); + setDeleteId(null); + }, onError: (e) => toast.error(e.message), }); - const handleOpen = () => { setEditId(null); setForm(defaultForm); setOpen(true); }; - const handleEdit = (expense: typeof expenses[0]) => { + const handleOpen = () => { + setEditId(null); + setForm(defaultForm); + setOpen(true); + }; + const handleEdit = (expense: (typeof expenses)[0]) => { setEditId(expense.id); setForm({ date: new Date(expense.date), @@ -98,21 +117,45 @@ export default function ExpensesPage() { setOpen(true); }; const handleSubmit = () => { - if (!form.description.trim()) { toast.error("Description is required"); return; } - if (form.amount <= 0) { toast.error("Amount must be greater than 0"); return; } - const payload = { ...form, clientId: form.clientId || undefined, category: form.category || undefined, notes: form.notes || undefined, taxDeductible: form.taxDeductible }; + if (!form.description.trim()) { + toast.error("Description is required"); + return; + } + if (form.amount <= 0) { + toast.error("Amount must be greater than 0"); + return; + } + const payload = { + ...form, + clientId: form.clientId || undefined, + category: form.category || undefined, + notes: form.notes || undefined, + taxDeductible: form.taxDeductible, + }; if (editId) update.mutate({ id: editId, ...payload }); else create.mutate(payload); }; const totalExpenses = expenses.reduce((s, e) => s + e.amount, 0); - const billableTotal = expenses.filter((e) => e.billable).reduce((s, e) => s + e.amount, 0); - const deductibleTotal = expenses.filter((e) => e.taxDeductible).reduce((s, e) => s + e.amount, 0); + const billableTotal = expenses + .filter((e) => e.billable) + .reduce((s, e) => s + e.amount, 0); + const deductibleTotal = expenses + .filter((e) => e.taxDeductible) + .reduce((s, e) => s + e.amount, 0); return (
- - @@ -121,25 +164,39 @@ export default function ExpensesPage() {
-

Total

-

{formatCurrency(totalExpenses)}

+

+ Total +

+

+ {formatCurrency(totalExpenses)} +

-

Billable

-

{formatCurrency(billableTotal)}

+

+ Billable +

+

+ {formatCurrency(billableTotal)} +

-

Deductible

-

{formatCurrency(deductibleTotal)}

+

+ Deductible +

+

+ {formatCurrency(deductibleTotal)} +

-

Count

+

+ Count +

{expenses.length}

@@ -154,34 +211,84 @@ export default function ExpensesPage() { {isLoading ? ( -
Loading…
+
+ Loading… +
) : expenses.length === 0 ? (
-

No expenses yet. Add your first expense.

+

+ No expenses yet. Add your first expense. +

) : (
{expenses.map((expense) => ( -
+

{expense.description}

- {expense.billable && Billable} - {expense.reimbursable && Reimbursable} - {expense.taxDeductible && Tax Deductible} - {expense.category && {expense.category}} + {expense.billable && ( + + Billable + + )} + {expense.reimbursable && ( + + Reimbursable + + )} + {expense.taxDeductible && ( + + Tax Deductible + + )} + {expense.category && ( + + {expense.category} + + )}

- {new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", year: "numeric" }).format(new Date(expense.date))} + {new Intl.DateTimeFormat("en-US", { + month: "short", + day: "numeric", + year: "numeric", + }).format(new Date(expense.date))} {expense.client ? ` · ${expense.client.name}` : ""}

- {expense.notes &&

{expense.notes}

} + {expense.notes && ( +

+ {expense.notes} +

+ )}
-

{formatCurrency(expense.amount, expense.currency)}

- - +

+ {formatCurrency(expense.amount, expense.currency)} +

+ +
))} @@ -199,70 +306,150 @@ export default function ExpensesPage() {
- setForm((p) => ({ ...p, description: e.target.value }))} placeholder="e.g. Laptop charger" /> + + setForm((p) => ({ ...p, description: e.target.value })) + } + placeholder="e.g. Laptop charger" + />
- setForm((p) => ({ ...p, amount: v }))} min={0} step={0.01} /> + setForm((p) => ({ ...p, amount: v }))} + min={0} + step={0.01} + />
- setForm((p) => ({ ...p, currency: v }))} + > + + + + + {SUPPORTED_CURRENCIES.map((c) => ( + + {c.code} + + ))} +
- setForm((p) => ({ ...p, date: d ?? new Date() }))} className="w-full" /> + + setForm((p) => ({ ...p, date: d ?? new Date() })) + } + className="w-full" + />
- + setForm((p) => ({ ...p, category: v === "none" ? "" : v })) + } + > + + + None - {EXPENSE_CATEGORIES.map((c) => {c})} + {EXPENSE_CATEGORIES.map((c) => ( + + {c} + + ))}
- + setForm((p) => ({ ...p, clientId: v === "none" ? "" : v })) + } + > + + + No client - {clients.map((c) => {c.name})} + {clients.map((c) => ( + + {c.name} + + ))}
- setForm((p) => ({ ...p, notes: e.target.value }))} placeholder="Additional details…" /> + + setForm((p) => ({ ...p, notes: e.target.value })) + } + placeholder="Additional details…" + />
- - + @@ -276,8 +463,14 @@ export default function ExpensesPage() { This action cannot be undone. - - + diff --git a/src/app/dashboard/invoices/[id]/_components/invoice-items-table.tsx b/src/app/dashboard/invoices/[id]/_components/invoice-items-table.tsx index d456954..19e08b4 100644 --- a/src/app/dashboard/invoices/[id]/_components/invoice-items-table.tsx +++ b/src/app/dashboard/invoices/[id]/_components/invoice-items-table.tsx @@ -53,14 +53,13 @@ const columns: ColumnDef[] = [ return ( <> {/* Desktop: plain description */} -
- {item.description} -
+
{item.description}
{/* Mobile: description + date + hours @ rate stacked */}

{item.description}

- {formatDate(item.date)} · {item.hours}h @ {formatCurrency(item.rate)}/hr + {formatDate(item.date)} · {item.hours}h @{" "} + {formatCurrency(item.rate)}/hr

diff --git a/src/app/dashboard/invoices/[id]/page.tsx b/src/app/dashboard/invoices/[id]/page.tsx index 4dbb736..30bb5a1 100644 --- a/src/app/dashboard/invoices/[id]/page.tsx +++ b/src/app/dashboard/invoices/[id]/page.tsx @@ -75,7 +75,7 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) { const handleMarkAsPaid = () => { updateStatus.mutate({ id: invoiceId, - status: "paid" as StoredInvoiceStatus, + status: "paid", }); }; @@ -109,17 +109,15 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) { const subtotal = invoice.items.reduce((sum, item) => sum + item.amount, 0); const taxAmount = (subtotal * invoice.taxRate) / 100; const total = subtotal + taxAmount; + const storedStatus = invoice.status as StoredInvoiceStatus; const effectiveStatus = getEffectiveInvoiceStatus( - invoice.status as StoredInvoiceStatus, - invoice.dueDate, - ); - const isOverdue = isInvoiceOverdue( - invoice.status as StoredInvoiceStatus, + storedStatus, invoice.dueDate, ); + const isOverdue = isInvoiceOverdue(storedStatus, invoice.dueDate); const getStatusType = (): StatusType => { - return effectiveStatus as StatusType; + return effectiveStatus; }; return ( diff --git a/src/app/dashboard/invoices/_components/invoices-data-table.tsx b/src/app/dashboard/invoices/_components/invoices-data-table.tsx index e86f667..838ec03 100644 --- a/src/app/dashboard/invoices/_components/invoices-data-table.tsx +++ b/src/app/dashboard/invoices/_components/invoices-data-table.tsx @@ -86,7 +86,7 @@ const getStatusType = (invoice: Invoice): StatusType => getEffectiveInvoiceStatus( invoice.status as StoredInvoiceStatus, invoice.dueDate, - ) as StatusType; + ); const formatDate = (date: Date) => new Intl.DateTimeFormat("en-US", { diff --git a/src/app/dashboard/invoices/import/page.tsx b/src/app/dashboard/invoices/import/page.tsx index 47963a3..6efcf77 100644 --- a/src/app/dashboard/invoices/import/page.tsx +++ b/src/app/dashboard/invoices/import/page.tsx @@ -29,7 +29,7 @@ function FormatInstructions() { -
+

DATE,DESCRIPTION,HOURS,RATE,AMOUNT

@@ -85,7 +85,7 @@ function FormatInstructions() { for importing time entries.

-
+
@@ -100,7 +100,7 @@ function FormatInstructions() {

Sample Row:

-
+

1/15/24,"Web development work",8,75.00,600.00

@@ -109,7 +109,7 @@ function FormatInstructions() {

Sample Filename:

-
+

2024-01-15.csv

@@ -168,7 +168,7 @@ function FileFormatHelp() {
-
+

CSV Files

@@ -178,7 +178,7 @@ function FileFormatHelp() {

-
+

Max Size

@@ -187,7 +187,7 @@ function FileFormatHelp() {

-
+

Validation

diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index 2fddfe5..5299a1b 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -28,9 +28,9 @@ import type { DashboardStats, RecentInvoice } from "./types"; // Hero section with clean mono design - // Enhanced stats cards with better visuals -function DashboardStats({ stats }: { stats: DashboardStats }) { // TODO: Import RouterOutput type +function DashboardStats({ stats }: { stats: DashboardStats }) { + // TODO: Import RouterOutput type const formatTrend = (value: number, isCount = false) => { if (isCount) { return value > 0 ? `+${value}` : value.toString(); @@ -193,10 +193,11 @@ function QuickActions() {
@@ -310,7 +311,11 @@ async function CurrentWork() { } // Enhanced recent activity -async function RecentActivity({ recentInvoices }: { recentInvoices: RecentInvoice[] }) { +async function RecentActivity({ + recentInvoices, +}: { + recentInvoices: RecentInvoice[]; +}) { // Use passed recentInvoices instead of fetching all const getStatusStyle = (status: string) => { diff --git a/src/app/dashboard/settings/_components/pdf-preview-frame.tsx b/src/app/dashboard/settings/_components/pdf-preview-frame.tsx new file mode 100644 index 0000000..4288209 --- /dev/null +++ b/src/app/dashboard/settings/_components/pdf-preview-frame.tsx @@ -0,0 +1,124 @@ +"use client"; + +import { BlobProvider } from "@react-pdf/renderer"; +import { + InvoicePDF, + type InvoiceData, + type PDFGenerationSettings, +} from "~/lib/pdf-export"; + +const previewInvoice: InvoiceData = { + invoiceNumber: "BV-2026-001", + issueDate: new Date("2026-04-30T12:00:00.000Z"), + dueDate: new Date("2026-05-30T12:00:00.000Z"), + status: "sent", + totalAmount: 3150, + taxRate: 0, + currency: "USD", + notes: "Thank you for the work. Payment is due within 30 days.", + business: { + name: "Sample Studio", + email: "hello@beenvoice.test", + phone: "(555) 014-1024", + addressLine1: "100 Terminal Way", + city: "New York", + state: "NY", + postalCode: "10001", + country: "USA", + website: "beenvoice.test", + }, + client: { + name: "Client Studio", + email: "ap@clientstudio.test", + addressLine1: "42 Market Street", + city: "Brooklyn", + state: "NY", + postalCode: "11201", + country: "USA", + }, + items: [ + { + date: new Date("2026-04-08T12:00:00.000Z"), + description: "Invoice workflow design and implementation", + hours: 12, + rate: 150, + amount: 1800, + }, + { + date: new Date("2026-04-16T12:00:00.000Z"), + description: "Client import cleanup", + hours: 5, + rate: 150, + amount: 750, + }, + { + date: new Date("2026-04-24T12:00:00.000Z"), + description: "Reporting polish", + hours: 4, + rate: 150, + amount: 600, + }, + ], +}; + +export function PdfPreviewFrame({ + settings, + businessName, +}: { + settings: Required; + businessName: string; +}) { + const previewBusinessName = + businessName.trim() !== "" + ? businessName + : (previewInvoice.business?.name ?? "Sample Studio"); + const invoice = { + ...previewInvoice, + business: { + ...previewInvoice.business, + name: previewBusinessName, + }, + }; + + return ( +
+
+ + PDF preview + + + Generated from sample invoice data + +
+ } + > + {({ url, loading, error }) => { + if (loading) { + return ( +
+ Rendering PDF preview... +
+ ); + } + + if (error || !url) { + return ( +
+ PDF preview could not be rendered. +
+ ); + } + + return ( +