diff --git a/.claude/.gitkeep b/.claude/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/.github/workflows/static.yml b/.github/workflows/static.yml
new file mode 100644
index 0000000..460f782
--- /dev/null
+++ b/.github/workflows/static.yml
@@ -0,0 +1,43 @@
+# Simple workflow for deploying static content to GitHub Pages
+name: Deploy static content to Pages
+
+on:
+ # Runs on pushes targeting the default branch
+ push:
+ branches: ["main"]
+
+ # Allows you to run this workflow manually from the Actions tab
+ workflow_dispatch:
+
+# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages
+permissions:
+ contents: read
+ pages: write
+ id-token: write
+
+# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued.
+# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete.
+concurrency:
+ group: "pages"
+ cancel-in-progress: false
+
+jobs:
+ # Single deploy job since we're just deploying
+ deploy:
+ environment:
+ name: github-pages
+ url: ${{ steps.deployment.outputs.page_url }}
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ - name: Setup Pages
+ uses: actions/configure-pages@v5
+ - name: Upload artifact
+ uses: actions/upload-pages-artifact@v3
+ with:
+ # Upload entire repository
+ path: '.'
+ - name: Deploy to GitHub Pages
+ id: deployment
+ uses: actions/deploy-pages@v5
diff --git a/.github/workflows/webpack.yml b/.github/workflows/webpack.yml
new file mode 100644
index 0000000..9626ff6
--- /dev/null
+++ b/.github/workflows/webpack.yml
@@ -0,0 +1,28 @@
+name: NodeJS with Webpack
+
+on:
+ push:
+ branches: [ "main" ]
+ pull_request:
+ branches: [ "main" ]
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+
+ strategy:
+ matrix:
+ node-version: [18.x, 20.x, 22.x]
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Use Node.js ${{ matrix.node-version }}
+ uses: actions/setup-node@v4
+ with:
+ node-version: ${{ matrix.node-version }}
+
+ - name: Build
+ run: |
+ npm install
+ npx webpack
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..2c5764d
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,35 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+
+node_modules
+dist
+dist-ssr
+*.local
+
+# Env / secrets
+.env
+.env.*
+!.env.example
+
+# Build caches
+.vite/
+
+# Local Claude settings
+.claude/settings.local.json
+
+# Editor directories and files
+.vscode/*
+!.vscode/extensions.json
+.idea
+.DS_Store
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
diff --git a/bun.lock b/bun.lock
new file mode 100644
index 0000000..d3c1cf3
--- /dev/null
+++ b/bun.lock
@@ -0,0 +1,1683 @@
+{
+ "lockfileVersion": 1,
+ "configVersion": 1,
+ "workspaces": {
+ "": {
+ "name": "vite_react_shadcn_ts",
+ "dependencies": {
+ "@dnd-kit/core": "^6.3.1",
+ "@dnd-kit/sortable": "^10.0.0",
+ "@dnd-kit/utilities": "^3.2.2",
+ "@hello-pangea/dnd": "^18.0.1",
+ "@hookform/resolvers": "^3.10.0",
+ "@lovable.dev/cloud-auth-js": "^1.1.1",
+ "@radix-ui/react-accordion": "^1.2.11",
+ "@radix-ui/react-alert-dialog": "^1.1.14",
+ "@radix-ui/react-aspect-ratio": "^1.1.7",
+ "@radix-ui/react-avatar": "^1.1.10",
+ "@radix-ui/react-checkbox": "^1.3.2",
+ "@radix-ui/react-collapsible": "^1.1.11",
+ "@radix-ui/react-context-menu": "^2.2.15",
+ "@radix-ui/react-dialog": "^1.1.14",
+ "@radix-ui/react-dropdown-menu": "^2.1.15",
+ "@radix-ui/react-hover-card": "^1.1.14",
+ "@radix-ui/react-label": "^2.1.7",
+ "@radix-ui/react-menubar": "^1.1.15",
+ "@radix-ui/react-navigation-menu": "^1.2.13",
+ "@radix-ui/react-popover": "^1.1.14",
+ "@radix-ui/react-progress": "^1.1.7",
+ "@radix-ui/react-radio-group": "^1.3.7",
+ "@radix-ui/react-scroll-area": "^1.2.9",
+ "@radix-ui/react-select": "^2.2.5",
+ "@radix-ui/react-separator": "^1.1.7",
+ "@radix-ui/react-slider": "^1.3.5",
+ "@radix-ui/react-slot": "^1.2.3",
+ "@radix-ui/react-switch": "^1.2.5",
+ "@radix-ui/react-tabs": "^1.1.12",
+ "@radix-ui/react-toast": "^1.2.14",
+ "@radix-ui/react-toggle": "^1.1.9",
+ "@radix-ui/react-toggle-group": "^1.1.10",
+ "@radix-ui/react-tooltip": "^1.2.7",
+ "@stripe/react-stripe-js": "^5.6.1",
+ "@stripe/stripe-js": "^8.10.0",
+ "@supabase/supabase-js": "^2.99.1",
+ "@tanstack/react-query": "^5.83.0",
+ "@types/google.maps": "^3.58.1",
+ "@types/leaflet": "1.9.12",
+ "@types/leaflet.markercluster": "^1.5.6",
+ "class-variance-authority": "^0.7.1",
+ "clsx": "^2.1.1",
+ "cmdk": "^1.1.1",
+ "date-fns": "^3.6.0",
+ "date-fns-tz": "^3.2.0",
+ "embla-carousel-react": "^8.6.0",
+ "exifr": "^7.1.3",
+ "framer-motion": "^12.36.0",
+ "html-to-text": "9.0.5",
+ "html2canvas": "^1.4.1",
+ "ical.js": "^2.2.1",
+ "input-otp": "^1.4.2",
+ "jspdf": "^4.2.1",
+ "jspdf-autotable": "^5.0.7",
+ "leaflet": "1.9.4",
+ "leaflet.markercluster": "^1.5.3",
+ "lucide-react": "^0.462.0",
+ "mammoth": "^1.12.0",
+ "mapbox-gl": "^3.0.0",
+ "mapbox-gl-leaflet": "^0.0.16",
+ "next-themes": "^0.3.0",
+ "pdf-lib": "^1.17.1",
+ "pdfjs-dist": "4.4.168",
+ "qrcode": "^1.5.4",
+ "react": "^18.3.1",
+ "react-day-picker": "^8.10.1",
+ "react-dom": "^18.3.1",
+ "react-dropzone": "^15.0.0",
+ "react-grid-layout": "^2.2.2",
+ "react-hook-form": "^7.61.1",
+ "react-leaflet": "4.2.1",
+ "react-pdf": "9.1.1",
+ "react-plaid-link": "^4.1.1",
+ "react-quill-new": "^3.8.3",
+ "react-resizable": "^3.1.3",
+ "react-resizable-panels": "^2.1.9",
+ "react-router-dom": "^6.30.1",
+ "recharts": "^2.15.4",
+ "sonner": "^1.7.4",
+ "tailwind-merge": "^2.6.0",
+ "tailwindcss-animate": "^1.0.7",
+ "uuid": "^13.0.0",
+ "vaul": "^0.9.9",
+ "xlsx": "^0.18.5",
+ "zod": "^3.25.76",
+ },
+ "devDependencies": {
+ "@eslint/js": "^9.32.0",
+ "@playwright/test": "^1.57.0",
+ "@tailwindcss/typography": "^0.5.16",
+ "@testing-library/jest-dom": "^6.6.0",
+ "@testing-library/react": "^16.0.0",
+ "@types/node": "^22.16.5",
+ "@types/react": "^18.3.23",
+ "@types/react-dom": "^18.3.7",
+ "@vitejs/plugin-react-swc": "^3.11.0",
+ "autoprefixer": "^10.4.21",
+ "eslint": "^9.32.0",
+ "eslint-plugin-react-hooks": "^5.2.0",
+ "eslint-plugin-react-refresh": "^0.4.20",
+ "globals": "^15.15.0",
+ "jsdom": "^20.0.3",
+ "lovable-tagger": "^1.1.13",
+ "postcss": "^8.5.6",
+ "tailwindcss": "^3.4.17",
+ "typescript": "^5.8.3",
+ "typescript-eslint": "^8.38.0",
+ "vite": "^5.4.19",
+ "vitest": "^3.2.4",
+ },
+ },
+ },
+ "packages": {
+ "@adobe/css-tools": ["@adobe/css-tools@4.4.4", "", {}, "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg=="],
+
+ "@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
+
+ "@babel/runtime": ["@babel/runtime@7.28.6", "", {}, "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA=="],
+
+ "@dnd-kit/accessibility": ["@dnd-kit/accessibility@3.1.1", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw=="],
+
+ "@dnd-kit/core": ["@dnd-kit/core@6.3.1", "", { "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ=="],
+
+ "@dnd-kit/sortable": ["@dnd-kit/sortable@10.0.0", "", { "dependencies": { "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@dnd-kit/core": "^6.3.0", "react": ">=16.8.0" } }, "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg=="],
+
+ "@dnd-kit/utilities": ["@dnd-kit/utilities@3.2.2", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg=="],
+
+ "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.0", "", { "os": "aix", "cpu": "ppc64" }, "sha512-O7vun9Sf8DFjH2UtqK8Ku3LkquL9SZL8OLY1T5NZkA34+wG3OQF7cl4Ql8vdNzM6fzBbYfLaiRLIOZ+2FOCgBQ=="],
+
+ "@esbuild/android-arm": ["@esbuild/android-arm@0.25.0", "", { "os": "android", "cpu": "arm" }, "sha512-PTyWCYYiU0+1eJKmw21lWtC+d08JDZPQ5g+kFyxP0V+es6VPPSUhM6zk8iImp2jbV6GwjX4pap0JFbUQN65X1g=="],
+
+ "@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.0", "", { "os": "android", "cpu": "arm64" }, "sha512-grvv8WncGjDSyUBjN9yHXNt+cq0snxXbDxy5pJtzMKGmmpPxeAmAhWxXI+01lU5rwZomDgD3kJwulEnhTRUd6g=="],
+
+ "@esbuild/android-x64": ["@esbuild/android-x64@0.25.0", "", { "os": "android", "cpu": "x64" }, "sha512-m/ix7SfKG5buCnxasr52+LI78SQ+wgdENi9CqyCXwjVR2X4Jkz+BpC3le3AoBPYTC9NHklwngVXvbJ9/Akhrfg=="],
+
+ "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-mVwdUb5SRkPayVadIOI78K7aAnPamoeFR2bT5nszFUZ9P8UpK4ratOdYbZZXYSqPKMHfS1wdHCJk1P1EZpRdvw=="],
+
+ "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-DgDaYsPWFTS4S3nWpFcMn/33ZZwAAeAFKNHNa1QN0rI4pUjgqf0f7ONmXf6d22tqTY+H9FNdgeaAa+YIFUn2Rg=="],
+
+ "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-VN4ocxy6dxefN1MepBx/iD1dH5K8qNtNe227I0mnTRjry8tj5MRk4zprLEdG8WPyAPb93/e4pSgi1SoHdgOa4w=="],
+
+ "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-mrSgt7lCh07FY+hDD1TxiTyIHyttn6vnjesnPoVDNmDfOmggTLXRv8Id5fNZey1gl/V2dyVK1VXXqVsQIiAk+A=="],
+
+ "@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.0", "", { "os": "linux", "cpu": "arm" }, "sha512-vkB3IYj2IDo3g9xX7HqhPYxVkNQe8qTK55fraQyTzTX/fxaDtXiEnavv9geOsonh2Fd2RMB+i5cbhu2zMNWJwg=="],
+
+ "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-9QAQjTWNDM/Vk2bgBl17yWuZxZNQIF0OUUuPZRKoDtqF2k4EtYbpyiG5/Dk7nqeK6kIJWPYldkOcBqjXjrUlmg=="],
+
+ "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.0", "", { "os": "linux", "cpu": "ia32" }, "sha512-43ET5bHbphBegyeqLb7I1eYn2P/JYGNmzzdidq/w0T8E2SsYL1U6un2NFROFRg1JZLTzdCoRomg8Rvf9M6W6Gg=="],
+
+ "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.0", "", { "os": "linux", "cpu": "none" }, "sha512-fC95c/xyNFueMhClxJmeRIj2yrSMdDfmqJnyOY4ZqsALkDrrKJfIg5NTMSzVBr5YW1jf+l7/cndBfP3MSDpoHw=="],
+
+ "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.0", "", { "os": "linux", "cpu": "none" }, "sha512-nkAMFju7KDW73T1DdH7glcyIptm95a7Le8irTQNO/qtkoyypZAnjchQgooFUDQhNAy4iu08N79W4T4pMBwhPwQ=="],
+
+ "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-NhyOejdhRGS8Iwv+KKR2zTq2PpysF9XqY+Zk77vQHqNbo/PwZCzB5/h7VGuREZm1fixhs4Q/qWRSi5zmAiO4Fw=="],
+
+ "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.0", "", { "os": "linux", "cpu": "none" }, "sha512-5S/rbP5OY+GHLC5qXp1y/Mx//e92L1YDqkiBbO9TQOvuFXM+iDqUNG5XopAnXoRH3FjIUDkeGcY1cgNvnXp/kA=="],
+
+ "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-XM2BFsEBz0Fw37V0zU4CXfcfuACMrppsMFKdYY2WuTS3yi8O1nFOhil/xhKTmE1nPmVyvQJjJivgDT+xh8pXJA=="],
+
+ "@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.0", "", { "os": "linux", "cpu": "x64" }, "sha512-9yl91rHw/cpwMCNytUDxwj2XjFpxML0y9HAOH9pNVQDpQrBxHy01Dx+vaMu0N1CKa/RzBD2hB4u//nfc+Sd3Cw=="],
+
+ "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.0", "", { "os": "none", "cpu": "arm64" }, "sha512-RuG4PSMPFfrkH6UwCAqBzauBWTygTvb1nxWasEJooGSJ/NwRw7b2HOwyRTQIU97Hq37l3npXoZGYMy3b3xYvPw=="],
+
+ "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.0", "", { "os": "none", "cpu": "x64" }, "sha512-jl+qisSB5jk01N5f7sPCsBENCOlPiS/xptD5yxOx2oqQfyourJwIKLRA2yqWdifj3owQZCL2sn6o08dBzZGQzA=="],
+
+ "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.0", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-21sUNbq2r84YE+SJDfaQRvdgznTD8Xc0oc3p3iW/a1EVWeNj/SdUCbm5U0itZPQYRuRTW20fPMWMpcrciH2EJw=="],
+
+ "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-2gwwriSMPcCFRlPlKx3zLQhfN/2WjJ2NSlg5TKLQOJdV0mSxIcYNTMhk3H3ulL/cak+Xj0lY1Ym9ysDV1igceg=="],
+
+ "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.0", "", { "os": "sunos", "cpu": "x64" }, "sha512-bxI7ThgLzPrPz484/S9jLlvUAHYMzy6I0XiU1ZMeAEOBcS0VePBFxh1JjTQt3Xiat5b6Oh4x7UC7IwKQKIJRIg=="],
+
+ "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-ZUAc2YK6JW89xTbXvftxdnYy3m4iHIkDtK3CLce8wg8M2L+YZhIvO1DKpxrd0Yr59AeNNkTiic9YLf6FTtXWMw=="],
+
+ "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-eSNxISBu8XweVEWG31/JzjkIGbGIJN/TrRoiSVZwZ6pkC6VX4Im/WV2cz559/TXLcYbcrDN8JtKgd9DJVIo8GA=="],
+
+ "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.0", "", { "os": "win32", "cpu": "x64" }, "sha512-ZENoHJBxA20C2zFzh6AI4fT6RraMzjYw4xKWemRTRmRVtN9c5DcH9r/f2ihEkMjOW5eGgrwCslG/+Y/3bL+DHQ=="],
+
+ "@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.7.0", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw=="],
+
+ "@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.1", "", {}, "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ=="],
+
+ "@eslint/config-array": ["@eslint/config-array@0.21.0", "", { "dependencies": { "@eslint/object-schema": "^2.1.6", "debug": "^4.3.1", "minimatch": "^3.1.2" } }, "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ=="],
+
+ "@eslint/config-helpers": ["@eslint/config-helpers@0.3.0", "", {}, "sha512-ViuymvFmcJi04qdZeDc2whTHryouGcDlaxPqarTD0ZE10ISpxGUVZGZDx4w01upyIynL3iu6IXH2bS1NhclQMw=="],
+
+ "@eslint/core": ["@eslint/core@0.15.1", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-bkOp+iumZCCbt1K1CmWf0R9pM5yKpDv+ZXtvSyQpudrI9kuFLp+bM2WOPXImuD/ceQuaa8f5pj93Y7zyECIGNA=="],
+
+ "@eslint/eslintrc": ["@eslint/eslintrc@3.3.1", "", { "dependencies": { "ajv": "^6.12.4", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.0", "minimatch": "^3.1.2", "strip-json-comments": "^3.1.1" } }, "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ=="],
+
+ "@eslint/js": ["@eslint/js@9.32.0", "", {}, "sha512-BBpRFZK3eX6uMLKz8WxFOBIFFcGFJ/g8XuwjTHCqHROSIsopI+ddn/d5Cfh36+7+e5edVS8dbSHnBNhrLEX0zg=="],
+
+ "@eslint/object-schema": ["@eslint/object-schema@2.1.6", "", {}, "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA=="],
+
+ "@eslint/plugin-kit": ["@eslint/plugin-kit@0.3.4", "", { "dependencies": { "@eslint/core": "^0.15.1", "levn": "^0.4.1" } }, "sha512-Ul5l+lHEcw3L5+k8POx6r74mxEYKG5kOb6Xpy2gCRW6zweT6TEhAf8vhxGgjhqrd/VO/Dirhsb+1hNpD1ue9hw=="],
+
+ "@floating-ui/core": ["@floating-ui/core@1.7.2", "", { "dependencies": { "@floating-ui/utils": "^0.2.10" } }, "sha512-wNB5ooIKHQc+Kui96jE/n69rHFWAVoxn5CAzL1Xdd8FG03cgY3MLO+GF9U3W737fYDSgPWA6MReKhBQBop6Pcw=="],
+
+ "@floating-ui/dom": ["@floating-ui/dom@1.7.2", "", { "dependencies": { "@floating-ui/core": "^1.7.2", "@floating-ui/utils": "^0.2.10" } }, "sha512-7cfaOQuCS27HD7DX+6ib2OrnW+b4ZBwDNnCcT0uTyidcmyWb03FnQqJybDBoCnpdxwBSfA94UAYlRCt7mV+TbA=="],
+
+ "@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.4", "", { "dependencies": { "@floating-ui/dom": "^1.7.2" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-JbbpPhp38UmXDDAu60RJmbeme37Jbgsm7NrHGgzYYFKmblzRUh6Pa641dII6LsjwF4XlScDrde2UAzDo/b9KPw=="],
+
+ "@floating-ui/utils": ["@floating-ui/utils@0.2.10", "", {}, "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="],
+
+ "@hello-pangea/dnd": ["@hello-pangea/dnd@18.0.1", "", { "dependencies": { "@babel/runtime": "^7.26.7", "css-box-model": "^1.2.1", "raf-schd": "^4.0.3", "react-redux": "^9.2.0", "redux": "^5.0.1" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-xojVWG8s/TGrKT1fC8K2tIWeejJYTAeJuj36zM//yEm/ZrnZUSFGS15BpO+jGZT1ybWvyXmeDJwPYb4dhWlbZQ=="],
+
+ "@hookform/resolvers": ["@hookform/resolvers@3.10.0", "", { "peerDependencies": { "react-hook-form": "^7.0.0" } }, "sha512-79Dv+3mDF7i+2ajj7SkypSKHhl1cbln1OGavqrsF7p6mbUv11xpqpacPsGDCTRvCSjEEIez2ef1NveSVL3b0Ag=="],
+
+ "@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="],
+
+ "@humanfs/node": ["@humanfs/node@0.16.6", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.3.0" } }, "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw=="],
+
+ "@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="],
+
+ "@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="],
+
+ "@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="],
+
+ "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.5", "", { "dependencies": { "@jridgewell/set-array": "^1.2.1", "@jridgewell/sourcemap-codec": "^1.4.10", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg=="],
+
+ "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
+
+ "@jridgewell/set-array": ["@jridgewell/set-array@1.2.1", "", {}, "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A=="],
+
+ "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
+
+ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.25", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ=="],
+
+ "@lovable.dev/cloud-auth-js": ["@lovable.dev/cloud-auth-js@1.1.1", "", {}, "sha512-80elU8dSJG6bho0Xnfj2oy53wp883nYXrG1Wy948LC/ZUaUQ0i9EGXQFmwTLOBFrWqxb6aNaOlZUvQ8BVGhjMQ=="],
+
+ "@mapbox/mapbox-gl-supported": ["@mapbox/mapbox-gl-supported@3.0.0", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/@mapbox/mapbox-gl-supported/-/mapbox-gl-supported-3.0.0.tgz", {}, "sha512-2XghOwu16ZwPJLOFVuIOaLbN0iKMn867evzXFyf0P22dqugezfJwLmdanAgU25ITvz1TvOfVP4jsDImlDJzcWg=="],
+
+ "@mapbox/node-pre-gyp": ["@mapbox/node-pre-gyp@1.0.11", "", { "dependencies": { "detect-libc": "^2.0.0", "https-proxy-agent": "^5.0.0", "make-dir": "^3.1.0", "node-fetch": "^2.6.7", "nopt": "^5.0.0", "npmlog": "^5.0.1", "rimraf": "^3.0.2", "semver": "^7.3.5", "tar": "^6.1.11" }, "bin": { "node-pre-gyp": "bin/node-pre-gyp" } }, "sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ=="],
+
+ "@mapbox/point-geometry": ["@mapbox/point-geometry@1.1.0", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/@mapbox/point-geometry/-/point-geometry-1.1.0.tgz", {}, "sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ=="],
+
+ "@mapbox/tiny-sdf": ["@mapbox/tiny-sdf@2.1.0", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/@mapbox/tiny-sdf/-/tiny-sdf-2.1.0.tgz", {}, "sha512-uFJhNh36BR4OCuWIEiWaEix9CA2WzT6CAIcqVjWYpnx8+QDtS+oC4QehRrx5cX4mgWs37MmKnwUejeHxVymzNg=="],
+
+ "@mapbox/unitbezier": ["@mapbox/unitbezier@0.0.1", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/@mapbox/unitbezier/-/unitbezier-0.0.1.tgz", {}, "sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw=="],
+
+ "@mapbox/vector-tile": ["@mapbox/vector-tile@2.0.4", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/@mapbox/vector-tile/-/vector-tile-2.0.4.tgz", { "dependencies": { "@mapbox/point-geometry": "~1.1.0", "@types/geojson": "^7946.0.16", "pbf": "^4.0.1" } }, "sha512-AkOLcbgGTdXScosBWwmmD7cDlvOjkg/DetGva26pIRiZPdeJYjYKarIlb4uxVzi6bwHO6EWH82eZ5Nuv4T5DUg=="],
+
+ "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
+
+ "@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="],
+
+ "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="],
+
+ "@pdf-lib/standard-fonts": ["@pdf-lib/standard-fonts@1.0.0", "", { "dependencies": { "pako": "^1.0.6" } }, "sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA=="],
+
+ "@pdf-lib/upng": ["@pdf-lib/upng@1.0.1", "", { "dependencies": { "pako": "^1.0.10" } }, "sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ=="],
+
+ "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="],
+
+ "@playwright/test": ["@playwright/test@1.58.2", "", { "dependencies": { "playwright": "1.58.2" }, "bin": { "playwright": "cli.js" } }, "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA=="],
+
+ "@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="],
+
+ "@radix-ui/primitive": ["@radix-ui/primitive@1.1.2", "", {}, "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA=="],
+
+ "@radix-ui/react-accordion": ["@radix-ui/react-accordion@1.2.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-collapsible": "1.1.11", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "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-primitive": "2.1.3", "@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" } }, "sha512-l3W5D54emV2ues7jjeG1xcyN7S3jnK3zE2zHqgn0CmMsy9lNJwmgcrmaxS+7ipw15FAivzKNzH3d5EcGoFKw0A=="],
+
+ "@radix-ui/react-alert-dialog": ["@radix-ui/react-alert-dialog@1.1.14", "", { "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-dialog": "1.1.14", "@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" } }, "sha512-IOZfZ3nPvN6lXpJTBCunFQPRSvK8MDgSc1FB85xnIpUKOw9en0dJj8JmCAxV7BiZdtYlUpmrQjoTFkVYtdoWzQ=="],
+
+ "@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.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" } }, "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w=="],
+
+ "@radix-ui/react-aspect-ratio": ["@radix-ui/react-aspect-ratio@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.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" } }, "sha512-Yq6lvO9HQyPwev1onK1daHCHqXVLzPhSVjmsNjCa2Zcxy2f7uJD2itDtxknv6FzAKCwD1qQkeVDmX/cev13n/g=="],
+
+ "@radix-ui/react-avatar": ["@radix-ui/react-avatar@1.1.10", "", { "dependencies": { "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-is-hydrated": "0.1.0", "@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" } }, "sha512-V8piFfWapM5OmNCXTzVQY+E1rDa53zY+MQ4Y7356v4fFz6vqCyUtIz2rUD44ZEdwg78/jKmMJHj07+C/Z/rcog=="],
+
+ "@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" } }, "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" } }, "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" } }, "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" } }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="],
+
+ "@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
+
+ "@radix-ui/react-context-menu": ["@radix-ui/react-context-menu@2.2.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-menu": "2.1.15", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@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" } }, "sha512-UsQUMjcYTsBjTSXw0P3GO0werEQvUY2plgRQuKoCTtkNr45q1DiL51j4m7gxhABzZ0BadoXNsIbg7F3KwiUBbw=="],
+
+ "@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.14", "", { "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-dismissable-layer": "1.1.10", "@radix-ui/react-focus-guards": "1.1.2", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.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" } }, "sha512-+CpweKjqpzTmwRwcYECQcNYbI8V9VSQt0SNFKeEBLgfucbsLssU6Ppq7wUdNXEGb573bMjFhVjKVll8rmV6zMw=="],
+
+ "@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw=="],
+
+ "@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.10", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "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" } }, "sha512-IM1zzRV4W3HtVgftdQiiOmA0AdJlCtMLe00FXaHwgt3rAnNsIyDqshvkIW3hj/iu5hu8ERP7KIYki6NkqDxAwQ=="],
+
+ "@radix-ui/react-dropdown-menu": ["@radix-ui/react-dropdown-menu@2.1.15", "", { "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-menu": "2.1.15", "@radix-ui/react-primitive": "2.1.3", "@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" } }, "sha512-mIBnOjgwo9AH3FyKaSWoSu/dYj6VdhJ7frEPiGTeXCdUFHjl9h3mFh2wwhEtINOmYXWhdpf1rY2minFsmaNgVQ=="],
+
+ "@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-fyjAACV62oPV925xFCrH8DR5xWhg9KYtJT4s3u54jxp+L/hbpTY2kIeEFFbFe+a/HCE94zGQMZLIpVTPVZDhaA=="],
+
+ "@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "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" } }, "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw=="],
+
+ "@radix-ui/react-hover-card": ["@radix-ui/react-hover-card@1.1.14", "", { "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-dismissable-layer": "1.1.10", "@radix-ui/react-popper": "1.2.7", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.3", "@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" } }, "sha512-CPYZ24Mhirm+g6D8jArmLzjYu4Eyg3TTUHswR26QgzXBHBe64BO/RHOJKzmF/Dxb4y4f9PKyJdwm/O/AhNkb+Q=="],
+
+ "@radix-ui/react-id": ["@radix-ui/react-id@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg=="],
+
+ "@radix-ui/react-label": ["@radix-ui/react-label@2.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.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" } }, "sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ=="],
+
+ "@radix-ui/react-menu": ["@radix-ui/react-menu@2.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.10", "@radix-ui/react-focus-guards": "1.1.2", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.7", "@radix-ui/react-portal": "1.1.9", "@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-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.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" } }, "sha512-tVlmA3Vb9n8SZSd+YSbuFR66l87Wiy4du+YE+0hzKQEANA+7cWKH1WgqcEX4pXqxUFQKrWQGHdvEfw00TjFiew=="],
+
+ "@radix-ui/react-menubar": ["@radix-ui/react-menubar@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "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-menu": "2.1.15", "@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" } }, "sha512-Z71C7LGD+YDYo3TV81paUs8f3Zbmkvg6VLRQpKYfzioOE6n7fOhA3ApK/V/2Odolxjoc4ENk8AYCjohCNayd5A=="],
+
+ "@radix-ui/react-navigation-menu": ["@radix-ui/react-navigation-menu@1.2.13", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.10", "@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-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-visually-hidden": "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" } }, "sha512-WG8wWfDiJlSF5hELjwfjSGOXcBR/ZMhBFCGYe8vERpC39CQYZeq1PQ2kaYHdye3V95d06H89KGMsVCIE4LWo3g=="],
+
+ "@radix-ui/react-popover": ["@radix-ui/react-popover@1.1.14", "", { "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-dismissable-layer": "1.1.10", "@radix-ui/react-focus-guards": "1.1.2", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.7", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.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" } }, "sha512-ODz16+1iIbGUfFEfKx2HTPKizg2MN39uIOV8MXeHnmdd3i/N9Wt7vU46wbHsqA0xoaQyXVcs0KIlBdOA2Y95bw=="],
+
+ "@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.7", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.7", "@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-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-rect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/rect": "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" } }, "sha512-IUFAccz1JyKcf/RjB552PlWwxjeCJB8/4KxT7EhBHOJM+mN7LdW+B3kacJXILm32xawcMMjb2i0cIZpo+f9kiQ=="],
+
+ "@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.9", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@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" } }, "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ=="],
+
+ "@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.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" } }, "sha512-ueDqRbdc4/bkaQT3GIpLQssRlFgWaL/U2z/S31qRwwLWoxHLgry3SIfCwhxeQNbirEUXFa+lq3RL3oBYXtcmIA=="],
+
+ "@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@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" } }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
+
+ "@radix-ui/react-progress": ["@radix-ui/react-progress@1.1.7", "", { "dependencies": { "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.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" } }, "sha512-vPdg/tF6YC/ynuBIJlk1mm7Le0VgW6ub6J2UWnTQ7/D23KXcPI1qy+0vBkgKgd38RCMJavBXpB83HPNFMTb0Fg=="],
+
+ "@radix-ui/react-radio-group": ["@radix-ui/react-radio-group@1.3.7", "", { "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-direction": "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", "@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" } }, "sha512-9w5XhD0KPOrm92OTTE0SysH3sYzHsSTHNvZgUBo/VZ80VdYyB5RneDbc0dKpURS24IxkoFRu/hI0i4XyfFwY6g=="],
+
+ "@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.10", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "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-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@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" } }, "sha512-dT9aOXUen9JSsxnMPv/0VqySQf5eDQ6LCk5Sw28kamz8wSOW2bJdlX2Bg5VUIIcV+6XlHpWTIuTPCf/UNIyq8Q=="],
+
+ "@radix-ui/react-scroll-area": ["@radix-ui/react-scroll-area@1.2.9", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@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" } }, "sha512-YSjEfBXnhUELsO2VzjdtYYD4CfQjvao+lhhrX5XsHD7/cyUNzljF1FHEbgTPN7LH2MClfwRMIsYlqTYpKTTe2A=="],
+
+ "@radix-ui/react-select": ["@radix-ui/react-select@2.2.5", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.2", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.10", "@radix-ui/react-focus-guards": "1.1.2", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.7", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.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" } }, "sha512-HnMTdXEVuuyzx63ME0ut4+sEMYW6oouHWNGUZc7ddvUWIcfCva/AMoqEW/3wnEllriMWBa0RHspCYnfCWJQYmA=="],
+
+ "@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.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" } }, "sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA=="],
+
+ "@radix-ui/react-slider": ["@radix-ui/react-slider@1.3.5", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.2", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@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", "@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" } }, "sha512-rkfe2pU2NBAYfGaxa3Mqosi7VZEWX5CxKaanRv0vZd4Zhl9fvQrg0VM93dv3xGLGfrHuoTRF3JXH8nb9g+B3fw=="],
+
+ "@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" } }, "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" } }, "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" } }, "sha512-GTVAlRVrQrSw3cEARM0nAx73ixrWDPNZAruETn3oHCNP6SbZ/hNxdxp+u7VkIEv3/sFoLq1PfcHrl7Pnp0CDpw=="],
+
+ "@radix-ui/react-toast": ["@radix-ui/react-toast@1.2.14", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.10", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-visually-hidden": "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" } }, "sha512-nAP5FBxBJGQ/YfUB+r+O6USFVkWq3gAInkxyEnmvEV5jtSbfDhfa4hwX8CraCnbjMLsE7XSf/K75l9xXY7joWg=="],
+
+ "@radix-ui/react-toggle": ["@radix-ui/react-toggle@1.1.9", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@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" } }, "sha512-ZoFkBBz9zv9GWer7wIjvdRxmh2wyc2oKWw6C6CseWd6/yq1DK/l5lJ+wnsmFwJZbBYqr02mrf8A2q/CVCuM3ZA=="],
+
+ "@radix-ui/react-toggle-group": ["@radix-ui/react-toggle-group@1.1.10", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.10", "@radix-ui/react-toggle": "1.1.9", "@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" } }, "sha512-kiU694Km3WFLTC75DdqgM/3Jauf3rD9wxeS9XtyWFKsBUeZA337lC+6uUazT7I1DhanZ5gyD5Stf8uf2dbQxOQ=="],
+
+ "@radix-ui/react-tooltip": ["@radix-ui/react-tooltip@1.2.7", "", { "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-dismissable-layer": "1.1.10", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.7", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-visually-hidden": "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" } }, "sha512-Ap+fNYwKTYJ9pzqW+Xe2HtMRbQ/EeWkj2qykZ6SuEV4iS/o1bZI5ssJbk4D2r8XuDuOBVz/tIx2JObtuqU+5Zw=="],
+
+ "@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" } }, "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg=="],
+
+ "@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.2.2", "", { "dependencies": { "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg=="],
+
+ "@radix-ui/react-use-effect-event": ["@radix-ui/react-use-effect-event@0.0.2", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA=="],
+
+ "@radix-ui/react-use-escape-keydown": ["@radix-ui/react-use-escape-keydown@1.1.1", "", { "dependencies": { "@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" } }, "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g=="],
+
+ "@radix-ui/react-use-is-hydrated": ["@radix-ui/react-use-is-hydrated@0.1.0", "", { "dependencies": { "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-U+UORVEq+cTnRIaostJv9AGdV3G6Y+zbVd+12e18jQ5A3c0xL03IhnHuiU4UV69wolOQp5GfR58NW/EgdQhwOA=="],
+
+ "@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="],
+
+ "@radix-ui/react-use-previous": ["@radix-ui/react-use-previous@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ=="],
+
+ "@radix-ui/react-use-rect": ["@radix-ui/react-use-rect@1.1.1", "", { "dependencies": { "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w=="],
+
+ "@radix-ui/react-use-size": ["@radix-ui/react-use-size@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ=="],
+
+ "@radix-ui/react-visually-hidden": ["@radix-ui/react-visually-hidden@1.2.3", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.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" } }, "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug=="],
+
+ "@radix-ui/rect": ["@radix-ui/rect@1.1.1", "", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="],
+
+ "@react-leaflet/core": ["@react-leaflet/core@2.1.0", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/@react-leaflet/core/-/core-2.1.0.tgz", { "peerDependencies": { "leaflet": "^1.9.0", "react": "^18.0.0", "react-dom": "^18.0.0" } }, "sha512-Qk7Pfu8BSarKGqILj4x7bCSZ1pjuAPZ+qmRwH5S7mDS91VSbVVsJSrW4qA+GPrro8t69gFYVMWb1Zc4yFmPiVg=="],
+
+ "@remix-run/router": ["@remix-run/router@1.23.0", "", {}, "sha512-O3rHJzAQKamUz1fvE0Qaw0xSFqsA/yafi2iqeE0pvdFtCO1viYx8QL6f3Ln/aCCTLxs68SLf0KPM9eSeM8yBnA=="],
+
+ "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="],
+
+ "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.24.0", "", { "os": "android", "cpu": "arm" }, "sha512-Q6HJd7Y6xdB48x8ZNVDOqsbh2uByBhgK8PiQgPhwkIw/HC/YX5Ghq2mQY5sRMZWHb3VsFkWooUVOZHKr7DmDIA=="],
+
+ "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.24.0", "", { "os": "android", "cpu": "arm64" }, "sha512-ijLnS1qFId8xhKjT81uBHuuJp2lU4x2yxa4ctFPtG+MqEE6+C5f/+X/bStmxapgmwLwiL3ih122xv8kVARNAZA=="],
+
+ "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.24.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-bIv+X9xeSs1XCk6DVvkO+S/z8/2AMt/2lMqdQbMrmVpgFvXlmde9mLcbQpztXm1tajC3raFDqegsH18HQPMYtA=="],
+
+ "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.24.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-X6/nOwoFN7RT2svEQWUsW/5C/fYMBe4fnLK9DQk4SX4mgVBiTA9h64kjUYPvGQ0F/9xwJ5U5UfTbl6BEjaQdBQ=="],
+
+ "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.24.0", "", { "os": "linux", "cpu": "arm" }, "sha512-0KXvIJQMOImLCVCz9uvvdPgfyWo93aHHp8ui3FrtOP57svqrF/roSSR5pjqL2hcMp0ljeGlU4q9o/rQaAQ3AYA=="],
+
+ "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.24.0", "", { "os": "linux", "cpu": "arm" }, "sha512-it2BW6kKFVh8xk/BnHfakEeoLPv8STIISekpoF+nBgWM4d55CZKc7T4Dx1pEbTnYm/xEKMgy1MNtYuoA8RFIWw=="],
+
+ "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.24.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-i0xTLXjqap2eRfulFVlSnM5dEbTVque/3Pi4g2y7cxrs7+a9De42z4XxKLYJ7+OhE3IgxvfQM7vQc43bwTgPwA=="],
+
+ "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.24.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-9E6MKUJhDuDh604Qco5yP/3qn3y7SLXYuiC0Rpr89aMScS2UAmK1wHP2b7KAa1nSjWJc/f/Lc0Wl1L47qjiyQw=="],
+
+ "@rollup/rollup-linux-powerpc64le-gnu": ["@rollup/rollup-linux-powerpc64le-gnu@4.24.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-2XFFPJ2XMEiF5Zi2EBf4h73oR1V/lycirxZxHZNc93SqDN/IWhYYSYj8I9381ikUFXZrz2v7r2tOVk2NBwxrWw=="],
+
+ "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.24.0", "", { "os": "linux", "cpu": "none" }, "sha512-M3Dg4hlwuntUCdzU7KjYqbbd+BLq3JMAOhCKdBE3TcMGMZbKkDdJ5ivNdehOssMCIokNHFOsv7DO4rlEOfyKpg=="],
+
+ "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.24.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-mjBaoo4ocxJppTorZVKWFpy1bfFj9FeCMJqzlMQGjpNPY9JwQi7OuS1axzNIk0nMX6jSgy6ZURDZ2w0QW6D56g=="],
+
+ "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.24.0", "", { "os": "linux", "cpu": "x64" }, "sha512-ZXFk7M72R0YYFN5q13niV0B7G8/5dcQ9JDp8keJSfr3GoZeXEoMHP/HlvqROA3OMbMdfr19IjCeNAnPUG93b6A=="],
+
+ "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.24.0", "", { "os": "linux", "cpu": "x64" }, "sha512-w1i+L7kAXZNdYl+vFvzSZy8Y1arS7vMgIy8wusXJzRrPyof5LAb02KGr1PD2EkRcl73kHulIID0M501lN+vobQ=="],
+
+ "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.24.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-VXBrnPWgBpVDCVY6XF3LEW0pOU51KbaHhccHw6AS6vBWIC60eqsH19DAeeObl+g8nKAz04QFdl/Cefta0xQtUQ=="],
+
+ "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.24.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-xrNcGDU0OxVcPTH/8n/ShH4UevZxKIO6HJFK0e15XItZP2UcaiLFd5kiX7hJnqCbSztUF8Qot+JWBC/QXRPYWQ=="],
+
+ "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.24.0", "", { "os": "win32", "cpu": "x64" }, "sha512-fbMkAF7fufku0N2dE5TBXcNlg0pt0cJue4xBRE2Qc5Vqikxr4VCgKj/ht6SMdFcOacVA9rqF70APJ8RN/4vMJw=="],
+
+ "@selderee/plugin-htmlparser2": ["@selderee/plugin-htmlparser2@0.11.0", "", { "dependencies": { "domhandler": "^5.0.3", "selderee": "^0.11.0" } }, "sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ=="],
+
+ "@stripe/react-stripe-js": ["@stripe/react-stripe-js@5.6.1", "", { "dependencies": { "prop-types": "^15.7.2" }, "peerDependencies": { "@stripe/stripe-js": ">=8.0.0 <9.0.0", "react": ">=16.8.0 <20.0.0", "react-dom": ">=16.8.0 <20.0.0" } }, "sha512-5xBrjkGmFvKvpMod6VvpOaFaa67eRbmieKeFTePZyOr/sUXzm7A3YY91l330pS0usUst5PxTZDUZHWfOc0v1GA=="],
+
+ "@stripe/stripe-js": ["@stripe/stripe-js@8.11.0", "", {}, "sha512-3fVF4z3efsgwgyj64nFK+6F4/vMw0mUXD2TBbOfftJtKVNx4JNv3CSfe1fY4DCtCk0JFp8/YPNcRkzgV0HJ8cg=="],
+
+ "@supabase/auth-js": ["@supabase/auth-js@2.99.2", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-uRGNXMKEw4VhwouNW7N0XDAGqJP9redHNDmWi17dTrcO1lvFfyRiXsqqfgnVC8aqtRn8kLkLPEzHjiRWsni+oQ=="],
+
+ "@supabase/functions-js": ["@supabase/functions-js@2.99.2", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-xuXQARvjdfB1UPK1yUceZ5EGjOLkVz4rBAaloS9foXiAuseWEdgWBCxkIAFRxGBLGX8Uzo8kseq90jhPb+07Vg=="],
+
+ "@supabase/postgrest-js": ["@supabase/postgrest-js@2.99.2", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-ueiOVkbkTQ7RskwVmjR8zxWYw3VKOMxo1+qep+Dx/SgApqyhWBGd92waQb45tbLc7ydB5x8El8utXOLQTuTojQ=="],
+
+ "@supabase/realtime-js": ["@supabase/realtime-js@2.99.2", "", { "dependencies": { "@types/phoenix": "^1.6.6", "@types/ws": "^8.18.1", "tslib": "2.8.1", "ws": "^8.18.2" } }, "sha512-J6Jm9601dkpZf3+EJ48ki2pM4sFtCNm/BI0l8iEnrczgg+JSEQkMoOW5VSpM54t0pNs69bsz5PTmYJahDZKiIQ=="],
+
+ "@supabase/storage-js": ["@supabase/storage-js@2.99.2", "", { "dependencies": { "iceberg-js": "^0.8.1", "tslib": "2.8.1" } }, "sha512-V/FF8kX8JGSefsVCG1spCLSrHdNR/JFeUMn1jS9KG/Eizjx+evtdKQKLJXFgIylY/bKTXKhc2SYDPIGrIhzsug=="],
+
+ "@supabase/supabase-js": ["@supabase/supabase-js@2.99.2", "", { "dependencies": { "@supabase/auth-js": "2.99.2", "@supabase/functions-js": "2.99.2", "@supabase/postgrest-js": "2.99.2", "@supabase/realtime-js": "2.99.2", "@supabase/storage-js": "2.99.2" } }, "sha512-179rn5wq0wBAqqGwAwR7TUGg2NOaP+fkd5FCVbYJXby85fsRNPFoNJN8YRBepqX2tN7JJcnTjqaAMXuNjiyisA=="],
+
+ "@swc/core": ["@swc/core@1.13.2", "", { "dependencies": { "@swc/counter": "^0.1.3", "@swc/types": "^0.1.23" }, "optionalDependencies": { "@swc/core-darwin-arm64": "1.13.2", "@swc/core-darwin-x64": "1.13.2", "@swc/core-linux-arm-gnueabihf": "1.13.2", "@swc/core-linux-arm64-gnu": "1.13.2", "@swc/core-linux-arm64-musl": "1.13.2", "@swc/core-linux-x64-gnu": "1.13.2", "@swc/core-linux-x64-musl": "1.13.2", "@swc/core-win32-arm64-msvc": "1.13.2", "@swc/core-win32-ia32-msvc": "1.13.2", "@swc/core-win32-x64-msvc": "1.13.2" }, "peerDependencies": { "@swc/helpers": ">=0.5.17" }, "optionalPeers": ["@swc/helpers"] }, "sha512-YWqn+0IKXDhqVLKoac4v2tV6hJqB/wOh8/Br8zjqeqBkKa77Qb0Kw2i7LOFzjFNZbZaPH6AlMGlBwNrxaauaAg=="],
+
+ "@swc/core-darwin-arm64": ["@swc/core-darwin-arm64@1.13.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-44p7ivuLSGFJ15Vly4ivLJjg3ARo4879LtEBAabcHhSZygpmkP8eyjyWxrH3OxkY1eRZSIJe8yRZPFw4kPXFPw=="],
+
+ "@swc/core-darwin-x64": ["@swc/core-darwin-x64@1.13.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-Lb9EZi7X2XDAVmuUlBm2UvVAgSCbD3qKqDCxSI4jEOddzVOpNCnyZ/xEampdngUIyDDhhJLYU9duC+Mcsv5Y+A=="],
+
+ "@swc/core-linux-arm-gnueabihf": ["@swc/core-linux-arm-gnueabihf@1.13.2", "", { "os": "linux", "cpu": "arm" }, "sha512-9TDe/92ee1x57x+0OqL1huG4BeljVx0nWW4QOOxp8CCK67Rpc/HHl2wciJ0Kl9Dxf2NvpNtkPvqj9+BUmM9WVA=="],
+
+ "@swc/core-linux-arm64-gnu": ["@swc/core-linux-arm64-gnu@1.13.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-KJUSl56DBk7AWMAIEcU83zl5mg3vlQYhLELhjwRFkGFMvghQvdqQ3zFOYa4TexKA7noBZa3C8fb24rI5sw9Exg=="],
+
+ "@swc/core-linux-arm64-musl": ["@swc/core-linux-arm64-musl@1.13.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-teU27iG1oyWpNh9CzcGQ48ClDRt/RCem7mYO7ehd2FY102UeTws2+OzLESS1TS1tEZipq/5xwx3FzbVgiolCiQ=="],
+
+ "@swc/core-linux-x64-gnu": ["@swc/core-linux-x64-gnu@1.13.2", "", { "os": "linux", "cpu": "x64" }, "sha512-dRPsyPyqpLD0HMRCRpYALIh4kdOir8pPg4AhNQZLehKowigRd30RcLXGNVZcc31Ua8CiPI4QSgjOIxK+EQe4LQ=="],
+
+ "@swc/core-linux-x64-musl": ["@swc/core-linux-x64-musl@1.13.2", "", { "os": "linux", "cpu": "x64" }, "sha512-CCxETW+KkYEQDqz1SYC15YIWYheqFC+PJVOW76Maa/8yu8Biw+HTAcblKf2isrlUtK8RvrQN94v3UXkC2NzCEw=="],
+
+ "@swc/core-win32-arm64-msvc": ["@swc/core-win32-arm64-msvc@1.13.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-Wv/QTA6PjyRLlmKcN6AmSI4jwSMRl0VTLGs57PHTqYRwwfwd7y4s2fIPJVBNbAlXd795dOEP6d/bGSQSyhOX3A=="],
+
+ "@swc/core-win32-ia32-msvc": ["@swc/core-win32-ia32-msvc@1.13.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-PuCdtNynEkUNbUXX/wsyUC+t4mamIU5y00lT5vJcAvco3/r16Iaxl5UCzhXYaWZSNVZMzPp9qN8NlSL8M5pPxw=="],
+
+ "@swc/core-win32-x64-msvc": ["@swc/core-win32-x64-msvc@1.13.2", "", { "os": "win32", "cpu": "x64" }, "sha512-qlmMkFZJus8cYuBURx1a3YAG2G7IW44i+FEYV5/32ylKkzGNAr9tDJSA53XNnNXkAB5EXSPsOz7bn5C3JlEtdQ=="],
+
+ "@swc/counter": ["@swc/counter@0.1.3", "", {}, "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ=="],
+
+ "@swc/types": ["@swc/types@0.1.23", "", { "dependencies": { "@swc/counter": "^0.1.3" } }, "sha512-u1iIVZV9Q0jxY+yM2vw/hZGDNudsN85bBpTqzAQ9rzkxW9D+e3aEM4Han+ow518gSewkXgjmEK0BD79ZcNVgPw=="],
+
+ "@tailwindcss/typography": ["@tailwindcss/typography@0.5.16", "", { "dependencies": { "lodash.castarray": "^4.4.0", "lodash.isplainobject": "^4.0.6", "lodash.merge": "^4.6.2", "postcss-selector-parser": "6.0.10" }, "peerDependencies": { "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" } }, "sha512-0wDLwCVF5V3x3b1SGXPCDcdsbDHMBe+lkFzBRaHeLvNi+nrrnZ1lA18u+OTWO8iSWU2GxUOCvlXtDuqftc1oiA=="],
+
+ "@tanstack/query-core": ["@tanstack/query-core@5.83.0", "", {}, "sha512-0M8dA+amXUkyz5cVUm/B+zSk3xkQAcuXuz5/Q/LveT4ots2rBpPTZOzd7yJa2Utsf8D2Upl5KyjhHRY+9lB/XA=="],
+
+ "@tanstack/react-query": ["@tanstack/react-query@5.83.0", "", { "dependencies": { "@tanstack/query-core": "5.83.0" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-/XGYhZ3foc5H0VM2jLSD/NyBRIOK4q9kfeml4+0x2DlL6xVuAcVEW+hTlTapAmejObg0i3eNqhkr2dT+eciwoQ=="],
+
+ "@testing-library/jest-dom": ["@testing-library/jest-dom@6.9.1", "", { "dependencies": { "@adobe/css-tools": "^4.4.0", "aria-query": "^5.0.0", "css.escape": "^1.5.1", "dom-accessibility-api": "^0.6.3", "picocolors": "^1.1.1", "redent": "^3.0.0" } }, "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA=="],
+
+ "@testing-library/react": ["@testing-library/react@16.3.2", "", { "dependencies": { "@babel/runtime": "^7.12.5" }, "peerDependencies": { "@types/react": "^18.0.0 || ^19.0.0", "@types/react-dom": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g=="],
+
+ "@tootallnate/once": ["@tootallnate/once@2.0.0", "", {}, "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A=="],
+
+ "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="],
+
+ "@types/d3-array": ["@types/d3-array@3.2.1", "", {}, "sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg=="],
+
+ "@types/d3-color": ["@types/d3-color@3.1.3", "", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="],
+
+ "@types/d3-ease": ["@types/d3-ease@3.0.2", "", {}, "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="],
+
+ "@types/d3-interpolate": ["@types/d3-interpolate@3.0.4", "", { "dependencies": { "@types/d3-color": "*" } }, "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA=="],
+
+ "@types/d3-path": ["@types/d3-path@3.1.0", "", {}, "sha512-P2dlU/q51fkOc/Gfl3Ul9kicV7l+ra934qBFXCFhrZMOL6du1TM0pm1ThYvENukyOn5h9v+yMJ9Fn5JK4QozrQ=="],
+
+ "@types/d3-scale": ["@types/d3-scale@4.0.8", "", { "dependencies": { "@types/d3-time": "*" } }, "sha512-gkK1VVTr5iNiYJ7vWDI+yUFFlszhNMtVeneJ6lUTKPjprsvLLI9/tgEGiXJOnlINJA8FyA88gfnQsHbybVZrYQ=="],
+
+ "@types/d3-shape": ["@types/d3-shape@3.1.6", "", { "dependencies": { "@types/d3-path": "*" } }, "sha512-5KKk5aKGu2I+O6SONMYSNflgiP0WfZIQvVUMan50wHsLG1G94JlxEVnCpQARfTtzytuY0p/9PXXZb3I7giofIA=="],
+
+ "@types/d3-time": ["@types/d3-time@3.0.3", "", {}, "sha512-2p6olUZ4w3s+07q3Tm2dbiMZy5pCDfYwtLXXHUnVzXgQlZ/OyPtUz6OL382BkOuGlLXqfT+wqv8Fw2v8/0geBw=="],
+
+ "@types/d3-timer": ["@types/d3-timer@3.0.2", "", {}, "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="],
+
+ "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="],
+
+ "@types/estree": ["@types/estree@1.0.6", "", {}, "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw=="],
+
+ "@types/geojson": ["@types/geojson@7946.0.16", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/@types/geojson/-/geojson-7946.0.16.tgz", {}, "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg=="],
+
+ "@types/geojson-vt": ["@types/geojson-vt@3.2.5", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/@types/geojson-vt/-/geojson-vt-3.2.5.tgz", { "dependencies": { "@types/geojson": "*" } }, "sha512-qDO7wqtprzlpe8FfQ//ClPV9xiuoh2nkIgiouIptON9w5jvD/fA4szvP9GBlDVdJ5dldAl0kX/sy3URbWwLx0g=="],
+
+ "@types/google.maps": ["@types/google.maps@3.58.1", "", {}, "sha512-X9QTSvGJ0nCfMzYOnaVs/k6/4L+7F5uCS+4iUmkLEls6J9S/Phv+m/i3mDeyc49ZBgwab3EFO1HEoBY7k98EGQ=="],
+
+ "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
+
+ "@types/leaflet": ["@types/leaflet@1.9.12", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/@types/leaflet/-/leaflet-1.9.12.tgz", { "dependencies": { "@types/geojson": "*" } }, "sha512-BK7XS+NyRI291HIo0HCfE18Lp8oA30H1gpi1tf0mF3TgiCEzanQjOqNZ4x126SXzzi2oNSZhZ5axJp1k0iM6jg=="],
+
+ "@types/leaflet.markercluster": ["@types/leaflet.markercluster@1.5.6", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/@types/leaflet.markercluster/-/leaflet.markercluster-1.5.6.tgz", { "dependencies": { "@types/leaflet": "^1.9" } }, "sha512-I7hZjO2+isVXGYWzKxBp8PsCzAYCJBc29qBdFpquOCkS7zFDqUsUvkEOyQHedsk/Cy5tocQzf+Ndorm5W9YKTQ=="],
+
+ "@types/node": ["@types/node@22.16.5", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-bJFoMATwIGaxxx8VJPeM8TonI8t579oRvgAuT8zFugJsJZgzqv0Fu8Mhp68iecjzG7cnN3mO2dJQ5uUM2EFrgQ=="],
+
+ "@types/pako": ["@types/pako@2.0.4", "", {}, "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw=="],
+
+ "@types/pbf": ["@types/pbf@3.0.5", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/@types/pbf/-/pbf-3.0.5.tgz", {}, "sha512-j3pOPiEcWZ34R6a6mN07mUkM4o4Lwf6hPNt8eilOeZhTFbxFXmKhvXl9Y28jotFPaI1bpPDJsbCprUoNke6OrA=="],
+
+ "@types/phoenix": ["@types/phoenix@1.6.7", "", {}, "sha512-oN9ive//QSBkf19rfDv45M7eZPi0eEXylht2OLEXicu5b4KoQ1OzXIw+xDSGWxSxe1JmepRR/ZH283vsu518/Q=="],
+
+ "@types/prop-types": ["@types/prop-types@15.7.13", "", {}, "sha512-hCZTSvwbzWGvhqxp/RqVqwU999pBf2vp7hzIjiYOsl8wqOmUxkQ6ddw1cV3l8811+kdUFus/q4d1Y3E3SyEifA=="],
+
+ "@types/raf": ["@types/raf@3.4.3", "", {}, "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw=="],
+
+ "@types/react": ["@types/react@18.3.23", "", { "dependencies": { "@types/prop-types": "*", "csstype": "^3.0.2" } }, "sha512-/LDXMQh55EzZQ0uVAZmKKhfENivEvWz6E+EYzh+/MCjMhNsotd+ZHhBGIjFDTi6+fz0OhQQQLbTgdQIxxCsC0w=="],
+
+ "@types/react-dom": ["@types/react-dom@18.3.7", "", { "peerDependencies": { "@types/react": "^18.0.0" } }, "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ=="],
+
+ "@types/supercluster": ["@types/supercluster@7.1.3", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/@types/supercluster/-/supercluster-7.1.3.tgz", { "dependencies": { "@types/geojson": "*" } }, "sha512-Z0pOY34GDFl3Q6hUFYf3HkTwKEE02e7QgtJppBt+beEAxnyOpJua+voGFvxINBHa06GwLFFym7gRPY2SiKIfIA=="],
+
+ "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
+
+ "@types/use-sync-external-store": ["@types/use-sync-external-store@0.0.6", "", {}, "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="],
+
+ "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
+
+ "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.38.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.38.0", "@typescript-eslint/type-utils": "8.38.0", "@typescript-eslint/utils": "8.38.0", "@typescript-eslint/visitor-keys": "8.38.0", "graphemer": "^1.4.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.38.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-CPoznzpuAnIOl4nhj4tRr4gIPj5AfKgkiJmGQDaq+fQnRJTYlcBjbX3wbciGmpoPf8DREufuPRe1tNMZnGdanA=="],
+
+ "@typescript-eslint/parser": ["@typescript-eslint/parser@8.38.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.38.0", "@typescript-eslint/types": "8.38.0", "@typescript-eslint/typescript-estree": "8.38.0", "@typescript-eslint/visitor-keys": "8.38.0", "debug": "^4.3.4" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-Zhy8HCvBUEfBECzIl1PKqF4p11+d0aUJS1GeUiuqK9WmOug8YCmC4h4bjyBvMyAMI9sbRczmrYL5lKg/YMbrcQ=="],
+
+ "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.38.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.38.0", "@typescript-eslint/types": "^8.38.0", "debug": "^4.3.4" }, "peerDependencies": { "typescript": ">=4.8.4 <5.9.0" } }, "sha512-dbK7Jvqcb8c9QfH01YB6pORpqX1mn5gDZc9n63Ak/+jD67oWXn3Gs0M6vddAN+eDXBCS5EmNWzbSxsn9SzFWWg=="],
+
+ "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.38.0", "", { "dependencies": { "@typescript-eslint/types": "8.38.0", "@typescript-eslint/visitor-keys": "8.38.0" } }, "sha512-WJw3AVlFFcdT9Ri1xs/lg8LwDqgekWXWhH3iAF+1ZM+QPd7oxQ6jvtW/JPwzAScxitILUIFs0/AnQ/UWHzbATQ=="],
+
+ "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.38.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <5.9.0" } }, "sha512-Lum9RtSE3EroKk/bYns+sPOodqb2Fv50XOl/gMviMKNvanETUuUcC9ObRbzrJ4VSd2JalPqgSAavwrPiPvnAiQ=="],
+
+ "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.38.0", "", { "dependencies": { "@typescript-eslint/types": "8.38.0", "@typescript-eslint/typescript-estree": "8.38.0", "@typescript-eslint/utils": "8.38.0", "debug": "^4.3.4", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-c7jAvGEZVf0ao2z+nnz8BUaHZD09Agbh+DY7qvBQqLiz8uJzRgVPj5YvOh8I8uEiH8oIUGIfHzMwUcGVco/SJg=="],
+
+ "@typescript-eslint/types": ["@typescript-eslint/types@8.38.0", "", {}, "sha512-wzkUfX3plUqij4YwWaJyqhiPE5UCRVlFpKn1oCRn2O1bJ592XxWJj8ROQ3JD5MYXLORW84063z3tZTb/cs4Tyw=="],
+
+ "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.38.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.38.0", "@typescript-eslint/tsconfig-utils": "8.38.0", "@typescript-eslint/types": "8.38.0", "@typescript-eslint/visitor-keys": "8.38.0", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", "minimatch": "^9.0.4", "semver": "^7.6.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "typescript": ">=4.8.4 <5.9.0" } }, "sha512-fooELKcAKzxux6fA6pxOflpNS0jc+nOQEEOipXFNjSlBS6fqrJOVY/whSn70SScHrcJ2LDsxWrneFoWYSVfqhQ=="],
+
+ "@typescript-eslint/utils": ["@typescript-eslint/utils@8.38.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", "@typescript-eslint/scope-manager": "8.38.0", "@typescript-eslint/types": "8.38.0", "@typescript-eslint/typescript-estree": "8.38.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-hHcMA86Hgt+ijJlrD8fX0j1j8w4C92zue/8LOPAFioIno+W0+L7KqE8QZKCcPGc/92Vs9x36w/4MPTJhqXdyvg=="],
+
+ "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.38.0", "", { "dependencies": { "@typescript-eslint/types": "8.38.0", "eslint-visitor-keys": "^4.2.1" } }, "sha512-pWrTcoFNWuwHlA9CvlfSsGWs14JxfN1TH25zM5L7o0pRLhsoZkDnTsXfQRJBEWJoV5DL0jf+Z+sxiud+K0mq1g=="],
+
+ "@vitejs/plugin-react-swc": ["@vitejs/plugin-react-swc@3.11.0", "", { "dependencies": { "@rolldown/pluginutils": "1.0.0-beta.27", "@swc/core": "^1.12.11" }, "peerDependencies": { "vite": "^4 || ^5 || ^6 || ^7" } }, "sha512-YTJCGFdNMHCMfjODYtxRNVAYmTWQ1Lb8PulP/2/f/oEEtglw8oKxKIZmmRkyXrVrHfsKOaVkAc3NT9/dMutO5w=="],
+
+ "@vitest/expect": ["@vitest/expect@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig=="],
+
+ "@vitest/mocker": ["@vitest/mocker@3.2.4", "", { "dependencies": { "@vitest/spy": "3.2.4", "estree-walker": "^3.0.3", "magic-string": "^0.30.17" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "optionalPeers": ["msw"] }, "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ=="],
+
+ "@vitest/pretty-format": ["@vitest/pretty-format@3.2.4", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA=="],
+
+ "@vitest/runner": ["@vitest/runner@3.2.4", "", { "dependencies": { "@vitest/utils": "3.2.4", "pathe": "^2.0.3", "strip-literal": "^3.0.0" } }, "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ=="],
+
+ "@vitest/snapshot": ["@vitest/snapshot@3.2.4", "", { "dependencies": { "@vitest/pretty-format": "3.2.4", "magic-string": "^0.30.17", "pathe": "^2.0.3" } }, "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ=="],
+
+ "@vitest/spy": ["@vitest/spy@3.2.4", "", { "dependencies": { "tinyspy": "^4.0.3" } }, "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw=="],
+
+ "@vitest/utils": ["@vitest/utils@3.2.4", "", { "dependencies": { "@vitest/pretty-format": "3.2.4", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" } }, "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA=="],
+
+ "@xmldom/xmldom": ["@xmldom/xmldom@0.8.12", "", {}, "sha512-9k/gHF6n/pAi/9tqr3m3aqkuiNosYTurLLUtc7xQ9sxB/wm7WPygCv8GYa6mS0fLJEHhqMC1ATYhz++U/lRHqg=="],
+
+ "abab": ["abab@2.0.6", "", {}, "sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA=="],
+
+ "abbrev": ["abbrev@1.1.1", "", {}, "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q=="],
+
+ "acorn": ["acorn@8.15.0", "", { "bin": "bin/acorn" }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
+
+ "acorn-globals": ["acorn-globals@7.0.1", "", { "dependencies": { "acorn": "^8.1.0", "acorn-walk": "^8.0.2" } }, "sha512-umOSDSDrfHbTNPuNpC2NSnnA3LUrqpevPb4T9jRx4MagXNS0rs+gwiTcAvqCRmsD6utzsrzNt+ebm00SNWiC3Q=="],
+
+ "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
+
+ "acorn-walk": ["acorn-walk@8.3.5", "", { "dependencies": { "acorn": "^8.11.0" } }, "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw=="],
+
+ "adler-32": ["adler-32@1.3.1", "", {}, "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A=="],
+
+ "agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="],
+
+ "ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="],
+
+ "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
+
+ "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
+
+ "any-promise": ["any-promise@1.3.0", "", {}, "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A=="],
+
+ "anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="],
+
+ "aproba": ["aproba@2.1.0", "", {}, "sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew=="],
+
+ "are-we-there-yet": ["are-we-there-yet@2.0.0", "", { "dependencies": { "delegates": "^1.0.0", "readable-stream": "^3.6.0" } }, "sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw=="],
+
+ "arg": ["arg@5.0.2", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="],
+
+ "argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="],
+
+ "aria-hidden": ["aria-hidden@1.2.4", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-y+CcFFwelSXpLZk/7fMB2mUbGtX9lKycf1MWJ7CaTIERyitVlyQx6C+sxcROU2BAJ24OiZyK+8wj2i8AlBoS3A=="],
+
+ "aria-query": ["aria-query@5.3.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="],
+
+ "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="],
+
+ "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="],
+
+ "attr-accept": ["attr-accept@2.2.5", "", {}, "sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ=="],
+
+ "autoprefixer": ["autoprefixer@10.4.21", "", { "dependencies": { "browserslist": "^4.24.4", "caniuse-lite": "^1.0.30001702", "fraction.js": "^4.3.7", "normalize-range": "^0.1.2", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.1.0" }, "bin": "bin/autoprefixer" }, "sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ=="],
+
+ "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
+
+ "base64-arraybuffer": ["base64-arraybuffer@1.0.2", "", {}, "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ=="],
+
+ "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
+
+ "binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="],
+
+ "bluebird": ["bluebird@3.4.7", "", {}, "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA=="],
+
+ "brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
+
+ "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
+
+ "browserslist": ["browserslist@4.25.1", "", { "dependencies": { "caniuse-lite": "^1.0.30001726", "electron-to-chromium": "^1.5.173", "node-releases": "^2.0.19", "update-browserslist-db": "^1.1.3" }, "bin": "cli.js" }, "sha512-KGj0KoOMXLpSNkkEI6Z6mShmQy0bc1I+T7K9N81k4WWMrfz+6fQ6es80B/YLAeRoKvjYE1YSHHOW1qe9xIVzHw=="],
+
+ "cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="],
+
+ "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=="],
+
+ "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="],
+
+ "camelcase": ["camelcase@5.3.1", "", {}, "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="],
+
+ "camelcase-css": ["camelcase-css@2.0.1", "", {}, "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA=="],
+
+ "caniuse-lite": ["caniuse-lite@1.0.30001727", "", {}, "sha512-pB68nIHmbN6L/4C6MH1DokyR3bYqFwjaSs/sWDHGj4CTcFtQUQMuJftVwWkXq7mNWOybD3KhUv3oWHoGxgP14Q=="],
+
+ "canvas": ["canvas@2.11.2", "", { "dependencies": { "@mapbox/node-pre-gyp": "^1.0.0", "nan": "^2.17.0", "simple-get": "^3.0.3" } }, "sha512-ItanGBMrmRV7Py2Z+Xhs7cT+FNt5K0vPL4p9EZ/UX/Mu7hFbkxSjKF2KVtPwX7UYWp7dRKnrTvReflgrItJbdw=="],
+
+ "canvg": ["canvg@3.0.11", "", { "dependencies": { "@babel/runtime": "^7.12.5", "@types/raf": "^3.4.0", "core-js": "^3.8.3", "raf": "^3.4.1", "regenerator-runtime": "^0.13.7", "rgbcolor": "^1.0.1", "stackblur-canvas": "^2.0.0", "svg-pathdata": "^6.0.3" } }, "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA=="],
+
+ "cfb": ["cfb@1.2.2", "", { "dependencies": { "adler-32": "~1.3.0", "crc-32": "~1.2.0" } }, "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA=="],
+
+ "chai": ["chai@5.3.3", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="],
+
+ "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
+
+ "cheap-ruler": ["cheap-ruler@4.0.0", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/cheap-ruler/-/cheap-ruler-4.0.0.tgz", {}, "sha512-0BJa8f4t141BYKQyn9NSQt1PguFQXMXwZiA5shfoaBYHAb2fFk2RAX+tiWMoQU+Agtzt3mdt0JtuyshAXqZ+Vw=="],
+
+ "check-error": ["check-error@2.1.3", "", {}, "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA=="],
+
+ "chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
+
+ "chownr": ["chownr@2.0.0", "", {}, "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ=="],
+
+ "class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
+
+ "cliui": ["cliui@6.0.0", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^6.2.0" } }, "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ=="],
+
+ "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
+
+ "cmdk": ["cmdk@1.1.1", "", { "dependencies": { "@radix-ui/react-compose-refs": "^1.1.1", "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-id": "^1.1.0", "@radix-ui/react-primitive": "^2.0.2" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "react-dom": "^18 || ^19 || ^19.0.0-rc" } }, "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg=="],
+
+ "codepage": ["codepage@1.15.0", "", {}, "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA=="],
+
+ "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
+
+ "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
+
+ "color-support": ["color-support@1.1.3", "", { "bin": "bin.js" }, "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg=="],
+
+ "combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="],
+
+ "commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="],
+
+ "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="],
+
+ "console-control-strings": ["console-control-strings@1.1.0", "", {}, "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ=="],
+
+ "core-js": ["core-js@3.48.0", "", {}, "sha512-zpEHTy1fjTMZCKLHUZoVeylt9XrzaIN2rbPXEt0k+q7JE5CkCZdo6bNq55bn24a69CH7ErAVLKijxJja4fw+UQ=="],
+
+ "core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="],
+
+ "crc-32": ["crc-32@1.2.2", "", { "bin": { "crc32": "bin/crc32.njs" } }, "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ=="],
+
+ "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
+
+ "css-box-model": ["css-box-model@1.2.1", "", { "dependencies": { "tiny-invariant": "^1.0.6" } }, "sha512-a7Vr4Q/kd/aw96bnJG332W9V9LkJO69JRcaCYDUqjp6/z0w6VcZjgAcTbgFxEPfBgdnAwlh3iwu+hLopa+flJw=="],
+
+ "css-line-break": ["css-line-break@2.1.0", "", { "dependencies": { "utrie": "^1.0.2" } }, "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w=="],
+
+ "css.escape": ["css.escape@1.5.1", "", {}, "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg=="],
+
+ "csscolorparser": ["csscolorparser@1.0.3", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/csscolorparser/-/csscolorparser-1.0.3.tgz", {}, "sha512-umPSgYwZkdFoUrH5hIq5kf0wPSXiro51nPw0j2K/c83KflkPSTBGMz6NJvMB+07VlL0y7VPo6QJcDjcgKTTm3w=="],
+
+ "cssesc": ["cssesc@3.0.0", "", { "bin": "bin/cssesc" }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="],
+
+ "cssom": ["cssom@0.5.0", "", {}, "sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw=="],
+
+ "cssstyle": ["cssstyle@2.3.0", "", { "dependencies": { "cssom": "~0.3.6" } }, "sha512-AZL67abkUzIuvcHqk7c09cezpGNcxUxU4Ioi/05xHk4DQeTkWmGYftIE6ctU6AEt+Gn4n1lDStOtj7FKycP71A=="],
+
+ "csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
+
+ "d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="],
+
+ "d3-color": ["d3-color@3.1.0", "", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="],
+
+ "d3-ease": ["d3-ease@3.0.1", "", {}, "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w=="],
+
+ "d3-format": ["d3-format@3.1.0", "", {}, "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA=="],
+
+ "d3-interpolate": ["d3-interpolate@3.0.1", "", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="],
+
+ "d3-path": ["d3-path@3.1.0", "", {}, "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ=="],
+
+ "d3-scale": ["d3-scale@4.0.2", "", { "dependencies": { "d3-array": "2.10.0 - 3", "d3-format": "1 - 3", "d3-interpolate": "1.2.0 - 3", "d3-time": "2.1.1 - 3", "d3-time-format": "2 - 4" } }, "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ=="],
+
+ "d3-shape": ["d3-shape@3.2.0", "", { "dependencies": { "d3-path": "^3.1.0" } }, "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA=="],
+
+ "d3-time": ["d3-time@3.1.0", "", { "dependencies": { "d3-array": "2 - 3" } }, "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q=="],
+
+ "d3-time-format": ["d3-time-format@4.1.0", "", { "dependencies": { "d3-time": "1 - 3" } }, "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg=="],
+
+ "d3-timer": ["d3-timer@3.0.1", "", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="],
+
+ "data-urls": ["data-urls@3.0.2", "", { "dependencies": { "abab": "^2.0.6", "whatwg-mimetype": "^3.0.0", "whatwg-url": "^11.0.0" } }, "sha512-Jy/tj3ldjZJo63sVAvg6LHt2mHvl4V6AgRAmNDtLdm7faqtsx+aJG42rsyCo9JCoRVKwPFzKlIPx3DIibwSIaQ=="],
+
+ "date-fns": ["date-fns@3.6.0", "", {}, "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww=="],
+
+ "date-fns-tz": ["date-fns-tz@3.2.0", "", { "peerDependencies": { "date-fns": "^3.0.0 || ^4.0.0" } }, "sha512-sg8HqoTEulcbbbVXeg84u5UnlsQa8GS5QXMqjjYIhS4abEVVKIUwe0/l/UhrZdKaL/W5eWZNlbTeEIiOXTcsBQ=="],
+
+ "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
+
+ "decamelize": ["decamelize@1.2.0", "", {}, "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA=="],
+
+ "decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="],
+
+ "decimal.js-light": ["decimal.js-light@2.5.1", "", {}, "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="],
+
+ "decompress-response": ["decompress-response@4.2.1", "", { "dependencies": { "mimic-response": "^2.0.0" } }, "sha512-jOSne2qbyE+/r8G1VU+G/82LBs2Fs4LAsTiLSHOCOMZQl2OKZ6i8i4IyHemTe+/yIXOtTcRQMzPcgyhoFlqPkw=="],
+
+ "deep-eql": ["deep-eql@5.0.2", "", {}, "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q=="],
+
+ "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="],
+
+ "deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="],
+
+ "delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="],
+
+ "delegates": ["delegates@1.0.0", "", {}, "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ=="],
+
+ "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
+
+ "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
+
+ "detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="],
+
+ "didyoumean": ["didyoumean@1.2.2", "", {}, "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw=="],
+
+ "dijkstrajs": ["dijkstrajs@1.0.3", "", {}, "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA=="],
+
+ "dingbat-to-unicode": ["dingbat-to-unicode@1.0.1", "", {}, "sha512-98l0sW87ZT58pU4i61wa2OHwxbiYSbuxsCBozaVnYX2iCnr3bLM3fIes1/ej7h1YdOKuKt/MLs706TVnALA65w=="],
+
+ "dlv": ["dlv@1.1.3", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="],
+
+ "dom-accessibility-api": ["dom-accessibility-api@0.6.3", "", {}, "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w=="],
+
+ "dom-helpers": ["dom-helpers@5.2.1", "", { "dependencies": { "@babel/runtime": "^7.8.7", "csstype": "^3.0.2" } }, "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA=="],
+
+ "dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="],
+
+ "domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="],
+
+ "domexception": ["domexception@4.0.0", "", { "dependencies": { "webidl-conversions": "^7.0.0" } }, "sha512-A2is4PLG+eeSfoTMA95/s4pvAoSo2mKtiM5jlHkAVewmiO8ISFTFKZjH7UAM1Atli/OT/7JHOrJRJiMKUZKYBw=="],
+
+ "domhandler": ["domhandler@5.0.3", "", { "dependencies": { "domelementtype": "^2.3.0" } }, "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w=="],
+
+ "dompurify": ["dompurify@3.3.3", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA=="],
+
+ "domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="],
+
+ "duck": ["duck@0.1.12", "", { "dependencies": { "underscore": "^1.13.1" } }, "sha512-wkctla1O6VfP89gQ+J/yDesM0S7B7XLXjKGzXxMDVFg7uEn706niAtyYovKbyq1oT9YwDcly721/iUWoc8MVRg=="],
+
+ "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
+
+ "earcut": ["earcut@3.0.2", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/earcut/-/earcut-3.0.2.tgz", {}, "sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ=="],
+
+ "eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="],
+
+ "electron-to-chromium": ["electron-to-chromium@1.5.192", "", {}, "sha512-rP8Ez0w7UNw/9j5eSXCe10o1g/8B1P5SM90PCCMVkIRQn2R0LEHWz4Eh9RnxkniuDe1W0cTSOB3MLlkTGDcuCg=="],
+
+ "embla-carousel": ["embla-carousel@8.6.0", "", {}, "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA=="],
+
+ "embla-carousel-react": ["embla-carousel-react@8.6.0", "", { "dependencies": { "embla-carousel": "8.6.0", "embla-carousel-reactive-utils": "8.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.1 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-0/PjqU7geVmo6F734pmPqpyHqiM99olvyecY7zdweCw+6tKEXnrE90pBiBbMMU8s5tICemzpQ3hi5EpxzGW+JA=="],
+
+ "embla-carousel-reactive-utils": ["embla-carousel-reactive-utils@8.6.0", "", { "peerDependencies": { "embla-carousel": "8.6.0" } }, "sha512-fMVUDUEx0/uIEDM0Mz3dHznDhfX+znCCDCeIophYb1QGVM7YThSWX+wz11zlYwWFOr74b4QLGg0hrGPJeG2s4A=="],
+
+ "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
+
+ "entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
+
+ "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
+
+ "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
+
+ "es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="],
+
+ "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
+
+ "es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="],
+
+ "esbuild": ["esbuild@0.25.0", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.0", "@esbuild/android-arm": "0.25.0", "@esbuild/android-arm64": "0.25.0", "@esbuild/android-x64": "0.25.0", "@esbuild/darwin-arm64": "0.25.0", "@esbuild/darwin-x64": "0.25.0", "@esbuild/freebsd-arm64": "0.25.0", "@esbuild/freebsd-x64": "0.25.0", "@esbuild/linux-arm": "0.25.0", "@esbuild/linux-arm64": "0.25.0", "@esbuild/linux-ia32": "0.25.0", "@esbuild/linux-loong64": "0.25.0", "@esbuild/linux-mips64el": "0.25.0", "@esbuild/linux-ppc64": "0.25.0", "@esbuild/linux-riscv64": "0.25.0", "@esbuild/linux-s390x": "0.25.0", "@esbuild/linux-x64": "0.25.0", "@esbuild/netbsd-arm64": "0.25.0", "@esbuild/netbsd-x64": "0.25.0", "@esbuild/openbsd-arm64": "0.25.0", "@esbuild/openbsd-x64": "0.25.0", "@esbuild/sunos-x64": "0.25.0", "@esbuild/win32-arm64": "0.25.0", "@esbuild/win32-ia32": "0.25.0", "@esbuild/win32-x64": "0.25.0" }, "bin": "bin/esbuild" }, "sha512-BXq5mqc8ltbaN34cDqWuYKyNhX8D/Z0J1xdtdQ8UcIIIyJyz+ZMKUt58tF3SrZ85jcfN/PZYhjR5uDQAYNVbuw=="],
+
+ "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
+
+ "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
+
+ "escodegen": ["escodegen@2.1.0", "", { "dependencies": { "esprima": "^4.0.1", "estraverse": "^5.2.0", "esutils": "^2.0.2" }, "optionalDependencies": { "source-map": "~0.6.1" }, "bin": { "escodegen": "bin/escodegen.js", "esgenerate": "bin/esgenerate.js" } }, "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w=="],
+
+ "eslint": ["eslint@9.32.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.0", "@eslint/config-helpers": "^0.3.0", "@eslint/core": "^0.15.0", "@eslint/eslintrc": "^3.3.1", "@eslint/js": "9.32.0", "@eslint/plugin-kit": "^0.3.4", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "@types/json-schema": "^7.0.15", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "bin": "bin/eslint.js" }, "sha512-LSehfdpgMeWcTZkWZVIJl+tkZ2nuSkyyB9C27MZqFWXuph7DvaowgcTvKqxvpLW1JZIk8PN7hFY3Rj9LQ7m7lg=="],
+
+ "eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@5.2.0", "", { "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" } }, "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg=="],
+
+ "eslint-plugin-react-refresh": ["eslint-plugin-react-refresh@0.4.20", "", { "peerDependencies": { "eslint": ">=8.40" } }, "sha512-XpbHQ2q5gUF8BGOX4dHe+71qoirYMhApEPZ7sfhF/dNnOF1UXnCMGZf79SFTBO7Bz5YEIT4TMieSlJBWhP9WBA=="],
+
+ "eslint-scope": ["eslint-scope@8.4.0", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="],
+
+ "eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="],
+
+ "espree": ["espree@10.4.0", "", { "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^4.2.1" } }, "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ=="],
+
+ "esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "bin/esparse.js", "esvalidate": "bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="],
+
+ "esquery": ["esquery@1.6.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg=="],
+
+ "esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="],
+
+ "estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="],
+
+ "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="],
+
+ "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
+
+ "eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="],
+
+ "exifr": ["exifr@7.1.3", "", {}, "sha512-g/aje2noHivrRSLbAUtBPWFbxKdKhgj/xr1vATDdUXPOFYJlQ62Ft0oy+72V6XLIpDJfHs6gXLbBLAolqOXYRw=="],
+
+ "expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="],
+
+ "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
+
+ "fast-diff": ["fast-diff@1.3.0", "", {}, "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw=="],
+
+ "fast-equals": ["fast-equals@4.0.3", "", {}, "sha512-G3BSX9cfKttjr+2o1O22tYMLq0DPluZnYtq1rXumE1SpL/F/SLIfHx08WYQoWSIpeMYf8sRbJ8++71+v6Pnxfg=="],
+
+ "fast-glob": ["fast-glob@3.3.2", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.4" } }, "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow=="],
+
+ "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="],
+
+ "fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="],
+
+ "fast-png": ["fast-png@6.4.0", "", { "dependencies": { "@types/pako": "^2.0.3", "iobuffer": "^5.3.2", "pako": "^2.1.0" } }, "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q=="],
+
+ "fastq": ["fastq@1.17.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w=="],
+
+ "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" } }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
+
+ "fflate": ["fflate@0.8.2", "", {}, "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A=="],
+
+ "file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="],
+
+ "file-selector": ["file-selector@2.1.2", "", { "dependencies": { "tslib": "^2.7.0" } }, "sha512-QgXo+mXTe8ljeqUFaX3QVHc5osSItJ/Km+xpocx0aSqWGMSCf6qYs/VnzZgS864Pjn5iceMRFigeAV7AfTlaig=="],
+
+ "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
+
+ "find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="],
+
+ "flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="],
+
+ "flatted": ["flatted@3.3.1", "", {}, "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw=="],
+
+ "foreground-child": ["foreground-child@3.3.0", "", { "dependencies": { "cross-spawn": "^7.0.0", "signal-exit": "^4.0.1" } }, "sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg=="],
+
+ "form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="],
+
+ "frac": ["frac@1.1.2", "", {}, "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA=="],
+
+ "fraction.js": ["fraction.js@4.3.7", "", {}, "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew=="],
+
+ "framer-motion": ["framer-motion@12.37.0", "", { "dependencies": { "motion-dom": "^12.37.0", "motion-utils": "^12.36.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid"] }, "sha512-j/QUcZS9Nw3NzZWoAbkzr3ETRFHyVeQMlGOUYUmG15U+uiyn9DqIktYruVPDcqY8I35qYR70JaZBvFmS6p+Pdg=="],
+
+ "fs-minipass": ["fs-minipass@2.1.0", "", { "dependencies": { "minipass": "^3.0.0" } }, "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg=="],
+
+ "fs.realpath": ["fs.realpath@1.0.0", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="],
+
+ "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
+
+ "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
+
+ "gauge": ["gauge@3.0.2", "", { "dependencies": { "aproba": "^1.0.3 || ^2.0.0", "color-support": "^1.1.2", "console-control-strings": "^1.0.0", "has-unicode": "^2.0.1", "object-assign": "^4.1.1", "signal-exit": "^3.0.0", "string-width": "^4.2.3", "strip-ansi": "^6.0.1", "wide-align": "^1.1.2" } }, "sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q=="],
+
+ "geojson-vt": ["geojson-vt@4.0.2", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/geojson-vt/-/geojson-vt-4.0.2.tgz", {}, "sha512-AV9ROqlNqoZEIJGfm1ncNjEXfkz2hdFlZf0qkVfmkwdKa8vj7H16YUOT81rJw1rdFhyEDlN2Tds91p/glzbl5A=="],
+
+ "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
+
+ "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
+
+ "get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="],
+
+ "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
+
+ "gl-matrix": ["gl-matrix@3.4.4", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/gl-matrix/-/gl-matrix-3.4.4.tgz", {}, "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ=="],
+
+ "glob": ["glob@10.4.5", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": "dist/esm/bin.mjs" }, "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg=="],
+
+ "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
+
+ "globals": ["globals@15.15.0", "", {}, "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg=="],
+
+ "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
+
+ "graphemer": ["graphemer@1.4.0", "", {}, "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag=="],
+
+ "grid-index": ["grid-index@1.1.0", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/grid-index/-/grid-index-1.1.0.tgz", {}, "sha512-HZRwumpOGUrHyxO5bqKZL0B0GlUpwtCAzZ42sgxUPniu33R1LSFH5yrIcBCHjkctCAh3mtWKcKd9J4vDDdeVHA=="],
+
+ "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
+
+ "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
+
+ "has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="],
+
+ "has-unicode": ["has-unicode@2.0.1", "", {}, "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ=="],
+
+ "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
+
+ "html-encoding-sniffer": ["html-encoding-sniffer@3.0.0", "", { "dependencies": { "whatwg-encoding": "^2.0.0" } }, "sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA=="],
+
+ "html-to-text": ["html-to-text@9.0.5", "", { "dependencies": { "@selderee/plugin-htmlparser2": "^0.11.0", "deepmerge": "^4.3.1", "dom-serializer": "^2.0.0", "htmlparser2": "^8.0.2", "selderee": "^0.11.0" } }, "sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg=="],
+
+ "html2canvas": ["html2canvas@1.4.1", "", { "dependencies": { "css-line-break": "^2.1.0", "text-segmentation": "^1.0.3" } }, "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA=="],
+
+ "htmlparser2": ["htmlparser2@8.0.2", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.0.1", "entities": "^4.4.0" } }, "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA=="],
+
+ "http-proxy-agent": ["http-proxy-agent@5.0.0", "", { "dependencies": { "@tootallnate/once": "2", "agent-base": "6", "debug": "4" } }, "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w=="],
+
+ "https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="],
+
+ "ical.js": ["ical.js@2.2.1", "", {}, "sha512-yK/UlPbEs316igb/tjRgbFA8ZV75rCsBJp/hWOatpyaPNlgw0dGDmU+FoicOcwX4xXkeXOkYiOmCqNPFpNPkQg=="],
+
+ "iceberg-js": ["iceberg-js@0.8.1", "", {}, "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA=="],
+
+ "iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
+
+ "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
+
+ "immediate": ["immediate@3.0.6", "", {}, "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ=="],
+
+ "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
+
+ "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="],
+
+ "indent-string": ["indent-string@4.0.0", "", {}, "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg=="],
+
+ "inflight": ["inflight@1.0.6", "", { "dependencies": { "once": "^1.3.0", "wrappy": "1" } }, "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA=="],
+
+ "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
+
+ "input-otp": ["input-otp@1.4.2", "", { "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-l3jWwYNvrEa6NTCt7BECfCm48GvwuZzkoeG3gBL2w4CHeOXW3eKFmf9UNYkNfYc3mxMrthMnxjIE07MT0zLBQA=="],
+
+ "internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="],
+
+ "iobuffer": ["iobuffer@5.4.0", "", {}, "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA=="],
+
+ "is-binary-path": ["is-binary-path@2.1.0", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="],
+
+ "is-core-module": ["is-core-module@2.15.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-z0vtXSwucUJtANQWldhbtbt7BnL0vxiFjIdDLAatwhDYty2bad6s+rijD6Ri4YuYJubLzIJLUidCh09e1djEVQ=="],
+
+ "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
+
+ "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
+
+ "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
+
+ "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="],
+
+ "is-potential-custom-element-name": ["is-potential-custom-element-name@1.0.1", "", {}, "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="],
+
+ "isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="],
+
+ "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
+
+ "jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="],
+
+ "jiti": ["jiti@1.21.6", "", { "bin": "bin/jiti.js" }, "sha512-2yTgeWTWzMWkHu6Jp9NKgePDaYHbntiwvYuuJLbbN9vl7DC9DvXKOB2BC3ZZ92D3cvV/aflH0osDfwpHepQ53w=="],
+
+ "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
+
+ "js-yaml": ["js-yaml@4.1.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": "bin/js-yaml.js" }, "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA=="],
+
+ "jsdom": ["jsdom@20.0.3", "", { "dependencies": { "abab": "^2.0.6", "acorn": "^8.8.1", "acorn-globals": "^7.0.0", "cssom": "^0.5.0", "cssstyle": "^2.3.0", "data-urls": "^3.0.2", "decimal.js": "^10.4.2", "domexception": "^4.0.0", "escodegen": "^2.0.0", "form-data": "^4.0.0", "html-encoding-sniffer": "^3.0.0", "http-proxy-agent": "^5.0.0", "https-proxy-agent": "^5.0.1", "is-potential-custom-element-name": "^1.0.1", "nwsapi": "^2.2.2", "parse5": "^7.1.1", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^4.1.2", "w3c-xmlserializer": "^4.0.0", "webidl-conversions": "^7.0.0", "whatwg-encoding": "^2.0.0", "whatwg-mimetype": "^3.0.0", "whatwg-url": "^11.0.0", "ws": "^8.11.0", "xml-name-validator": "^4.0.0" }, "peerDependencies": { "canvas": "^2.5.0" } }, "sha512-SYhBvTh89tTfCD/CRdSOm13mOBa42iTaTyfyEWBdKcGdPxPtLFBXuHR8XHb33YNYaP+lLbmSvBTsnoesCNJEsQ=="],
+
+ "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="],
+
+ "json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
+
+ "json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="],
+
+ "jspdf": ["jspdf@4.2.1", "", { "dependencies": { "@babel/runtime": "^7.28.6", "fast-png": "^6.2.0", "fflate": "^0.8.1" }, "optionalDependencies": { "canvg": "^3.0.11", "core-js": "^3.6.0", "dompurify": "^3.3.1", "html2canvas": "^1.0.0-rc.5" } }, "sha512-YyAXyvnmjTbR4bHQRLzex3CuINCDlQnBqoSYyjJwTP2x9jDLuKDzy7aKUl0hgx3uhcl7xzg32agn5vlie6HIlQ=="],
+
+ "jspdf-autotable": ["jspdf-autotable@5.0.7", "", { "peerDependencies": { "jspdf": "^2 || ^3 || ^4" } }, "sha512-2wr7H6liNDBYNwt25hMQwXkEWFOEopgKIvR1Eukuw6Zmprm/ZcnmLTQEjW7Xx3FCbD3v7pflLcnMAv/h1jFDQw=="],
+
+ "jszip": ["jszip@3.10.1", "", { "dependencies": { "lie": "~3.3.0", "pako": "~1.0.2", "readable-stream": "~2.3.6", "setimmediate": "^1.0.5" } }, "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g=="],
+
+ "kdbush": ["kdbush@4.0.2", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/kdbush/-/kdbush-4.0.2.tgz", {}, "sha512-WbCVYJ27Sz8zi9Q7Q0xHC+05iwkm3Znipc2XTlrnJbsHMYktW4hPhXUE8Ys1engBrvffoSCqbil1JQAa7clRpA=="],
+
+ "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
+
+ "leac": ["leac@0.6.0", "", {}, "sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg=="],
+
+ "leaflet": ["leaflet@1.9.4", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/leaflet/-/leaflet-1.9.4.tgz", {}, "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA=="],
+
+ "leaflet.markercluster": ["leaflet.markercluster@1.5.3", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/leaflet.markercluster/-/leaflet.markercluster-1.5.3.tgz", { "peerDependencies": { "leaflet": "^1.3.1" } }, "sha512-vPTw/Bndq7eQHjLBVlWpnGeLa3t+3zGiuM7fJwCkiMFq+nmRuG3RI3f7f4N4TDX7T4NpbAXpR2+NTRSEGfCSeA=="],
+
+ "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="],
+
+ "lie": ["lie@3.3.0", "", { "dependencies": { "immediate": "~3.0.5" } }, "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ=="],
+
+ "lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="],
+
+ "lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
+
+ "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
+
+ "lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="],
+
+ "lodash-es": ["lodash-es@4.17.23", "", {}, "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg=="],
+
+ "lodash.castarray": ["lodash.castarray@4.4.0", "", {}, "sha512-aVx8ztPv7/2ULbArGJ2Y42bG1mEQ5mGjpdvrbJcJFU3TbYybe+QlLS4pst9zV52ymy2in1KpFPiZnAOATxD4+Q=="],
+
+ "lodash.clonedeep": ["lodash.clonedeep@4.5.0", "", {}, "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ=="],
+
+ "lodash.isequal": ["lodash.isequal@4.5.0", "", {}, "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ=="],
+
+ "lodash.isplainobject": ["lodash.isplainobject@4.0.6", "", {}, "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA=="],
+
+ "lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="],
+
+ "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": "cli.js" }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="],
+
+ "lop": ["lop@0.4.2", "", { "dependencies": { "duck": "^0.1.12", "option": "~0.2.1", "underscore": "^1.13.1" } }, "sha512-RefILVDQ4DKoRZsJ4Pj22TxE3omDO47yFpkIBoDKzkqPRISs5U1cnAdg/5583YPkWPaLIYHOKRMQSvjFsO26cw=="],
+
+ "loupe": ["loupe@3.2.1", "", {}, "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ=="],
+
+ "lovable-tagger": ["lovable-tagger@1.1.13", "", { "dependencies": { "esbuild": "^0.25.0", "tailwindcss": "^3.4.17" }, "peerDependencies": { "vite": ">=5.0.0 <8.0.0" } }, "sha512-RBEYDxao7Xf8ya29L0cd+ocE7Gs80xPOIOwwck65Hoie8YDKViuXi3UYV14DoNWIvaJ7WVPf7SG3cc844nFqGA=="],
+
+ "lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
+
+ "lucide-react": ["lucide-react@0.462.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" } }, "sha512-NTL7EbAao9IFtuSivSZgrAh4fZd09Lr+6MTkqIxuHaH2nnYiYIzXPo06cOxHg9wKLdj6LL8TByG4qpePqwgx/g=="],
+
+ "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
+
+ "make-cancellable-promise": ["make-cancellable-promise@1.3.2", "", {}, "sha512-GCXh3bq/WuMbS+Ky4JBPW1hYTOU+znU+Q5m9Pu+pI8EoUqIHk9+tviOKC6/qhHh8C4/As3tzJ69IF32kdz85ww=="],
+
+ "make-dir": ["make-dir@3.1.0", "", { "dependencies": { "semver": "^6.0.0" } }, "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw=="],
+
+ "make-event-props": ["make-event-props@1.6.2", "", {}, "sha512-iDwf7mA03WPiR8QxvcVHmVWEPfMY1RZXerDVNCRYW7dUr2ppH3J58Rwb39/WG39yTZdRSxr3x+2v22tvI0VEvA=="],
+
+ "mammoth": ["mammoth@1.12.0", "", { "dependencies": { "@xmldom/xmldom": "^0.8.6", "argparse": "~1.0.3", "base64-js": "^1.5.1", "bluebird": "~3.4.0", "dingbat-to-unicode": "^1.0.1", "jszip": "^3.7.1", "lop": "^0.4.2", "path-is-absolute": "^1.0.0", "underscore": "^1.13.1", "xmlbuilder": "^10.0.0" }, "bin": "bin/mammoth" }, "sha512-cwnK1RIcRdDMi2HRx2EXGYlxqIEh0Oo3bLhorgnsVJi2UkbX1+jKxuBNR9PC5+JaX7EkmJxFPmo6mjLpqShI2w=="],
+
+ "mapbox-gl": ["mapbox-gl@3.22.0", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/mapbox-gl/-/mapbox-gl-3.22.0.tgz", { "dependencies": { "@mapbox/mapbox-gl-supported": "^3.0.0", "@mapbox/point-geometry": "^1.1.0", "@mapbox/tiny-sdf": "^2.0.6", "@mapbox/unitbezier": "^0.0.1", "@mapbox/vector-tile": "^2.0.4", "@types/geojson": "^7946.0.16", "@types/geojson-vt": "^3.2.5", "@types/pbf": "^3.0.5", "@types/supercluster": "^7.1.3", "cheap-ruler": "^4.0.0", "csscolorparser": "~1.0.3", "earcut": "^3.0.1", "geojson-vt": "^4.0.2", "gl-matrix": "^3.4.4", "grid-index": "^1.1.0", "kdbush": "^4.0.2", "martinez-polygon-clipping": "^0.8.1", "murmurhash-js": "^1.0.0", "pbf": "^4.0.1", "potpack": "^2.0.0", "quickselect": "^3.0.0", "supercluster": "^8.0.1", "tinyqueue": "^3.0.0" } }, "sha512-ZIpF+oAMcQoDlvABmiRkHoydyBR9zI6CyDeVRa2/iyua0/B2+rPuIzoCV/CgN14P5F0HVk53GIZw220WSqJPyA=="],
+
+ "mapbox-gl-leaflet": ["mapbox-gl-leaflet@0.0.16", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/mapbox-gl-leaflet/-/mapbox-gl-leaflet-0.0.16.tgz", { "peerDependencies": { "leaflet": "^1.0.0", "mapbox-gl": "*" } }, "sha512-w4bpZrKHOWDZqUzhDOjIPL6Pc4tD10TVR/z8Iwp3hlUaf8PVqfxPINrcBLkcOg0+xFZSX3uka6Vl6NeO7KUYXw=="],
+
+ "martinez-polygon-clipping": ["martinez-polygon-clipping@0.8.1", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/martinez-polygon-clipping/-/martinez-polygon-clipping-0.8.1.tgz", { "dependencies": { "robust-predicates": "^2.0.4", "splaytree": "^0.1.4", "tinyqueue": "3.0.0" } }, "sha512-9PLLMzMPI6ihHox4Ns6LpVBLpRc7sbhULybZ/wyaY8sY3ECNe2+hxm1hA2/9bEEpRrdpjoeduBuZLg2aq1cSIQ=="],
+
+ "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
+
+ "merge-refs": ["merge-refs@1.3.0", "", { "peerDependencies": { "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-nqXPXbso+1dcKDpPCXvwZyJILz+vSLqGGOnDrYHQYE+B8n9JTCekVLC65AfCpR4ggVyA/45Y0iR9LDyS2iI+zA=="],
+
+ "merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="],
+
+ "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
+
+ "mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
+
+ "mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
+
+ "mimic-response": ["mimic-response@2.1.0", "", {}, "sha512-wXqjST+SLt7R009ySCglWBCFpjUygmCIfD790/kVbiGmUgfYGuB14PiTd5DwVxSV4NcYHjzMkoj5LjQZwTQLEA=="],
+
+ "min-indent": ["min-indent@1.0.1", "", {}, "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg=="],
+
+ "minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="],
+
+ "minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="],
+
+ "minizlib": ["minizlib@2.1.2", "", { "dependencies": { "minipass": "^3.0.0", "yallist": "^4.0.0" } }, "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg=="],
+
+ "mkdirp": ["mkdirp@1.0.4", "", { "bin": "bin/cmd.js" }, "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw=="],
+
+ "motion-dom": ["motion-dom@12.37.0", "", { "dependencies": { "motion-utils": "^12.36.0" } }, "sha512-LnppZuwX1jQizRWTl9LBLMN3RbAEmdQkX/2Af0UW70NCqYJI/7GfI83vQP9Ucel/Avc0Tf2ZWy8FHawuc0O6Vg=="],
+
+ "motion-utils": ["motion-utils@12.36.0", "", {}, "sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg=="],
+
+ "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
+
+ "murmurhash-js": ["murmurhash-js@1.0.0", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/murmurhash-js/-/murmurhash-js-1.0.0.tgz", {}, "sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw=="],
+
+ "mz": ["mz@2.7.0", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="],
+
+ "nan": ["nan@2.26.2", "", {}, "sha512-0tTvBTYkt3tdGw22nrAy50x7gpbGCCFH3AFcyS5WiUu7Eu4vWlri1woE6qHBSfy11vksDqkiwjOnlR7WV8G1Hw=="],
+
+ "nanoid": ["nanoid@3.3.11", "", { "bin": "bin/nanoid.cjs" }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
+
+ "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
+
+ "next-themes": ["next-themes@0.3.0", "", { "peerDependencies": { "react": "^16.8 || ^17 || ^18", "react-dom": "^16.8 || ^17 || ^18" } }, "sha512-/QHIrsYpd6Kfk7xakK4svpDI5mmXP0gfvCoJdGpZQ2TOrQZmsW0QxjaiLn8wbIKjtm4BTSqLoix4lxYYOnLJ/w=="],
+
+ "node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="],
+
+ "node-releases": ["node-releases@2.0.19", "", {}, "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw=="],
+
+ "nopt": ["nopt@5.0.0", "", { "dependencies": { "abbrev": "1" }, "bin": "bin/nopt.js" }, "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ=="],
+
+ "normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="],
+
+ "normalize-range": ["normalize-range@0.1.2", "", {}, "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA=="],
+
+ "npmlog": ["npmlog@5.0.1", "", { "dependencies": { "are-we-there-yet": "^2.0.0", "console-control-strings": "^1.1.0", "gauge": "^3.0.0", "set-blocking": "^2.0.0" } }, "sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw=="],
+
+ "nwsapi": ["nwsapi@2.2.23", "", {}, "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ=="],
+
+ "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
+
+ "object-hash": ["object-hash@3.0.0", "", {}, "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw=="],
+
+ "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
+
+ "option": ["option@0.2.4", "", {}, "sha512-pkEqbDyl8ou5cpq+VsnQbe/WlEy5qS7xPzMS1U55OCG9KPvwFD46zDbxQIj3egJSFc3D+XhYOPUzz49zQAVy7A=="],
+
+ "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="],
+
+ "p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="],
+
+ "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="],
+
+ "p-try": ["p-try@2.2.0", "", {}, "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="],
+
+ "package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="],
+
+ "pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="],
+
+ "parchment": ["parchment@3.0.0", "", {}, "sha512-HUrJFQ/StvgmXRcQ1ftY6VEZUq3jA2t9ncFN4F84J/vN0/FPpQF+8FKXb3l6fLces6q0uOHj6NJn+2xvZnxO6A=="],
+
+ "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="],
+
+ "parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
+
+ "parseley": ["parseley@0.12.1", "", { "dependencies": { "leac": "^0.6.0", "peberminta": "^0.9.0" } }, "sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw=="],
+
+ "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
+
+ "path-is-absolute": ["path-is-absolute@1.0.1", "", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="],
+
+ "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
+
+ "path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
+
+ "path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="],
+
+ "path2d": ["path2d@0.2.2", "", {}, "sha512-+vnG6S4dYcYxZd+CZxzXCNKdELYZSKfohrk98yajCo1PtRoDgCTrrwOvK1GT0UoAdVszagDVllQc0U1vaX4NUQ=="],
+
+ "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
+
+ "pathval": ["pathval@2.0.1", "", {}, "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ=="],
+
+ "pbf": ["pbf@4.0.1", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/pbf/-/pbf-4.0.1.tgz", { "dependencies": { "resolve-protobuf-schema": "^2.1.0" }, "bin": { "pbf": "bin/pbf" } }, "sha512-SuLdBvS42z33m8ejRbInMapQe8n0D3vN/Xd5fmWM3tufNgRQFBpaW2YVJxQZV4iPNqb0vEFvssMEo5w9c6BTIA=="],
+
+ "pdf-lib": ["pdf-lib@1.17.1", "", { "dependencies": { "@pdf-lib/standard-fonts": "^1.0.0", "@pdf-lib/upng": "^1.0.1", "pako": "^1.0.11", "tslib": "^1.11.1" } }, "sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw=="],
+
+ "pdfjs-dist": ["pdfjs-dist@4.4.168", "", { "optionalDependencies": { "canvas": "^2.11.2", "path2d": "^0.2.0" } }, "sha512-MbkAjpwka/dMHaCfQ75RY1FXX3IewBVu6NGZOcxerRFlaBiIkZmUoR0jotX5VUzYZEXAGzSFtknWs5xRKliXPA=="],
+
+ "peberminta": ["peberminta@0.9.0", "", {}, "sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ=="],
+
+ "performance-now": ["performance-now@2.1.0", "", {}, "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow=="],
+
+ "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
+
+ "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
+
+ "pify": ["pify@2.3.0", "", {}, "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog=="],
+
+ "pirates": ["pirates@4.0.6", "", {}, "sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg=="],
+
+ "playwright": ["playwright@1.58.2", "", { "dependencies": { "playwright-core": "1.58.2" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": "cli.js" }, "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A=="],
+
+ "playwright-core": ["playwright-core@1.58.2", "", { "bin": "cli.js" }, "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg=="],
+
+ "pngjs": ["pngjs@5.0.0", "", {}, "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw=="],
+
+ "postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
+
+ "postcss-import": ["postcss-import@15.1.0", "", { "dependencies": { "postcss-value-parser": "^4.0.0", "read-cache": "^1.0.0", "resolve": "^1.1.7" }, "peerDependencies": { "postcss": "^8.0.0" } }, "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew=="],
+
+ "postcss-js": ["postcss-js@4.0.1", "", { "dependencies": { "camelcase-css": "^2.0.1" }, "peerDependencies": { "postcss": "^8.4.21" } }, "sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw=="],
+
+ "postcss-load-config": ["postcss-load-config@4.0.2", "", { "dependencies": { "lilconfig": "^3.0.0", "yaml": "^2.3.4" }, "peerDependencies": { "postcss": ">=8.0.9", "ts-node": ">=9.0.0" }, "optionalPeers": ["ts-node"] }, "sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ=="],
+
+ "postcss-nested": ["postcss-nested@6.2.0", "", { "dependencies": { "postcss-selector-parser": "^6.1.1" }, "peerDependencies": { "postcss": "^8.2.14" } }, "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ=="],
+
+ "postcss-selector-parser": ["postcss-selector-parser@6.0.10", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w=="],
+
+ "postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="],
+
+ "potpack": ["potpack@2.1.0", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/potpack/-/potpack-2.1.0.tgz", {}, "sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ=="],
+
+ "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
+
+ "process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="],
+
+ "prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="],
+
+ "protocol-buffers-schema": ["protocol-buffers-schema@3.6.1", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/protocol-buffers-schema/-/protocol-buffers-schema-3.6.1.tgz", {}, "sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ=="],
+
+ "psl": ["psl@1.15.0", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w=="],
+
+ "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
+
+ "qrcode": ["qrcode@1.5.4", "", { "dependencies": { "dijkstrajs": "^1.0.1", "pngjs": "^5.0.0", "yargs": "^15.3.1" }, "bin": "bin/qrcode" }, "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg=="],
+
+ "querystringify": ["querystringify@2.2.0", "", {}, "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ=="],
+
+ "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
+
+ "quickselect": ["quickselect@3.0.0", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/quickselect/-/quickselect-3.0.0.tgz", {}, "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g=="],
+
+ "quill": ["quill@2.0.3", "", { "dependencies": { "eventemitter3": "^5.0.1", "lodash-es": "^4.17.21", "parchment": "^3.0.0", "quill-delta": "^5.1.0" } }, "sha512-xEYQBqfYx/sfb33VJiKnSJp8ehloavImQ2A6564GAbqG55PGw1dAWUn1MUbQB62t0azawUS2CZZhWCjO8gRvTw=="],
+
+ "quill-delta": ["quill-delta@5.1.0", "", { "dependencies": { "fast-diff": "^1.3.0", "lodash.clonedeep": "^4.5.0", "lodash.isequal": "^4.5.0" } }, "sha512-X74oCeRI4/p0ucjb5Ma8adTXd9Scumz367kkMK5V/IatcX6A0vlgLgKbzXWy5nZmCGeNJm2oQX0d2Eqj+ZIlCA=="],
+
+ "raf": ["raf@3.4.1", "", { "dependencies": { "performance-now": "^2.1.0" } }, "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA=="],
+
+ "raf-schd": ["raf-schd@4.0.3", "", {}, "sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ=="],
+
+ "react": ["react@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="],
+
+ "react-day-picker": ["react-day-picker@8.10.1", "", { "peerDependencies": { "date-fns": "^2.28.0 || ^3.0.0", "react": "^16.8.0 || ^17.0.0 || ^18.0.0" } }, "sha512-TMx7fNbhLk15eqcMt+7Z7S2KF7mfTId/XJDjKE8f+IUcFn0l08/kI4FiYTL/0yuOLmEcbR4Fwe3GJf/NiiMnPA=="],
+
+ "react-dom": ["react-dom@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" }, "peerDependencies": { "react": "^18.3.1" } }, "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw=="],
+
+ "react-draggable": ["react-draggable@4.5.0", "", { "dependencies": { "clsx": "^2.1.1", "prop-types": "^15.8.1" }, "peerDependencies": { "react": ">= 16.3.0", "react-dom": ">= 16.3.0" } }, "sha512-VC+HBLEZ0XJxnOxVAZsdRi8rD04Iz3SiiKOoYzamjylUcju/hP9np/aZdLHf/7WOD268WMoNJMvYfB5yAK45cw=="],
+
+ "react-dropzone": ["react-dropzone@15.0.0", "", { "dependencies": { "attr-accept": "^2.2.4", "file-selector": "^2.1.0", "prop-types": "^15.8.1" }, "peerDependencies": { "react": ">= 16.8 || 18.0.0" } }, "sha512-lGjYV/EoqEjEWPnmiSvH4v5IoIAwQM2W4Z1C0Q/Pw2xD0eVzKPS359BQTUMum+1fa0kH2nrKjuavmTPOGhpLPg=="],
+
+ "react-grid-layout": ["react-grid-layout@2.2.2", "", { "dependencies": { "clsx": "^2.1.1", "fast-equals": "^4.0.3", "prop-types": "^15.8.1", "react-draggable": "^4.4.6", "react-resizable": "^3.0.5", "resize-observer-polyfill": "^1.5.1" }, "peerDependencies": { "react": ">= 16.3.0", "react-dom": ">= 16.3.0" } }, "sha512-yNo9pxQWoxHWRAwHGSVT4DEGELYPyQ7+q9lFclb5jcqeFzva63/2F72CryS/jiTIr/SBIlTaDdyjqH+ODg8oBw=="],
+
+ "react-hook-form": ["react-hook-form@7.61.1", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-2vbXUFDYgqEgM2RcXcAT2PwDW/80QARi+PKmHy5q2KhuKvOlG8iIYgf7eIlIANR5trW9fJbP4r5aub3a4egsew=="],
+
+ "react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="],
+
+ "react-leaflet": ["react-leaflet@4.2.1", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/react-leaflet/-/react-leaflet-4.2.1.tgz", { "dependencies": { "@react-leaflet/core": "^2.1.0" }, "peerDependencies": { "leaflet": "^1.9.0", "react": "^18.0.0", "react-dom": "^18.0.0" } }, "sha512-p9chkvhcKrWn/H/1FFeVSqLdReGwn2qmiobOQGO3BifX+/vV/39qhY8dGqbdcPh1e6jxh/QHriLXr7a4eLFK4Q=="],
+
+ "react-pdf": ["react-pdf@9.1.1", "", { "dependencies": { "clsx": "^2.0.0", "dequal": "^2.0.3", "make-cancellable-promise": "^1.3.1", "make-event-props": "^1.6.0", "merge-refs": "^1.3.0", "pdfjs-dist": "4.4.168", "tiny-invariant": "^1.0.0", "warning": "^4.0.0" }, "peerDependencies": { "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Cn3RTJZMqVOOCgLMRXDamLk4LPGfyB2Np3OwQAUjmHIh47EpuGW1OpAA1Z1GVDLoHx4d5duEDo/YbUkDbr4QFQ=="],
+
+ "react-plaid-link": ["react-plaid-link@4.1.1", "", { "dependencies": { "prop-types": "^15.7.2" }, "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19", "react-dom": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-xzAYWQIT/gk+u6lwFAMEZ20f9+AUsCwVyfm64/iudMsyuWANta4wm3Jb7N+APSwuKIR9VUlTkYDhPjLamIGcPA=="],
+
+ "react-quill-new": ["react-quill-new@3.8.3", "", { "dependencies": { "lodash-es": "^4.17.21", "quill": "~2.0.3" }, "peerDependencies": { "quill-delta": "^5.1.0", "react": "^16 || ^17 || ^18 || ^19", "react-dom": "^16 || ^17 || ^18 || ^19" } }, "sha512-c96PYqFTo0pI4R3e79B3rH9LUIce1kIQbmTBu/imJQZk8305ogyLyBqKKjG2UoInDlquXqePSzmBo2aVia3ttw=="],
+
+ "react-redux": ["react-redux@9.2.0", "", { "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" }, "peerDependencies": { "@types/react": "^18.2.25 || ^19", "react": "^18.0 || ^19", "redux": "^5.0.0" } }, "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g=="],
+
+ "react-remove-scroll": ["react-remove-scroll@2.7.1", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-HpMh8+oahmIdOuS5aFKKY6Pyog+FNaZV/XyJOq7b4YFwsFHe5yYfdbIalI4k3vU2nSDql7YskmUseHsRrJqIPA=="],
+
+ "react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="],
+
+ "react-resizable": ["react-resizable@3.1.3", "", { "dependencies": { "prop-types": "15.x", "react-draggable": "^4.5.0" }, "peerDependencies": { "react": ">= 16.3", "react-dom": ">= 16.3" } }, "sha512-liJBNayhX7qA4tBJiBD321FDhJxgGTJ07uzH5zSORXoE8h7PyEZ8mLqmosST7ppf6C4zUsbd2gzDMmBCfFp9Lw=="],
+
+ "react-resizable-panels": ["react-resizable-panels@2.1.9", "", { "peerDependencies": { "react": "^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-z77+X08YDIrgAes4jl8xhnUu1LNIRp4+E7cv4xHmLOxxUPO/ML7PSrE813b90vj7xvQ1lcf7g2uA9GeMZonjhQ=="],
+
+ "react-router": ["react-router@6.30.1", "", { "dependencies": { "@remix-run/router": "1.23.0" }, "peerDependencies": { "react": ">=16.8" } }, "sha512-X1m21aEmxGXqENEPG3T6u0Th7g0aS4ZmoNynhbs+Cn+q+QGTLt+d5IQ2bHAXKzKcxGJjxACpVbnYQSCRcfxHlQ=="],
+
+ "react-router-dom": ["react-router-dom@6.30.1", "", { "dependencies": { "@remix-run/router": "1.23.0", "react-router": "6.30.1" }, "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-llKsgOkZdbPU1Eg3zK8lCn+sjD9wMRZZPuzmdWWX5SUs8OFkN5HnFVC0u5KMeMaC9aoancFI/KoLuKPqN+hxHw=="],
+
+ "react-smooth": ["react-smooth@4.0.4", "", { "dependencies": { "fast-equals": "^5.0.1", "prop-types": "^15.8.1", "react-transition-group": "^4.4.5" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q=="],
+
+ "react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="],
+
+ "react-transition-group": ["react-transition-group@4.4.5", "", { "dependencies": { "@babel/runtime": "^7.5.5", "dom-helpers": "^5.0.1", "loose-envify": "^1.4.0", "prop-types": "^15.6.2" }, "peerDependencies": { "react": ">=16.6.0", "react-dom": ">=16.6.0" } }, "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g=="],
+
+ "read-cache": ["read-cache@1.0.0", "", { "dependencies": { "pify": "^2.3.0" } }, "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA=="],
+
+ "readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="],
+
+ "readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
+
+ "recharts": ["recharts@2.15.4", "", { "dependencies": { "clsx": "^2.0.0", "eventemitter3": "^4.0.1", "lodash": "^4.17.21", "react-is": "^18.3.1", "react-smooth": "^4.0.4", "recharts-scale": "^0.4.4", "tiny-invariant": "^1.3.1", "victory-vendor": "^36.6.8" }, "peerDependencies": { "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw=="],
+
+ "recharts-scale": ["recharts-scale@0.4.5", "", { "dependencies": { "decimal.js-light": "^2.4.1" } }, "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w=="],
+
+ "redent": ["redent@3.0.0", "", { "dependencies": { "indent-string": "^4.0.0", "strip-indent": "^3.0.0" } }, "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg=="],
+
+ "redux": ["redux@5.0.1", "", {}, "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w=="],
+
+ "regenerator-runtime": ["regenerator-runtime@0.13.11", "", {}, "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg=="],
+
+ "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
+
+ "require-main-filename": ["require-main-filename@2.0.0", "", {}, "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg=="],
+
+ "requires-port": ["requires-port@1.0.0", "", {}, "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ=="],
+
+ "resize-observer-polyfill": ["resize-observer-polyfill@1.5.1", "", {}, "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg=="],
+
+ "resolve": ["resolve@1.22.8", "", { "dependencies": { "is-core-module": "^2.13.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": "bin/resolve" }, "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw=="],
+
+ "resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
+
+ "resolve-protobuf-schema": ["resolve-protobuf-schema@2.1.0", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz", { "dependencies": { "protocol-buffers-schema": "^3.3.1" } }, "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ=="],
+
+ "reusify": ["reusify@1.0.4", "", {}, "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw=="],
+
+ "rgbcolor": ["rgbcolor@1.0.1", "", {}, "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw=="],
+
+ "rimraf": ["rimraf@3.0.2", "", { "dependencies": { "glob": "^7.1.3" }, "bin": "bin.js" }, "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA=="],
+
+ "robust-predicates": ["robust-predicates@2.0.4", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/robust-predicates/-/robust-predicates-2.0.4.tgz", {}, "sha512-l4NwboJM74Ilm4VKfbAtFeGq7aEjWL+5kVFcmgFA2MrdnQWx9iE/tUGvxY5HyMI7o/WpSIUFLbC5fbeaHgSCYg=="],
+
+ "rollup": ["rollup@4.24.0", "", { "dependencies": { "@types/estree": "1.0.6" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.24.0", "@rollup/rollup-android-arm64": "4.24.0", "@rollup/rollup-darwin-arm64": "4.24.0", "@rollup/rollup-darwin-x64": "4.24.0", "@rollup/rollup-linux-arm-gnueabihf": "4.24.0", "@rollup/rollup-linux-arm-musleabihf": "4.24.0", "@rollup/rollup-linux-arm64-gnu": "4.24.0", "@rollup/rollup-linux-arm64-musl": "4.24.0", "@rollup/rollup-linux-powerpc64le-gnu": "4.24.0", "@rollup/rollup-linux-riscv64-gnu": "4.24.0", "@rollup/rollup-linux-s390x-gnu": "4.24.0", "@rollup/rollup-linux-x64-gnu": "4.24.0", "@rollup/rollup-linux-x64-musl": "4.24.0", "@rollup/rollup-win32-arm64-msvc": "4.24.0", "@rollup/rollup-win32-ia32-msvc": "4.24.0", "@rollup/rollup-win32-x64-msvc": "4.24.0", "fsevents": "~2.3.2" }, "bin": "dist/bin/rollup" }, "sha512-DOmrlGSXNk1DM0ljiQA+i+o0rSLhtii1je5wgk60j49d1jHT5YYttBv1iWOnYSTG+fZZESUOSNiAl89SIet+Cg=="],
+
+ "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="],
+
+ "safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="],
+
+ "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
+
+ "saxes": ["saxes@6.0.0", "", { "dependencies": { "xmlchars": "^2.2.0" } }, "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA=="],
+
+ "scheduler": ["scheduler@0.23.2", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ=="],
+
+ "selderee": ["selderee@0.11.0", "", { "dependencies": { "parseley": "^0.12.0" } }, "sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA=="],
+
+ "semver": ["semver@7.7.2", "", { "bin": "bin/semver.js" }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
+
+ "set-blocking": ["set-blocking@2.0.0", "", {}, "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw=="],
+
+ "setimmediate": ["setimmediate@1.0.5", "", {}, "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA=="],
+
+ "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
+
+ "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
+
+ "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="],
+
+ "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
+
+ "simple-concat": ["simple-concat@1.0.1", "", {}, "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q=="],
+
+ "simple-get": ["simple-get@3.1.1", "", { "dependencies": { "decompress-response": "^4.2.0", "once": "^1.3.1", "simple-concat": "^1.0.0" } }, "sha512-CQ5LTKGfCpvE1K0n2us+kuMPbk/q0EKl82s4aheV9oXjFEz6W/Y7oQFVJuU6QG77hRT4Ghb5RURteF5vnWjupA=="],
+
+ "sonner": ["sonner@1.7.4", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-DIS8z4PfJRbIyfVFDVnK9rO3eYDtse4Omcm6bt0oEr5/jtLgysmjuBl1frJ9E/EQZrFmKx2A8m/s5s9CRXIzhw=="],
+
+ "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
+
+ "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
+
+ "splaytree": ["splaytree@0.1.4", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/splaytree/-/splaytree-0.1.4.tgz", {}, "sha512-D50hKrjZgBzqD3FT2Ek53f2dcDLAQT8SSGrzj3vidNH5ISRgceeGVJ2dQIthKOuayqFXfFjXheHNo4bbt9LhRQ=="],
+
+ "sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="],
+
+ "ssf": ["ssf@0.11.2", "", { "dependencies": { "frac": "~1.1.2" } }, "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g=="],
+
+ "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
+
+ "stackblur-canvas": ["stackblur-canvas@2.7.0", "", {}, "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ=="],
+
+ "std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="],
+
+ "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
+
+ "string-width-cjs": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
+
+ "string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
+
+ "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
+
+ "strip-ansi-cjs": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
+
+ "strip-indent": ["strip-indent@3.0.0", "", { "dependencies": { "min-indent": "^1.0.0" } }, "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ=="],
+
+ "strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="],
+
+ "strip-literal": ["strip-literal@3.1.0", "", { "dependencies": { "js-tokens": "^9.0.1" } }, "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg=="],
+
+ "sucrase": ["sucrase@3.35.0", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", "commander": "^4.0.0", "glob": "^10.3.10", "lines-and-columns": "^1.1.6", "mz": "^2.7.0", "pirates": "^4.0.1", "ts-interface-checker": "^0.1.9" }, "bin": { "sucrase": "bin/sucrase", "sucrase-node": "bin/sucrase-node" } }, "sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA=="],
+
+ "supercluster": ["supercluster@8.0.1", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/supercluster/-/supercluster-8.0.1.tgz", { "dependencies": { "kdbush": "^4.0.2" } }, "sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ=="],
+
+ "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
+
+ "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
+
+ "svg-pathdata": ["svg-pathdata@6.0.3", "", {}, "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw=="],
+
+ "symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="],
+
+ "tailwind-merge": ["tailwind-merge@2.6.0", "", {}, "sha512-P+Vu1qXfzediirmHOC3xKGAYeZtPcV9g76X+xg2FD4tYgR71ewMA35Y3sCz3zhiN/dwefRpJX0yBcgwi1fXNQA=="],
+
+ "tailwindcss": ["tailwindcss@3.4.17", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.6.0", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.3.2", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.21.6", "lilconfig": "^3.1.3", "micromatch": "^4.0.8", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.1.1", "postcss": "^8.4.47", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.2", "postcss-nested": "^6.2.0", "postcss-selector-parser": "^6.1.2", "resolve": "^1.22.8", "sucrase": "^3.35.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og=="],
+
+ "tailwindcss-animate": ["tailwindcss-animate@1.0.7", "", { "peerDependencies": { "tailwindcss": ">=3.0.0 || insiders" } }, "sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA=="],
+
+ "tar": ["tar@6.2.1", "", { "dependencies": { "chownr": "^2.0.0", "fs-minipass": "^2.0.0", "minipass": "^5.0.0", "minizlib": "^2.1.1", "mkdirp": "^1.0.3", "yallist": "^4.0.0" } }, "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A=="],
+
+ "text-segmentation": ["text-segmentation@1.0.3", "", { "dependencies": { "utrie": "^1.0.2" } }, "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw=="],
+
+ "thenify": ["thenify@3.3.1", "", { "dependencies": { "any-promise": "^1.0.0" } }, "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw=="],
+
+ "thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="],
+
+ "tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="],
+
+ "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="],
+
+ "tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="],
+
+ "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
+
+ "tinypool": ["tinypool@1.1.1", "", {}, "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg=="],
+
+ "tinyqueue": ["tinyqueue@3.0.0", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/tinyqueue/-/tinyqueue-3.0.0.tgz", {}, "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g=="],
+
+ "tinyrainbow": ["tinyrainbow@2.0.0", "", {}, "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw=="],
+
+ "tinyspy": ["tinyspy@4.0.4", "", {}, "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q=="],
+
+ "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
+
+ "tough-cookie": ["tough-cookie@4.1.4", "", { "dependencies": { "psl": "^1.1.33", "punycode": "^2.1.1", "universalify": "^0.2.0", "url-parse": "^1.5.3" } }, "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag=="],
+
+ "tr46": ["tr46@3.0.0", "", { "dependencies": { "punycode": "^2.1.1" } }, "sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA=="],
+
+ "ts-api-utils": ["ts-api-utils@2.1.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ=="],
+
+ "ts-interface-checker": ["ts-interface-checker@0.1.13", "", {}, "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA=="],
+
+ "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
+
+ "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
+
+ "typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="],
+
+ "typescript-eslint": ["typescript-eslint@8.38.0", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.38.0", "@typescript-eslint/parser": "8.38.0", "@typescript-eslint/typescript-estree": "8.38.0", "@typescript-eslint/utils": "8.38.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-FsZlrYK6bPDGoLeZRuvx2v6qrM03I0U0SnfCLPs/XCCPCFD80xU9Pg09H/K+XFa68uJuZo7l/Xhs+eDRg2l3hg=="],
+
+ "underscore": ["underscore@1.13.8", "", {}, "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ=="],
+
+ "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
+
+ "universalify": ["universalify@0.2.0", "", {}, "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg=="],
+
+ "update-browserslist-db": ["update-browserslist-db@1.1.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": "cli.js" }, "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw=="],
+
+ "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
+
+ "url-parse": ["url-parse@1.5.10", "", { "dependencies": { "querystringify": "^2.1.1", "requires-port": "^1.0.0" } }, "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ=="],
+
+ "use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="],
+
+ "use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="],
+
+ "use-sync-external-store": ["use-sync-external-store@1.5.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A=="],
+
+ "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
+
+ "utrie": ["utrie@1.0.2", "", { "dependencies": { "base64-arraybuffer": "^1.0.2" } }, "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw=="],
+
+ "uuid": ["uuid@13.0.0", "", { "bin": "dist-node/bin/uuid" }, "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w=="],
+
+ "vaul": ["vaul@0.9.9", "", { "dependencies": { "@radix-ui/react-dialog": "^1.1.1" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0", "react-dom": "^16.8 || ^17.0 || ^18.0" } }, "sha512-7afKg48srluhZwIkaU+lgGtFCUsYBSGOl8vcc8N/M3YQlZFlynHD15AE+pwrYdc826o7nrIND4lL9Y6b9WWZZQ=="],
+
+ "victory-vendor": ["victory-vendor@36.9.2", "", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ=="],
+
+ "vite": ["vite@5.4.19", "", { "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", "rollup": "^4.20.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || >=20.0.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" }, "optionalPeers": ["less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser"], "bin": "bin/vite.js" }, "sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA=="],
+
+ "vite-node": ["vite-node@3.2.4", "", { "dependencies": { "cac": "^6.7.14", "debug": "^4.4.1", "es-module-lexer": "^1.7.0", "pathe": "^2.0.3", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "bin": "vite-node.mjs" }, "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg=="],
+
+ "vitest": ["vitest@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", "@vitest/mocker": "3.2.4", "@vitest/pretty-format": "^3.2.4", "@vitest/runner": "3.2.4", "@vitest/snapshot": "3.2.4", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "debug": "^4.4.1", "expect-type": "^1.2.1", "magic-string": "^0.30.17", "pathe": "^2.0.3", "picomatch": "^4.0.2", "std-env": "^3.9.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.2", "tinyglobby": "^0.2.14", "tinypool": "^1.1.1", "tinyrainbow": "^2.0.0", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", "vite-node": "3.2.4", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@types/debug": "^4.1.12", "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "@vitest/browser": "3.2.4", "@vitest/ui": "3.2.4", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@types/debug", "@vitest/browser", "@vitest/ui", "happy-dom"], "bin": "vitest.mjs" }, "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A=="],
+
+ "w3c-xmlserializer": ["w3c-xmlserializer@4.0.0", "", { "dependencies": { "xml-name-validator": "^4.0.0" } }, "sha512-d+BFHzbiCx6zGfz0HyQ6Rg69w9k19nviJspaj4yNscGjrHu94sVP+aRm75yEbCh+r2/yR+7q6hux9LVtbuTGBw=="],
+
+ "warning": ["warning@4.0.3", "", { "dependencies": { "loose-envify": "^1.0.0" } }, "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w=="],
+
+ "webidl-conversions": ["webidl-conversions@7.0.0", "", {}, "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g=="],
+
+ "whatwg-encoding": ["whatwg-encoding@2.0.0", "", { "dependencies": { "iconv-lite": "0.6.3" } }, "sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg=="],
+
+ "whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="],
+
+ "whatwg-url": ["whatwg-url@11.0.0", "", { "dependencies": { "tr46": "^3.0.0", "webidl-conversions": "^7.0.0" } }, "sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ=="],
+
+ "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
+
+ "which-module": ["which-module@2.0.1", "", {}, "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ=="],
+
+ "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": "cli.js" }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="],
+
+ "wide-align": ["wide-align@1.1.5", "", { "dependencies": { "string-width": "^1.0.2 || 2 || 3 || 4" } }, "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg=="],
+
+ "wmf": ["wmf@1.0.2", "", {}, "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw=="],
+
+ "word": ["word@0.3.0", "", {}, "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA=="],
+
+ "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
+
+ "wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="],
+
+ "wrap-ansi-cjs": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
+
+ "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
+
+ "ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="],
+
+ "xlsx": ["xlsx@0.18.5", "", { "dependencies": { "adler-32": "~1.3.0", "cfb": "~1.2.1", "codepage": "~1.15.0", "crc-32": "~1.2.1", "ssf": "~0.11.2", "wmf": "~1.0.1", "word": "~0.3.0" }, "bin": "bin/xlsx.njs" }, "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ=="],
+
+ "xml-name-validator": ["xml-name-validator@4.0.0", "", {}, "sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw=="],
+
+ "xmlbuilder": ["xmlbuilder@10.1.1", "", {}, "sha512-OyzrcFLL/nb6fMGHbiRDuPup9ljBycsdCypwuyg5AAHvyWzGfChJpCXMG88AGTIMFhGZ9RccFN1e6lhg3hkwKg=="],
+
+ "xmlchars": ["xmlchars@2.2.0", "", {}, "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="],
+
+ "y18n": ["y18n@4.0.3", "", {}, "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ=="],
+
+ "yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
+
+ "yaml": ["yaml@2.6.0", "", { "bin": "bin.mjs" }, "sha512-a6ae//JvKDEra2kdi1qzCyrJW/WZCgFi8ydDV+eXExl95t+5R+ijnqHJbz9tmMh8FUjx3iv2fCQ4dclAQlO2UQ=="],
+
+ "yargs": ["yargs@15.4.1", "", { "dependencies": { "cliui": "^6.0.0", "decamelize": "^1.2.0", "find-up": "^4.1.0", "get-caller-file": "^2.0.1", "require-directory": "^2.1.1", "require-main-filename": "^2.0.0", "set-blocking": "^2.0.0", "string-width": "^4.2.0", "which-module": "^2.0.0", "y18n": "^4.0.0", "yargs-parser": "^18.1.2" } }, "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A=="],
+
+ "yargs-parser": ["yargs-parser@18.1.3", "", { "dependencies": { "camelcase": "^5.0.0", "decamelize": "^1.2.0" } }, "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ=="],
+
+ "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
+
+ "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
+
+ "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
+
+ "@eslint/eslintrc/globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="],
+
+ "@humanfs/node/@humanwhocodes/retry": ["@humanwhocodes/retry@0.3.1", "", {}, "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA=="],
+
+ "@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="],
+
+ "@isaacs/cliui/strip-ansi": ["strip-ansi@7.1.0", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ=="],
+
+ "@isaacs/cliui/wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="],
+
+ "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="],
+
+ "@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
+
+ "anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
+
+ "are-we-there-yet/readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
+
+ "chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
+
+ "cssstyle/cssom": ["cssom@0.3.8", "", {}, "sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg=="],
+
+ "dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
+
+ "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
+
+ "fast-png/pako": ["pako@2.1.0", "", {}, "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug=="],
+
+ "fs-minipass/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="],
+
+ "gauge/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
+
+ "glob/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
+
+ "htmlparser2/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
+
+ "js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
+
+ "make-dir/semver": ["semver@6.3.1", "", { "bin": "bin/semver.js" }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
+
+ "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
+
+ "minizlib/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="],
+
+ "node-fetch/whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="],
+
+ "pdf-lib/tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="],
+
+ "playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
+
+ "postcss-nested/postcss-selector-parser": ["postcss-selector-parser@6.1.2", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg=="],
+
+ "prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
+
+ "quill/eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="],
+
+ "react-smooth/fast-equals": ["fast-equals@5.2.2", "", {}, "sha512-V7/RktU11J3I36Nwq2JnZEM7tNm17eBJz+u25qdxBZeCKiX6BkVSZQjwWIr+IobgnZy+ag73tTZgZi7tr0LrBw=="],
+
+ "readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
+
+ "rimraf/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="],
+
+ "strip-literal/js-tokens": ["js-tokens@9.0.1", "", {}, "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ=="],
+
+ "tailwindcss/postcss-selector-parser": ["postcss-selector-parser@6.1.2", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg=="],
+
+ "tar/minipass": ["minipass@5.0.0", "", {}, "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ=="],
+
+ "vite/esbuild": ["esbuild@0.21.5", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.21.5", "@esbuild/android-arm": "0.21.5", "@esbuild/android-arm64": "0.21.5", "@esbuild/android-x64": "0.21.5", "@esbuild/darwin-arm64": "0.21.5", "@esbuild/darwin-x64": "0.21.5", "@esbuild/freebsd-arm64": "0.21.5", "@esbuild/freebsd-x64": "0.21.5", "@esbuild/linux-arm": "0.21.5", "@esbuild/linux-arm64": "0.21.5", "@esbuild/linux-ia32": "0.21.5", "@esbuild/linux-loong64": "0.21.5", "@esbuild/linux-mips64el": "0.21.5", "@esbuild/linux-ppc64": "0.21.5", "@esbuild/linux-riscv64": "0.21.5", "@esbuild/linux-s390x": "0.21.5", "@esbuild/linux-x64": "0.21.5", "@esbuild/netbsd-x64": "0.21.5", "@esbuild/openbsd-x64": "0.21.5", "@esbuild/sunos-x64": "0.21.5", "@esbuild/win32-arm64": "0.21.5", "@esbuild/win32-ia32": "0.21.5", "@esbuild/win32-x64": "0.21.5" }, "bin": "bin/esbuild" }, "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw=="],
+
+ "yargs/find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="],
+
+ "@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="],
+
+ "@isaacs/cliui/strip-ansi/ansi-regex": ["ansi-regex@6.1.0", "", {}, "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA=="],
+
+ "@isaacs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.1", "", {}, "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug=="],
+
+ "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
+
+ "glob/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
+
+ "node-fetch/whatwg-url/tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="],
+
+ "node-fetch/whatwg-url/webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
+
+ "vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.21.5", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ=="],
+
+ "vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.21.5", "", { "os": "android", "cpu": "arm" }, "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg=="],
+
+ "vite/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.21.5", "", { "os": "android", "cpu": "arm64" }, "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A=="],
+
+ "vite/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.21.5", "", { "os": "android", "cpu": "x64" }, "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA=="],
+
+ "vite/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.21.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ=="],
+
+ "vite/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.21.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw=="],
+
+ "vite/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.21.5", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g=="],
+
+ "vite/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.21.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ=="],
+
+ "vite/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.21.5", "", { "os": "linux", "cpu": "arm" }, "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA=="],
+
+ "vite/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.21.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q=="],
+
+ "vite/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.21.5", "", { "os": "linux", "cpu": "ia32" }, "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg=="],
+
+ "vite/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg=="],
+
+ "vite/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg=="],
+
+ "vite/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.21.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w=="],
+
+ "vite/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA=="],
+
+ "vite/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.21.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A=="],
+
+ "vite/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.21.5", "", { "os": "linux", "cpu": "x64" }, "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ=="],
+
+ "vite/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.21.5", "", { "os": "none", "cpu": "x64" }, "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg=="],
+
+ "vite/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.21.5", "", { "os": "openbsd", "cpu": "x64" }, "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow=="],
+
+ "vite/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.21.5", "", { "os": "sunos", "cpu": "x64" }, "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg=="],
+
+ "vite/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.21.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A=="],
+
+ "vite/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.21.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA=="],
+
+ "vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.21.5", "", { "os": "win32", "cpu": "x64" }, "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw=="],
+
+ "yargs/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
+
+ "yargs/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="],
+
+ "yargs/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="],
+ }
+}
diff --git a/bun.lockb b/bun.lockb
new file mode 100755
index 0000000..330d007
Binary files /dev/null and b/bun.lockb differ
diff --git a/components.json b/components.json
new file mode 100644
index 0000000..62e1011
--- /dev/null
+++ b/components.json
@@ -0,0 +1,20 @@
+{
+ "$schema": "https://ui.shadcn.com/schema.json",
+ "style": "default",
+ "rsc": false,
+ "tsx": true,
+ "tailwind": {
+ "config": "tailwind.config.ts",
+ "css": "src/index.css",
+ "baseColor": "slate",
+ "cssVariables": true,
+ "prefix": ""
+ },
+ "aliases": {
+ "components": "@/components",
+ "utils": "@/lib/utils",
+ "ui": "@/components/ui",
+ "lib": "@/lib",
+ "hooks": "@/hooks"
+ }
+}
diff --git a/eslint.config.js b/eslint.config.js
new file mode 100644
index 0000000..40f72cc
--- /dev/null
+++ b/eslint.config.js
@@ -0,0 +1,26 @@
+import js from "@eslint/js";
+import globals from "globals";
+import reactHooks from "eslint-plugin-react-hooks";
+import reactRefresh from "eslint-plugin-react-refresh";
+import tseslint from "typescript-eslint";
+
+export default tseslint.config(
+ { ignores: ["dist"] },
+ {
+ extends: [js.configs.recommended, ...tseslint.configs.recommended],
+ files: ["**/*.{ts,tsx}"],
+ languageOptions: {
+ ecmaVersion: 2020,
+ globals: globals.browser,
+ },
+ plugins: {
+ "react-hooks": reactHooks,
+ "react-refresh": reactRefresh,
+ },
+ rules: {
+ ...reactHooks.configs.recommended.rules,
+ "react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
+ "@typescript-eslint/no-unused-vars": "off",
+ },
+ },
+);
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..dfaa736
--- /dev/null
+++ b/index.html
@@ -0,0 +1,41 @@
+
+
+
+
+
+
+ Community Cloud | Avria Community Management
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/mem/preferences/external-supabase.md b/mem/preferences/external-supabase.md
new file mode 100644
index 0000000..15da0b6
--- /dev/null
+++ b/mem/preferences/external-supabase.md
@@ -0,0 +1,12 @@
+---
+name: External Supabase target
+description: Always target external Supabase yqdefzjapnzabowsgoyd, never Lovable Cloud
+type: preference
+---
+The live app uses external Supabase project `yqdefzjapnzabowsgoyd` (set in `vite.config.ts`), NOT Lovable Cloud (`xckpovwdmnlravxzurcr`).
+
+**How to apply:**
+- Never apply migrations or deploy edge functions to Lovable Cloud for this project.
+- Package any DB migrations + edge function changes as a deploy bundle in `/mnt/documents/` for the user to apply against the external project.
+- For DB inspection/debugging, ask the user to run queries on the external project — do not rely on Lovable Cloud `read_query` results, they reflect the wrong DB.
+- Code changes (frontend, edge function source) still go in the repo as normal.
\ No newline at end of file
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..40e7f4d
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,10561 @@
+{
+ "name": "vite_react_shadcn_ts",
+ "version": "0.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "vite_react_shadcn_ts",
+ "version": "0.0.0",
+ "dependencies": {
+ "@dnd-kit/core": "^6.3.1",
+ "@dnd-kit/sortable": "^10.0.0",
+ "@dnd-kit/utilities": "^3.2.2",
+ "@hello-pangea/dnd": "^18.0.1",
+ "@hookform/resolvers": "^3.10.0",
+ "@lovable.dev/cloud-auth-js": "^1.1.1",
+ "@radix-ui/react-accordion": "^1.2.11",
+ "@radix-ui/react-alert-dialog": "^1.1.14",
+ "@radix-ui/react-aspect-ratio": "^1.1.7",
+ "@radix-ui/react-avatar": "^1.1.10",
+ "@radix-ui/react-checkbox": "^1.3.2",
+ "@radix-ui/react-collapsible": "^1.1.11",
+ "@radix-ui/react-context-menu": "^2.2.15",
+ "@radix-ui/react-dialog": "^1.1.14",
+ "@radix-ui/react-dropdown-menu": "^2.1.15",
+ "@radix-ui/react-hover-card": "^1.1.14",
+ "@radix-ui/react-label": "^2.1.7",
+ "@radix-ui/react-menubar": "^1.1.15",
+ "@radix-ui/react-navigation-menu": "^1.2.13",
+ "@radix-ui/react-popover": "^1.1.14",
+ "@radix-ui/react-progress": "^1.1.7",
+ "@radix-ui/react-radio-group": "^1.3.7",
+ "@radix-ui/react-scroll-area": "^1.2.9",
+ "@radix-ui/react-select": "^2.2.5",
+ "@radix-ui/react-separator": "^1.1.7",
+ "@radix-ui/react-slider": "^1.3.5",
+ "@radix-ui/react-slot": "^1.2.3",
+ "@radix-ui/react-switch": "^1.2.5",
+ "@radix-ui/react-tabs": "^1.1.12",
+ "@radix-ui/react-toast": "^1.2.14",
+ "@radix-ui/react-toggle": "^1.1.9",
+ "@radix-ui/react-toggle-group": "^1.1.10",
+ "@radix-ui/react-tooltip": "^1.2.7",
+ "@stripe/react-stripe-js": "^5.6.1",
+ "@stripe/stripe-js": "^8.10.0",
+ "@supabase/supabase-js": "^2.99.1",
+ "@tanstack/react-query": "^5.83.0",
+ "@types/google.maps": "^3.58.1",
+ "class-variance-authority": "^0.7.1",
+ "clsx": "^2.1.1",
+ "cmdk": "^1.1.1",
+ "date-fns": "^3.6.0",
+ "date-fns-tz": "^3.2.0",
+ "embla-carousel-react": "^8.6.0",
+ "exifr": "^7.1.3",
+ "framer-motion": "^12.36.0",
+ "html-to-text": "9.0.5",
+ "html2canvas": "^1.4.1",
+ "ical.js": "^2.2.1",
+ "input-otp": "^1.4.2",
+ "jspdf": "^4.2.1",
+ "jspdf-autotable": "^5.0.7",
+ "lucide-react": "^0.462.0",
+ "mammoth": "^1.12.0",
+ "next-themes": "^0.3.0",
+ "pdf-lib": "^1.17.1",
+ "pdfjs-dist": "4.4.168",
+ "qrcode": "^1.5.4",
+ "react": "^18.3.1",
+ "react-day-picker": "^8.10.1",
+ "react-dom": "^18.3.1",
+ "react-dropzone": "^15.0.0",
+ "react-grid-layout": "^2.2.2",
+ "react-hook-form": "^7.61.1",
+ "react-pdf": "9.1.1",
+ "react-quill-new": "^3.8.3",
+ "react-resizable": "^3.1.3",
+ "react-resizable-panels": "^2.1.9",
+ "react-router-dom": "^6.30.1",
+ "recharts": "^2.15.4",
+ "sonner": "^1.7.4",
+ "tailwind-merge": "^2.6.0",
+ "tailwindcss-animate": "^1.0.7",
+ "uuid": "^13.0.0",
+ "vaul": "^0.9.9",
+ "xlsx": "^0.18.5",
+ "zod": "^3.25.76"
+ },
+ "devDependencies": {
+ "@eslint/js": "^9.32.0",
+ "@playwright/test": "^1.57.0",
+ "@tailwindcss/typography": "^0.5.16",
+ "@testing-library/jest-dom": "^6.6.0",
+ "@testing-library/react": "^16.0.0",
+ "@types/node": "^22.16.5",
+ "@types/react": "^18.3.23",
+ "@types/react-dom": "^18.3.7",
+ "@vitejs/plugin-react-swc": "^3.11.0",
+ "autoprefixer": "^10.4.21",
+ "eslint": "^9.32.0",
+ "eslint-plugin-react-hooks": "^5.2.0",
+ "eslint-plugin-react-refresh": "^0.4.20",
+ "globals": "^15.15.0",
+ "jsdom": "^20.0.3",
+ "lovable-tagger": "^1.1.13",
+ "postcss": "^8.5.6",
+ "tailwindcss": "^3.4.17",
+ "typescript": "^5.8.3",
+ "typescript-eslint": "^8.38.0",
+ "vite": "^5.4.19",
+ "vitest": "^3.2.4"
+ }
+ },
+ "node_modules/@adobe/css-tools": {
+ "version": "4.4.4",
+ "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz",
+ "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@alloc/quick-lru": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
+ "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/@babel/runtime": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz",
+ "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@dnd-kit/accessibility": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
+ "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.8.0"
+ }
+ },
+ "node_modules/@dnd-kit/core": {
+ "version": "6.3.1",
+ "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
+ "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@dnd-kit/accessibility": "^3.1.1",
+ "@dnd-kit/utilities": "^3.2.2",
+ "tslib": "^2.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.8.0",
+ "react-dom": ">=16.8.0"
+ }
+ },
+ "node_modules/@dnd-kit/sortable": {
+ "version": "10.0.0",
+ "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
+ "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
+ "license": "MIT",
+ "dependencies": {
+ "@dnd-kit/utilities": "^3.2.2",
+ "tslib": "^2.0.0"
+ },
+ "peerDependencies": {
+ "@dnd-kit/core": "^6.3.0",
+ "react": ">=16.8.0"
+ }
+ },
+ "node_modules/@dnd-kit/utilities": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
+ "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.8.0"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
+ "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz",
+ "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz",
+ "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz",
+ "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz",
+ "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz",
+ "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz",
+ "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz",
+ "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz",
+ "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz",
+ "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz",
+ "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz",
+ "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz",
+ "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz",
+ "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz",
+ "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz",
+ "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",
+ "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.25.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.0.tgz",
+ "integrity": "sha512-RuG4PSMPFfrkH6UwCAqBzauBWTygTvb1nxWasEJooGSJ/NwRw7b2HOwyRTQIU97Hq37l3npXoZGYMy3b3xYvPw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
+ "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.25.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.0.tgz",
+ "integrity": "sha512-21sUNbq2r84YE+SJDfaQRvdgznTD8Xc0oc3p3iW/a1EVWeNj/SdUCbm5U0itZPQYRuRTW20fPMWMpcrciH2EJw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
+ "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
+ "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz",
+ "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz",
+ "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz",
+ "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@eslint-community/eslint-utils": {
+ "version": "4.7.0",
+ "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz",
+ "integrity": "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "eslint-visitor-keys": "^3.4.3"
+ },
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0"
+ }
+ },
+ "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": {
+ "version": "3.4.3",
+ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz",
+ "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/@eslint-community/regexpp": {
+ "version": "4.12.1",
+ "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz",
+ "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^12.0.0 || ^14.0.0 || >=16.0.0"
+ }
+ },
+ "node_modules/@eslint/config-array": {
+ "version": "0.21.0",
+ "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.0.tgz",
+ "integrity": "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@eslint/object-schema": "^2.1.6",
+ "debug": "^4.3.1",
+ "minimatch": "^3.1.2"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ }
+ },
+ "node_modules/@eslint/config-helpers": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.3.0.tgz",
+ "integrity": "sha512-ViuymvFmcJi04qdZeDc2whTHryouGcDlaxPqarTD0ZE10ISpxGUVZGZDx4w01upyIynL3iu6IXH2bS1NhclQMw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ }
+ },
+ "node_modules/@eslint/core": {
+ "version": "0.15.1",
+ "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.1.tgz",
+ "integrity": "sha512-bkOp+iumZCCbt1K1CmWf0R9pM5yKpDv+ZXtvSyQpudrI9kuFLp+bM2WOPXImuD/ceQuaa8f5pj93Y7zyECIGNA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@types/json-schema": "^7.0.15"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ }
+ },
+ "node_modules/@eslint/eslintrc": {
+ "version": "3.3.1",
+ "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz",
+ "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ajv": "^6.12.4",
+ "debug": "^4.3.2",
+ "espree": "^10.0.1",
+ "globals": "^14.0.0",
+ "ignore": "^5.2.0",
+ "import-fresh": "^3.2.1",
+ "js-yaml": "^4.1.0",
+ "minimatch": "^3.1.2",
+ "strip-json-comments": "^3.1.1"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/@eslint/eslintrc/node_modules/globals": {
+ "version": "14.0.0",
+ "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz",
+ "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/@eslint/js": {
+ "version": "9.32.0",
+ "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.32.0.tgz",
+ "integrity": "sha512-BBpRFZK3eX6uMLKz8WxFOBIFFcGFJ/g8XuwjTHCqHROSIsopI+ddn/d5Cfh36+7+e5edVS8dbSHnBNhrLEX0zg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://eslint.org/donate"
+ }
+ },
+ "node_modules/@eslint/object-schema": {
+ "version": "2.1.6",
+ "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz",
+ "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ }
+ },
+ "node_modules/@eslint/plugin-kit": {
+ "version": "0.3.4",
+ "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.4.tgz",
+ "integrity": "sha512-Ul5l+lHEcw3L5+k8POx6r74mxEYKG5kOb6Xpy2gCRW6zweT6TEhAf8vhxGgjhqrd/VO/Dirhsb+1hNpD1ue9hw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@eslint/core": "^0.15.1",
+ "levn": "^0.4.1"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ }
+ },
+ "node_modules/@floating-ui/core": {
+ "version": "1.7.2",
+ "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.2.tgz",
+ "integrity": "sha512-wNB5ooIKHQc+Kui96jE/n69rHFWAVoxn5CAzL1Xdd8FG03cgY3MLO+GF9U3W737fYDSgPWA6MReKhBQBop6Pcw==",
+ "license": "MIT",
+ "dependencies": {
+ "@floating-ui/utils": "^0.2.10"
+ }
+ },
+ "node_modules/@floating-ui/dom": {
+ "version": "1.7.2",
+ "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.2.tgz",
+ "integrity": "sha512-7cfaOQuCS27HD7DX+6ib2OrnW+b4ZBwDNnCcT0uTyidcmyWb03FnQqJybDBoCnpdxwBSfA94UAYlRCt7mV+TbA==",
+ "license": "MIT",
+ "dependencies": {
+ "@floating-ui/core": "^1.7.2",
+ "@floating-ui/utils": "^0.2.10"
+ }
+ },
+ "node_modules/@floating-ui/react-dom": {
+ "version": "2.1.4",
+ "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.4.tgz",
+ "integrity": "sha512-JbbpPhp38UmXDDAu60RJmbeme37Jbgsm7NrHGgzYYFKmblzRUh6Pa641dII6LsjwF4XlScDrde2UAzDo/b9KPw==",
+ "license": "MIT",
+ "dependencies": {
+ "@floating-ui/dom": "^1.7.2"
+ },
+ "peerDependencies": {
+ "react": ">=16.8.0",
+ "react-dom": ">=16.8.0"
+ }
+ },
+ "node_modules/@floating-ui/utils": {
+ "version": "0.2.10",
+ "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz",
+ "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==",
+ "license": "MIT"
+ },
+ "node_modules/@hello-pangea/dnd": {
+ "version": "18.0.1",
+ "resolved": "https://registry.npmjs.org/@hello-pangea/dnd/-/dnd-18.0.1.tgz",
+ "integrity": "sha512-xojVWG8s/TGrKT1fC8K2tIWeejJYTAeJuj36zM//yEm/ZrnZUSFGS15BpO+jGZT1ybWvyXmeDJwPYb4dhWlbZQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@babel/runtime": "^7.26.7",
+ "css-box-model": "^1.2.1",
+ "raf-schd": "^4.0.3",
+ "react-redux": "^9.2.0",
+ "redux": "^5.0.1"
+ },
+ "peerDependencies": {
+ "react": "^18.0.0 || ^19.0.0",
+ "react-dom": "^18.0.0 || ^19.0.0"
+ }
+ },
+ "node_modules/@hookform/resolvers": {
+ "version": "3.10.0",
+ "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-3.10.0.tgz",
+ "integrity": "sha512-79Dv+3mDF7i+2ajj7SkypSKHhl1cbln1OGavqrsF7p6mbUv11xpqpacPsGDCTRvCSjEEIez2ef1NveSVL3b0Ag==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react-hook-form": "^7.0.0"
+ }
+ },
+ "node_modules/@humanfs/core": {
+ "version": "0.19.1",
+ "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
+ "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18.18.0"
+ }
+ },
+ "node_modules/@humanfs/node": {
+ "version": "0.16.6",
+ "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.6.tgz",
+ "integrity": "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@humanfs/core": "^0.19.1",
+ "@humanwhocodes/retry": "^0.3.0"
+ },
+ "engines": {
+ "node": ">=18.18.0"
+ }
+ },
+ "node_modules/@humanfs/node/node_modules/@humanwhocodes/retry": {
+ "version": "0.3.1",
+ "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.1.tgz",
+ "integrity": "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18.18"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/nzakas"
+ }
+ },
+ "node_modules/@humanwhocodes/module-importer": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz",
+ "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=12.22"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/nzakas"
+ }
+ },
+ "node_modules/@humanwhocodes/retry": {
+ "version": "0.4.3",
+ "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz",
+ "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18.18"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/nzakas"
+ }
+ },
+ "node_modules/@isaacs/cliui": {
+ "version": "8.0.2",
+ "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
+ "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "string-width": "^5.1.2",
+ "string-width-cjs": "npm:string-width@^4.2.0",
+ "strip-ansi": "^7.0.1",
+ "strip-ansi-cjs": "npm:strip-ansi@^6.0.1",
+ "wrap-ansi": "^8.1.0",
+ "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@jridgewell/gen-mapping": {
+ "version": "0.3.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz",
+ "integrity": "sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/set-array": "^1.2.1",
+ "@jridgewell/sourcemap-codec": "^1.4.10",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/set-array": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz",
+ "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.25",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz",
+ "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
+ "node_modules/@lovable.dev/cloud-auth-js": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@lovable.dev/cloud-auth-js/-/cloud-auth-js-1.1.1.tgz",
+ "integrity": "sha512-80elU8dSJG6bho0Xnfj2oy53wp883nYXrG1Wy948LC/ZUaUQ0i9EGXQFmwTLOBFrWqxb6aNaOlZUvQ8BVGhjMQ==",
+ "license": "MIT"
+ },
+ "node_modules/@mapbox/node-pre-gyp": {
+ "version": "1.0.11",
+ "resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz",
+ "integrity": "sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ==",
+ "license": "BSD-3-Clause",
+ "optional": true,
+ "dependencies": {
+ "detect-libc": "^2.0.0",
+ "https-proxy-agent": "^5.0.0",
+ "make-dir": "^3.1.0",
+ "node-fetch": "^2.6.7",
+ "nopt": "^5.0.0",
+ "npmlog": "^5.0.1",
+ "rimraf": "^3.0.2",
+ "semver": "^7.3.5",
+ "tar": "^6.1.11"
+ },
+ "bin": {
+ "node-pre-gyp": "bin/node-pre-gyp"
+ }
+ },
+ "node_modules/@nodelib/fs.scandir": {
+ "version": "2.1.5",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
+ "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@nodelib/fs.stat": "2.0.5",
+ "run-parallel": "^1.1.9"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@nodelib/fs.stat": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
+ "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@nodelib/fs.walk": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
+ "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@nodelib/fs.scandir": "2.1.5",
+ "fastq": "^1.6.0"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@pdf-lib/standard-fonts": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/@pdf-lib/standard-fonts/-/standard-fonts-1.0.0.tgz",
+ "integrity": "sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA==",
+ "license": "MIT",
+ "dependencies": {
+ "pako": "^1.0.6"
+ }
+ },
+ "node_modules/@pdf-lib/standard-fonts/node_modules/pako": {
+ "version": "1.0.11",
+ "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
+ "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
+ "license": "(MIT AND Zlib)"
+ },
+ "node_modules/@pdf-lib/upng": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@pdf-lib/upng/-/upng-1.0.1.tgz",
+ "integrity": "sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==",
+ "license": "MIT",
+ "dependencies": {
+ "pako": "^1.0.10"
+ }
+ },
+ "node_modules/@pdf-lib/upng/node_modules/pako": {
+ "version": "1.0.11",
+ "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
+ "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
+ "license": "(MIT AND Zlib)"
+ },
+ "node_modules/@pkgjs/parseargs": {
+ "version": "0.11.0",
+ "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
+ "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">=14"
+ }
+ },
+ "node_modules/@playwright/test": {
+ "version": "1.58.2",
+ "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
+ "integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright": "1.58.2"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@radix-ui/number": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz",
+ "integrity": "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==",
+ "license": "MIT"
+ },
+ "node_modules/@radix-ui/primitive": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.2.tgz",
+ "integrity": "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA==",
+ "license": "MIT"
+ },
+ "node_modules/@radix-ui/react-accordion": {
+ "version": "1.2.11",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.2.11.tgz",
+ "integrity": "sha512-l3W5D54emV2ues7jjeG1xcyN7S3jnK3zE2zHqgn0CmMsy9lNJwmgcrmaxS+7ipw15FAivzKNzH3d5EcGoFKw0A==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.2",
+ "@radix-ui/react-collapsible": "1.1.11",
+ "@radix-ui/react-collection": "1.1.7",
+ "@radix-ui/react-compose-refs": "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-primitive": "2.1.3",
+ "@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"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-alert-dialog": {
+ "version": "1.1.14",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.14.tgz",
+ "integrity": "sha512-IOZfZ3nPvN6lXpJTBCunFQPRSvK8MDgSc1FB85xnIpUKOw9en0dJj8JmCAxV7BiZdtYlUpmrQjoTFkVYtdoWzQ==",
+ "license": "MIT",
+ "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-dialog": "1.1.14",
+ "@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"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-arrow": {
+ "version": "1.1.7",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz",
+ "integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-primitive": "2.1.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"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-aspect-ratio": {
+ "version": "1.1.7",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-aspect-ratio/-/react-aspect-ratio-1.1.7.tgz",
+ "integrity": "sha512-Yq6lvO9HQyPwev1onK1daHCHqXVLzPhSVjmsNjCa2Zcxy2f7uJD2itDtxknv6FzAKCwD1qQkeVDmX/cev13n/g==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-primitive": "2.1.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"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-avatar": {
+ "version": "1.1.10",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.1.10.tgz",
+ "integrity": "sha512-V8piFfWapM5OmNCXTzVQY+E1rDa53zY+MQ4Y7356v4fFz6vqCyUtIz2rUD44ZEdwg78/jKmMJHj07+C/Z/rcog==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-use-callback-ref": "1.1.1",
+ "@radix-ui/react-use-is-hydrated": "0.1.0",
+ "@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"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-checkbox": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.2.tgz",
+ "integrity": "sha512-yd+dI56KZqawxKZrJ31eENUwqc1QSqg4OZ15rybGjF2ZNwMO+wCyHzAVLRp9qoYJf7kYy0YpZ2b0JCzJ42HZpA==",
+ "license": "MIT",
+ "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"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-collapsible": {
+ "version": "1.1.11",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.11.tgz",
+ "integrity": "sha512-2qrRsVGSCYasSz1RFOorXwl0H7g7J1frQtgpQgYrt+MOidtPAINHn9CPovQXb83r8ahapdx3Tu0fa/pdFFSdPg==",
+ "license": "MIT",
+ "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"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-collection": {
+ "version": "1.1.7",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz",
+ "integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==",
+ "license": "MIT",
+ "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"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-compose-refs": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
+ "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-context": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz",
+ "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-context-menu": {
+ "version": "2.2.15",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-context-menu/-/react-context-menu-2.2.15.tgz",
+ "integrity": "sha512-UsQUMjcYTsBjTSXw0P3GO0werEQvUY2plgRQuKoCTtkNr45q1DiL51j4m7gxhABzZ0BadoXNsIbg7F3KwiUBbw==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.2",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-menu": "2.1.15",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-use-callback-ref": "1.1.1",
+ "@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"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-dialog": {
+ "version": "1.1.14",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.14.tgz",
+ "integrity": "sha512-+CpweKjqpzTmwRwcYECQcNYbI8V9VSQt0SNFKeEBLgfucbsLssU6Ppq7wUdNXEGb573bMjFhVjKVll8rmV6zMw==",
+ "license": "MIT",
+ "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-dismissable-layer": "1.1.10",
+ "@radix-ui/react-focus-guards": "1.1.2",
+ "@radix-ui/react-focus-scope": "1.1.7",
+ "@radix-ui/react-id": "1.1.1",
+ "@radix-ui/react-portal": "1.1.9",
+ "@radix-ui/react-presence": "1.1.4",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-slot": "1.2.3",
+ "@radix-ui/react-use-controllable-state": "1.2.2",
+ "aria-hidden": "^1.2.4",
+ "react-remove-scroll": "^2.6.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"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-direction": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz",
+ "integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-dismissable-layer": {
+ "version": "1.1.10",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.10.tgz",
+ "integrity": "sha512-IM1zzRV4W3HtVgftdQiiOmA0AdJlCtMLe00FXaHwgt3rAnNsIyDqshvkIW3hj/iu5hu8ERP7KIYki6NkqDxAwQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.2",
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-use-callback-ref": "1.1.1",
+ "@radix-ui/react-use-escape-keydown": "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"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-dropdown-menu": {
+ "version": "2.1.15",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.15.tgz",
+ "integrity": "sha512-mIBnOjgwo9AH3FyKaSWoSu/dYj6VdhJ7frEPiGTeXCdUFHjl9h3mFh2wwhEtINOmYXWhdpf1rY2minFsmaNgVQ==",
+ "license": "MIT",
+ "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-menu": "2.1.15",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@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"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-focus-guards": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.2.tgz",
+ "integrity": "sha512-fyjAACV62oPV925xFCrH8DR5xWhg9KYtJT4s3u54jxp+L/hbpTY2kIeEFFbFe+a/HCE94zGQMZLIpVTPVZDhaA==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-focus-scope": {
+ "version": "1.1.7",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz",
+ "integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-use-callback-ref": "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"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-hover-card": {
+ "version": "1.1.14",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-hover-card/-/react-hover-card-1.1.14.tgz",
+ "integrity": "sha512-CPYZ24Mhirm+g6D8jArmLzjYu4Eyg3TTUHswR26QgzXBHBe64BO/RHOJKzmF/Dxb4y4f9PKyJdwm/O/AhNkb+Q==",
+ "license": "MIT",
+ "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-dismissable-layer": "1.1.10",
+ "@radix-ui/react-popper": "1.2.7",
+ "@radix-ui/react-portal": "1.1.9",
+ "@radix-ui/react-presence": "1.1.4",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@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"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-id": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz",
+ "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-use-layout-effect": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-label": {
+ "version": "2.1.7",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.7.tgz",
+ "integrity": "sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-primitive": "2.1.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"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-menu": {
+ "version": "2.1.15",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.15.tgz",
+ "integrity": "sha512-tVlmA3Vb9n8SZSd+YSbuFR66l87Wiy4du+YE+0hzKQEANA+7cWKH1WgqcEX4pXqxUFQKrWQGHdvEfw00TjFiew==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.2",
+ "@radix-ui/react-collection": "1.1.7",
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-direction": "1.1.1",
+ "@radix-ui/react-dismissable-layer": "1.1.10",
+ "@radix-ui/react-focus-guards": "1.1.2",
+ "@radix-ui/react-focus-scope": "1.1.7",
+ "@radix-ui/react-id": "1.1.1",
+ "@radix-ui/react-popper": "1.2.7",
+ "@radix-ui/react-portal": "1.1.9",
+ "@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-slot": "1.2.3",
+ "@radix-ui/react-use-callback-ref": "1.1.1",
+ "aria-hidden": "^1.2.4",
+ "react-remove-scroll": "^2.6.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"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-menubar": {
+ "version": "1.1.15",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-menubar/-/react-menubar-1.1.15.tgz",
+ "integrity": "sha512-Z71C7LGD+YDYo3TV81paUs8f3Zbmkvg6VLRQpKYfzioOE6n7fOhA3ApK/V/2Odolxjoc4ENk8AYCjohCNayd5A==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.2",
+ "@radix-ui/react-collection": "1.1.7",
+ "@radix-ui/react-compose-refs": "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-menu": "2.1.15",
+ "@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"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-navigation-menu": {
+ "version": "1.2.13",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-navigation-menu/-/react-navigation-menu-1.2.13.tgz",
+ "integrity": "sha512-WG8wWfDiJlSF5hELjwfjSGOXcBR/ZMhBFCGYe8vERpC39CQYZeq1PQ2kaYHdye3V95d06H89KGMsVCIE4LWo3g==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.2",
+ "@radix-ui/react-collection": "1.1.7",
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-direction": "1.1.1",
+ "@radix-ui/react-dismissable-layer": "1.1.10",
+ "@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-callback-ref": "1.1.1",
+ "@radix-ui/react-use-controllable-state": "1.2.2",
+ "@radix-ui/react-use-layout-effect": "1.1.1",
+ "@radix-ui/react-use-previous": "1.1.1",
+ "@radix-ui/react-visually-hidden": "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"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-popover": {
+ "version": "1.1.14",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.14.tgz",
+ "integrity": "sha512-ODz16+1iIbGUfFEfKx2HTPKizg2MN39uIOV8MXeHnmdd3i/N9Wt7vU46wbHsqA0xoaQyXVcs0KIlBdOA2Y95bw==",
+ "license": "MIT",
+ "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-dismissable-layer": "1.1.10",
+ "@radix-ui/react-focus-guards": "1.1.2",
+ "@radix-ui/react-focus-scope": "1.1.7",
+ "@radix-ui/react-id": "1.1.1",
+ "@radix-ui/react-popper": "1.2.7",
+ "@radix-ui/react-portal": "1.1.9",
+ "@radix-ui/react-presence": "1.1.4",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-slot": "1.2.3",
+ "@radix-ui/react-use-controllable-state": "1.2.2",
+ "aria-hidden": "^1.2.4",
+ "react-remove-scroll": "^2.6.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"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-popper": {
+ "version": "1.2.7",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.7.tgz",
+ "integrity": "sha512-IUFAccz1JyKcf/RjB552PlWwxjeCJB8/4KxT7EhBHOJM+mN7LdW+B3kacJXILm32xawcMMjb2i0cIZpo+f9kiQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@floating-ui/react-dom": "^2.0.0",
+ "@radix-ui/react-arrow": "1.1.7",
+ "@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-callback-ref": "1.1.1",
+ "@radix-ui/react-use-layout-effect": "1.1.1",
+ "@radix-ui/react-use-rect": "1.1.1",
+ "@radix-ui/react-use-size": "1.1.1",
+ "@radix-ui/rect": "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"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-portal": {
+ "version": "1.1.9",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz",
+ "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-primitive": "2.1.3",
+ "@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"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-presence": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.4.tgz",
+ "integrity": "sha512-ueDqRbdc4/bkaQT3GIpLQssRlFgWaL/U2z/S31qRwwLWoxHLgry3SIfCwhxeQNbirEUXFa+lq3RL3oBYXtcmIA==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-compose-refs": "1.1.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"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-primitive": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
+ "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@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"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-progress": {
+ "version": "1.1.7",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.7.tgz",
+ "integrity": "sha512-vPdg/tF6YC/ynuBIJlk1mm7Le0VgW6ub6J2UWnTQ7/D23KXcPI1qy+0vBkgKgd38RCMJavBXpB83HPNFMTb0Fg==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-primitive": "2.1.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"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-radio-group": {
+ "version": "1.3.7",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.3.7.tgz",
+ "integrity": "sha512-9w5XhD0KPOrm92OTTE0SysH3sYzHsSTHNvZgUBo/VZ80VdYyB5RneDbc0dKpURS24IxkoFRu/hI0i4XyfFwY6g==",
+ "license": "MIT",
+ "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-direction": "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",
+ "@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"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-roving-focus": {
+ "version": "1.1.10",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.10.tgz",
+ "integrity": "sha512-dT9aOXUen9JSsxnMPv/0VqySQf5eDQ6LCk5Sw28kamz8wSOW2bJdlX2Bg5VUIIcV+6XlHpWTIuTPCf/UNIyq8Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.2",
+ "@radix-ui/react-collection": "1.1.7",
+ "@radix-ui/react-compose-refs": "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-primitive": "2.1.3",
+ "@radix-ui/react-use-callback-ref": "1.1.1",
+ "@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"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-scroll-area": {
+ "version": "1.2.9",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.9.tgz",
+ "integrity": "sha512-YSjEfBXnhUELsO2VzjdtYYD4CfQjvao+lhhrX5XsHD7/cyUNzljF1FHEbgTPN7LH2MClfwRMIsYlqTYpKTTe2A==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/number": "1.1.1",
+ "@radix-ui/primitive": "1.1.2",
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-direction": "1.1.1",
+ "@radix-ui/react-presence": "1.1.4",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-use-callback-ref": "1.1.1",
+ "@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"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-select": {
+ "version": "2.2.5",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.5.tgz",
+ "integrity": "sha512-HnMTdXEVuuyzx63ME0ut4+sEMYW6oouHWNGUZc7ddvUWIcfCva/AMoqEW/3wnEllriMWBa0RHspCYnfCWJQYmA==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/number": "1.1.1",
+ "@radix-ui/primitive": "1.1.2",
+ "@radix-ui/react-collection": "1.1.7",
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-direction": "1.1.1",
+ "@radix-ui/react-dismissable-layer": "1.1.10",
+ "@radix-ui/react-focus-guards": "1.1.2",
+ "@radix-ui/react-focus-scope": "1.1.7",
+ "@radix-ui/react-id": "1.1.1",
+ "@radix-ui/react-popper": "1.2.7",
+ "@radix-ui/react-portal": "1.1.9",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-slot": "1.2.3",
+ "@radix-ui/react-use-callback-ref": "1.1.1",
+ "@radix-ui/react-use-controllable-state": "1.2.2",
+ "@radix-ui/react-use-layout-effect": "1.1.1",
+ "@radix-ui/react-use-previous": "1.1.1",
+ "@radix-ui/react-visually-hidden": "1.2.3",
+ "aria-hidden": "^1.2.4",
+ "react-remove-scroll": "^2.6.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"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-separator": {
+ "version": "1.1.7",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.7.tgz",
+ "integrity": "sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-primitive": "2.1.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"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-slider": {
+ "version": "1.3.5",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.3.5.tgz",
+ "integrity": "sha512-rkfe2pU2NBAYfGaxa3Mqosi7VZEWX5CxKaanRv0vZd4Zhl9fvQrg0VM93dv3xGLGfrHuoTRF3JXH8nb9g+B3fw==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/number": "1.1.1",
+ "@radix-ui/primitive": "1.1.2",
+ "@radix-ui/react-collection": "1.1.7",
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-direction": "1.1.1",
+ "@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",
+ "@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"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-slot": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
+ "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
+ "license": "MIT",
+ "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"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-switch": {
+ "version": "1.2.5",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.2.5.tgz",
+ "integrity": "sha512-5ijLkak6ZMylXsaImpZ8u4Rlf5grRmoc0p0QeX9VJtlrM4f5m3nCTX8tWga/zOA8PZYIR/t0p2Mnvd7InrJ6yQ==",
+ "license": "MIT",
+ "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"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-tabs": {
+ "version": "1.1.12",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.12.tgz",
+ "integrity": "sha512-GTVAlRVrQrSw3cEARM0nAx73ixrWDPNZAruETn3oHCNP6SbZ/hNxdxp+u7VkIEv3/sFoLq1PfcHrl7Pnp0CDpw==",
+ "license": "MIT",
+ "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"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-toast": {
+ "version": "1.2.14",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.2.14.tgz",
+ "integrity": "sha512-nAP5FBxBJGQ/YfUB+r+O6USFVkWq3gAInkxyEnmvEV5jtSbfDhfa4hwX8CraCnbjMLsE7XSf/K75l9xXY7joWg==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.2",
+ "@radix-ui/react-collection": "1.1.7",
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-dismissable-layer": "1.1.10",
+ "@radix-ui/react-portal": "1.1.9",
+ "@radix-ui/react-presence": "1.1.4",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-use-callback-ref": "1.1.1",
+ "@radix-ui/react-use-controllable-state": "1.2.2",
+ "@radix-ui/react-use-layout-effect": "1.1.1",
+ "@radix-ui/react-visually-hidden": "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"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-toggle": {
+ "version": "1.1.9",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.9.tgz",
+ "integrity": "sha512-ZoFkBBz9zv9GWer7wIjvdRxmh2wyc2oKWw6C6CseWd6/yq1DK/l5lJ+wnsmFwJZbBYqr02mrf8A2q/CVCuM3ZA==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.2",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@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"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-toggle-group": {
+ "version": "1.1.10",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle-group/-/react-toggle-group-1.1.10.tgz",
+ "integrity": "sha512-kiU694Km3WFLTC75DdqgM/3Jauf3rD9wxeS9XtyWFKsBUeZA337lC+6uUazT7I1DhanZ5gyD5Stf8uf2dbQxOQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.2",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-direction": "1.1.1",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-roving-focus": "1.1.10",
+ "@radix-ui/react-toggle": "1.1.9",
+ "@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"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-tooltip": {
+ "version": "1.2.7",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.7.tgz",
+ "integrity": "sha512-Ap+fNYwKTYJ9pzqW+Xe2HtMRbQ/EeWkj2qykZ6SuEV4iS/o1bZI5ssJbk4D2r8XuDuOBVz/tIx2JObtuqU+5Zw==",
+ "license": "MIT",
+ "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-dismissable-layer": "1.1.10",
+ "@radix-ui/react-id": "1.1.1",
+ "@radix-ui/react-popper": "1.2.7",
+ "@radix-ui/react-portal": "1.1.9",
+ "@radix-ui/react-presence": "1.1.4",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-slot": "1.2.3",
+ "@radix-ui/react-use-controllable-state": "1.2.2",
+ "@radix-ui/react-visually-hidden": "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"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-use-callback-ref": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
+ "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-use-controllable-state": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz",
+ "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-use-effect-event": "0.0.2",
+ "@radix-ui/react-use-layout-effect": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-use-effect-event": {
+ "version": "0.0.2",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz",
+ "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-use-layout-effect": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-use-escape-keydown": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz",
+ "integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==",
+ "license": "MIT",
+ "dependencies": {
+ "@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"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-use-is-hydrated": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-is-hydrated/-/react-use-is-hydrated-0.1.0.tgz",
+ "integrity": "sha512-U+UORVEq+cTnRIaostJv9AGdV3G6Y+zbVd+12e18jQ5A3c0xL03IhnHuiU4UV69wolOQp5GfR58NW/EgdQhwOA==",
+ "license": "MIT",
+ "dependencies": {
+ "use-sync-external-store": "^1.5.0"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-use-layout-effect": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz",
+ "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-use-previous": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz",
+ "integrity": "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-use-rect": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz",
+ "integrity": "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/rect": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-use-size": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz",
+ "integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-use-layout-effect": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-visually-hidden": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz",
+ "integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-primitive": "2.1.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"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/rect": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz",
+ "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==",
+ "license": "MIT"
+ },
+ "node_modules/@remix-run/router": {
+ "version": "1.23.0",
+ "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.0.tgz",
+ "integrity": "sha512-O3rHJzAQKamUz1fvE0Qaw0xSFqsA/yafi2iqeE0pvdFtCO1viYx8QL6f3Ln/aCCTLxs68SLf0KPM9eSeM8yBnA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/@rolldown/pluginutils": {
+ "version": "1.0.0-beta.27",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
+ "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@rollup/rollup-android-arm-eabi": {
+ "version": "4.24.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.24.0.tgz",
+ "integrity": "sha512-Q6HJd7Y6xdB48x8ZNVDOqsbh2uByBhgK8PiQgPhwkIw/HC/YX5Ghq2mQY5sRMZWHb3VsFkWooUVOZHKr7DmDIA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-android-arm64": {
+ "version": "4.24.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.24.0.tgz",
+ "integrity": "sha512-ijLnS1qFId8xhKjT81uBHuuJp2lU4x2yxa4ctFPtG+MqEE6+C5f/+X/bStmxapgmwLwiL3ih122xv8kVARNAZA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-arm64": {
+ "version": "4.24.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.24.0.tgz",
+ "integrity": "sha512-bIv+X9xeSs1XCk6DVvkO+S/z8/2AMt/2lMqdQbMrmVpgFvXlmde9mLcbQpztXm1tajC3raFDqegsH18HQPMYtA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-x64": {
+ "version": "4.24.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.24.0.tgz",
+ "integrity": "sha512-X6/nOwoFN7RT2svEQWUsW/5C/fYMBe4fnLK9DQk4SX4mgVBiTA9h64kjUYPvGQ0F/9xwJ5U5UfTbl6BEjaQdBQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+ "version": "4.24.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.24.0.tgz",
+ "integrity": "sha512-0KXvIJQMOImLCVCz9uvvdPgfyWo93aHHp8ui3FrtOP57svqrF/roSSR5pjqL2hcMp0ljeGlU4q9o/rQaAQ3AYA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+ "version": "4.24.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.24.0.tgz",
+ "integrity": "sha512-it2BW6kKFVh8xk/BnHfakEeoLPv8STIISekpoF+nBgWM4d55CZKc7T4Dx1pEbTnYm/xEKMgy1MNtYuoA8RFIWw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
+ "version": "4.24.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.24.0.tgz",
+ "integrity": "sha512-i0xTLXjqap2eRfulFVlSnM5dEbTVque/3Pi4g2y7cxrs7+a9De42z4XxKLYJ7+OhE3IgxvfQM7vQc43bwTgPwA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
+ "version": "4.24.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.24.0.tgz",
+ "integrity": "sha512-9E6MKUJhDuDh604Qco5yP/3qn3y7SLXYuiC0Rpr89aMScS2UAmK1wHP2b7KAa1nSjWJc/f/Lc0Wl1L47qjiyQw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-powerpc64le-gnu": {
+ "version": "4.24.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.24.0.tgz",
+ "integrity": "sha512-2XFFPJ2XMEiF5Zi2EBf4h73oR1V/lycirxZxHZNc93SqDN/IWhYYSYj8I9381ikUFXZrz2v7r2tOVk2NBwxrWw==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+ "version": "4.24.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.24.0.tgz",
+ "integrity": "sha512-M3Dg4hlwuntUCdzU7KjYqbbd+BLq3JMAOhCKdBE3TcMGMZbKkDdJ5ivNdehOssMCIokNHFOsv7DO4rlEOfyKpg==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
+ "version": "4.24.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.24.0.tgz",
+ "integrity": "sha512-mjBaoo4ocxJppTorZVKWFpy1bfFj9FeCMJqzlMQGjpNPY9JwQi7OuS1axzNIk0nMX6jSgy6ZURDZ2w0QW6D56g==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
+ "version": "4.24.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.24.0.tgz",
+ "integrity": "sha512-ZXFk7M72R0YYFN5q13niV0B7G8/5dcQ9JDp8keJSfr3GoZeXEoMHP/HlvqROA3OMbMdfr19IjCeNAnPUG93b6A==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-musl": {
+ "version": "4.24.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.24.0.tgz",
+ "integrity": "sha512-w1i+L7kAXZNdYl+vFvzSZy8Y1arS7vMgIy8wusXJzRrPyof5LAb02KGr1PD2EkRcl73kHulIID0M501lN+vobQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
+ "version": "4.24.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.24.0.tgz",
+ "integrity": "sha512-VXBrnPWgBpVDCVY6XF3LEW0pOU51KbaHhccHw6AS6vBWIC60eqsH19DAeeObl+g8nKAz04QFdl/Cefta0xQtUQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
+ "version": "4.24.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.24.0.tgz",
+ "integrity": "sha512-xrNcGDU0OxVcPTH/8n/ShH4UevZxKIO6HJFK0e15XItZP2UcaiLFd5kiX7hJnqCbSztUF8Qot+JWBC/QXRPYWQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
+ "version": "4.24.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.24.0.tgz",
+ "integrity": "sha512-fbMkAF7fufku0N2dE5TBXcNlg0pt0cJue4xBRE2Qc5Vqikxr4VCgKj/ht6SMdFcOacVA9rqF70APJ8RN/4vMJw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@selderee/plugin-htmlparser2": {
+ "version": "0.11.0",
+ "resolved": "https://registry.npmjs.org/@selderee/plugin-htmlparser2/-/plugin-htmlparser2-0.11.0.tgz",
+ "integrity": "sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==",
+ "license": "MIT",
+ "dependencies": {
+ "domhandler": "^5.0.3",
+ "selderee": "^0.11.0"
+ },
+ "funding": {
+ "url": "https://ko-fi.com/killymxi"
+ }
+ },
+ "node_modules/@stripe/react-stripe-js": {
+ "version": "5.6.1",
+ "resolved": "https://registry.npmjs.org/@stripe/react-stripe-js/-/react-stripe-js-5.6.1.tgz",
+ "integrity": "sha512-5xBrjkGmFvKvpMod6VvpOaFaa67eRbmieKeFTePZyOr/sUXzm7A3YY91l330pS0usUst5PxTZDUZHWfOc0v1GA==",
+ "license": "MIT",
+ "dependencies": {
+ "prop-types": "^15.7.2"
+ },
+ "peerDependencies": {
+ "@stripe/stripe-js": ">=8.0.0 <9.0.0",
+ "react": ">=16.8.0 <20.0.0",
+ "react-dom": ">=16.8.0 <20.0.0"
+ }
+ },
+ "node_modules/@stripe/stripe-js": {
+ "version": "8.11.0",
+ "resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-8.11.0.tgz",
+ "integrity": "sha512-3fVF4z3efsgwgyj64nFK+6F4/vMw0mUXD2TBbOfftJtKVNx4JNv3CSfe1fY4DCtCk0JFp8/YPNcRkzgV0HJ8cg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.16"
+ }
+ },
+ "node_modules/@supabase/auth-js": {
+ "version": "2.99.2",
+ "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.99.2.tgz",
+ "integrity": "sha512-uRGNXMKEw4VhwouNW7N0XDAGqJP9redHNDmWi17dTrcO1lvFfyRiXsqqfgnVC8aqtRn8kLkLPEzHjiRWsni+oQ==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "2.8.1"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@supabase/functions-js": {
+ "version": "2.99.2",
+ "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.99.2.tgz",
+ "integrity": "sha512-xuXQARvjdfB1UPK1yUceZ5EGjOLkVz4rBAaloS9foXiAuseWEdgWBCxkIAFRxGBLGX8Uzo8kseq90jhPb+07Vg==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "2.8.1"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@supabase/postgrest-js": {
+ "version": "2.99.2",
+ "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.99.2.tgz",
+ "integrity": "sha512-ueiOVkbkTQ7RskwVmjR8zxWYw3VKOMxo1+qep+Dx/SgApqyhWBGd92waQb45tbLc7ydB5x8El8utXOLQTuTojQ==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "2.8.1"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@supabase/realtime-js": {
+ "version": "2.99.2",
+ "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.99.2.tgz",
+ "integrity": "sha512-J6Jm9601dkpZf3+EJ48ki2pM4sFtCNm/BI0l8iEnrczgg+JSEQkMoOW5VSpM54t0pNs69bsz5PTmYJahDZKiIQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/phoenix": "^1.6.6",
+ "@types/ws": "^8.18.1",
+ "tslib": "2.8.1",
+ "ws": "^8.18.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@supabase/storage-js": {
+ "version": "2.99.2",
+ "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.99.2.tgz",
+ "integrity": "sha512-V/FF8kX8JGSefsVCG1spCLSrHdNR/JFeUMn1jS9KG/Eizjx+evtdKQKLJXFgIylY/bKTXKhc2SYDPIGrIhzsug==",
+ "license": "MIT",
+ "dependencies": {
+ "iceberg-js": "^0.8.1",
+ "tslib": "2.8.1"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@supabase/supabase-js": {
+ "version": "2.99.2",
+ "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.99.2.tgz",
+ "integrity": "sha512-179rn5wq0wBAqqGwAwR7TUGg2NOaP+fkd5FCVbYJXby85fsRNPFoNJN8YRBepqX2tN7JJcnTjqaAMXuNjiyisA==",
+ "license": "MIT",
+ "dependencies": {
+ "@supabase/auth-js": "2.99.2",
+ "@supabase/functions-js": "2.99.2",
+ "@supabase/postgrest-js": "2.99.2",
+ "@supabase/realtime-js": "2.99.2",
+ "@supabase/storage-js": "2.99.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@swc/core": {
+ "version": "1.13.2",
+ "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.13.2.tgz",
+ "integrity": "sha512-YWqn+0IKXDhqVLKoac4v2tV6hJqB/wOh8/Br8zjqeqBkKa77Qb0Kw2i7LOFzjFNZbZaPH6AlMGlBwNrxaauaAg==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@swc/counter": "^0.1.3",
+ "@swc/types": "^0.1.23"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/swc"
+ },
+ "optionalDependencies": {
+ "@swc/core-darwin-arm64": "1.13.2",
+ "@swc/core-darwin-x64": "1.13.2",
+ "@swc/core-linux-arm-gnueabihf": "1.13.2",
+ "@swc/core-linux-arm64-gnu": "1.13.2",
+ "@swc/core-linux-arm64-musl": "1.13.2",
+ "@swc/core-linux-x64-gnu": "1.13.2",
+ "@swc/core-linux-x64-musl": "1.13.2",
+ "@swc/core-win32-arm64-msvc": "1.13.2",
+ "@swc/core-win32-ia32-msvc": "1.13.2",
+ "@swc/core-win32-x64-msvc": "1.13.2"
+ },
+ "peerDependencies": {
+ "@swc/helpers": ">=0.5.17"
+ },
+ "peerDependenciesMeta": {
+ "@swc/helpers": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@swc/core-darwin-arm64": {
+ "version": "1.13.2",
+ "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.13.2.tgz",
+ "integrity": "sha512-44p7ivuLSGFJ15Vly4ivLJjg3ARo4879LtEBAabcHhSZygpmkP8eyjyWxrH3OxkY1eRZSIJe8yRZPFw4kPXFPw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0 AND MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@swc/core-darwin-x64": {
+ "version": "1.13.2",
+ "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.13.2.tgz",
+ "integrity": "sha512-Lb9EZi7X2XDAVmuUlBm2UvVAgSCbD3qKqDCxSI4jEOddzVOpNCnyZ/xEampdngUIyDDhhJLYU9duC+Mcsv5Y+A==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0 AND MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@swc/core-linux-arm-gnueabihf": {
+ "version": "1.13.2",
+ "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.13.2.tgz",
+ "integrity": "sha512-9TDe/92ee1x57x+0OqL1huG4BeljVx0nWW4QOOxp8CCK67Rpc/HHl2wciJ0Kl9Dxf2NvpNtkPvqj9+BUmM9WVA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@swc/core-linux-arm64-gnu": {
+ "version": "1.13.2",
+ "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.13.2.tgz",
+ "integrity": "sha512-KJUSl56DBk7AWMAIEcU83zl5mg3vlQYhLELhjwRFkGFMvghQvdqQ3zFOYa4TexKA7noBZa3C8fb24rI5sw9Exg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0 AND MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@swc/core-linux-arm64-musl": {
+ "version": "1.13.2",
+ "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.13.2.tgz",
+ "integrity": "sha512-teU27iG1oyWpNh9CzcGQ48ClDRt/RCem7mYO7ehd2FY102UeTws2+OzLESS1TS1tEZipq/5xwx3FzbVgiolCiQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0 AND MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@swc/core-linux-x64-gnu": {
+ "version": "1.13.2",
+ "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.13.2.tgz",
+ "integrity": "sha512-dRPsyPyqpLD0HMRCRpYALIh4kdOir8pPg4AhNQZLehKowigRd30RcLXGNVZcc31Ua8CiPI4QSgjOIxK+EQe4LQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0 AND MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@swc/core-linux-x64-musl": {
+ "version": "1.13.2",
+ "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.13.2.tgz",
+ "integrity": "sha512-CCxETW+KkYEQDqz1SYC15YIWYheqFC+PJVOW76Maa/8yu8Biw+HTAcblKf2isrlUtK8RvrQN94v3UXkC2NzCEw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0 AND MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@swc/core-win32-arm64-msvc": {
+ "version": "1.13.2",
+ "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.13.2.tgz",
+ "integrity": "sha512-Wv/QTA6PjyRLlmKcN6AmSI4jwSMRl0VTLGs57PHTqYRwwfwd7y4s2fIPJVBNbAlXd795dOEP6d/bGSQSyhOX3A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0 AND MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@swc/core-win32-ia32-msvc": {
+ "version": "1.13.2",
+ "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.13.2.tgz",
+ "integrity": "sha512-PuCdtNynEkUNbUXX/wsyUC+t4mamIU5y00lT5vJcAvco3/r16Iaxl5UCzhXYaWZSNVZMzPp9qN8NlSL8M5pPxw==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "Apache-2.0 AND MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@swc/core-win32-x64-msvc": {
+ "version": "1.13.2",
+ "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.13.2.tgz",
+ "integrity": "sha512-qlmMkFZJus8cYuBURx1a3YAG2G7IW44i+FEYV5/32ylKkzGNAr9tDJSA53XNnNXkAB5EXSPsOz7bn5C3JlEtdQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0 AND MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@swc/counter": {
+ "version": "0.1.3",
+ "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz",
+ "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==",
+ "dev": true,
+ "license": "Apache-2.0"
+ },
+ "node_modules/@swc/types": {
+ "version": "0.1.23",
+ "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.23.tgz",
+ "integrity": "sha512-u1iIVZV9Q0jxY+yM2vw/hZGDNudsN85bBpTqzAQ9rzkxW9D+e3aEM4Han+ow518gSewkXgjmEK0BD79ZcNVgPw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@swc/counter": "^0.1.3"
+ }
+ },
+ "node_modules/@tailwindcss/typography": {
+ "version": "0.5.16",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.16.tgz",
+ "integrity": "sha512-0wDLwCVF5V3x3b1SGXPCDcdsbDHMBe+lkFzBRaHeLvNi+nrrnZ1lA18u+OTWO8iSWU2GxUOCvlXtDuqftc1oiA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "lodash.castarray": "^4.4.0",
+ "lodash.isplainobject": "^4.0.6",
+ "lodash.merge": "^4.6.2",
+ "postcss-selector-parser": "6.0.10"
+ },
+ "peerDependencies": {
+ "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1"
+ }
+ },
+ "node_modules/@tailwindcss/typography/node_modules/postcss-selector-parser": {
+ "version": "6.0.10",
+ "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz",
+ "integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==",
+ "dev": true,
+ "dependencies": {
+ "cssesc": "^3.0.0",
+ "util-deprecate": "^1.0.2"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/@tanstack/query-core": {
+ "version": "5.83.0",
+ "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.83.0.tgz",
+ "integrity": "sha512-0M8dA+amXUkyz5cVUm/B+zSk3xkQAcuXuz5/Q/LveT4ots2rBpPTZOzd7yJa2Utsf8D2Upl5KyjhHRY+9lB/XA==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ }
+ },
+ "node_modules/@tanstack/react-query": {
+ "version": "5.83.0",
+ "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.83.0.tgz",
+ "integrity": "sha512-/XGYhZ3foc5H0VM2jLSD/NyBRIOK4q9kfeml4+0x2DlL6xVuAcVEW+hTlTapAmejObg0i3eNqhkr2dT+eciwoQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@tanstack/query-core": "5.83.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ },
+ "peerDependencies": {
+ "react": "^18 || ^19"
+ }
+ },
+ "node_modules/@testing-library/jest-dom": {
+ "version": "6.9.1",
+ "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz",
+ "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@adobe/css-tools": "^4.4.0",
+ "aria-query": "^5.0.0",
+ "css.escape": "^1.5.1",
+ "dom-accessibility-api": "^0.6.3",
+ "picocolors": "^1.1.1",
+ "redent": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=14",
+ "npm": ">=6",
+ "yarn": ">=1"
+ }
+ },
+ "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz",
+ "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@testing-library/react": {
+ "version": "16.3.2",
+ "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz",
+ "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.12.5"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@testing-library/dom": "^10.0.0",
+ "@types/react": "^18.0.0 || ^19.0.0",
+ "@types/react-dom": "^18.0.0 || ^19.0.0",
+ "react": "^18.0.0 || ^19.0.0",
+ "react-dom": "^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@tootallnate/once": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz",
+ "integrity": "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@types/chai": {
+ "version": "5.2.3",
+ "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
+ "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/deep-eql": "*",
+ "assertion-error": "^2.0.1"
+ }
+ },
+ "node_modules/@types/d3-array": {
+ "version": "3.2.1",
+ "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.1.tgz",
+ "integrity": "sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-color": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
+ "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-ease": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
+ "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-interpolate": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
+ "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-color": "*"
+ }
+ },
+ "node_modules/@types/d3-path": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.0.tgz",
+ "integrity": "sha512-P2dlU/q51fkOc/Gfl3Ul9kicV7l+ra934qBFXCFhrZMOL6du1TM0pm1ThYvENukyOn5h9v+yMJ9Fn5JK4QozrQ==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-scale": {
+ "version": "4.0.8",
+ "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.8.tgz",
+ "integrity": "sha512-gkK1VVTr5iNiYJ7vWDI+yUFFlszhNMtVeneJ6lUTKPjprsvLLI9/tgEGiXJOnlINJA8FyA88gfnQsHbybVZrYQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-time": "*"
+ }
+ },
+ "node_modules/@types/d3-shape": {
+ "version": "3.1.6",
+ "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.6.tgz",
+ "integrity": "sha512-5KKk5aKGu2I+O6SONMYSNflgiP0WfZIQvVUMan50wHsLG1G94JlxEVnCpQARfTtzytuY0p/9PXXZb3I7giofIA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-path": "*"
+ }
+ },
+ "node_modules/@types/d3-time": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.3.tgz",
+ "integrity": "sha512-2p6olUZ4w3s+07q3Tm2dbiMZy5pCDfYwtLXXHUnVzXgQlZ/OyPtUz6OL382BkOuGlLXqfT+wqv8Fw2v8/0geBw==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-timer": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
+ "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
+ "license": "MIT"
+ },
+ "node_modules/@types/deep-eql": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
+ "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.6",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz",
+ "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/google.maps": {
+ "version": "3.58.1",
+ "resolved": "https://registry.npmjs.org/@types/google.maps/-/google.maps-3.58.1.tgz",
+ "integrity": "sha512-X9QTSvGJ0nCfMzYOnaVs/k6/4L+7F5uCS+4iUmkLEls6J9S/Phv+m/i3mDeyc49ZBgwab3EFO1HEoBY7k98EGQ==",
+ "license": "MIT"
+ },
+ "node_modules/@types/json-schema": {
+ "version": "7.0.15",
+ "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
+ "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/node": {
+ "version": "22.16.5",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-22.16.5.tgz",
+ "integrity": "sha512-bJFoMATwIGaxxx8VJPeM8TonI8t579oRvgAuT8zFugJsJZgzqv0Fu8Mhp68iecjzG7cnN3mO2dJQ5uUM2EFrgQ==",
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~6.21.0"
+ }
+ },
+ "node_modules/@types/pako": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz",
+ "integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==",
+ "license": "MIT"
+ },
+ "node_modules/@types/phoenix": {
+ "version": "1.6.7",
+ "resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.7.tgz",
+ "integrity": "sha512-oN9ive//QSBkf19rfDv45M7eZPi0eEXylht2OLEXicu5b4KoQ1OzXIw+xDSGWxSxe1JmepRR/ZH283vsu518/Q==",
+ "license": "MIT"
+ },
+ "node_modules/@types/prop-types": {
+ "version": "15.7.13",
+ "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.13.tgz",
+ "integrity": "sha512-hCZTSvwbzWGvhqxp/RqVqwU999pBf2vp7hzIjiYOsl8wqOmUxkQ6ddw1cV3l8811+kdUFus/q4d1Y3E3SyEifA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/raf": {
+ "version": "3.4.3",
+ "resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz",
+ "integrity": "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==",
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/@types/react": {
+ "version": "18.3.23",
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.23.tgz",
+ "integrity": "sha512-/LDXMQh55EzZQ0uVAZmKKhfENivEvWz6E+EYzh+/MCjMhNsotd+ZHhBGIjFDTi6+fz0OhQQQLbTgdQIxxCsC0w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/prop-types": "*",
+ "csstype": "^3.0.2"
+ }
+ },
+ "node_modules/@types/react-dom": {
+ "version": "18.3.7",
+ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
+ "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "^18.0.0"
+ }
+ },
+ "node_modules/@types/trusted-types": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
+ "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/@types/use-sync-external-store": {
+ "version": "0.0.6",
+ "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
+ "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
+ "license": "MIT"
+ },
+ "node_modules/@types/ws": {
+ "version": "8.18.1",
+ "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
+ "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@typescript-eslint/eslint-plugin": {
+ "version": "8.38.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.38.0.tgz",
+ "integrity": "sha512-CPoznzpuAnIOl4nhj4tRr4gIPj5AfKgkiJmGQDaq+fQnRJTYlcBjbX3wbciGmpoPf8DREufuPRe1tNMZnGdanA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-community/regexpp": "^4.10.0",
+ "@typescript-eslint/scope-manager": "8.38.0",
+ "@typescript-eslint/type-utils": "8.38.0",
+ "@typescript-eslint/utils": "8.38.0",
+ "@typescript-eslint/visitor-keys": "8.38.0",
+ "graphemer": "^1.4.0",
+ "ignore": "^7.0.0",
+ "natural-compare": "^1.4.0",
+ "ts-api-utils": "^2.1.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "@typescript-eslint/parser": "^8.38.0",
+ "eslint": "^8.57.0 || ^9.0.0",
+ "typescript": ">=4.8.4 <5.9.0"
+ }
+ },
+ "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": {
+ "version": "7.0.5",
+ "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz",
+ "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 4"
+ }
+ },
+ "node_modules/@typescript-eslint/parser": {
+ "version": "8.38.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.38.0.tgz",
+ "integrity": "sha512-Zhy8HCvBUEfBECzIl1PKqF4p11+d0aUJS1GeUiuqK9WmOug8YCmC4h4bjyBvMyAMI9sbRczmrYL5lKg/YMbrcQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/scope-manager": "8.38.0",
+ "@typescript-eslint/types": "8.38.0",
+ "@typescript-eslint/typescript-estree": "8.38.0",
+ "@typescript-eslint/visitor-keys": "8.38.0",
+ "debug": "^4.3.4"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.57.0 || ^9.0.0",
+ "typescript": ">=4.8.4 <5.9.0"
+ }
+ },
+ "node_modules/@typescript-eslint/project-service": {
+ "version": "8.38.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.38.0.tgz",
+ "integrity": "sha512-dbK7Jvqcb8c9QfH01YB6pORpqX1mn5gDZc9n63Ak/+jD67oWXn3Gs0M6vddAN+eDXBCS5EmNWzbSxsn9SzFWWg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/tsconfig-utils": "^8.38.0",
+ "@typescript-eslint/types": "^8.38.0",
+ "debug": "^4.3.4"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4 <5.9.0"
+ }
+ },
+ "node_modules/@typescript-eslint/scope-manager": {
+ "version": "8.38.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.38.0.tgz",
+ "integrity": "sha512-WJw3AVlFFcdT9Ri1xs/lg8LwDqgekWXWhH3iAF+1ZM+QPd7oxQ6jvtW/JPwzAScxitILUIFs0/AnQ/UWHzbATQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/types": "8.38.0",
+ "@typescript-eslint/visitor-keys": "8.38.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/@typescript-eslint/tsconfig-utils": {
+ "version": "8.38.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.38.0.tgz",
+ "integrity": "sha512-Lum9RtSE3EroKk/bYns+sPOodqb2Fv50XOl/gMviMKNvanETUuUcC9ObRbzrJ4VSd2JalPqgSAavwrPiPvnAiQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4 <5.9.0"
+ }
+ },
+ "node_modules/@typescript-eslint/type-utils": {
+ "version": "8.38.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.38.0.tgz",
+ "integrity": "sha512-c7jAvGEZVf0ao2z+nnz8BUaHZD09Agbh+DY7qvBQqLiz8uJzRgVPj5YvOh8I8uEiH8oIUGIfHzMwUcGVco/SJg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/types": "8.38.0",
+ "@typescript-eslint/typescript-estree": "8.38.0",
+ "@typescript-eslint/utils": "8.38.0",
+ "debug": "^4.3.4",
+ "ts-api-utils": "^2.1.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.57.0 || ^9.0.0",
+ "typescript": ">=4.8.4 <5.9.0"
+ }
+ },
+ "node_modules/@typescript-eslint/types": {
+ "version": "8.38.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.38.0.tgz",
+ "integrity": "sha512-wzkUfX3plUqij4YwWaJyqhiPE5UCRVlFpKn1oCRn2O1bJ592XxWJj8ROQ3JD5MYXLORW84063z3tZTb/cs4Tyw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/@typescript-eslint/typescript-estree": {
+ "version": "8.38.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.38.0.tgz",
+ "integrity": "sha512-fooELKcAKzxux6fA6pxOflpNS0jc+nOQEEOipXFNjSlBS6fqrJOVY/whSn70SScHrcJ2LDsxWrneFoWYSVfqhQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/project-service": "8.38.0",
+ "@typescript-eslint/tsconfig-utils": "8.38.0",
+ "@typescript-eslint/types": "8.38.0",
+ "@typescript-eslint/visitor-keys": "8.38.0",
+ "debug": "^4.3.4",
+ "fast-glob": "^3.3.2",
+ "is-glob": "^4.0.3",
+ "minimatch": "^9.0.4",
+ "semver": "^7.6.0",
+ "ts-api-utils": "^2.1.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4 <5.9.0"
+ }
+ },
+ "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
+ "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0"
+ }
+ },
+ "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
+ "version": "9.0.5",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
+ "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/@typescript-eslint/utils": {
+ "version": "8.38.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.38.0.tgz",
+ "integrity": "sha512-hHcMA86Hgt+ijJlrD8fX0j1j8w4C92zue/8LOPAFioIno+W0+L7KqE8QZKCcPGc/92Vs9x36w/4MPTJhqXdyvg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-community/eslint-utils": "^4.7.0",
+ "@typescript-eslint/scope-manager": "8.38.0",
+ "@typescript-eslint/types": "8.38.0",
+ "@typescript-eslint/typescript-estree": "8.38.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.57.0 || ^9.0.0",
+ "typescript": ">=4.8.4 <5.9.0"
+ }
+ },
+ "node_modules/@typescript-eslint/visitor-keys": {
+ "version": "8.38.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.38.0.tgz",
+ "integrity": "sha512-pWrTcoFNWuwHlA9CvlfSsGWs14JxfN1TH25zM5L7o0pRLhsoZkDnTsXfQRJBEWJoV5DL0jf+Z+sxiud+K0mq1g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/types": "8.38.0",
+ "eslint-visitor-keys": "^4.2.1"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/@vitejs/plugin-react-swc": {
+ "version": "3.11.0",
+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-react-swc/-/plugin-react-swc-3.11.0.tgz",
+ "integrity": "sha512-YTJCGFdNMHCMfjODYtxRNVAYmTWQ1Lb8PulP/2/f/oEEtglw8oKxKIZmmRkyXrVrHfsKOaVkAc3NT9/dMutO5w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@rolldown/pluginutils": "1.0.0-beta.27",
+ "@swc/core": "^1.12.11"
+ },
+ "peerDependencies": {
+ "vite": "^4 || ^5 || ^6 || ^7"
+ }
+ },
+ "node_modules/@vitest/expect": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz",
+ "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/chai": "^5.2.2",
+ "@vitest/spy": "3.2.4",
+ "@vitest/utils": "3.2.4",
+ "chai": "^5.2.0",
+ "tinyrainbow": "^2.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/mocker": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz",
+ "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/spy": "3.2.4",
+ "estree-walker": "^3.0.3",
+ "magic-string": "^0.30.17"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "msw": "^2.4.9",
+ "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0"
+ },
+ "peerDependenciesMeta": {
+ "msw": {
+ "optional": true
+ },
+ "vite": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vitest/pretty-format": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz",
+ "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tinyrainbow": "^2.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/runner": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz",
+ "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/utils": "3.2.4",
+ "pathe": "^2.0.3",
+ "strip-literal": "^3.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/snapshot": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz",
+ "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "3.2.4",
+ "magic-string": "^0.30.17",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/spy": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz",
+ "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tinyspy": "^4.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/utils": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz",
+ "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "3.2.4",
+ "loupe": "^3.1.4",
+ "tinyrainbow": "^2.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@xmldom/xmldom": {
+ "version": "0.8.12",
+ "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.12.tgz",
+ "integrity": "sha512-9k/gHF6n/pAi/9tqr3m3aqkuiNosYTurLLUtc7xQ9sxB/wm7WPygCv8GYa6mS0fLJEHhqMC1ATYhz++U/lRHqg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.0.0"
+ }
+ },
+ "node_modules/abab": {
+ "version": "2.0.6",
+ "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz",
+ "integrity": "sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==",
+ "deprecated": "Use your platform's native atob() and btoa() methods instead",
+ "dev": true,
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/abbrev": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz",
+ "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==",
+ "license": "ISC",
+ "optional": true
+ },
+ "node_modules/acorn": {
+ "version": "8.15.0",
+ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
+ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "acorn": "bin/acorn"
+ },
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/acorn-globals": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/acorn-globals/-/acorn-globals-7.0.1.tgz",
+ "integrity": "sha512-umOSDSDrfHbTNPuNpC2NSnnA3LUrqpevPb4T9jRx4MagXNS0rs+gwiTcAvqCRmsD6utzsrzNt+ebm00SNWiC3Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "acorn": "^8.1.0",
+ "acorn-walk": "^8.0.2"
+ }
+ },
+ "node_modules/acorn-jsx": {
+ "version": "5.3.2",
+ "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz",
+ "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ }
+ },
+ "node_modules/acorn-walk": {
+ "version": "8.3.5",
+ "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz",
+ "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "acorn": "^8.11.0"
+ },
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/adler-32": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz",
+ "integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=0.8"
+ }
+ },
+ "node_modules/agent-base": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
+ "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
+ "devOptional": true,
+ "license": "MIT",
+ "dependencies": {
+ "debug": "4"
+ },
+ "engines": {
+ "node": ">= 6.0.0"
+ }
+ },
+ "node_modules/ajv": {
+ "version": "6.12.6",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
+ "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fast-deep-equal": "^3.1.1",
+ "fast-json-stable-stringify": "^2.0.0",
+ "json-schema-traverse": "^0.4.1",
+ "uri-js": "^4.2.2"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/epoberezkin"
+ }
+ },
+ "node_modules/ansi-regex": {
+ "version": "6.1.0",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz",
+ "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-regex?sponsor=1"
+ }
+ },
+ "node_modules/ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "license": "MIT",
+ "dependencies": {
+ "color-convert": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/any-promise": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
+ "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/anymatch": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
+ "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "normalize-path": "^3.0.0",
+ "picomatch": "^2.0.4"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/aproba": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.1.0.tgz",
+ "integrity": "sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==",
+ "license": "ISC",
+ "optional": true
+ },
+ "node_modules/are-we-there-yet": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-2.0.0.tgz",
+ "integrity": "sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw==",
+ "deprecated": "This package is no longer supported.",
+ "license": "ISC",
+ "optional": true,
+ "dependencies": {
+ "delegates": "^1.0.0",
+ "readable-stream": "^3.6.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/are-we-there-yet/node_modules/readable-stream": {
+ "version": "3.6.2",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
+ "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "inherits": "^2.0.3",
+ "string_decoder": "^1.1.1",
+ "util-deprecate": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/arg": {
+ "version": "5.0.2",
+ "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
+ "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/argparse": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
+ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
+ "dev": true,
+ "license": "Python-2.0"
+ },
+ "node_modules/aria-hidden": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.4.tgz",
+ "integrity": "sha512-y+CcFFwelSXpLZk/7fMB2mUbGtX9lKycf1MWJ7CaTIERyitVlyQx6C+sxcROU2BAJ24OiZyK+8wj2i8AlBoS3A==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/aria-query": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
+ "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "dequal": "^2.0.3"
+ }
+ },
+ "node_modules/assertion-error": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
+ "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/asynckit": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
+ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/attr-accept": {
+ "version": "2.2.5",
+ "resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-2.2.5.tgz",
+ "integrity": "sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/autoprefixer": {
+ "version": "10.4.21",
+ "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz",
+ "integrity": "sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/autoprefixer"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "browserslist": "^4.24.4",
+ "caniuse-lite": "^1.0.30001702",
+ "fraction.js": "^4.3.7",
+ "normalize-range": "^0.1.2",
+ "picocolors": "^1.1.1",
+ "postcss-value-parser": "^4.2.0"
+ },
+ "bin": {
+ "autoprefixer": "bin/autoprefixer"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ },
+ "peerDependencies": {
+ "postcss": "^8.1.0"
+ }
+ },
+ "node_modules/balanced-match": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
+ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
+ "devOptional": true,
+ "license": "MIT"
+ },
+ "node_modules/base64-arraybuffer": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
+ "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6.0"
+ }
+ },
+ "node_modules/base64-js": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
+ "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/binary-extensions": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
+ "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/bluebird": {
+ "version": "3.4.7",
+ "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.4.7.tgz",
+ "integrity": "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==",
+ "license": "MIT"
+ },
+ "node_modules/brace-expansion": {
+ "version": "1.1.12",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
+ "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
+ "devOptional": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0",
+ "concat-map": "0.0.1"
+ }
+ },
+ "node_modules/braces": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
+ "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fill-range": "^7.1.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/browserslist": {
+ "version": "4.25.1",
+ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.1.tgz",
+ "integrity": "sha512-KGj0KoOMXLpSNkkEI6Z6mShmQy0bc1I+T7K9N81k4WWMrfz+6fQ6es80B/YLAeRoKvjYE1YSHHOW1qe9xIVzHw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "caniuse-lite": "^1.0.30001726",
+ "electron-to-chromium": "^1.5.173",
+ "node-releases": "^2.0.19",
+ "update-browserslist-db": "^1.1.3"
+ },
+ "bin": {
+ "browserslist": "cli.js"
+ },
+ "engines": {
+ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
+ }
+ },
+ "node_modules/cac": {
+ "version": "6.7.14",
+ "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
+ "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/call-bind-apply-helpers": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/callsites": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
+ "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/camelcase": {
+ "version": "5.3.1",
+ "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
+ "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/camelcase-css": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
+ "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/caniuse-lite": {
+ "version": "1.0.30001727",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001727.tgz",
+ "integrity": "sha512-pB68nIHmbN6L/4C6MH1DokyR3bYqFwjaSs/sWDHGj4CTcFtQUQMuJftVwWkXq7mNWOybD3KhUv3oWHoGxgP14Q==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "CC-BY-4.0"
+ },
+ "node_modules/canvas": {
+ "version": "2.11.2",
+ "resolved": "https://registry.npmjs.org/canvas/-/canvas-2.11.2.tgz",
+ "integrity": "sha512-ItanGBMrmRV7Py2Z+Xhs7cT+FNt5K0vPL4p9EZ/UX/Mu7hFbkxSjKF2KVtPwX7UYWp7dRKnrTvReflgrItJbdw==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@mapbox/node-pre-gyp": "^1.0.0",
+ "nan": "^2.17.0",
+ "simple-get": "^3.0.3"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/canvg": {
+ "version": "3.0.11",
+ "resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.11.tgz",
+ "integrity": "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@babel/runtime": "^7.12.5",
+ "@types/raf": "^3.4.0",
+ "core-js": "^3.8.3",
+ "raf": "^3.4.1",
+ "regenerator-runtime": "^0.13.7",
+ "rgbcolor": "^1.0.1",
+ "stackblur-canvas": "^2.0.0",
+ "svg-pathdata": "^6.0.3"
+ },
+ "engines": {
+ "node": ">=10.0.0"
+ }
+ },
+ "node_modules/cfb": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz",
+ "integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "adler-32": "~1.3.0",
+ "crc-32": "~1.2.0"
+ },
+ "engines": {
+ "node": ">=0.8"
+ }
+ },
+ "node_modules/chai": {
+ "version": "5.3.3",
+ "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz",
+ "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "assertion-error": "^2.0.1",
+ "check-error": "^2.1.1",
+ "deep-eql": "^5.0.1",
+ "loupe": "^3.1.0",
+ "pathval": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/chalk": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
+ "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^4.1.0",
+ "supports-color": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/chalk?sponsor=1"
+ }
+ },
+ "node_modules/check-error": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz",
+ "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 16"
+ }
+ },
+ "node_modules/chokidar": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
+ "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "anymatch": "~3.1.2",
+ "braces": "~3.0.2",
+ "glob-parent": "~5.1.2",
+ "is-binary-path": "~2.1.0",
+ "is-glob": "~4.0.1",
+ "normalize-path": "~3.0.0",
+ "readdirp": "~3.6.0"
+ },
+ "engines": {
+ "node": ">= 8.10.0"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/chokidar/node_modules/glob-parent": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
+ "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "is-glob": "^4.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/chownr": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz",
+ "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==",
+ "license": "ISC",
+ "optional": true,
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/class-variance-authority": {
+ "version": "0.7.1",
+ "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz",
+ "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==",
+ "dependencies": {
+ "clsx": "^2.1.1"
+ },
+ "funding": {
+ "url": "https://polar.sh/cva"
+ }
+ },
+ "node_modules/cliui": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
+ "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
+ "license": "ISC",
+ "dependencies": {
+ "string-width": "^4.2.0",
+ "strip-ansi": "^6.0.0",
+ "wrap-ansi": "^6.2.0"
+ }
+ },
+ "node_modules/cliui/node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/cliui/node_modules/emoji-regex": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+ "license": "MIT"
+ },
+ "node_modules/cliui/node_modules/string-width": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+ "license": "MIT",
+ "dependencies": {
+ "emoji-regex": "^8.0.0",
+ "is-fullwidth-code-point": "^3.0.0",
+ "strip-ansi": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/cliui/node_modules/strip-ansi": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/cliui/node_modules/wrap-ansi": {
+ "version": "6.2.0",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
+ "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^4.0.0",
+ "string-width": "^4.1.0",
+ "strip-ansi": "^6.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/clsx": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
+ "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/cmdk": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.1.1.tgz",
+ "integrity": "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-compose-refs": "^1.1.1",
+ "@radix-ui/react-dialog": "^1.1.6",
+ "@radix-ui/react-id": "^1.1.0",
+ "@radix-ui/react-primitive": "^2.0.2"
+ },
+ "peerDependencies": {
+ "react": "^18 || ^19 || ^19.0.0-rc",
+ "react-dom": "^18 || ^19 || ^19.0.0-rc"
+ }
+ },
+ "node_modules/codepage": {
+ "version": "1.15.0",
+ "resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
+ "integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=0.8"
+ }
+ },
+ "node_modules/color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "license": "MIT",
+ "dependencies": {
+ "color-name": "~1.1.4"
+ },
+ "engines": {
+ "node": ">=7.0.0"
+ }
+ },
+ "node_modules/color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "license": "MIT"
+ },
+ "node_modules/color-support": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz",
+ "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==",
+ "license": "ISC",
+ "optional": true,
+ "bin": {
+ "color-support": "bin.js"
+ }
+ },
+ "node_modules/combined-stream": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
+ "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "delayed-stream": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/commander": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
+ "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/concat-map": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
+ "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
+ "devOptional": true,
+ "license": "MIT"
+ },
+ "node_modules/console-control-strings": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz",
+ "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==",
+ "license": "ISC",
+ "optional": true
+ },
+ "node_modules/core-js": {
+ "version": "3.48.0",
+ "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.48.0.tgz",
+ "integrity": "sha512-zpEHTy1fjTMZCKLHUZoVeylt9XrzaIN2rbPXEt0k+q7JE5CkCZdo6bNq55bn24a69CH7ErAVLKijxJja4fw+UQ==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/core-js"
+ }
+ },
+ "node_modules/core-util-is": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
+ "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
+ "license": "MIT"
+ },
+ "node_modules/crc-32": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
+ "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
+ "license": "Apache-2.0",
+ "bin": {
+ "crc32": "bin/crc32.njs"
+ },
+ "engines": {
+ "node": ">=0.8"
+ }
+ },
+ "node_modules/cross-spawn": {
+ "version": "7.0.6",
+ "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
+ "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
+ "dev": true,
+ "dependencies": {
+ "path-key": "^3.1.0",
+ "shebang-command": "^2.0.0",
+ "which": "^2.0.1"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/css-box-model": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/css-box-model/-/css-box-model-1.2.1.tgz",
+ "integrity": "sha512-a7Vr4Q/kd/aw96bnJG332W9V9LkJO69JRcaCYDUqjp6/z0w6VcZjgAcTbgFxEPfBgdnAwlh3iwu+hLopa+flJw==",
+ "license": "MIT",
+ "dependencies": {
+ "tiny-invariant": "^1.0.6"
+ }
+ },
+ "node_modules/css-line-break": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz",
+ "integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==",
+ "license": "MIT",
+ "dependencies": {
+ "utrie": "^1.0.2"
+ }
+ },
+ "node_modules/css.escape": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz",
+ "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/cssesc": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
+ "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "cssesc": "bin/cssesc"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/cssom": {
+ "version": "0.5.0",
+ "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.5.0.tgz",
+ "integrity": "sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/cssstyle": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-2.3.0.tgz",
+ "integrity": "sha512-AZL67abkUzIuvcHqk7c09cezpGNcxUxU4Ioi/05xHk4DQeTkWmGYftIE6ctU6AEt+Gn4n1lDStOtj7FKycP71A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "cssom": "~0.3.6"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/cssstyle/node_modules/cssom": {
+ "version": "0.3.8",
+ "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.3.8.tgz",
+ "integrity": "sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/csstype": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
+ "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
+ "license": "MIT"
+ },
+ "node_modules/d3-array": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
+ "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
+ "license": "ISC",
+ "dependencies": {
+ "internmap": "1 - 2"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-color": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
+ "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-ease": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
+ "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-format": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz",
+ "integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-interpolate": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
+ "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-color": "1 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-path": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
+ "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-scale": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
+ "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-array": "2.10.0 - 3",
+ "d3-format": "1 - 3",
+ "d3-interpolate": "1.2.0 - 3",
+ "d3-time": "2.1.1 - 3",
+ "d3-time-format": "2 - 4"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-shape": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
+ "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-path": "^3.1.0"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-time": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
+ "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-array": "2 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-time-format": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
+ "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-time": "1 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-timer": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
+ "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/data-urls": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-3.0.2.tgz",
+ "integrity": "sha512-Jy/tj3ldjZJo63sVAvg6LHt2mHvl4V6AgRAmNDtLdm7faqtsx+aJG42rsyCo9JCoRVKwPFzKlIPx3DIibwSIaQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "abab": "^2.0.6",
+ "whatwg-mimetype": "^3.0.0",
+ "whatwg-url": "^11.0.0"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/date-fns": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz",
+ "integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/kossnocorp"
+ }
+ },
+ "node_modules/date-fns-tz": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/date-fns-tz/-/date-fns-tz-3.2.0.tgz",
+ "integrity": "sha512-sg8HqoTEulcbbbVXeg84u5UnlsQa8GS5QXMqjjYIhS4abEVVKIUwe0/l/UhrZdKaL/W5eWZNlbTeEIiOXTcsBQ==",
+ "license": "MIT",
+ "peerDependencies": {
+ "date-fns": "^3.0.0 || ^4.0.0"
+ }
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "devOptional": true,
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/decamelize": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
+ "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/decimal.js": {
+ "version": "10.6.0",
+ "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
+ "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/decimal.js-light": {
+ "version": "2.5.1",
+ "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
+ "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
+ "license": "MIT"
+ },
+ "node_modules/decompress-response": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-4.2.1.tgz",
+ "integrity": "sha512-jOSne2qbyE+/r8G1VU+G/82LBs2Fs4LAsTiLSHOCOMZQl2OKZ6i8i4IyHemTe+/yIXOtTcRQMzPcgyhoFlqPkw==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "mimic-response": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/deep-eql": {
+ "version": "5.0.2",
+ "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
+ "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/deep-is": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
+ "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/deepmerge": {
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
+ "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/delayed-stream": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
+ "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/delegates": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz",
+ "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==",
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/dequal": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
+ "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "license": "Apache-2.0",
+ "optional": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/detect-node-es": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz",
+ "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==",
+ "license": "MIT"
+ },
+ "node_modules/didyoumean": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
+ "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==",
+ "dev": true,
+ "license": "Apache-2.0"
+ },
+ "node_modules/dijkstrajs": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
+ "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
+ "license": "MIT"
+ },
+ "node_modules/dingbat-to-unicode": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/dingbat-to-unicode/-/dingbat-to-unicode-1.0.1.tgz",
+ "integrity": "sha512-98l0sW87ZT58pU4i61wa2OHwxbiYSbuxsCBozaVnYX2iCnr3bLM3fIes1/ej7h1YdOKuKt/MLs706TVnALA65w==",
+ "license": "BSD-2-Clause"
+ },
+ "node_modules/dlv": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
+ "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/dom-helpers": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
+ "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.8.7",
+ "csstype": "^3.0.2"
+ }
+ },
+ "node_modules/dom-serializer": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
+ "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
+ "license": "MIT",
+ "dependencies": {
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.2",
+ "entities": "^4.2.0"
+ },
+ "funding": {
+ "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
+ }
+ },
+ "node_modules/domelementtype": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
+ "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fb55"
+ }
+ ],
+ "license": "BSD-2-Clause"
+ },
+ "node_modules/domexception": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/domexception/-/domexception-4.0.0.tgz",
+ "integrity": "sha512-A2is4PLG+eeSfoTMA95/s4pvAoSo2mKtiM5jlHkAVewmiO8ISFTFKZjH7UAM1Atli/OT/7JHOrJRJiMKUZKYBw==",
+ "deprecated": "Use your platform's native DOMException instead",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "webidl-conversions": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/domhandler": {
+ "version": "5.0.3",
+ "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
+ "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "domelementtype": "^2.3.0"
+ },
+ "engines": {
+ "node": ">= 4"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/domhandler?sponsor=1"
+ }
+ },
+ "node_modules/dompurify": {
+ "version": "3.3.3",
+ "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz",
+ "integrity": "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==",
+ "license": "(MPL-2.0 OR Apache-2.0)",
+ "optional": true,
+ "optionalDependencies": {
+ "@types/trusted-types": "^2.0.7"
+ }
+ },
+ "node_modules/domutils": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
+ "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "dom-serializer": "^2.0.0",
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.3"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/domutils?sponsor=1"
+ }
+ },
+ "node_modules/duck": {
+ "version": "0.1.12",
+ "resolved": "https://registry.npmjs.org/duck/-/duck-0.1.12.tgz",
+ "integrity": "sha512-wkctla1O6VfP89gQ+J/yDesM0S7B7XLXjKGzXxMDVFg7uEn706niAtyYovKbyq1oT9YwDcly721/iUWoc8MVRg==",
+ "license": "BSD",
+ "dependencies": {
+ "underscore": "^1.13.1"
+ }
+ },
+ "node_modules/dunder-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/eastasianwidth": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
+ "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/electron-to-chromium": {
+ "version": "1.5.192",
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.192.tgz",
+ "integrity": "sha512-rP8Ez0w7UNw/9j5eSXCe10o1g/8B1P5SM90PCCMVkIRQn2R0LEHWz4Eh9RnxkniuDe1W0cTSOB3MLlkTGDcuCg==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/embla-carousel": {
+ "version": "8.6.0",
+ "resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.6.0.tgz",
+ "integrity": "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==",
+ "license": "MIT"
+ },
+ "node_modules/embla-carousel-react": {
+ "version": "8.6.0",
+ "resolved": "https://registry.npmjs.org/embla-carousel-react/-/embla-carousel-react-8.6.0.tgz",
+ "integrity": "sha512-0/PjqU7geVmo6F734pmPqpyHqiM99olvyecY7zdweCw+6tKEXnrE90pBiBbMMU8s5tICemzpQ3hi5EpxzGW+JA==",
+ "license": "MIT",
+ "dependencies": {
+ "embla-carousel": "8.6.0",
+ "embla-carousel-reactive-utils": "8.6.0"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.1 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
+ }
+ },
+ "node_modules/embla-carousel-reactive-utils": {
+ "version": "8.6.0",
+ "resolved": "https://registry.npmjs.org/embla-carousel-reactive-utils/-/embla-carousel-reactive-utils-8.6.0.tgz",
+ "integrity": "sha512-fMVUDUEx0/uIEDM0Mz3dHznDhfX+znCCDCeIophYb1QGVM7YThSWX+wz11zlYwWFOr74b4QLGg0hrGPJeG2s4A==",
+ "license": "MIT",
+ "peerDependencies": {
+ "embla-carousel": "8.6.0"
+ }
+ },
+ "node_modules/emoji-regex": {
+ "version": "9.2.2",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
+ "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/entities": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
+ "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "node_modules/es-define-property": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-errors": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-module-lexer": {
+ "version": "1.7.0",
+ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
+ "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/es-object-atoms": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
+ "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-set-tostringtag": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
+ "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.6",
+ "has-tostringtag": "^1.0.2",
+ "hasown": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/esbuild": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
+ "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.21.5",
+ "@esbuild/android-arm": "0.21.5",
+ "@esbuild/android-arm64": "0.21.5",
+ "@esbuild/android-x64": "0.21.5",
+ "@esbuild/darwin-arm64": "0.21.5",
+ "@esbuild/darwin-x64": "0.21.5",
+ "@esbuild/freebsd-arm64": "0.21.5",
+ "@esbuild/freebsd-x64": "0.21.5",
+ "@esbuild/linux-arm": "0.21.5",
+ "@esbuild/linux-arm64": "0.21.5",
+ "@esbuild/linux-ia32": "0.21.5",
+ "@esbuild/linux-loong64": "0.21.5",
+ "@esbuild/linux-mips64el": "0.21.5",
+ "@esbuild/linux-ppc64": "0.21.5",
+ "@esbuild/linux-riscv64": "0.21.5",
+ "@esbuild/linux-s390x": "0.21.5",
+ "@esbuild/linux-x64": "0.21.5",
+ "@esbuild/netbsd-x64": "0.21.5",
+ "@esbuild/openbsd-x64": "0.21.5",
+ "@esbuild/sunos-x64": "0.21.5",
+ "@esbuild/win32-arm64": "0.21.5",
+ "@esbuild/win32-ia32": "0.21.5",
+ "@esbuild/win32-x64": "0.21.5"
+ }
+ },
+ "node_modules/escalade": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
+ "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/escape-string-regexp": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
+ "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/escodegen": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz",
+ "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "esprima": "^4.0.1",
+ "estraverse": "^5.2.0",
+ "esutils": "^2.0.2"
+ },
+ "bin": {
+ "escodegen": "bin/escodegen.js",
+ "esgenerate": "bin/esgenerate.js"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "optionalDependencies": {
+ "source-map": "~0.6.1"
+ }
+ },
+ "node_modules/eslint": {
+ "version": "9.32.0",
+ "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.32.0.tgz",
+ "integrity": "sha512-LSehfdpgMeWcTZkWZVIJl+tkZ2nuSkyyB9C27MZqFWXuph7DvaowgcTvKqxvpLW1JZIk8PN7hFY3Rj9LQ7m7lg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-community/eslint-utils": "^4.2.0",
+ "@eslint-community/regexpp": "^4.12.1",
+ "@eslint/config-array": "^0.21.0",
+ "@eslint/config-helpers": "^0.3.0",
+ "@eslint/core": "^0.15.0",
+ "@eslint/eslintrc": "^3.3.1",
+ "@eslint/js": "9.32.0",
+ "@eslint/plugin-kit": "^0.3.4",
+ "@humanfs/node": "^0.16.6",
+ "@humanwhocodes/module-importer": "^1.0.1",
+ "@humanwhocodes/retry": "^0.4.2",
+ "@types/estree": "^1.0.6",
+ "@types/json-schema": "^7.0.15",
+ "ajv": "^6.12.4",
+ "chalk": "^4.0.0",
+ "cross-spawn": "^7.0.6",
+ "debug": "^4.3.2",
+ "escape-string-regexp": "^4.0.0",
+ "eslint-scope": "^8.4.0",
+ "eslint-visitor-keys": "^4.2.1",
+ "espree": "^10.4.0",
+ "esquery": "^1.5.0",
+ "esutils": "^2.0.2",
+ "fast-deep-equal": "^3.1.3",
+ "file-entry-cache": "^8.0.0",
+ "find-up": "^5.0.0",
+ "glob-parent": "^6.0.2",
+ "ignore": "^5.2.0",
+ "imurmurhash": "^0.1.4",
+ "is-glob": "^4.0.0",
+ "json-stable-stringify-without-jsonify": "^1.0.1",
+ "lodash.merge": "^4.6.2",
+ "minimatch": "^3.1.2",
+ "natural-compare": "^1.4.0",
+ "optionator": "^0.9.3"
+ },
+ "bin": {
+ "eslint": "bin/eslint.js"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://eslint.org/donate"
+ },
+ "peerDependencies": {
+ "jiti": "*"
+ },
+ "peerDependenciesMeta": {
+ "jiti": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/eslint-plugin-react-hooks": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.2.0.tgz",
+ "integrity": "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "peerDependencies": {
+ "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0"
+ }
+ },
+ "node_modules/eslint-plugin-react-refresh": {
+ "version": "0.4.20",
+ "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.20.tgz",
+ "integrity": "sha512-XpbHQ2q5gUF8BGOX4dHe+71qoirYMhApEPZ7sfhF/dNnOF1UXnCMGZf79SFTBO7Bz5YEIT4TMieSlJBWhP9WBA==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "eslint": ">=8.40"
+ }
+ },
+ "node_modules/eslint-scope": {
+ "version": "8.4.0",
+ "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz",
+ "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "esrecurse": "^4.3.0",
+ "estraverse": "^5.2.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/eslint-visitor-keys": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz",
+ "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/espree": {
+ "version": "10.4.0",
+ "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz",
+ "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "acorn": "^8.15.0",
+ "acorn-jsx": "^5.3.2",
+ "eslint-visitor-keys": "^4.2.1"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/esprima": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
+ "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "bin": {
+ "esparse": "bin/esparse.js",
+ "esvalidate": "bin/esvalidate.js"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/esquery": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz",
+ "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "estraverse": "^5.1.0"
+ },
+ "engines": {
+ "node": ">=0.10"
+ }
+ },
+ "node_modules/esrecurse": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz",
+ "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "estraverse": "^5.2.0"
+ },
+ "engines": {
+ "node": ">=4.0"
+ }
+ },
+ "node_modules/estraverse": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
+ "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=4.0"
+ }
+ },
+ "node_modules/estree-walker": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
+ "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.0"
+ }
+ },
+ "node_modules/esutils": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
+ "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/eventemitter3": {
+ "version": "4.0.7",
+ "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
+ "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
+ "license": "MIT"
+ },
+ "node_modules/exifr": {
+ "version": "7.1.3",
+ "resolved": "https://registry.npmjs.org/exifr/-/exifr-7.1.3.tgz",
+ "integrity": "sha512-g/aje2noHivrRSLbAUtBPWFbxKdKhgj/xr1vATDdUXPOFYJlQ62Ft0oy+72V6XLIpDJfHs6gXLbBLAolqOXYRw==",
+ "license": "MIT"
+ },
+ "node_modules/expect-type": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
+ "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
+ "node_modules/fast-deep-equal": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/fast-diff": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz",
+ "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==",
+ "license": "Apache-2.0"
+ },
+ "node_modules/fast-equals": {
+ "version": "5.2.2",
+ "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.2.2.tgz",
+ "integrity": "sha512-V7/RktU11J3I36Nwq2JnZEM7tNm17eBJz+u25qdxBZeCKiX6BkVSZQjwWIr+IobgnZy+ag73tTZgZi7tr0LrBw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/fast-glob": {
+ "version": "3.3.2",
+ "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz",
+ "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@nodelib/fs.stat": "^2.0.2",
+ "@nodelib/fs.walk": "^1.2.3",
+ "glob-parent": "^5.1.2",
+ "merge2": "^1.3.0",
+ "micromatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">=8.6.0"
+ }
+ },
+ "node_modules/fast-glob/node_modules/glob-parent": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
+ "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "is-glob": "^4.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/fast-json-stable-stringify": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
+ "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/fast-levenshtein": {
+ "version": "2.0.6",
+ "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
+ "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/fast-png": {
+ "version": "6.4.0",
+ "resolved": "https://registry.npmjs.org/fast-png/-/fast-png-6.4.0.tgz",
+ "integrity": "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/pako": "^2.0.3",
+ "iobuffer": "^5.3.2",
+ "pako": "^2.1.0"
+ }
+ },
+ "node_modules/fastq": {
+ "version": "1.17.1",
+ "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz",
+ "integrity": "sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "reusify": "^1.0.4"
+ }
+ },
+ "node_modules/fflate": {
+ "version": "0.8.2",
+ "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
+ "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==",
+ "license": "MIT"
+ },
+ "node_modules/file-entry-cache": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
+ "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "flat-cache": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=16.0.0"
+ }
+ },
+ "node_modules/file-selector": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/file-selector/-/file-selector-2.1.2.tgz",
+ "integrity": "sha512-QgXo+mXTe8ljeqUFaX3QVHc5osSItJ/Km+xpocx0aSqWGMSCf6qYs/VnzZgS864Pjn5iceMRFigeAV7AfTlaig==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.7.0"
+ },
+ "engines": {
+ "node": ">= 12"
+ }
+ },
+ "node_modules/fill-range": {
+ "version": "7.1.1",
+ "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
+ "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "to-regex-range": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/find-up": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
+ "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "locate-path": "^6.0.0",
+ "path-exists": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/flat-cache": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz",
+ "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "flatted": "^3.2.9",
+ "keyv": "^4.5.4"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/flatted": {
+ "version": "3.3.1",
+ "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.1.tgz",
+ "integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/foreground-child": {
+ "version": "3.3.0",
+ "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.0.tgz",
+ "integrity": "sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "cross-spawn": "^7.0.0",
+ "signal-exit": "^4.0.1"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/form-data": {
+ "version": "4.0.5",
+ "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
+ "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "asynckit": "^0.4.0",
+ "combined-stream": "^1.0.8",
+ "es-set-tostringtag": "^2.1.0",
+ "hasown": "^2.0.2",
+ "mime-types": "^2.1.12"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/frac": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz",
+ "integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=0.8"
+ }
+ },
+ "node_modules/fraction.js": {
+ "version": "4.3.7",
+ "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz",
+ "integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "*"
+ },
+ "funding": {
+ "type": "patreon",
+ "url": "https://github.com/sponsors/rawify"
+ }
+ },
+ "node_modules/framer-motion": {
+ "version": "12.37.0",
+ "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.37.0.tgz",
+ "integrity": "sha512-j/QUcZS9Nw3NzZWoAbkzr3ETRFHyVeQMlGOUYUmG15U+uiyn9DqIktYruVPDcqY8I35qYR70JaZBvFmS6p+Pdg==",
+ "license": "MIT",
+ "dependencies": {
+ "motion-dom": "^12.37.0",
+ "motion-utils": "^12.36.0",
+ "tslib": "^2.4.0"
+ },
+ "peerDependencies": {
+ "@emotion/is-prop-valid": "*",
+ "react": "^18.0.0 || ^19.0.0",
+ "react-dom": "^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@emotion/is-prop-valid": {
+ "optional": true
+ },
+ "react": {
+ "optional": true
+ },
+ "react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/fs-minipass": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz",
+ "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==",
+ "license": "ISC",
+ "optional": true,
+ "dependencies": {
+ "minipass": "^3.0.0"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/fs-minipass/node_modules/minipass": {
+ "version": "3.3.6",
+ "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz",
+ "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==",
+ "license": "ISC",
+ "optional": true,
+ "dependencies": {
+ "yallist": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/fs.realpath": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
+ "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
+ "license": "ISC",
+ "optional": true
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/function-bind": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/gauge": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/gauge/-/gauge-3.0.2.tgz",
+ "integrity": "sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q==",
+ "deprecated": "This package is no longer supported.",
+ "license": "ISC",
+ "optional": true,
+ "dependencies": {
+ "aproba": "^1.0.3 || ^2.0.0",
+ "color-support": "^1.1.2",
+ "console-control-strings": "^1.0.0",
+ "has-unicode": "^2.0.1",
+ "object-assign": "^4.1.1",
+ "signal-exit": "^3.0.0",
+ "string-width": "^4.2.3",
+ "strip-ansi": "^6.0.1",
+ "wide-align": "^1.1.2"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/gauge/node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/gauge/node_modules/emoji-regex": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/gauge/node_modules/signal-exit": {
+ "version": "3.0.7",
+ "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
+ "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
+ "license": "ISC",
+ "optional": true
+ },
+ "node_modules/gauge/node_modules/string-width": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "emoji-regex": "^8.0.0",
+ "is-fullwidth-code-point": "^3.0.0",
+ "strip-ansi": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/gauge/node_modules/strip-ansi": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/get-caller-file": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
+ "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
+ "license": "ISC",
+ "engines": {
+ "node": "6.* || 8.* || >= 10.*"
+ }
+ },
+ "node_modules/get-intrinsic": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "es-define-property": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.1.1",
+ "function-bind": "^1.1.2",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "hasown": "^2.0.2",
+ "math-intrinsics": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-nonce": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz",
+ "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/get-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "dunder-proto": "^1.0.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/glob": {
+ "version": "10.4.5",
+ "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz",
+ "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "foreground-child": "^3.1.0",
+ "jackspeak": "^3.1.2",
+ "minimatch": "^9.0.4",
+ "minipass": "^7.1.2",
+ "package-json-from-dist": "^1.0.0",
+ "path-scurry": "^1.11.1"
+ },
+ "bin": {
+ "glob": "dist/esm/bin.mjs"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/glob-parent": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
+ "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "is-glob": "^4.0.3"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
+ "node_modules/glob/node_modules/brace-expansion": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
+ "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0"
+ }
+ },
+ "node_modules/glob/node_modules/minimatch": {
+ "version": "9.0.5",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
+ "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/globals": {
+ "version": "15.15.0",
+ "resolved": "https://registry.npmjs.org/globals/-/globals-15.15.0.tgz",
+ "integrity": "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/gopd": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/graphemer": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz",
+ "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/has-flag": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/has-symbols": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-tostringtag": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
+ "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "has-symbols": "^1.0.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-unicode": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz",
+ "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==",
+ "license": "ISC",
+ "optional": true
+ },
+ "node_modules/hasown": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
+ "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/html-encoding-sniffer": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz",
+ "integrity": "sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "whatwg-encoding": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/html-to-text": {
+ "version": "9.0.5",
+ "resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-9.0.5.tgz",
+ "integrity": "sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg==",
+ "license": "MIT",
+ "dependencies": {
+ "@selderee/plugin-htmlparser2": "^0.11.0",
+ "deepmerge": "^4.3.1",
+ "dom-serializer": "^2.0.0",
+ "htmlparser2": "^8.0.2",
+ "selderee": "^0.11.0"
+ },
+ "engines": {
+ "node": ">=14"
+ }
+ },
+ "node_modules/html2canvas": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz",
+ "integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==",
+ "license": "MIT",
+ "dependencies": {
+ "css-line-break": "^2.1.0",
+ "text-segmentation": "^1.0.3"
+ },
+ "engines": {
+ "node": ">=8.0.0"
+ }
+ },
+ "node_modules/htmlparser2": {
+ "version": "8.0.2",
+ "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz",
+ "integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==",
+ "funding": [
+ "https://github.com/fb55/htmlparser2?sponsor=1",
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fb55"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.3",
+ "domutils": "^3.0.1",
+ "entities": "^4.4.0"
+ }
+ },
+ "node_modules/http-proxy-agent": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz",
+ "integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@tootallnate/once": "2",
+ "agent-base": "6",
+ "debug": "4"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/https-proxy-agent": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
+ "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
+ "devOptional": true,
+ "license": "MIT",
+ "dependencies": {
+ "agent-base": "6",
+ "debug": "4"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/ical.js": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/ical.js/-/ical.js-2.2.1.tgz",
+ "integrity": "sha512-yK/UlPbEs316igb/tjRgbFA8ZV75rCsBJp/hWOatpyaPNlgw0dGDmU+FoicOcwX4xXkeXOkYiOmCqNPFpNPkQg==",
+ "license": "MPL-2.0"
+ },
+ "node_modules/iceberg-js": {
+ "version": "0.8.1",
+ "resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz",
+ "integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/iconv-lite": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
+ "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/ignore": {
+ "version": "5.3.2",
+ "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
+ "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 4"
+ }
+ },
+ "node_modules/immediate": {
+ "version": "3.0.6",
+ "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
+ "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
+ "license": "MIT"
+ },
+ "node_modules/import-fresh": {
+ "version": "3.3.1",
+ "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
+ "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "parent-module": "^1.0.0",
+ "resolve-from": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/imurmurhash": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
+ "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.8.19"
+ }
+ },
+ "node_modules/indent-string": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz",
+ "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/inflight": {
+ "version": "1.0.6",
+ "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
+ "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==",
+ "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.",
+ "license": "ISC",
+ "optional": true,
+ "dependencies": {
+ "once": "^1.3.0",
+ "wrappy": "1"
+ }
+ },
+ "node_modules/inherits": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+ "license": "ISC"
+ },
+ "node_modules/input-otp": {
+ "version": "1.4.2",
+ "resolved": "https://registry.npmjs.org/input-otp/-/input-otp-1.4.2.tgz",
+ "integrity": "sha512-l3jWwYNvrEa6NTCt7BECfCm48GvwuZzkoeG3gBL2w4CHeOXW3eKFmf9UNYkNfYc3mxMrthMnxjIE07MT0zLBQA==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc"
+ }
+ },
+ "node_modules/internmap": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
+ "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/iobuffer": {
+ "version": "5.4.0",
+ "resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-5.4.0.tgz",
+ "integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==",
+ "license": "MIT"
+ },
+ "node_modules/is-binary-path": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
+ "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "binary-extensions": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/is-core-module": {
+ "version": "2.15.1",
+ "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.15.1.tgz",
+ "integrity": "sha512-z0vtXSwucUJtANQWldhbtbt7BnL0vxiFjIdDLAatwhDYty2bad6s+rijD6Ri4YuYJubLzIJLUidCh09e1djEVQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "hasown": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-extglob": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
+ "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-fullwidth-code-point": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
+ "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/is-glob": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
+ "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-extglob": "^2.1.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-number": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
+ "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.12.0"
+ }
+ },
+ "node_modules/is-potential-custom-element-name": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
+ "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/isarray": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+ "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+ "license": "MIT"
+ },
+ "node_modules/isexe": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
+ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/jackspeak": {
+ "version": "3.4.3",
+ "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz",
+ "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "@isaacs/cliui": "^8.0.2"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ },
+ "optionalDependencies": {
+ "@pkgjs/parseargs": "^0.11.0"
+ }
+ },
+ "node_modules/jiti": {
+ "version": "1.21.6",
+ "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.6.tgz",
+ "integrity": "sha512-2yTgeWTWzMWkHu6Jp9NKgePDaYHbntiwvYuuJLbbN9vl7DC9DvXKOB2BC3ZZ92D3cvV/aflH0osDfwpHepQ53w==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "jiti": "bin/jiti.js"
+ }
+ },
+ "node_modules/js-tokens": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
+ "license": "MIT"
+ },
+ "node_modules/js-yaml": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
+ "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "argparse": "^2.0.1"
+ },
+ "bin": {
+ "js-yaml": "bin/js-yaml.js"
+ }
+ },
+ "node_modules/jsdom": {
+ "version": "20.0.3",
+ "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-20.0.3.tgz",
+ "integrity": "sha512-SYhBvTh89tTfCD/CRdSOm13mOBa42iTaTyfyEWBdKcGdPxPtLFBXuHR8XHb33YNYaP+lLbmSvBTsnoesCNJEsQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "abab": "^2.0.6",
+ "acorn": "^8.8.1",
+ "acorn-globals": "^7.0.0",
+ "cssom": "^0.5.0",
+ "cssstyle": "^2.3.0",
+ "data-urls": "^3.0.2",
+ "decimal.js": "^10.4.2",
+ "domexception": "^4.0.0",
+ "escodegen": "^2.0.0",
+ "form-data": "^4.0.0",
+ "html-encoding-sniffer": "^3.0.0",
+ "http-proxy-agent": "^5.0.0",
+ "https-proxy-agent": "^5.0.1",
+ "is-potential-custom-element-name": "^1.0.1",
+ "nwsapi": "^2.2.2",
+ "parse5": "^7.1.1",
+ "saxes": "^6.0.0",
+ "symbol-tree": "^3.2.4",
+ "tough-cookie": "^4.1.2",
+ "w3c-xmlserializer": "^4.0.0",
+ "webidl-conversions": "^7.0.0",
+ "whatwg-encoding": "^2.0.0",
+ "whatwg-mimetype": "^3.0.0",
+ "whatwg-url": "^11.0.0",
+ "ws": "^8.11.0",
+ "xml-name-validator": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "peerDependencies": {
+ "canvas": "^2.5.0"
+ },
+ "peerDependenciesMeta": {
+ "canvas": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/json-buffer": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
+ "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/json-schema-traverse": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
+ "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/json-stable-stringify-without-jsonify": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
+ "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/jspdf": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/jspdf/-/jspdf-4.2.1.tgz",
+ "integrity": "sha512-YyAXyvnmjTbR4bHQRLzex3CuINCDlQnBqoSYyjJwTP2x9jDLuKDzy7aKUl0hgx3uhcl7xzg32agn5vlie6HIlQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.28.6",
+ "fast-png": "^6.2.0",
+ "fflate": "^0.8.1"
+ },
+ "optionalDependencies": {
+ "canvg": "^3.0.11",
+ "core-js": "^3.6.0",
+ "dompurify": "^3.3.1",
+ "html2canvas": "^1.0.0-rc.5"
+ }
+ },
+ "node_modules/jspdf-autotable": {
+ "version": "5.0.7",
+ "resolved": "https://registry.npmjs.org/jspdf-autotable/-/jspdf-autotable-5.0.7.tgz",
+ "integrity": "sha512-2wr7H6liNDBYNwt25hMQwXkEWFOEopgKIvR1Eukuw6Zmprm/ZcnmLTQEjW7Xx3FCbD3v7pflLcnMAv/h1jFDQw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "jspdf": "^2 || ^3 || ^4"
+ }
+ },
+ "node_modules/jszip": {
+ "version": "3.10.1",
+ "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
+ "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
+ "license": "(MIT OR GPL-3.0-or-later)",
+ "dependencies": {
+ "lie": "~3.3.0",
+ "pako": "~1.0.2",
+ "readable-stream": "~2.3.6",
+ "setimmediate": "^1.0.5"
+ }
+ },
+ "node_modules/jszip/node_modules/pako": {
+ "version": "1.0.11",
+ "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
+ "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
+ "license": "(MIT AND Zlib)"
+ },
+ "node_modules/keyv": {
+ "version": "4.5.4",
+ "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
+ "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "json-buffer": "3.0.1"
+ }
+ },
+ "node_modules/leac": {
+ "version": "0.6.0",
+ "resolved": "https://registry.npmjs.org/leac/-/leac-0.6.0.tgz",
+ "integrity": "sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://ko-fi.com/killymxi"
+ }
+ },
+ "node_modules/levn": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
+ "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "prelude-ls": "^1.2.1",
+ "type-check": "~0.4.0"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/lie": {
+ "version": "3.3.0",
+ "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
+ "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
+ "license": "MIT",
+ "dependencies": {
+ "immediate": "~3.0.5"
+ }
+ },
+ "node_modules/lilconfig": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
+ "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/antonk52"
+ }
+ },
+ "node_modules/lines-and-columns": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
+ "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/locate-path": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
+ "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "p-locate": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/lodash": {
+ "version": "4.17.21",
+ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
+ "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
+ "license": "MIT"
+ },
+ "node_modules/lodash-es": {
+ "version": "4.17.23",
+ "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.23.tgz",
+ "integrity": "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.castarray": {
+ "version": "4.4.0",
+ "resolved": "https://registry.npmjs.org/lodash.castarray/-/lodash.castarray-4.4.0.tgz",
+ "integrity": "sha512-aVx8ztPv7/2ULbArGJ2Y42bG1mEQ5mGjpdvrbJcJFU3TbYybe+QlLS4pst9zV52ymy2in1KpFPiZnAOATxD4+Q==",
+ "dev": true
+ },
+ "node_modules/lodash.clonedeep": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz",
+ "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isequal": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz",
+ "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==",
+ "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isplainobject": {
+ "version": "4.0.6",
+ "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
+ "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
+ "dev": true
+ },
+ "node_modules/lodash.merge": {
+ "version": "4.6.2",
+ "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
+ "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/loose-envify": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
+ "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
+ "license": "MIT",
+ "dependencies": {
+ "js-tokens": "^3.0.0 || ^4.0.0"
+ },
+ "bin": {
+ "loose-envify": "cli.js"
+ }
+ },
+ "node_modules/lop": {
+ "version": "0.4.2",
+ "resolved": "https://registry.npmjs.org/lop/-/lop-0.4.2.tgz",
+ "integrity": "sha512-RefILVDQ4DKoRZsJ4Pj22TxE3omDO47yFpkIBoDKzkqPRISs5U1cnAdg/5583YPkWPaLIYHOKRMQSvjFsO26cw==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "duck": "^0.1.12",
+ "option": "~0.2.1",
+ "underscore": "^1.13.1"
+ }
+ },
+ "node_modules/loupe": {
+ "version": "3.2.1",
+ "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
+ "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/lovable-tagger": {
+ "version": "1.1.13",
+ "resolved": "https://registry.npmjs.org/lovable-tagger/-/lovable-tagger-1.1.13.tgz",
+ "integrity": "sha512-RBEYDxao7Xf8ya29L0cd+ocE7Gs80xPOIOwwck65Hoie8YDKViuXi3UYV14DoNWIvaJ7WVPf7SG3cc844nFqGA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "^0.25.0",
+ "tailwindcss": "^3.4.17"
+ },
+ "peerDependencies": {
+ "vite": ">=5.0.0 <8.0.0"
+ }
+ },
+ "node_modules/lovable-tagger/node_modules/@esbuild/aix-ppc64": {
+ "version": "0.25.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.0.tgz",
+ "integrity": "sha512-O7vun9Sf8DFjH2UtqK8Ku3LkquL9SZL8OLY1T5NZkA34+wG3OQF7cl4Ql8vdNzM6fzBbYfLaiRLIOZ+2FOCgBQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/lovable-tagger/node_modules/@esbuild/android-arm": {
+ "version": "0.25.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.0.tgz",
+ "integrity": "sha512-PTyWCYYiU0+1eJKmw21lWtC+d08JDZPQ5g+kFyxP0V+es6VPPSUhM6zk8iImp2jbV6GwjX4pap0JFbUQN65X1g==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/lovable-tagger/node_modules/@esbuild/android-arm64": {
+ "version": "0.25.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.0.tgz",
+ "integrity": "sha512-grvv8WncGjDSyUBjN9yHXNt+cq0snxXbDxy5pJtzMKGmmpPxeAmAhWxXI+01lU5rwZomDgD3kJwulEnhTRUd6g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/lovable-tagger/node_modules/@esbuild/android-x64": {
+ "version": "0.25.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.0.tgz",
+ "integrity": "sha512-m/ix7SfKG5buCnxasr52+LI78SQ+wgdENi9CqyCXwjVR2X4Jkz+BpC3le3AoBPYTC9NHklwngVXvbJ9/Akhrfg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/lovable-tagger/node_modules/@esbuild/darwin-arm64": {
+ "version": "0.25.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.0.tgz",
+ "integrity": "sha512-mVwdUb5SRkPayVadIOI78K7aAnPamoeFR2bT5nszFUZ9P8UpK4ratOdYbZZXYSqPKMHfS1wdHCJk1P1EZpRdvw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/lovable-tagger/node_modules/@esbuild/darwin-x64": {
+ "version": "0.25.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.0.tgz",
+ "integrity": "sha512-DgDaYsPWFTS4S3nWpFcMn/33ZZwAAeAFKNHNa1QN0rI4pUjgqf0f7ONmXf6d22tqTY+H9FNdgeaAa+YIFUn2Rg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/lovable-tagger/node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.25.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.0.tgz",
+ "integrity": "sha512-VN4ocxy6dxefN1MepBx/iD1dH5K8qNtNe227I0mnTRjry8tj5MRk4zprLEdG8WPyAPb93/e4pSgi1SoHdgOa4w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/lovable-tagger/node_modules/@esbuild/freebsd-x64": {
+ "version": "0.25.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.0.tgz",
+ "integrity": "sha512-mrSgt7lCh07FY+hDD1TxiTyIHyttn6vnjesnPoVDNmDfOmggTLXRv8Id5fNZey1gl/V2dyVK1VXXqVsQIiAk+A==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/lovable-tagger/node_modules/@esbuild/linux-arm": {
+ "version": "0.25.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.0.tgz",
+ "integrity": "sha512-vkB3IYj2IDo3g9xX7HqhPYxVkNQe8qTK55fraQyTzTX/fxaDtXiEnavv9geOsonh2Fd2RMB+i5cbhu2zMNWJwg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/lovable-tagger/node_modules/@esbuild/linux-arm64": {
+ "version": "0.25.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.0.tgz",
+ "integrity": "sha512-9QAQjTWNDM/Vk2bgBl17yWuZxZNQIF0OUUuPZRKoDtqF2k4EtYbpyiG5/Dk7nqeK6kIJWPYldkOcBqjXjrUlmg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/lovable-tagger/node_modules/@esbuild/linux-ia32": {
+ "version": "0.25.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.0.tgz",
+ "integrity": "sha512-43ET5bHbphBegyeqLb7I1eYn2P/JYGNmzzdidq/w0T8E2SsYL1U6un2NFROFRg1JZLTzdCoRomg8Rvf9M6W6Gg==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/lovable-tagger/node_modules/@esbuild/linux-loong64": {
+ "version": "0.25.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.0.tgz",
+ "integrity": "sha512-fC95c/xyNFueMhClxJmeRIj2yrSMdDfmqJnyOY4ZqsALkDrrKJfIg5NTMSzVBr5YW1jf+l7/cndBfP3MSDpoHw==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/lovable-tagger/node_modules/@esbuild/linux-mips64el": {
+ "version": "0.25.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.0.tgz",
+ "integrity": "sha512-nkAMFju7KDW73T1DdH7glcyIptm95a7Le8irTQNO/qtkoyypZAnjchQgooFUDQhNAy4iu08N79W4T4pMBwhPwQ==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/lovable-tagger/node_modules/@esbuild/linux-ppc64": {
+ "version": "0.25.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.0.tgz",
+ "integrity": "sha512-NhyOejdhRGS8Iwv+KKR2zTq2PpysF9XqY+Zk77vQHqNbo/PwZCzB5/h7VGuREZm1fixhs4Q/qWRSi5zmAiO4Fw==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/lovable-tagger/node_modules/@esbuild/linux-riscv64": {
+ "version": "0.25.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.0.tgz",
+ "integrity": "sha512-5S/rbP5OY+GHLC5qXp1y/Mx//e92L1YDqkiBbO9TQOvuFXM+iDqUNG5XopAnXoRH3FjIUDkeGcY1cgNvnXp/kA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/lovable-tagger/node_modules/@esbuild/linux-s390x": {
+ "version": "0.25.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.0.tgz",
+ "integrity": "sha512-XM2BFsEBz0Fw37V0zU4CXfcfuACMrppsMFKdYY2WuTS3yi8O1nFOhil/xhKTmE1nPmVyvQJjJivgDT+xh8pXJA==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/lovable-tagger/node_modules/@esbuild/linux-x64": {
+ "version": "0.25.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.0.tgz",
+ "integrity": "sha512-9yl91rHw/cpwMCNytUDxwj2XjFpxML0y9HAOH9pNVQDpQrBxHy01Dx+vaMu0N1CKa/RzBD2hB4u//nfc+Sd3Cw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/lovable-tagger/node_modules/@esbuild/netbsd-x64": {
+ "version": "0.25.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.0.tgz",
+ "integrity": "sha512-jl+qisSB5jk01N5f7sPCsBENCOlPiS/xptD5yxOx2oqQfyourJwIKLRA2yqWdifj3owQZCL2sn6o08dBzZGQzA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/lovable-tagger/node_modules/@esbuild/openbsd-x64": {
+ "version": "0.25.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.0.tgz",
+ "integrity": "sha512-2gwwriSMPcCFRlPlKx3zLQhfN/2WjJ2NSlg5TKLQOJdV0mSxIcYNTMhk3H3ulL/cak+Xj0lY1Ym9ysDV1igceg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/lovable-tagger/node_modules/@esbuild/sunos-x64": {
+ "version": "0.25.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.0.tgz",
+ "integrity": "sha512-bxI7ThgLzPrPz484/S9jLlvUAHYMzy6I0XiU1ZMeAEOBcS0VePBFxh1JjTQt3Xiat5b6Oh4x7UC7IwKQKIJRIg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/lovable-tagger/node_modules/@esbuild/win32-arm64": {
+ "version": "0.25.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.0.tgz",
+ "integrity": "sha512-ZUAc2YK6JW89xTbXvftxdnYy3m4iHIkDtK3CLce8wg8M2L+YZhIvO1DKpxrd0Yr59AeNNkTiic9YLf6FTtXWMw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/lovable-tagger/node_modules/@esbuild/win32-ia32": {
+ "version": "0.25.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.0.tgz",
+ "integrity": "sha512-eSNxISBu8XweVEWG31/JzjkIGbGIJN/TrRoiSVZwZ6pkC6VX4Im/WV2cz559/TXLcYbcrDN8JtKgd9DJVIo8GA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/lovable-tagger/node_modules/@esbuild/win32-x64": {
+ "version": "0.25.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.0.tgz",
+ "integrity": "sha512-ZENoHJBxA20C2zFzh6AI4fT6RraMzjYw4xKWemRTRmRVtN9c5DcH9r/f2ihEkMjOW5eGgrwCslG/+Y/3bL+DHQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/lovable-tagger/node_modules/esbuild": {
+ "version": "0.25.0",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.0.tgz",
+ "integrity": "sha512-BXq5mqc8ltbaN34cDqWuYKyNhX8D/Z0J1xdtdQ8UcIIIyJyz+ZMKUt58tF3SrZ85jcfN/PZYhjR5uDQAYNVbuw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.25.0",
+ "@esbuild/android-arm": "0.25.0",
+ "@esbuild/android-arm64": "0.25.0",
+ "@esbuild/android-x64": "0.25.0",
+ "@esbuild/darwin-arm64": "0.25.0",
+ "@esbuild/darwin-x64": "0.25.0",
+ "@esbuild/freebsd-arm64": "0.25.0",
+ "@esbuild/freebsd-x64": "0.25.0",
+ "@esbuild/linux-arm": "0.25.0",
+ "@esbuild/linux-arm64": "0.25.0",
+ "@esbuild/linux-ia32": "0.25.0",
+ "@esbuild/linux-loong64": "0.25.0",
+ "@esbuild/linux-mips64el": "0.25.0",
+ "@esbuild/linux-ppc64": "0.25.0",
+ "@esbuild/linux-riscv64": "0.25.0",
+ "@esbuild/linux-s390x": "0.25.0",
+ "@esbuild/linux-x64": "0.25.0",
+ "@esbuild/netbsd-arm64": "0.25.0",
+ "@esbuild/netbsd-x64": "0.25.0",
+ "@esbuild/openbsd-arm64": "0.25.0",
+ "@esbuild/openbsd-x64": "0.25.0",
+ "@esbuild/sunos-x64": "0.25.0",
+ "@esbuild/win32-arm64": "0.25.0",
+ "@esbuild/win32-ia32": "0.25.0",
+ "@esbuild/win32-x64": "0.25.0"
+ }
+ },
+ "node_modules/lru-cache": {
+ "version": "10.4.3",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
+ "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/lucide-react": {
+ "version": "0.462.0",
+ "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.462.0.tgz",
+ "integrity": "sha512-NTL7EbAao9IFtuSivSZgrAh4fZd09Lr+6MTkqIxuHaH2nnYiYIzXPo06cOxHg9wKLdj6LL8TByG4qpePqwgx/g==",
+ "peerDependencies": {
+ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc"
+ }
+ },
+ "node_modules/magic-string": {
+ "version": "0.30.21",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
+ "node_modules/make-cancellable-promise": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/make-cancellable-promise/-/make-cancellable-promise-1.3.2.tgz",
+ "integrity": "sha512-GCXh3bq/WuMbS+Ky4JBPW1hYTOU+znU+Q5m9Pu+pI8EoUqIHk9+tviOKC6/qhHh8C4/As3tzJ69IF32kdz85ww==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/wojtekmaj/make-cancellable-promise?sponsor=1"
+ }
+ },
+ "node_modules/make-dir": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz",
+ "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "semver": "^6.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/make-dir/node_modules/semver": {
+ "version": "6.3.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
+ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
+ "license": "ISC",
+ "optional": true,
+ "bin": {
+ "semver": "bin/semver.js"
+ }
+ },
+ "node_modules/make-event-props": {
+ "version": "1.6.2",
+ "resolved": "https://registry.npmjs.org/make-event-props/-/make-event-props-1.6.2.tgz",
+ "integrity": "sha512-iDwf7mA03WPiR8QxvcVHmVWEPfMY1RZXerDVNCRYW7dUr2ppH3J58Rwb39/WG39yTZdRSxr3x+2v22tvI0VEvA==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/wojtekmaj/make-event-props?sponsor=1"
+ }
+ },
+ "node_modules/mammoth": {
+ "version": "1.12.0",
+ "resolved": "https://registry.npmjs.org/mammoth/-/mammoth-1.12.0.tgz",
+ "integrity": "sha512-cwnK1RIcRdDMi2HRx2EXGYlxqIEh0Oo3bLhorgnsVJi2UkbX1+jKxuBNR9PC5+JaX7EkmJxFPmo6mjLpqShI2w==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "@xmldom/xmldom": "^0.8.6",
+ "argparse": "~1.0.3",
+ "base64-js": "^1.5.1",
+ "bluebird": "~3.4.0",
+ "dingbat-to-unicode": "^1.0.1",
+ "jszip": "^3.7.1",
+ "lop": "^0.4.2",
+ "path-is-absolute": "^1.0.0",
+ "underscore": "^1.13.1",
+ "xmlbuilder": "^10.0.0"
+ },
+ "bin": {
+ "mammoth": "bin/mammoth"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
+ "node_modules/mammoth/node_modules/argparse": {
+ "version": "1.0.10",
+ "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
+ "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
+ "license": "MIT",
+ "dependencies": {
+ "sprintf-js": "~1.0.2"
+ }
+ },
+ "node_modules/math-intrinsics": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/merge-refs": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/merge-refs/-/merge-refs-1.3.0.tgz",
+ "integrity": "sha512-nqXPXbso+1dcKDpPCXvwZyJILz+vSLqGGOnDrYHQYE+B8n9JTCekVLC65AfCpR4ggVyA/45Y0iR9LDyS2iI+zA==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/wojtekmaj/merge-refs?sponsor=1"
+ },
+ "peerDependencies": {
+ "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/merge2": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
+ "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/micromatch": {
+ "version": "4.0.8",
+ "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
+ "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "braces": "^3.0.3",
+ "picomatch": "^2.3.1"
+ },
+ "engines": {
+ "node": ">=8.6"
+ }
+ },
+ "node_modules/mime-db": {
+ "version": "1.52.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
+ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime-types": {
+ "version": "2.1.35",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
+ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": "1.52.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mimic-response": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-2.1.0.tgz",
+ "integrity": "sha512-wXqjST+SLt7R009ySCglWBCFpjUygmCIfD790/kVbiGmUgfYGuB14PiTd5DwVxSV4NcYHjzMkoj5LjQZwTQLEA==",
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/min-indent": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
+ "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/minimatch": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
+ "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
+ "devOptional": true,
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^1.1.7"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/minipass": {
+ "version": "7.1.2",
+ "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
+ "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ }
+ },
+ "node_modules/minizlib": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz",
+ "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "minipass": "^3.0.0",
+ "yallist": "^4.0.0"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/minizlib/node_modules/minipass": {
+ "version": "3.3.6",
+ "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz",
+ "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==",
+ "license": "ISC",
+ "optional": true,
+ "dependencies": {
+ "yallist": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/mkdirp": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz",
+ "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==",
+ "license": "MIT",
+ "optional": true,
+ "bin": {
+ "mkdirp": "bin/cmd.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/motion-dom": {
+ "version": "12.37.0",
+ "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.37.0.tgz",
+ "integrity": "sha512-LnppZuwX1jQizRWTl9LBLMN3RbAEmdQkX/2Af0UW70NCqYJI/7GfI83vQP9Ucel/Avc0Tf2ZWy8FHawuc0O6Vg==",
+ "license": "MIT",
+ "dependencies": {
+ "motion-utils": "^12.36.0"
+ }
+ },
+ "node_modules/motion-utils": {
+ "version": "12.36.0",
+ "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.36.0.tgz",
+ "integrity": "sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg==",
+ "license": "MIT"
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "devOptional": true,
+ "license": "MIT"
+ },
+ "node_modules/mz": {
+ "version": "2.7.0",
+ "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
+ "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "any-promise": "^1.0.0",
+ "object-assign": "^4.0.1",
+ "thenify-all": "^1.0.0"
+ }
+ },
+ "node_modules/nan": {
+ "version": "2.26.2",
+ "resolved": "https://registry.npmjs.org/nan/-/nan-2.26.2.tgz",
+ "integrity": "sha512-0tTvBTYkt3tdGw22nrAy50x7gpbGCCFH3AFcyS5WiUu7Eu4vWlri1woE6qHBSfy11vksDqkiwjOnlR7WV8G1Hw==",
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.11",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
+ "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/natural-compare": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
+ "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/next-themes": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.3.0.tgz",
+ "integrity": "sha512-/QHIrsYpd6Kfk7xakK4svpDI5mmXP0gfvCoJdGpZQ2TOrQZmsW0QxjaiLn8wbIKjtm4BTSqLoix4lxYYOnLJ/w==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": "^16.8 || ^17 || ^18",
+ "react-dom": "^16.8 || ^17 || ^18"
+ }
+ },
+ "node_modules/node-fetch": {
+ "version": "2.7.0",
+ "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
+ "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "whatwg-url": "^5.0.0"
+ },
+ "engines": {
+ "node": "4.x || >=6.0.0"
+ },
+ "peerDependencies": {
+ "encoding": "^0.1.0"
+ },
+ "peerDependenciesMeta": {
+ "encoding": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/node-fetch/node_modules/tr46": {
+ "version": "0.0.3",
+ "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
+ "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/node-fetch/node_modules/webidl-conversions": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
+ "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
+ "license": "BSD-2-Clause",
+ "optional": true
+ },
+ "node_modules/node-fetch/node_modules/whatwg-url": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
+ "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tr46": "~0.0.3",
+ "webidl-conversions": "^3.0.0"
+ }
+ },
+ "node_modules/node-releases": {
+ "version": "2.0.19",
+ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz",
+ "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/nopt": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz",
+ "integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==",
+ "license": "ISC",
+ "optional": true,
+ "dependencies": {
+ "abbrev": "1"
+ },
+ "bin": {
+ "nopt": "bin/nopt.js"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/normalize-path": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
+ "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/normalize-range": {
+ "version": "0.1.2",
+ "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz",
+ "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/npmlog": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-5.0.1.tgz",
+ "integrity": "sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw==",
+ "deprecated": "This package is no longer supported.",
+ "license": "ISC",
+ "optional": true,
+ "dependencies": {
+ "are-we-there-yet": "^2.0.0",
+ "console-control-strings": "^1.1.0",
+ "gauge": "^3.0.0",
+ "set-blocking": "^2.0.0"
+ }
+ },
+ "node_modules/nwsapi": {
+ "version": "2.2.23",
+ "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.23.tgz",
+ "integrity": "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/object-assign": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
+ "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/object-hash": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz",
+ "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/once": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
+ "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
+ "license": "ISC",
+ "optional": true,
+ "dependencies": {
+ "wrappy": "1"
+ }
+ },
+ "node_modules/option": {
+ "version": "0.2.4",
+ "resolved": "https://registry.npmjs.org/option/-/option-0.2.4.tgz",
+ "integrity": "sha512-pkEqbDyl8ou5cpq+VsnQbe/WlEy5qS7xPzMS1U55OCG9KPvwFD46zDbxQIj3egJSFc3D+XhYOPUzz49zQAVy7A==",
+ "license": "BSD-2-Clause"
+ },
+ "node_modules/optionator": {
+ "version": "0.9.4",
+ "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
+ "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "deep-is": "^0.1.3",
+ "fast-levenshtein": "^2.0.6",
+ "levn": "^0.4.1",
+ "prelude-ls": "^1.2.1",
+ "type-check": "^0.4.0",
+ "word-wrap": "^1.2.5"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/p-limit": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
+ "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "yocto-queue": "^0.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/p-locate": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
+ "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "p-limit": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/p-try": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
+ "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/package-json-from-dist": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
+ "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
+ "dev": true,
+ "license": "BlueOak-1.0.0"
+ },
+ "node_modules/pako": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz",
+ "integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==",
+ "license": "(MIT AND Zlib)"
+ },
+ "node_modules/parchment": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/parchment/-/parchment-3.0.0.tgz",
+ "integrity": "sha512-HUrJFQ/StvgmXRcQ1ftY6VEZUq3jA2t9ncFN4F84J/vN0/FPpQF+8FKXb3l6fLces6q0uOHj6NJn+2xvZnxO6A==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/parent-module": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
+ "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "callsites": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/parse5": {
+ "version": "7.3.0",
+ "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
+ "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "entities": "^6.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/inikulin/parse5?sponsor=1"
+ }
+ },
+ "node_modules/parse5/node_modules/entities": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
+ "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "node_modules/parseley": {
+ "version": "0.12.1",
+ "resolved": "https://registry.npmjs.org/parseley/-/parseley-0.12.1.tgz",
+ "integrity": "sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw==",
+ "license": "MIT",
+ "dependencies": {
+ "leac": "^0.6.0",
+ "peberminta": "^0.9.0"
+ },
+ "funding": {
+ "url": "https://ko-fi.com/killymxi"
+ }
+ },
+ "node_modules/path-exists": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
+ "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/path-is-absolute": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
+ "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/path-key": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
+ "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/path-parse": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
+ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/path-scurry": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz",
+ "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "lru-cache": "^10.2.0",
+ "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0"
+ },
+ "engines": {
+ "node": ">=16 || 14 >=14.18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/path2d": {
+ "version": "0.2.2",
+ "resolved": "https://registry.npmjs.org/path2d/-/path2d-0.2.2.tgz",
+ "integrity": "sha512-+vnG6S4dYcYxZd+CZxzXCNKdELYZSKfohrk98yajCo1PtRoDgCTrrwOvK1GT0UoAdVszagDVllQc0U1vaX4NUQ==",
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/pathe": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
+ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/pathval": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz",
+ "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 14.16"
+ }
+ },
+ "node_modules/pdf-lib": {
+ "version": "1.17.1",
+ "resolved": "https://registry.npmjs.org/pdf-lib/-/pdf-lib-1.17.1.tgz",
+ "integrity": "sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==",
+ "license": "MIT",
+ "dependencies": {
+ "@pdf-lib/standard-fonts": "^1.0.0",
+ "@pdf-lib/upng": "^1.0.1",
+ "pako": "^1.0.11",
+ "tslib": "^1.11.1"
+ }
+ },
+ "node_modules/pdf-lib/node_modules/pako": {
+ "version": "1.0.11",
+ "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
+ "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
+ "license": "(MIT AND Zlib)"
+ },
+ "node_modules/pdf-lib/node_modules/tslib": {
+ "version": "1.14.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
+ "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
+ "license": "0BSD"
+ },
+ "node_modules/pdfjs-dist": {
+ "version": "4.4.168",
+ "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-4.4.168.tgz",
+ "integrity": "sha512-MbkAjpwka/dMHaCfQ75RY1FXX3IewBVu6NGZOcxerRFlaBiIkZmUoR0jotX5VUzYZEXAGzSFtknWs5xRKliXPA==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "canvas": "^2.11.2",
+ "path2d": "^0.2.0"
+ }
+ },
+ "node_modules/peberminta": {
+ "version": "0.9.0",
+ "resolved": "https://registry.npmjs.org/peberminta/-/peberminta-0.9.0.tgz",
+ "integrity": "sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://ko-fi.com/killymxi"
+ }
+ },
+ "node_modules/performance-now": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz",
+ "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==",
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
+ "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/pify": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz",
+ "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/pirates": {
+ "version": "4.0.6",
+ "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.6.tgz",
+ "integrity": "sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/playwright": {
+ "version": "1.58.2",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
+ "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright-core": "1.58.2"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "fsevents": "2.3.2"
+ }
+ },
+ "node_modules/playwright-core": {
+ "version": "1.58.2",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
+ "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "playwright-core": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/playwright/node_modules/fsevents": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
+ "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/pngjs": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
+ "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.6",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
+ "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.11",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/postcss-import": {
+ "version": "15.1.0",
+ "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz",
+ "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "postcss-value-parser": "^4.0.0",
+ "read-cache": "^1.0.0",
+ "resolve": "^1.1.7"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "peerDependencies": {
+ "postcss": "^8.0.0"
+ }
+ },
+ "node_modules/postcss-js": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.0.1.tgz",
+ "integrity": "sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "camelcase-css": "^2.0.1"
+ },
+ "engines": {
+ "node": "^12 || ^14 || >= 16"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ "peerDependencies": {
+ "postcss": "^8.4.21"
+ }
+ },
+ "node_modules/postcss-load-config": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-4.0.2.tgz",
+ "integrity": "sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "lilconfig": "^3.0.0",
+ "yaml": "^2.3.4"
+ },
+ "engines": {
+ "node": ">= 14"
+ },
+ "peerDependencies": {
+ "postcss": ">=8.0.9",
+ "ts-node": ">=9.0.0"
+ },
+ "peerDependenciesMeta": {
+ "postcss": {
+ "optional": true
+ },
+ "ts-node": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/postcss-nested": {
+ "version": "6.2.0",
+ "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz",
+ "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "postcss-selector-parser": "^6.1.1"
+ },
+ "engines": {
+ "node": ">=12.0"
+ },
+ "peerDependencies": {
+ "postcss": "^8.2.14"
+ }
+ },
+ "node_modules/postcss-selector-parser": {
+ "version": "6.1.2",
+ "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz",
+ "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "cssesc": "^3.0.0",
+ "util-deprecate": "^1.0.2"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/postcss-value-parser": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
+ "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/prelude-ls": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
+ "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/process-nextick-args": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
+ "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
+ "license": "MIT"
+ },
+ "node_modules/prop-types": {
+ "version": "15.8.1",
+ "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
+ "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.4.0",
+ "object-assign": "^4.1.1",
+ "react-is": "^16.13.1"
+ }
+ },
+ "node_modules/prop-types/node_modules/react-is": {
+ "version": "16.13.1",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
+ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
+ "license": "MIT"
+ },
+ "node_modules/psl": {
+ "version": "1.15.0",
+ "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz",
+ "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "punycode": "^2.3.1"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/lupomontero"
+ }
+ },
+ "node_modules/punycode": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
+ "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/qrcode": {
+ "version": "1.5.4",
+ "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
+ "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
+ "license": "MIT",
+ "dependencies": {
+ "dijkstrajs": "^1.0.1",
+ "pngjs": "^5.0.0",
+ "yargs": "^15.3.1"
+ },
+ "bin": {
+ "qrcode": "bin/qrcode"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
+ "node_modules/querystringify": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz",
+ "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/queue-microtask": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
+ "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/quill": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/quill/-/quill-2.0.3.tgz",
+ "integrity": "sha512-xEYQBqfYx/sfb33VJiKnSJp8ehloavImQ2A6564GAbqG55PGw1dAWUn1MUbQB62t0azawUS2CZZhWCjO8gRvTw==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "eventemitter3": "^5.0.1",
+ "lodash-es": "^4.17.21",
+ "parchment": "^3.0.0",
+ "quill-delta": "^5.1.0"
+ },
+ "engines": {
+ "npm": ">=8.2.3"
+ }
+ },
+ "node_modules/quill-delta": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/quill-delta/-/quill-delta-5.1.0.tgz",
+ "integrity": "sha512-X74oCeRI4/p0ucjb5Ma8adTXd9Scumz367kkMK5V/IatcX6A0vlgLgKbzXWy5nZmCGeNJm2oQX0d2Eqj+ZIlCA==",
+ "license": "MIT",
+ "dependencies": {
+ "fast-diff": "^1.3.0",
+ "lodash.clonedeep": "^4.5.0",
+ "lodash.isequal": "^4.5.0"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ }
+ },
+ "node_modules/quill/node_modules/eventemitter3": {
+ "version": "5.0.4",
+ "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
+ "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
+ "license": "MIT"
+ },
+ "node_modules/raf": {
+ "version": "3.4.1",
+ "resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz",
+ "integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "performance-now": "^2.1.0"
+ }
+ },
+ "node_modules/raf-schd": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/raf-schd/-/raf-schd-4.0.3.tgz",
+ "integrity": "sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ==",
+ "license": "MIT"
+ },
+ "node_modules/react": {
+ "version": "18.3.1",
+ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
+ "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-day-picker": {
+ "version": "8.10.1",
+ "resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-8.10.1.tgz",
+ "integrity": "sha512-TMx7fNbhLk15eqcMt+7Z7S2KF7mfTId/XJDjKE8f+IUcFn0l08/kI4FiYTL/0yuOLmEcbR4Fwe3GJf/NiiMnPA==",
+ "license": "MIT",
+ "funding": {
+ "type": "individual",
+ "url": "https://github.com/sponsors/gpbl"
+ },
+ "peerDependencies": {
+ "date-fns": "^2.28.0 || ^3.0.0",
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0"
+ }
+ },
+ "node_modules/react-dom": {
+ "version": "18.3.1",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
+ "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.1.0",
+ "scheduler": "^0.23.2"
+ },
+ "peerDependencies": {
+ "react": "^18.3.1"
+ }
+ },
+ "node_modules/react-draggable": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmjs.org/react-draggable/-/react-draggable-4.5.0.tgz",
+ "integrity": "sha512-VC+HBLEZ0XJxnOxVAZsdRi8rD04Iz3SiiKOoYzamjylUcju/hP9np/aZdLHf/7WOD268WMoNJMvYfB5yAK45cw==",
+ "license": "MIT",
+ "dependencies": {
+ "clsx": "^2.1.1",
+ "prop-types": "^15.8.1"
+ },
+ "peerDependencies": {
+ "react": ">= 16.3.0",
+ "react-dom": ">= 16.3.0"
+ }
+ },
+ "node_modules/react-dropzone": {
+ "version": "15.0.0",
+ "resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-15.0.0.tgz",
+ "integrity": "sha512-lGjYV/EoqEjEWPnmiSvH4v5IoIAwQM2W4Z1C0Q/Pw2xD0eVzKPS359BQTUMum+1fa0kH2nrKjuavmTPOGhpLPg==",
+ "license": "MIT",
+ "dependencies": {
+ "attr-accept": "^2.2.4",
+ "file-selector": "^2.1.0",
+ "prop-types": "^15.8.1"
+ },
+ "engines": {
+ "node": ">= 10.13"
+ },
+ "peerDependencies": {
+ "react": ">= 16.8 || 18.0.0"
+ }
+ },
+ "node_modules/react-grid-layout": {
+ "version": "2.2.2",
+ "resolved": "https://registry.npmjs.org/react-grid-layout/-/react-grid-layout-2.2.2.tgz",
+ "integrity": "sha512-yNo9pxQWoxHWRAwHGSVT4DEGELYPyQ7+q9lFclb5jcqeFzva63/2F72CryS/jiTIr/SBIlTaDdyjqH+ODg8oBw==",
+ "license": "MIT",
+ "dependencies": {
+ "clsx": "^2.1.1",
+ "fast-equals": "^4.0.3",
+ "prop-types": "^15.8.1",
+ "react-draggable": "^4.4.6",
+ "react-resizable": "^3.0.5",
+ "resize-observer-polyfill": "^1.5.1"
+ },
+ "peerDependencies": {
+ "react": ">= 16.3.0",
+ "react-dom": ">= 16.3.0"
+ }
+ },
+ "node_modules/react-grid-layout/node_modules/fast-equals": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-4.0.3.tgz",
+ "integrity": "sha512-G3BSX9cfKttjr+2o1O22tYMLq0DPluZnYtq1rXumE1SpL/F/SLIfHx08WYQoWSIpeMYf8sRbJ8++71+v6Pnxfg==",
+ "license": "MIT"
+ },
+ "node_modules/react-hook-form": {
+ "version": "7.61.1",
+ "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.61.1.tgz",
+ "integrity": "sha512-2vbXUFDYgqEgM2RcXcAT2PwDW/80QARi+PKmHy5q2KhuKvOlG8iIYgf7eIlIANR5trW9fJbP4r5aub3a4egsew==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/react-hook-form"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17 || ^18 || ^19"
+ }
+ },
+ "node_modules/react-is": {
+ "version": "18.3.1",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
+ "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
+ "license": "MIT"
+ },
+ "node_modules/react-pdf": {
+ "version": "9.1.1",
+ "resolved": "https://registry.npmjs.org/react-pdf/-/react-pdf-9.1.1.tgz",
+ "integrity": "sha512-Cn3RTJZMqVOOCgLMRXDamLk4LPGfyB2Np3OwQAUjmHIh47EpuGW1OpAA1Z1GVDLoHx4d5duEDo/YbUkDbr4QFQ==",
+ "license": "MIT",
+ "dependencies": {
+ "clsx": "^2.0.0",
+ "dequal": "^2.0.3",
+ "make-cancellable-promise": "^1.3.1",
+ "make-event-props": "^1.6.0",
+ "merge-refs": "^1.3.0",
+ "pdfjs-dist": "4.4.168",
+ "tiny-invariant": "^1.0.0",
+ "warning": "^4.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/wojtekmaj/react-pdf?sponsor=1"
+ },
+ "peerDependencies": {
+ "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
+ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/react-quill-new": {
+ "version": "3.8.3",
+ "resolved": "https://registry.npmjs.org/react-quill-new/-/react-quill-new-3.8.3.tgz",
+ "integrity": "sha512-c96PYqFTo0pI4R3e79B3rH9LUIce1kIQbmTBu/imJQZk8305ogyLyBqKKjG2UoInDlquXqePSzmBo2aVia3ttw==",
+ "license": "MIT",
+ "dependencies": {
+ "lodash-es": "^4.17.21",
+ "quill": "~2.0.3"
+ },
+ "peerDependencies": {
+ "quill-delta": "^5.1.0",
+ "react": "^16 || ^17 || ^18 || ^19",
+ "react-dom": "^16 || ^17 || ^18 || ^19"
+ }
+ },
+ "node_modules/react-redux": {
+ "version": "9.2.0",
+ "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
+ "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/use-sync-external-store": "^0.0.6",
+ "use-sync-external-store": "^1.4.0"
+ },
+ "peerDependencies": {
+ "@types/react": "^18.2.25 || ^19",
+ "react": "^18.0 || ^19",
+ "redux": "^5.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "redux": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/react-remove-scroll": {
+ "version": "2.7.1",
+ "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.1.tgz",
+ "integrity": "sha512-HpMh8+oahmIdOuS5aFKKY6Pyog+FNaZV/XyJOq7b4YFwsFHe5yYfdbIalI4k3vU2nSDql7YskmUseHsRrJqIPA==",
+ "license": "MIT",
+ "dependencies": {
+ "react-remove-scroll-bar": "^2.3.7",
+ "react-style-singleton": "^2.2.3",
+ "tslib": "^2.1.0",
+ "use-callback-ref": "^1.3.3",
+ "use-sidecar": "^1.1.3"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/react-remove-scroll-bar": {
+ "version": "2.3.8",
+ "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz",
+ "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==",
+ "license": "MIT",
+ "dependencies": {
+ "react-style-singleton": "^2.2.2",
+ "tslib": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/react-resizable": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/react-resizable/-/react-resizable-3.1.3.tgz",
+ "integrity": "sha512-liJBNayhX7qA4tBJiBD321FDhJxgGTJ07uzH5zSORXoE8h7PyEZ8mLqmosST7ppf6C4zUsbd2gzDMmBCfFp9Lw==",
+ "license": "MIT",
+ "dependencies": {
+ "prop-types": "15.x",
+ "react-draggable": "^4.5.0"
+ },
+ "peerDependencies": {
+ "react": ">= 16.3",
+ "react-dom": ">= 16.3"
+ }
+ },
+ "node_modules/react-resizable-panels": {
+ "version": "2.1.9",
+ "resolved": "https://registry.npmjs.org/react-resizable-panels/-/react-resizable-panels-2.1.9.tgz",
+ "integrity": "sha512-z77+X08YDIrgAes4jl8xhnUu1LNIRp4+E7cv4xHmLOxxUPO/ML7PSrE813b90vj7xvQ1lcf7g2uA9GeMZonjhQ==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": "^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc",
+ "react-dom": "^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
+ }
+ },
+ "node_modules/react-router": {
+ "version": "6.30.1",
+ "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.1.tgz",
+ "integrity": "sha512-X1m21aEmxGXqENEPG3T6u0Th7g0aS4ZmoNynhbs+Cn+q+QGTLt+d5IQ2bHAXKzKcxGJjxACpVbnYQSCRcfxHlQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@remix-run/router": "1.23.0"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.8"
+ }
+ },
+ "node_modules/react-router-dom": {
+ "version": "6.30.1",
+ "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.1.tgz",
+ "integrity": "sha512-llKsgOkZdbPU1Eg3zK8lCn+sjD9wMRZZPuzmdWWX5SUs8OFkN5HnFVC0u5KMeMaC9aoancFI/KoLuKPqN+hxHw==",
+ "license": "MIT",
+ "dependencies": {
+ "@remix-run/router": "1.23.0",
+ "react-router": "6.30.1"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.8",
+ "react-dom": ">=16.8"
+ }
+ },
+ "node_modules/react-smooth": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz",
+ "integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==",
+ "license": "MIT",
+ "dependencies": {
+ "fast-equals": "^5.0.1",
+ "prop-types": "^15.8.1",
+ "react-transition-group": "^4.4.5"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
+ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
+ "node_modules/react-style-singleton": {
+ "version": "2.2.3",
+ "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
+ "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==",
+ "license": "MIT",
+ "dependencies": {
+ "get-nonce": "^1.0.0",
+ "tslib": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/react-transition-group": {
+ "version": "4.4.5",
+ "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
+ "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "@babel/runtime": "^7.5.5",
+ "dom-helpers": "^5.0.1",
+ "loose-envify": "^1.4.0",
+ "prop-types": "^15.6.2"
+ },
+ "peerDependencies": {
+ "react": ">=16.6.0",
+ "react-dom": ">=16.6.0"
+ }
+ },
+ "node_modules/read-cache": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
+ "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "pify": "^2.3.0"
+ }
+ },
+ "node_modules/readable-stream": {
+ "version": "2.3.8",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
+ "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
+ "license": "MIT",
+ "dependencies": {
+ "core-util-is": "~1.0.0",
+ "inherits": "~2.0.3",
+ "isarray": "~1.0.0",
+ "process-nextick-args": "~2.0.0",
+ "safe-buffer": "~5.1.1",
+ "string_decoder": "~1.1.1",
+ "util-deprecate": "~1.0.1"
+ }
+ },
+ "node_modules/readdirp": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
+ "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "picomatch": "^2.2.1"
+ },
+ "engines": {
+ "node": ">=8.10.0"
+ }
+ },
+ "node_modules/recharts": {
+ "version": "2.15.4",
+ "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz",
+ "integrity": "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==",
+ "license": "MIT",
+ "dependencies": {
+ "clsx": "^2.0.0",
+ "eventemitter3": "^4.0.1",
+ "lodash": "^4.17.21",
+ "react-is": "^18.3.1",
+ "react-smooth": "^4.0.4",
+ "recharts-scale": "^0.4.4",
+ "tiny-invariant": "^1.3.1",
+ "victory-vendor": "^36.6.8"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "peerDependencies": {
+ "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
+ "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
+ "node_modules/recharts-scale": {
+ "version": "0.4.5",
+ "resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz",
+ "integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==",
+ "license": "MIT",
+ "dependencies": {
+ "decimal.js-light": "^2.4.1"
+ }
+ },
+ "node_modules/redent": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
+ "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "indent-string": "^4.0.0",
+ "strip-indent": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/redux": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
+ "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
+ "license": "MIT"
+ },
+ "node_modules/regenerator-runtime": {
+ "version": "0.13.11",
+ "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
+ "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==",
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/require-directory": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
+ "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/require-main-filename": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
+ "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
+ "license": "ISC"
+ },
+ "node_modules/requires-port": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
+ "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/resize-observer-polyfill": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz",
+ "integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==",
+ "license": "MIT"
+ },
+ "node_modules/resolve": {
+ "version": "1.22.8",
+ "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz",
+ "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-core-module": "^2.13.0",
+ "path-parse": "^1.0.7",
+ "supports-preserve-symlinks-flag": "^1.0.0"
+ },
+ "bin": {
+ "resolve": "bin/resolve"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/resolve-from": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
+ "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/reusify": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz",
+ "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "iojs": ">=1.0.0",
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/rgbcolor": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/rgbcolor/-/rgbcolor-1.0.1.tgz",
+ "integrity": "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==",
+ "license": "MIT OR SEE LICENSE IN FEEL-FREE.md",
+ "optional": true,
+ "engines": {
+ "node": ">= 0.8.15"
+ }
+ },
+ "node_modules/rimraf": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
+ "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
+ "deprecated": "Rimraf versions prior to v4 are no longer supported",
+ "license": "ISC",
+ "optional": true,
+ "dependencies": {
+ "glob": "^7.1.3"
+ },
+ "bin": {
+ "rimraf": "bin.js"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/rimraf/node_modules/glob": {
+ "version": "7.2.3",
+ "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
+ "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
+ "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me",
+ "license": "ISC",
+ "optional": true,
+ "dependencies": {
+ "fs.realpath": "^1.0.0",
+ "inflight": "^1.0.4",
+ "inherits": "2",
+ "minimatch": "^3.1.1",
+ "once": "^1.3.0",
+ "path-is-absolute": "^1.0.0"
+ },
+ "engines": {
+ "node": "*"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/rollup": {
+ "version": "4.24.0",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.24.0.tgz",
+ "integrity": "sha512-DOmrlGSXNk1DM0ljiQA+i+o0rSLhtii1je5wgk60j49d1jHT5YYttBv1iWOnYSTG+fZZESUOSNiAl89SIet+Cg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "1.0.6"
+ },
+ "bin": {
+ "rollup": "dist/bin/rollup"
+ },
+ "engines": {
+ "node": ">=18.0.0",
+ "npm": ">=8.0.0"
+ },
+ "optionalDependencies": {
+ "@rollup/rollup-android-arm-eabi": "4.24.0",
+ "@rollup/rollup-android-arm64": "4.24.0",
+ "@rollup/rollup-darwin-arm64": "4.24.0",
+ "@rollup/rollup-darwin-x64": "4.24.0",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.24.0",
+ "@rollup/rollup-linux-arm-musleabihf": "4.24.0",
+ "@rollup/rollup-linux-arm64-gnu": "4.24.0",
+ "@rollup/rollup-linux-arm64-musl": "4.24.0",
+ "@rollup/rollup-linux-powerpc64le-gnu": "4.24.0",
+ "@rollup/rollup-linux-riscv64-gnu": "4.24.0",
+ "@rollup/rollup-linux-s390x-gnu": "4.24.0",
+ "@rollup/rollup-linux-x64-gnu": "4.24.0",
+ "@rollup/rollup-linux-x64-musl": "4.24.0",
+ "@rollup/rollup-win32-arm64-msvc": "4.24.0",
+ "@rollup/rollup-win32-ia32-msvc": "4.24.0",
+ "@rollup/rollup-win32-x64-msvc": "4.24.0",
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/run-parallel": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
+ "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "queue-microtask": "^1.2.2"
+ }
+ },
+ "node_modules/safe-buffer": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
+ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
+ "license": "MIT"
+ },
+ "node_modules/safer-buffer": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/saxes": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
+ "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "xmlchars": "^2.2.0"
+ },
+ "engines": {
+ "node": ">=v12.22.7"
+ }
+ },
+ "node_modules/scheduler": {
+ "version": "0.23.2",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
+ "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.1.0"
+ }
+ },
+ "node_modules/selderee": {
+ "version": "0.11.0",
+ "resolved": "https://registry.npmjs.org/selderee/-/selderee-0.11.0.tgz",
+ "integrity": "sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA==",
+ "license": "MIT",
+ "dependencies": {
+ "parseley": "^0.12.0"
+ },
+ "funding": {
+ "url": "https://ko-fi.com/killymxi"
+ }
+ },
+ "node_modules/semver": {
+ "version": "7.7.2",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
+ "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==",
+ "devOptional": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/set-blocking": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
+ "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
+ "license": "ISC"
+ },
+ "node_modules/setimmediate": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
+ "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
+ "license": "MIT"
+ },
+ "node_modules/shebang-command": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
+ "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "shebang-regex": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/shebang-regex": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
+ "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/siginfo": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
+ "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/signal-exit": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
+ "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/simple-concat": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
+ "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/simple-get": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-3.1.1.tgz",
+ "integrity": "sha512-CQ5LTKGfCpvE1K0n2us+kuMPbk/q0EKl82s4aheV9oXjFEz6W/Y7oQFVJuU6QG77hRT4Ghb5RURteF5vnWjupA==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "decompress-response": "^4.2.0",
+ "once": "^1.3.1",
+ "simple-concat": "^1.0.0"
+ }
+ },
+ "node_modules/sonner": {
+ "version": "1.7.4",
+ "resolved": "https://registry.npmjs.org/sonner/-/sonner-1.7.4.tgz",
+ "integrity": "sha512-DIS8z4PfJRbIyfVFDVnK9rO3eYDtse4Omcm6bt0oEr5/jtLgysmjuBl1frJ9E/EQZrFmKx2A8m/s5s9CRXIzhw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc",
+ "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc"
+ }
+ },
+ "node_modules/source-map": {
+ "version": "0.6.1",
+ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
+ "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "optional": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/sprintf-js": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
+ "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/ssf": {
+ "version": "0.11.2",
+ "resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
+ "integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "frac": "~1.1.2"
+ },
+ "engines": {
+ "node": ">=0.8"
+ }
+ },
+ "node_modules/stackback": {
+ "version": "0.0.2",
+ "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
+ "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/stackblur-canvas": {
+ "version": "2.7.0",
+ "resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz",
+ "integrity": "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==",
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">=0.1.14"
+ }
+ },
+ "node_modules/std-env": {
+ "version": "3.10.0",
+ "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
+ "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/string_decoder": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+ "license": "MIT",
+ "dependencies": {
+ "safe-buffer": "~5.1.0"
+ }
+ },
+ "node_modules/string-width": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
+ "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "eastasianwidth": "^0.2.0",
+ "emoji-regex": "^9.2.2",
+ "strip-ansi": "^7.0.1"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/string-width-cjs": {
+ "name": "string-width",
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "emoji-regex": "^8.0.0",
+ "is-fullwidth-code-point": "^3.0.0",
+ "strip-ansi": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/string-width-cjs/node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/string-width-cjs/node_modules/emoji-regex": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/string-width-cjs/node_modules/strip-ansi": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/strip-ansi": {
+ "version": "7.1.0",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz",
+ "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/strip-ansi?sponsor=1"
+ }
+ },
+ "node_modules/strip-ansi-cjs": {
+ "name": "strip-ansi",
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/strip-ansi-cjs/node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/strip-indent": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz",
+ "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "min-indent": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/strip-json-comments": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
+ "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/strip-literal": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz",
+ "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "js-tokens": "^9.0.1"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/antfu"
+ }
+ },
+ "node_modules/strip-literal/node_modules/js-tokens": {
+ "version": "9.0.1",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz",
+ "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/sucrase": {
+ "version": "3.35.0",
+ "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz",
+ "integrity": "sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.2",
+ "commander": "^4.0.0",
+ "glob": "^10.3.10",
+ "lines-and-columns": "^1.1.6",
+ "mz": "^2.7.0",
+ "pirates": "^4.0.1",
+ "ts-interface-checker": "^0.1.9"
+ },
+ "bin": {
+ "sucrase": "bin/sucrase",
+ "sucrase-node": "bin/sucrase-node"
+ },
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ }
+ },
+ "node_modules/supports-color": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "has-flag": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/supports-preserve-symlinks-flag": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
+ "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/svg-pathdata": {
+ "version": "6.0.3",
+ "resolved": "https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-6.0.3.tgz",
+ "integrity": "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==",
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
+ "node_modules/symbol-tree": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
+ "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tailwind-merge": {
+ "version": "2.6.0",
+ "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.0.tgz",
+ "integrity": "sha512-P+Vu1qXfzediirmHOC3xKGAYeZtPcV9g76X+xg2FD4tYgR71ewMA35Y3sCz3zhiN/dwefRpJX0yBcgwi1fXNQA==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/dcastil"
+ }
+ },
+ "node_modules/tailwindcss": {
+ "version": "3.4.17",
+ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz",
+ "integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@alloc/quick-lru": "^5.2.0",
+ "arg": "^5.0.2",
+ "chokidar": "^3.6.0",
+ "didyoumean": "^1.2.2",
+ "dlv": "^1.1.3",
+ "fast-glob": "^3.3.2",
+ "glob-parent": "^6.0.2",
+ "is-glob": "^4.0.3",
+ "jiti": "^1.21.6",
+ "lilconfig": "^3.1.3",
+ "micromatch": "^4.0.8",
+ "normalize-path": "^3.0.0",
+ "object-hash": "^3.0.0",
+ "picocolors": "^1.1.1",
+ "postcss": "^8.4.47",
+ "postcss-import": "^15.1.0",
+ "postcss-js": "^4.0.1",
+ "postcss-load-config": "^4.0.2",
+ "postcss-nested": "^6.2.0",
+ "postcss-selector-parser": "^6.1.2",
+ "resolve": "^1.22.8",
+ "sucrase": "^3.35.0"
+ },
+ "bin": {
+ "tailwind": "lib/cli.js",
+ "tailwindcss": "lib/cli.js"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/tailwindcss-animate": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/tailwindcss-animate/-/tailwindcss-animate-1.0.7.tgz",
+ "integrity": "sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==",
+ "license": "MIT",
+ "peerDependencies": {
+ "tailwindcss": ">=3.0.0 || insiders"
+ }
+ },
+ "node_modules/tar": {
+ "version": "6.2.1",
+ "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz",
+ "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==",
+ "deprecated": "Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me",
+ "license": "ISC",
+ "optional": true,
+ "dependencies": {
+ "chownr": "^2.0.0",
+ "fs-minipass": "^2.0.0",
+ "minipass": "^5.0.0",
+ "minizlib": "^2.1.1",
+ "mkdirp": "^1.0.3",
+ "yallist": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/tar/node_modules/minipass": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz",
+ "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==",
+ "license": "ISC",
+ "optional": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/text-segmentation": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz",
+ "integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==",
+ "license": "MIT",
+ "dependencies": {
+ "utrie": "^1.0.2"
+ }
+ },
+ "node_modules/thenify": {
+ "version": "3.3.1",
+ "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
+ "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "any-promise": "^1.0.0"
+ }
+ },
+ "node_modules/thenify-all": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz",
+ "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "thenify": ">= 3.1.0 < 4"
+ },
+ "engines": {
+ "node": ">=0.8"
+ }
+ },
+ "node_modules/tiny-invariant": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
+ "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
+ "license": "MIT"
+ },
+ "node_modules/tinybench": {
+ "version": "2.9.0",
+ "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
+ "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tinyexec": {
+ "version": "0.3.2",
+ "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz",
+ "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.15",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
+ "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.3"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/tinyglobby/node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/tinyglobby/node_modules/picomatch": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
+ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/tinypool": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz",
+ "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.0.0 || >=20.0.0"
+ }
+ },
+ "node_modules/tinyrainbow": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz",
+ "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/tinyspy": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz",
+ "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/to-regex-range": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
+ "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-number": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=8.0"
+ }
+ },
+ "node_modules/tough-cookie": {
+ "version": "4.1.4",
+ "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz",
+ "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "psl": "^1.1.33",
+ "punycode": "^2.1.1",
+ "universalify": "^0.2.0",
+ "url-parse": "^1.5.3"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/tr46": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/tr46/-/tr46-3.0.0.tgz",
+ "integrity": "sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "punycode": "^2.1.1"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/ts-api-utils": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz",
+ "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.12"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4"
+ }
+ },
+ "node_modules/ts-interface-checker": {
+ "version": "0.1.13",
+ "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
+ "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==",
+ "dev": true,
+ "license": "Apache-2.0"
+ },
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "license": "0BSD"
+ },
+ "node_modules/type-check": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
+ "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "prelude-ls": "^1.2.1"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "5.8.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
+ "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/typescript-eslint": {
+ "version": "8.38.0",
+ "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.38.0.tgz",
+ "integrity": "sha512-FsZlrYK6bPDGoLeZRuvx2v6qrM03I0U0SnfCLPs/XCCPCFD80xU9Pg09H/K+XFa68uJuZo7l/Xhs+eDRg2l3hg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/eslint-plugin": "8.38.0",
+ "@typescript-eslint/parser": "8.38.0",
+ "@typescript-eslint/typescript-estree": "8.38.0",
+ "@typescript-eslint/utils": "8.38.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.57.0 || ^9.0.0",
+ "typescript": ">=4.8.4 <5.9.0"
+ }
+ },
+ "node_modules/underscore": {
+ "version": "1.13.8",
+ "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.8.tgz",
+ "integrity": "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==",
+ "license": "MIT"
+ },
+ "node_modules/undici-types": {
+ "version": "6.21.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
+ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
+ "license": "MIT"
+ },
+ "node_modules/universalify": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz",
+ "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 4.0.0"
+ }
+ },
+ "node_modules/update-browserslist-db": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz",
+ "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "escalade": "^3.2.0",
+ "picocolors": "^1.1.1"
+ },
+ "bin": {
+ "update-browserslist-db": "cli.js"
+ },
+ "peerDependencies": {
+ "browserslist": ">= 4.21.0"
+ }
+ },
+ "node_modules/uri-js": {
+ "version": "4.4.1",
+ "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
+ "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "punycode": "^2.1.0"
+ }
+ },
+ "node_modules/url-parse": {
+ "version": "1.5.10",
+ "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz",
+ "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "querystringify": "^2.1.1",
+ "requires-port": "^1.0.0"
+ }
+ },
+ "node_modules/use-callback-ref": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz",
+ "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/use-sidecar": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz",
+ "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==",
+ "license": "MIT",
+ "dependencies": {
+ "detect-node-es": "^1.1.0",
+ "tslib": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/use-sync-external-store": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz",
+ "integrity": "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
+ "node_modules/util-deprecate": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
+ "license": "MIT"
+ },
+ "node_modules/utrie": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz",
+ "integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==",
+ "license": "MIT",
+ "dependencies": {
+ "base64-arraybuffer": "^1.0.2"
+ }
+ },
+ "node_modules/uuid": {
+ "version": "13.0.0",
+ "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz",
+ "integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==",
+ "funding": [
+ "https://github.com/sponsors/broofa",
+ "https://github.com/sponsors/ctavan"
+ ],
+ "license": "MIT",
+ "bin": {
+ "uuid": "dist-node/bin/uuid"
+ }
+ },
+ "node_modules/vaul": {
+ "version": "0.9.9",
+ "resolved": "https://registry.npmjs.org/vaul/-/vaul-0.9.9.tgz",
+ "integrity": "sha512-7afKg48srluhZwIkaU+lgGtFCUsYBSGOl8vcc8N/M3YQlZFlynHD15AE+pwrYdc826o7nrIND4lL9Y6b9WWZZQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-dialog": "^1.1.1"
+ },
+ "peerDependencies": {
+ "react": "^16.8 || ^17.0 || ^18.0",
+ "react-dom": "^16.8 || ^17.0 || ^18.0"
+ }
+ },
+ "node_modules/victory-vendor": {
+ "version": "36.9.2",
+ "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz",
+ "integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==",
+ "license": "MIT AND ISC",
+ "dependencies": {
+ "@types/d3-array": "^3.0.3",
+ "@types/d3-ease": "^3.0.0",
+ "@types/d3-interpolate": "^3.0.1",
+ "@types/d3-scale": "^4.0.2",
+ "@types/d3-shape": "^3.1.0",
+ "@types/d3-time": "^3.0.0",
+ "@types/d3-timer": "^3.0.0",
+ "d3-array": "^3.1.6",
+ "d3-ease": "^3.0.1",
+ "d3-interpolate": "^3.0.1",
+ "d3-scale": "^4.0.2",
+ "d3-shape": "^3.1.0",
+ "d3-time": "^3.0.0",
+ "d3-timer": "^3.0.1"
+ }
+ },
+ "node_modules/vite": {
+ "version": "5.4.19",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.19.tgz",
+ "integrity": "sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "^0.21.3",
+ "postcss": "^8.4.43",
+ "rollup": "^4.20.0"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^18.0.0 || >=20.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^18.0.0 || >=20.0.0",
+ "less": "*",
+ "lightningcss": "^1.21.0",
+ "sass": "*",
+ "sass-embedded": "*",
+ "stylus": "*",
+ "sugarss": "*",
+ "terser": "^5.4.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "lightningcss": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vite-node": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz",
+ "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "cac": "^6.7.14",
+ "debug": "^4.4.1",
+ "es-module-lexer": "^1.7.0",
+ "pathe": "^2.0.3",
+ "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0"
+ },
+ "bin": {
+ "vite-node": "vite-node.mjs"
+ },
+ "engines": {
+ "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/vitest": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz",
+ "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/chai": "^5.2.2",
+ "@vitest/expect": "3.2.4",
+ "@vitest/mocker": "3.2.4",
+ "@vitest/pretty-format": "^3.2.4",
+ "@vitest/runner": "3.2.4",
+ "@vitest/snapshot": "3.2.4",
+ "@vitest/spy": "3.2.4",
+ "@vitest/utils": "3.2.4",
+ "chai": "^5.2.0",
+ "debug": "^4.4.1",
+ "expect-type": "^1.2.1",
+ "magic-string": "^0.30.17",
+ "pathe": "^2.0.3",
+ "picomatch": "^4.0.2",
+ "std-env": "^3.9.0",
+ "tinybench": "^2.9.0",
+ "tinyexec": "^0.3.2",
+ "tinyglobby": "^0.2.14",
+ "tinypool": "^1.1.1",
+ "tinyrainbow": "^2.0.0",
+ "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0",
+ "vite-node": "3.2.4",
+ "why-is-node-running": "^2.3.0"
+ },
+ "bin": {
+ "vitest": "vitest.mjs"
+ },
+ "engines": {
+ "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "@edge-runtime/vm": "*",
+ "@types/debug": "^4.1.12",
+ "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
+ "@vitest/browser": "3.2.4",
+ "@vitest/ui": "3.2.4",
+ "happy-dom": "*",
+ "jsdom": "*"
+ },
+ "peerDependenciesMeta": {
+ "@edge-runtime/vm": {
+ "optional": true
+ },
+ "@types/debug": {
+ "optional": true
+ },
+ "@types/node": {
+ "optional": true
+ },
+ "@vitest/browser": {
+ "optional": true
+ },
+ "@vitest/ui": {
+ "optional": true
+ },
+ "happy-dom": {
+ "optional": true
+ },
+ "jsdom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vitest/node_modules/picomatch": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
+ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/w3c-xmlserializer": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-4.0.0.tgz",
+ "integrity": "sha512-d+BFHzbiCx6zGfz0HyQ6Rg69w9k19nviJspaj4yNscGjrHu94sVP+aRm75yEbCh+r2/yR+7q6hux9LVtbuTGBw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "xml-name-validator": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=14"
+ }
+ },
+ "node_modules/warning": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz",
+ "integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.0.0"
+ }
+ },
+ "node_modules/webidl-conversions": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
+ "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/whatwg-encoding": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-2.0.0.tgz",
+ "integrity": "sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==",
+ "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "iconv-lite": "0.6.3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/whatwg-mimetype": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz",
+ "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/whatwg-url": {
+ "version": "11.0.0",
+ "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-11.0.0.tgz",
+ "integrity": "sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tr46": "^3.0.0",
+ "webidl-conversions": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/which": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+ "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "isexe": "^2.0.0"
+ },
+ "bin": {
+ "node-which": "bin/node-which"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/which-module": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
+ "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
+ "license": "ISC"
+ },
+ "node_modules/why-is-node-running": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
+ "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "siginfo": "^2.0.0",
+ "stackback": "0.0.2"
+ },
+ "bin": {
+ "why-is-node-running": "cli.js"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/wide-align": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz",
+ "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==",
+ "license": "ISC",
+ "optional": true,
+ "dependencies": {
+ "string-width": "^1.0.2 || 2 || 3 || 4"
+ }
+ },
+ "node_modules/wide-align/node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/wide-align/node_modules/emoji-regex": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/wide-align/node_modules/string-width": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "emoji-regex": "^8.0.0",
+ "is-fullwidth-code-point": "^3.0.0",
+ "strip-ansi": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/wide-align/node_modules/strip-ansi": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/wmf": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
+ "integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=0.8"
+ }
+ },
+ "node_modules/word": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz",
+ "integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=0.8"
+ }
+ },
+ "node_modules/word-wrap": {
+ "version": "1.2.5",
+ "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
+ "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/wrap-ansi": {
+ "version": "8.1.0",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz",
+ "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^6.1.0",
+ "string-width": "^5.0.1",
+ "strip-ansi": "^7.0.1"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
+ }
+ },
+ "node_modules/wrap-ansi-cjs": {
+ "name": "wrap-ansi",
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
+ "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^4.0.0",
+ "string-width": "^4.1.0",
+ "strip-ansi": "^6.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
+ }
+ },
+ "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/wrap-ansi-cjs/node_modules/string-width": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "emoji-regex": "^8.0.0",
+ "is-fullwidth-code-point": "^3.0.0",
+ "strip-ansi": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/wrap-ansi/node_modules/ansi-styles": {
+ "version": "6.2.1",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz",
+ "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/wrappy": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
+ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
+ "license": "ISC",
+ "optional": true
+ },
+ "node_modules/ws": {
+ "version": "8.19.0",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
+ "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.0.0"
+ },
+ "peerDependencies": {
+ "bufferutil": "^4.0.1",
+ "utf-8-validate": ">=5.0.2"
+ },
+ "peerDependenciesMeta": {
+ "bufferutil": {
+ "optional": true
+ },
+ "utf-8-validate": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/xlsx": {
+ "version": "0.18.5",
+ "resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz",
+ "integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "adler-32": "~1.3.0",
+ "cfb": "~1.2.1",
+ "codepage": "~1.15.0",
+ "crc-32": "~1.2.1",
+ "ssf": "~0.11.2",
+ "wmf": "~1.0.1",
+ "word": "~0.3.0"
+ },
+ "bin": {
+ "xlsx": "bin/xlsx.njs"
+ },
+ "engines": {
+ "node": ">=0.8"
+ }
+ },
+ "node_modules/xml-name-validator": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz",
+ "integrity": "sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/xmlbuilder": {
+ "version": "10.1.1",
+ "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-10.1.1.tgz",
+ "integrity": "sha512-OyzrcFLL/nb6fMGHbiRDuPup9ljBycsdCypwuyg5AAHvyWzGfChJpCXMG88AGTIMFhGZ9RccFN1e6lhg3hkwKg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4.0"
+ }
+ },
+ "node_modules/xmlchars": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
+ "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/y18n": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
+ "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
+ "license": "ISC"
+ },
+ "node_modules/yallist": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
+ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
+ "license": "ISC",
+ "optional": true
+ },
+ "node_modules/yaml": {
+ "version": "2.6.0",
+ "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.6.0.tgz",
+ "integrity": "sha512-a6ae//JvKDEra2kdi1qzCyrJW/WZCgFi8ydDV+eXExl95t+5R+ijnqHJbz9tmMh8FUjx3iv2fCQ4dclAQlO2UQ==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "yaml": "bin.mjs"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/yargs": {
+ "version": "15.4.1",
+ "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
+ "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
+ "license": "MIT",
+ "dependencies": {
+ "cliui": "^6.0.0",
+ "decamelize": "^1.2.0",
+ "find-up": "^4.1.0",
+ "get-caller-file": "^2.0.1",
+ "require-directory": "^2.1.1",
+ "require-main-filename": "^2.0.0",
+ "set-blocking": "^2.0.0",
+ "string-width": "^4.2.0",
+ "which-module": "^2.0.0",
+ "y18n": "^4.0.0",
+ "yargs-parser": "^18.1.2"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/yargs-parser": {
+ "version": "18.1.3",
+ "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
+ "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
+ "license": "ISC",
+ "dependencies": {
+ "camelcase": "^5.0.0",
+ "decamelize": "^1.2.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/yargs/node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/yargs/node_modules/emoji-regex": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+ "license": "MIT"
+ },
+ "node_modules/yargs/node_modules/find-up": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
+ "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
+ "license": "MIT",
+ "dependencies": {
+ "locate-path": "^5.0.0",
+ "path-exists": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/yargs/node_modules/locate-path": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
+ "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
+ "license": "MIT",
+ "dependencies": {
+ "p-locate": "^4.1.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/yargs/node_modules/p-limit": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
+ "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
+ "license": "MIT",
+ "dependencies": {
+ "p-try": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/yargs/node_modules/p-locate": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
+ "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
+ "license": "MIT",
+ "dependencies": {
+ "p-limit": "^2.2.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/yargs/node_modules/string-width": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+ "license": "MIT",
+ "dependencies": {
+ "emoji-regex": "^8.0.0",
+ "is-fullwidth-code-point": "^3.0.0",
+ "strip-ansi": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/yargs/node_modules/strip-ansi": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/yocto-queue": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
+ "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/zod": {
+ "version": "3.25.76",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
+ "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
+ }
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..6b3a552
--- /dev/null
+++ b/package.json
@@ -0,0 +1,126 @@
+{
+ "name": "vite_react_shadcn_ts",
+ "private": true,
+ "version": "0.0.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build",
+ "build:dev": "vite build --mode development",
+ "lint": "eslint .",
+ "preview": "vite preview",
+ "test": "vitest run",
+ "test:watch": "vitest"
+ },
+ "dependencies": {
+ "@dnd-kit/core": "^6.3.1",
+ "@dnd-kit/sortable": "^10.0.0",
+ "@dnd-kit/utilities": "^3.2.2",
+ "@hello-pangea/dnd": "^18.0.1",
+ "@hookform/resolvers": "^3.10.0",
+ "@lovable.dev/cloud-auth-js": "^1.1.1",
+ "@radix-ui/react-accordion": "^1.2.11",
+ "@radix-ui/react-alert-dialog": "^1.1.14",
+ "@radix-ui/react-aspect-ratio": "^1.1.7",
+ "@radix-ui/react-avatar": "^1.1.10",
+ "@radix-ui/react-checkbox": "^1.3.2",
+ "@radix-ui/react-collapsible": "^1.1.11",
+ "@radix-ui/react-context-menu": "^2.2.15",
+ "@radix-ui/react-dialog": "^1.1.14",
+ "@radix-ui/react-dropdown-menu": "^2.1.15",
+ "@radix-ui/react-hover-card": "^1.1.14",
+ "@radix-ui/react-label": "^2.1.7",
+ "@radix-ui/react-menubar": "^1.1.15",
+ "@radix-ui/react-navigation-menu": "^1.2.13",
+ "@radix-ui/react-popover": "^1.1.14",
+ "@radix-ui/react-progress": "^1.1.7",
+ "@radix-ui/react-radio-group": "^1.3.7",
+ "@radix-ui/react-scroll-area": "^1.2.9",
+ "@radix-ui/react-select": "^2.2.5",
+ "@radix-ui/react-separator": "^1.1.7",
+ "@radix-ui/react-slider": "^1.3.5",
+ "@radix-ui/react-slot": "^1.2.3",
+ "@radix-ui/react-switch": "^1.2.5",
+ "@radix-ui/react-tabs": "^1.1.12",
+ "@radix-ui/react-toast": "^1.2.14",
+ "@radix-ui/react-toggle": "^1.1.9",
+ "@radix-ui/react-toggle-group": "^1.1.10",
+ "@radix-ui/react-tooltip": "^1.2.7",
+ "@stripe/react-stripe-js": "^5.6.1",
+ "@stripe/stripe-js": "^8.10.0",
+ "@supabase/supabase-js": "^2.99.1",
+ "@tanstack/react-query": "^5.83.0",
+ "@types/google.maps": "^3.58.1",
+ "@types/leaflet": "1.9.12",
+ "@types/leaflet.markercluster": "^1.5.6",
+ "class-variance-authority": "^0.7.1",
+ "clsx": "^2.1.1",
+ "cmdk": "^1.1.1",
+ "date-fns": "^3.6.0",
+ "date-fns-tz": "^3.2.0",
+ "embla-carousel-react": "^8.6.0",
+ "exifr": "^7.1.3",
+ "framer-motion": "^12.36.0",
+ "html-to-text": "9.0.5",
+ "html2canvas": "^1.4.1",
+ "ical.js": "^2.2.1",
+ "input-otp": "^1.4.2",
+ "jspdf": "^4.2.1",
+ "jspdf-autotable": "^5.0.7",
+ "leaflet": "1.9.4",
+ "leaflet.markercluster": "^1.5.3",
+ "lucide-react": "^0.462.0",
+ "mammoth": "^1.12.0",
+ "mapbox-gl": "^3.0.0",
+ "mapbox-gl-leaflet": "^0.0.16",
+ "next-themes": "^0.3.0",
+ "pdf-lib": "^1.17.1",
+ "pdfjs-dist": "4.4.168",
+ "qrcode": "^1.5.4",
+ "react": "^18.3.1",
+ "react-day-picker": "^8.10.1",
+ "react-dom": "^18.3.1",
+ "react-dropzone": "^15.0.0",
+ "react-grid-layout": "^2.2.2",
+ "react-hook-form": "^7.61.1",
+ "react-leaflet": "4.2.1",
+ "react-pdf": "9.1.1",
+ "react-plaid-link": "^4.1.1",
+ "react-quill-new": "^3.8.3",
+ "react-resizable": "^3.1.3",
+ "react-resizable-panels": "^2.1.9",
+ "react-router-dom": "^6.30.1",
+ "recharts": "^2.15.4",
+ "sonner": "^1.7.4",
+ "tailwind-merge": "^2.6.0",
+ "tailwindcss-animate": "^1.0.7",
+ "uuid": "^13.0.0",
+ "vaul": "^0.9.9",
+ "xlsx": "^0.18.5",
+ "zod": "^3.25.76"
+ },
+ "devDependencies": {
+ "@eslint/js": "^9.32.0",
+ "@playwright/test": "^1.57.0",
+ "@tailwindcss/typography": "^0.5.16",
+ "@testing-library/jest-dom": "^6.6.0",
+ "@testing-library/react": "^16.0.0",
+ "@types/node": "^22.16.5",
+ "@types/react": "^18.3.23",
+ "@types/react-dom": "^18.3.7",
+ "@vitejs/plugin-react-swc": "^3.11.0",
+ "autoprefixer": "^10.4.21",
+ "eslint": "^9.32.0",
+ "eslint-plugin-react-hooks": "^5.2.0",
+ "eslint-plugin-react-refresh": "^0.4.20",
+ "globals": "^15.15.0",
+ "jsdom": "^20.0.3",
+ "lovable-tagger": "^1.1.13",
+ "postcss": "^8.5.6",
+ "tailwindcss": "^3.4.17",
+ "typescript": "^5.8.3",
+ "typescript-eslint": "^8.38.0",
+ "vite": "^5.4.19",
+ "vitest": "^3.2.4"
+ }
+}
diff --git a/playwright-fixture.ts b/playwright-fixture.ts
new file mode 100644
index 0000000..7d471c1
--- /dev/null
+++ b/playwright-fixture.ts
@@ -0,0 +1,3 @@
+// Re-export the base fixture from the package
+// Override or extend test/expect here if needed
+export { test, expect } from "lovable-agent-playwright-config/fixture";
diff --git a/playwright.config.ts b/playwright.config.ts
new file mode 100644
index 0000000..ec19e95
--- /dev/null
+++ b/playwright.config.ts
@@ -0,0 +1,10 @@
+import { createLovableConfig } from "lovable-agent-playwright-config/config";
+
+export default createLovableConfig({
+ // Add your custom playwright configuration overrides here
+ // Example:
+ // timeout: 60000,
+ // use: {
+ // baseURL: 'http://localhost:3000',
+ // },
+});
diff --git a/postcss.config.js b/postcss.config.js
new file mode 100644
index 0000000..2aa7205
--- /dev/null
+++ b/postcss.config.js
@@ -0,0 +1,6 @@
+export default {
+ plugins: {
+ tailwindcss: {},
+ autoprefixer: {},
+ },
+};
diff --git a/public/favicon.ico b/public/favicon.ico
new file mode 100644
index 0000000..cacd21e
Binary files /dev/null and b/public/favicon.ico differ
diff --git a/public/placeholder.svg b/public/placeholder.svg
new file mode 100644
index 0000000..e763910
--- /dev/null
+++ b/public/placeholder.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/public/robots.txt b/public/robots.txt
new file mode 100644
index 0000000..6018e70
--- /dev/null
+++ b/public/robots.txt
@@ -0,0 +1,14 @@
+User-agent: Googlebot
+Allow: /
+
+User-agent: Bingbot
+Allow: /
+
+User-agent: Twitterbot
+Allow: /
+
+User-agent: facebookexternalhit
+Allow: /
+
+User-agent: *
+Allow: /
diff --git a/src/App.css b/src/App.css
new file mode 100644
index 0000000..e89f365
--- /dev/null
+++ b/src/App.css
@@ -0,0 +1,43 @@
+#root {
+ width: 100%;
+ max-width: 100%;
+ margin: 0;
+ padding: 0;
+ text-align: left;
+}
+
+.logo {
+ height: 6em;
+ padding: 1.5em;
+ will-change: filter;
+ transition: filter 300ms;
+}
+.logo:hover {
+ filter: drop-shadow(0 0 2em #646cffaa);
+}
+.logo.react:hover {
+ filter: drop-shadow(0 0 2em #61dafbaa);
+}
+
+@keyframes logo-spin {
+ from {
+ transform: rotate(0deg);
+ }
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+@media (prefers-reduced-motion: no-preference) {
+ a:nth-of-type(2) .logo {
+ animation: logo-spin infinite 20s linear;
+ }
+}
+
+.card {
+ padding: 2em;
+}
+
+.read-the-docs {
+ color: #888;
+}
diff --git a/src/App.tsx b/src/App.tsx
new file mode 100644
index 0000000..99216d9
--- /dev/null
+++ b/src/App.tsx
@@ -0,0 +1,568 @@
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { BrowserRouter, Route, Routes } from "react-router-dom";
+import { Toaster as Sonner } from "@/components/ui/sonner";
+import { Toaster } from "@/components/ui/toaster";
+import { TooltipProvider } from "@/components/ui/tooltip";
+import { AuthProvider } from "@/contexts/AuthContext";
+import { ViewAsBanner } from "@/components/ViewAsBanner";
+import Index from "./pages/Index";
+import Auth from "./pages/Auth";
+import ResetPasswordPage from "./pages/ResetPasswordPage";
+import NotFound from "./pages/NotFound";
+import PublicFormSubmitPage from "./pages/PublicFormSubmitPage";
+import VendorInsuranceSubmitPage from "./pages/VendorInsuranceSubmitPage";
+import VendorProfileSubmitPage from "./pages/VendorProfileSubmitPage";
+import TenantInfoSubmitPage from "./pages/TenantInfoSubmitPage";
+import UnsubscribePage from "./pages/UnsubscribePage";
+import RVRenterPortalPage from "./pages/rv-portal/RVRenterPortalPage";
+import CodeRegistrationPage from "./pages/CodeRegistrationPage";
+import SignupCodesPage from "./pages/SignupCodesPage";
+import {
+ AccountingLayout,
+ AccountingDashboardPage,
+ AccountingChartOfAccountsPage,
+ AccountingJournalEntriesPage,
+ AccountingInvoicesPage,
+ AccountingBillsPage,
+ AccountingCustomersPage,
+ AccountingVendorsPage,
+ AccountingDepositsPage,
+ AccountingReceivePaymentsPage,
+ AccountingBankingPage,
+ AccountingReconciliationPage,
+ AccountingBudgetsPage,
+ AccountingAssessmentsPage,
+ AccountingWorkOrdersPage,
+ AccountingOpeningBalancesPage,
+ AccountingExpensesPage,
+ AccountingEstimatesPage,
+ AccountingReconcileDetailPage,
+ AccountingBudgetDetailPage,
+ AccountingCustomerDetailPage,
+ AccountingSettingsLayout,
+ AccountingGeneralSettingsPage,
+ AccountingCheckSetupPage,
+ AccountingIntegrationsPage,
+ AccountingReportsPage as PlatformAccountingReportsPage,
+} from "./pages/accounting/AccountingIndex";
+
+// Admin/Manager Layout & Pages
+import DashboardLayout from "./layouts/DashboardLayout";
+import Dashboard from "./pages/Dashboard";
+import AssociationsPage from "./pages/AssociationsPage";
+import UnitsPage from "./pages/UnitsPage";
+import OwnersPage from "./pages/OwnersPage";
+import BulkUpdatesPage from "./pages/BulkUpdatesPage";
+import OwnerProfilePage from "./pages/OwnerProfilePage";
+import ViolationsPage from "./pages/ViolationsPage";
+import ARCApplicationsPage from "./pages/ARCApplicationsPage";
+import ARCInboundEmailsPage from "./pages/ARCInboundEmailsPage";
+import ReportGeneratorPage from "./pages/ReportGeneratorPage";
+import AssociationDetailPage from "./pages/AssociationDetailPage";
+import GeneralLedgerPage from "./pages/GeneralLedgerPage";
+import BillableExpensesPage from "./pages/BillableExpensesPage";
+import InvoiceClientsPage from "./pages/InvoiceClientsPage";
+import BoardMembersPage from "./pages/BoardMembersPage";
+import AnnouncementsPage from "./pages/AnnouncementsPage";
+import RemindersPage from "./pages/RemindersPage";
+import SettingsPage from "./pages/SettingsPage";
+import UnitProfilePage from "./pages/UnitProfilePage";
+import ProjectsPage from "./pages/ProjectsPage";
+import ProjectDetailPage from "./pages/ProjectDetailPage";
+import CalendarPage from "./pages/CalendarPage";
+import CallLogPage from "./pages/CallLogPage";
+import DocumentsPage from "./pages/DocumentsPage";
+import FormsLettersPage from "./pages/FormsLettersPage";
+import OwnerUpdatesPage from "./pages/OwnerUpdatesPage";
+import StatusUpdatesPage from "./pages/StatusUpdatesPage";
+import TasksPage from "./pages/TasksPage";
+import InspectionsPage from "./pages/InspectionsPage";
+import AIInvoiceParserPage from "./pages/AIInvoiceParserPage";
+import BlockedDatesPage from "./pages/BlockedDatesPage";
+import ChecklistsPage from "./pages/ChecklistsPage";
+import InvoiceTrackingPage from "./pages/InvoiceTrackingPage";
+import ClientInvoicesPage from "./pages/ClientInvoicesPage";
+import PayablesPage from "./pages/PayablesPage";
+import PaymentsPage from "./pages/PaymentsPage";
+import UserManagementPage from "./pages/UserManagementPage";
+import BidsQuotesPage from "./pages/BidsQuotesPage";
+import BillApprovalsPage from "./pages/BillApprovalsPage";
+import BillDetailPage from "./pages/BillDetailPage";
+import BoardVotesPage from "./pages/BoardVotesPage";
+import ElectionsPage from "./pages/ElectionsPage";
+
+import ClientRequestsPage from "./pages/ClientRequestsPage";
+import CollectionsPage from "./pages/CollectionsPage";
+import EstoppelsPage from "./pages/EstoppelsPage";
+import HomeownerRequestsPage from "./pages/HomeownerRequestsPage";
+import LegalMattersPage from "./pages/LegalMattersPage";
+import ParkingPage from "./pages/ParkingPage";
+import PaymentPlansPage from "./pages/PaymentPlansPage";
+import BudgetManagementPage from "./pages/BudgetManagementPage";
+import BankAccountsPage from "./pages/BankAccountsPage";
+import BankRegisterPage from "./pages/BankRegisterPage";
+
+import ReconciliationsPage from "./pages/ReconciliationsPage";
+import ImportTransactionsPage from "./pages/ImportTransactionsPage";
+import WriteChecksPage from "./pages/WriteChecksPage";
+import PrintChecksPage from "./pages/PrintChecksPage";
+import CompanyLedgerPage from "./pages/CompanyLedgerPage";
+import CompanyBankAccountsPage from "./pages/CompanyBankAccountsPage";
+import CompanyBankAccountsHubPage from "./pages/CompanyBankAccountsHubPage";
+import CompanyBankRegisterPage from "./pages/CompanyBankRegisterPage";
+import CompanyChecksPage from "./pages/CompanyChecksPage";
+import AccountingReportsPage from "./pages/AccountingReportsPage";
+import ComposeEmailPage from "./pages/ComposeEmailPage";
+import EmailHistoryPage from "./pages/EmailHistoryPage";
+import EmailRoutingPage from "./pages/EmailRoutingPage";
+import EmailSendersPage from "./pages/EmailSendersPage";
+
+import EmailTemplatesPage from "./pages/EmailTemplatesPage";
+import NotifyBoardPage from "./pages/NotifyBoardPage";
+import NotifyOwnersPage from "./pages/NotifyOwnersPage";
+import MailchimpPage from "./pages/MailchimpPage";
+import DataMigration from "./pages/DataMigration";
+import MediaLibraryPage from "./pages/MediaLibraryPage";
+import MigrationFieldsPage from "./pages/MigrationFieldsPage";
+import TimeTrackingPage from "./pages/TimeTrackingPage";
+
+// New financial pages
+import VendorsPage from "./pages/VendorsPage";
+import VendorDetailPage from "./pages/VendorDetailPage";
+import BillsPage from "./pages/BillsPage";
+import InboundBillsPage from "./pages/InboundBillsPage";
+import DirectoryPage from "./pages/DirectoryPage";
+import CommitteesPage from "./pages/CommitteesPage";
+import BillApprovalsHubPage from "./pages/BillApprovalsHubPage";
+import BankAccountsHubPage from "./pages/BankAccountsHubPage";
+import ChartOfAccountsPage from "./pages/ChartOfAccountsPage";
+import OwnerLedgerPage from "./pages/OwnerLedgerPage";
+import RecordOwnerPaymentPage from "./pages/RecordOwnerPaymentPage";
+import DepositBatchesPage from "./pages/DepositBatchesPage";
+import TransfersPage from "./pages/TransfersPage";
+import ZohoBooksSettingsPage from "./pages/settings/ZohoBooksSettingsPage";
+import BrandingSettingsPage from "./pages/settings/BrandingSettingsPage";
+import RolePermissionsPage from "./pages/settings/RolePermissionsPage";
+import GeneralSettingsPage from "./pages/settings/GeneralSettingsPage";
+import PortalFunctionVisibilityPage from "./pages/settings/PortalFunctionVisibilityPage";
+import MyProfilePage from "./pages/MyProfilePage";
+import AdminStripeAccountsPage from "./pages/AdminStripeAccountsPage";
+import BuildiumSettingsPage from "./pages/settings/BuildiumSettingsPage";
+import BuildiumImportReviewPage from "./pages/settings/BuildiumImportReviewPage";
+import RecurringRulesPage from "./pages/settings/RecurringRulesPage";
+import ZohoFinancialReportsPage from "./pages/ZohoFinancialReportsPage";
+import FinancialOverviewPage from "./pages/FinancialOverviewPage";
+import RecentLedgerUpdatesPage from "./pages/RecentLedgerUpdatesPage";
+import OutstandingBalancesPage from "./pages/OutstandingBalancesPage";
+import BulkChargesPage from "./pages/BulkChargesPage";
+import LedgerChargesReportPage from "./pages/LedgerChargesReportPage";
+import DocuSignEnvelopesPage from "./pages/DocuSignEnvelopesPage";
+import AvriaSignEnvelopesPage from "./pages/AvriaSignEnvelopesPage";
+import PublicSignPage from "./pages/PublicSignPage";
+import CollaborativeDocumentsPageAdmin from "./components/collaborative/CollaborativeDocumentsPage";
+
+import FormInboxPage from "./pages/FormInboxPage";
+import ComplianceChecklistPage from "./pages/ComplianceChecklistPage";
+import ComplianceChecklistsHubPage from "./pages/ComplianceChecklistsHubPage";
+
+// Client Portal Layout & Pages
+import ClientLayout from "./layouts/ClientLayout";
+import ClientHomePage from "./pages/client/ClientHomePage";
+import ClientDocumentsPage from "./pages/client/ClientDocumentsPage";
+import ClientTasksPage from "./pages/client/ClientTasksPage";
+import ClientViolationsPage from "./pages/client/ClientViolationsPage";
+import ClientViolationReportsPage from "./pages/client/ClientViolationReportsPage";
+import ClientCalendarPage from "./pages/client/ClientCalendarPage";
+import ClientPersonalCalendarPage from "./pages/client/ClientPersonalCalendarPage";
+import ClientProjectsPage from "./pages/client/ClientProjectsPage";
+import ClientCollectionsPage from "./pages/client/ClientCollectionsPage";
+import ClientEstoppelsPage from "./pages/client/ClientEstoppelsPage";
+import ClientStatusUpdatesPage from "./pages/client/ClientStatusUpdatesPage";
+import ClientOwnerUpdatesPage from "./pages/client/ClientOwnerUpdatesPage";
+import ClientHomeownerRequestsPage from "./pages/client/ClientHomeownerRequestsPage";
+import ClientParkingPage from "./pages/client/ClientParkingPage";
+import ClientBoardVotesPage from "./pages/client/ClientBoardVotesPage";
+import ClientPaymentPlansPage from "./pages/client/ClientPaymentPlansPage";
+import ClientBidsQuotesPage from "./pages/client/ClientBidsQuotesPage";
+import ClientCallLogsPage from "./pages/client/ClientCallLogsPage";
+import ClientDirectoryPage from "./pages/client/ClientDirectoryPage";
+
+// Homeowner Portal Layout & Pages
+import HomeownerLayout from "./layouts/HomeownerLayout";
+import HomeownerHomePage from "./pages/homeowner/HomeownerHomePage";
+import HomeownerProfilePage from "./pages/homeowner/HomeownerProfilePage";
+import HomeownerLedgerPage from "./pages/homeowner/HomeownerLedgerPage";
+import HomeownerDocumentsPage from "./pages/homeowner/HomeownerDocumentsPage";
+import HomeownerStatementsPage from "./pages/homeowner/HomeownerStatementsPage";
+import HomeownerPaymentsPage from "./pages/homeowner/HomeownerPaymentsPage";
+import HomeownerARCPage from "./pages/homeowner/HomeownerARCPage";
+import HomeownerElectionsPage from "./pages/homeowner/HomeownerElectionsPage";
+import HomeownerViolationsPage from "./pages/homeowner/HomeownerViolationsPage";
+import HomeownerAmenityCalendarPage from "./pages/homeowner/HomeownerAmenityCalendarPage";
+import HomeownerDirectoryPage from "./pages/homeowner/HomeownerDirectoryPage";
+import HomeownerTicketsPage from "./pages/homeowner/HomeownerTicketsPage";
+
+// Board Member Pages
+import BoardProjectsPage from "./pages/board/BoardProjectsPage";
+import BoardCalendarPage from "./pages/board/BoardCalendarPage";
+import BoardDocumentsPage from "./pages/board/BoardDocumentsPage";
+import BoardStatusUpdatesPage from "./pages/board/BoardStatusUpdatesPage";
+import BoardTasksPage from "./pages/board/BoardTasksPage";
+import BoardARCPage from "./pages/board/BoardARCPage";
+import BoardBidsQuotesPage from "./pages/board/BoardBidsQuotesPage";
+import BoardBillApprovalsPage from "./pages/board/BoardBillApprovalsPage";
+import BoardSubmitInvoicePage from "./pages/board/BoardSubmitInvoicePage";
+import BoardBoardVotesPage from "./pages/board/BoardBoardVotesPage";
+import BoardClientRequestsPage from "./pages/board/BoardClientRequestsPage";
+import BoardHomeownerRequestsPage from "./pages/board/BoardHomeownerRequestsPage";
+import BoardParkingPage from "./pages/board/BoardParkingPage";
+import BoardAnnouncementsPage from "./pages/board/BoardAnnouncementsPage";
+import BoardViolationsPage from "./pages/board/BoardViolationsPage";
+import BoardOwnerRosterPage from "./pages/board/BoardOwnerRosterPage";
+import BoardBillDetailPage from "./pages/board/BoardBillDetailPage";
+import BoardReportsPage from "./pages/board/BoardReportsPage";
+import BoardFinancialReportsPage from "./pages/board/BoardFinancialReportsPage";
+import BoardFinancialOverviewPage from "./pages/board/BoardFinancialOverviewPage";
+import BoardMessagesPage from "./pages/board/BoardMessagesPage";
+import BoardCollaborativeDocsPage from "./pages/board/BoardCollaborativeDocsPage";
+import BoardElectionsPage from "./pages/board/BoardElectionsPage";
+import BoardResourcesPage from "./pages/board/BoardResourcesPage";
+import BoardEstoppelsPage from "./pages/board/BoardEstoppelsPage";
+import ManageBoardResourcesPage from "./pages/ManageBoardResourcesPage";
+import MessagesPage from "./pages/MessagesPage";
+import HomeownerMessagesPage from "./pages/homeowner/HomeownerMessagesPage";
+
+// Legal Portal
+import LegalLayout from "./layouts/LegalLayout";
+import LegalCasesPage from "./pages/legal/LegalCasesPage";
+import LegalCaseDetailPage from "./pages/legal/LegalCaseDetailPage";
+
+// ARC Committee Portal
+import ArcLayout from "./layouts/ArcLayout";
+import ArcCommitteePage from "./pages/arc/ArcCommitteePage";
+
+// Master Board Portal
+import MasterBoardLayout from "./layouts/MasterBoardLayout";
+import MasterBoardDashboardPage from "./pages/master-board/MasterBoardDashboardPage";
+
+// Public pages (no auth required)
+import ViolationResponsePage from "./pages/ViolationResponsePage";
+import SharedAccessPage from "./pages/SharedAccessPage";
+import VerifyDocumentPage from "./pages/VerifyDocumentPage";
+import PrivacyPolicyPage from "./pages/PrivacyPolicyPage";
+import TermsOfServicePage from "./pages/TermsOfServicePage";
+import CommunityPage from "./pages/CommunityPage";
+import CommunityAmenityPage from "./pages/CommunityAmenityPage";
+import RVBoatLotsPage from "./pages/RVBoatLotsPage";
+import PublicRVBoatWaitlistPage from "./pages/PublicRVBoatWaitlistPage";
+import BookingConfirmationPage from "./pages/BookingConfirmationPage";
+import ElectionVotePage from "./pages/ElectionVotePage";
+import BoardVotePublicPage from "./pages/BoardVotePublicPage";
+import BillApprovePublicPage from "./pages/BillApprovePublicPage";
+const queryClient = new QueryClient();
+
+const App = () => (
+
+
+
+
+
+
+
+
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+
+ {/* ─── Admin / Manager Portal ─── */}
+ }>
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+
+ } />
+
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ }>
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ }>
+ } />
+ } />
+ } />
+
+
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+
+ } />
+ } />
+ } />
+ } />
+ } />
+ {/* Financial module */}
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ }>
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+
+
+
+ {/* ─── Client Portal ─── */}
+ }>
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+
+
+ {/* ─── Homeowner Portal ─── */}
+ }>
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ {/* Board Member Routes */}
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+
+
+ {/* ─── Legal Portal ─── */}
+ }>
+ } />
+ } />
+
+
+ {/* ─── ARC Committee Portal ─── */}
+ }>
+ } />
+
+
+ {/* ─── Master Board Portal ─── */}
+ }>
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+
+
+ } />
+
+
+
+
+
+);
+
+export default App;
diff --git a/src/assets/acm-icon.png b/src/assets/acm-icon.png
new file mode 100644
index 0000000..b797910
Binary files /dev/null and b/src/assets/acm-icon.png differ
diff --git a/src/assets/acm-invoice-logo.png b/src/assets/acm-invoice-logo.png
new file mode 100644
index 0000000..9e04cc6
Binary files /dev/null and b/src/assets/acm-invoice-logo.png differ
diff --git a/src/assets/acm-logo-full.png b/src/assets/acm-logo-full.png
new file mode 100644
index 0000000..6831315
Binary files /dev/null and b/src/assets/acm-logo-full.png differ
diff --git a/src/assets/acm-logo.png b/src/assets/acm-logo.png
new file mode 100644
index 0000000..e640d99
Binary files /dev/null and b/src/assets/acm-logo.png differ
diff --git a/src/assets/acm-nav-icon.png b/src/assets/acm-nav-icon.png
new file mode 100644
index 0000000..2f9144c
Binary files /dev/null and b/src/assets/acm-nav-icon.png differ
diff --git a/src/assets/favicon-logo.png b/src/assets/favicon-logo.png
new file mode 100644
index 0000000..2705c57
Binary files /dev/null and b/src/assets/favicon-logo.png differ
diff --git a/src/assets/fonts/MICRCHECK.ttf b/src/assets/fonts/MICRCHECK.ttf
new file mode 100644
index 0000000..5893e39
Binary files /dev/null and b/src/assets/fonts/MICRCHECK.ttf differ
diff --git a/src/assets/fonts/micrCheckFont.ts b/src/assets/fonts/micrCheckFont.ts
new file mode 100644
index 0000000..9b38cce
--- /dev/null
+++ b/src/assets/fonts/micrCheckFont.ts
@@ -0,0 +1,2 @@
+// Auto-generated from MICRCHECK.ttf — base64 for jsPDF embedding
+export const MICR_CHECK_FONT_BASE64 = "AAEAAAALAIAAAwAwT1MvMrtBkeEAAAE4AAAAVmNtYXDSybvqAAACvAAAAVJnYXNw//8AAwAAExgAAAAIZ2x5ZpynGhUAAASoAAAJBGhlYWTrDc3tAAAAvAAAADZoaGVhDpsGoAAAAPQAAAAkaG10eGA/JtsAAAGQAAABLGxvY2F0FnZqAAAEEAAAAJhtYXhwBK0AZQAAARgAAAAgbmFtZQ+asMUAAA2sAAAErHBvc3R5hHamAAASWAAAAL4AAQAAAAEAADE94CVfDzz1AAsIAAAAAADCahkfAAAAAMJvaiwBAAAABhcFpAAAAAkAAQAAAAAAAAABAAAHPv5OAEMIHwEA/+cGFwABAAAAAAAAAAAAAAAAAAAASwABAAAASwA0AAMAAAAAAAIAEAAvAEIAAAQMAAAAAAAAAAEEsgGQAAUACAWaBTMAAAEbBZoFMwAAA9EAZgISAAACAAAAAAAAAAAAoAACr1AAePsAAAAAAAAAAEhMICAAQAAgA34F0/5RATMHPgGyYAABn9/3AAAAAAYAAQAAAAAAAjkAAAI5AAAF/gGSBf4DegX+A3oF/gLRBf4CNQX+AtIF/gI7Bf4C0QX+AZIF/gI1AjkAAAI5AAAErAAABKwAAASsAAAEcwAACB8AAAX+AaUF/gGMBf4BkgX+AZIFVgAABOMAAAY5AAAFxwAAAjkAAAQAAAAFVgAABHMAAAaqAAAFxwAABjkAAAVWAAAGOQAABccAAAVWAAAE4wAABccAAAVWAAAHjQAABVYAAAVWAAAE4wAAAjkAAAI5AAACOQAAA8EAAARzAAACqgAABf4BpQX+AYwF/gGSBf4BkgRzAAACOQAABHMAAARzAAABxwAAAccAAAQAAAABxwAABqoAAARzAAAEcwAABHMAAARzAAACqgAABAAAAAI5AAAEcwAABAAAAAAAAAIAAQAAAAAAFAADAAEAAAEaAAABBgAAAQAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAABAAACAAAAAAAAAAAAAAAAAAADBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4/QEFCQ0RFRkdISUoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEADgAAAAKAAgAAgACACAAdgCgA37//wAAACAALwCgA37////i/9T/YvyRAAEAAAAAAAAAAAAAAAAAAAAWABYAFgAWAEgAegC6APIBHgFeAZwB0gIcAlICUgJSAlICUgJSAlICUgKYAt4DJANqA2oDagNqA2oDagNqA2oDagNqA2oDagNqA2oDagNqA2oDagNqA2oDagNqA2oDagNqA2oDagNqA2oDsAP2BDwEggSCBIIEggSCBIIEggSCBIIEggSCBIIEggSCBIIEggSCBIIEggACAQAAAAUABQAAAwAHAAAhESERJSERIQEABAD8IAPA/EAFAPsAIATAAAAAAAIBkgAABf4FngAPAB8AAAEUBiMhIiY1ETQ2MyEyFhUDETQmIyEiBhURFBYzITI2Bf63gv4Ggre3ggH6gretQi/90C5CQi4CMC9CATmCt7eCAyyCt7eC/LQDci9CQi/8ji5CQgABA3oAAAX+BZ0AIwAAJRQGIyEiJjURNDYzMjURNCMiJj0BNDY7ATIWFREUFjsBMhYVBf4sH/4SHywsH0tLHywsH68fLDAVrx8sSx8sLB8B7R8sMgG8LCwfah8sLB/9YxMfLB8AAQN6AAAGCgWdACwAAAEUBiMhIgYVERQWMyEyFRQjISImNRE0NjMhMjY1ETQmIyEiJj0BNDYzITIWFQX+LB/+tR8sLB8BV0tL/gYfLCwfAUsfLCwf/rUfLCwfAe4fLAK1HywsH/7PHyxSUSwfAoofLCwfATgfLCwfGR8sLB8AAAABAtEAAAX+BZ4AKQAAJRQjISI9ATQzITI1ETQjISI9ATQzITI1ETQjISI9ATQzITIVERQWMxYVBf4//VE/PwFkPz/+oj8/AV4/P/6oPj4B+z5IIj8/Pz8rPz4BTD4/OD8+AUs/PzI+Pv3HIDgeIAAAAQI1AAAF/gWdAB8AACUUKwEiPQE0JyEiNRE0OwEyFREUFjsBMjc2NTQ7ATIVBf4/1D9X/h4+Prw+KRa1LxAMP9Q/Pz8/r0QNPgPiPj781Bk5GBI0Pz8AAAEC0gAABf4FnQAsAAAlFAYjISI1NDMhMjY1ETQmIyEiJjURNDYzITIWHQEUBiMhIgYVERQWMyEyFhUF/iwf/WlKSgH0HywsH/4ZHywsHwKKHywsH/4YHywsHwHoHyxLHyxRUiwfAT4gLCsgAo8fLCwfGR8sLB/+zx8sLB8AAAAAAgI7AAAF/gWdACAALAAAJRQjISI1ETQzITIVERQrASI9ATQmKwEiFREUFxYzITIVAzU0IyEiHQEUMyEyBf4//Ls/PwIHPj4mPi0YvD4cFxgClj+pPv4FPj4B+z4/Pz8FID4+/vM/P2oXKEX+HyIWEz7+oqg/P6g/AAAAAAEC0QAABf4FnQAkAAABFAcFERQrASI1ETQ/ASURNCcmIyEiBwYdARQrASI1ETQzITIVBf4//wA+LD8lBAEWFBca/qIPExY/Kz8/Aq8/AzMiHHb9wD8/AoQmEwJ/AS0VGR0VFxm8Pj4Baz4+AAMBkgAABf4FnQAbACcAMwAAJRQGIyEiJjURNDYzMjc2NxE0MyEyFREWMzIWFSURNCMhIhURFDMhMhkBNCMhIhURFDMhMgX+Nyf8UCc3NychEgkPPwKjPg43KjT+tT/+qD4+AVg/P/6oPj4BWD9eJzc3JwIaJjcaDjACMj4+/cFLNifmAWU+Pv6bPv3AAV8/P/6hPgACAjUAAAX+BZ0AFwAjAAAlFAYrASImNRE0JiMhIiY1ETQ2MyEyFhUDETQjISIVERQzITIF/jcnkCY3OR/+OCc3NycDDSc3pT/9/D8/AgQ/Xic3NycBzhk+NycCXyY3Nyb+HgFfPz/+oT4AAAADAaUAAAYXBaQADwAfAC8AAAEUBiMhIiY1ETQ2MyEyFhURFAYjISImNRE0NjMhMhYVBRQGKwEiJjURNDY7ATIWFQYXLB/+rh8sLB8BUh8sLB/+rh8sLB8BUh8s/NksH7UfLCwftR8sBA4fLCwfAUsfLCwf+vIfLCwfAUsfLCwfVx8sLB8DLR8sLB8AAAMBjAAABfgFpAAPAB8ALwAAARQGKwEiJjURNDY7ATIWFQEUBisBIiY1ETQ2OwEyFhUBFAYrASImNRE0NjsBMhYVBfgsH68fLCwfrx8s/h8sHxMfLCwfEx8s/rosH68fLCwfrx8sA2UfLCwfAfQfLCwf/M0fLCwfAfQgKysg/DEfLCwfAfQfLCwfAAAAAwGSAOkF/gVPAA8AHwAvAAABFAYjISImNRE0NjMhMhYVARQGKwEiJjURNDY7ATIWFQEUBisBIiY1ETQ2OwEyFhUF/iwf/qIfLCwfAV4fLP18LB8THywsHxMfLP7BLB8THywsHxMfLAMPHywsHwH1HywsH/wwHywsHwMzHywsH/zNHywsHwMzHywsHwADAZIBjQXrBBQADwAfAC8AAAEUBisBIiY1ETQ2OwEyFhUBFAYrASImNRE0NjsBMhYVARQGKwEiJjURNDY7ATIWFQXrLB8THywsHxMfLP7BLB+pHywsH6kfLP4lLB+pHywsH6kfLAHYHywsHwHxHywsH/4PHywsHwHxHywsH/4PHywsHwHxHywsHwAAAAMBpQAABhcFpAAPAB8ALwAAARQGIyEiJjURNDYzITIWFREUBiMhIiY1ETQ2MyEyFhUFFAYrASImNRE0NjsBMhYVBhcsH/6uHywsHwFSHywsH/6uHywsHwFSHyz82SwftR8sLB+1HywEDh8sLB8BSx8sLB/68h8sLB8BSx8sLB9XHywsHwMtHywsHwAAAwGMAAAF+AWkAA8AHwAvAAABFAYrASImNRE0NjsBMhYVARQGKwEiJjURNDY7ATIWFQEUBisBIiY1ETQ2OwEyFhUF+Cwfrx8sLB+vHyz+HywfEx8sLB8THyz+uiwfrx8sLB+vHywDZR8sLB8B9B8sLB/8zR8sLB8B9CArKyD8MR8sLB8B9B8sLB8AAAADAZIA6QX+BU8ADwAfAC8AAAEUBiMhIiY1ETQ2MyEyFhUBFAYrASImNRE0NjsBMhYVARQGKwEiJjURNDY7ATIWFQX+LB/+oh8sLB8BXh8s/XwsHxMfLCwfEx8s/sEsHxMfLCwfEx8sAw8fLCwfAfUfLCwf/DAfLCwfAzMfLCwf/M0fLCwfAzMfLCwfAAMBkgGNBesEFAAPAB8ALwAAARQGKwEiJjURNDY7ATIWFQEUBisBIiY1ETQ2OwEyFhUBFAYrASImNRE0NjsBMhYVBessHxMfLCwfEx8s/sEsH6kfLCwfqR8s/iUsH6kfLCwfqR8sAdgfLCwfAfEfLCwf/g8fLCwfAfEfLCwf/g8fLCwfAfEfLCwfAAAAAAAoAeYAAQAAAAAAAAAqAAAAAQAAAAAAAQAJADEAAQAAAAAAAgAHACoAAQAAAAAAAwAWADEAAQAAAAAABAAJADEAAQAAAAAABQAqAEcAAQAAAAAABgAJADEAAQAAAAAACgA/AHEAAwABBAMAAgAMAnwAAwABBAUAAgAQALAAAwABBAYAAgAMAMAAAwABBAcAAgAQAMwAAwABBAgAAgAQANwAAwABBAkAAABUAOwAAwABBAkAAQASAU4AAwABBAkAAgAOAUAAAwABBAkAAwAsAU4AAwABBAkABAASAU4AAwABBAkABQBUAXoAAwABBAkABgASAU4AAwABBAkACgB+Ac4AAwABBAoAAgAMAnwAAwABBAsAAgAQAkwAAwABBAwAAgAMAnwAAwABBA4AAgAMApoAAwABBBAAAgAOAlwAAwABBBMAAgASAmoAAwABBBQAAgAMAnwAAwABBBUAAgAQAnwAAwABBBYAAgAMAnwAAwABBBkAAgAOAowAAwABBBsAAgAQApoAAwABBB0AAgAMAnwAAwABBB8AAgAMAnwAAwABBCQAAgAOAqoAAwABBC0AAgAOArgAAwABCAoAAgAMAnwAAwABCBYAAgAMAnwAAwABDAoAAgAMAnwAAwABDAwAAgAMAnxUeXBlZmFjZSCpICgpLiA8MjAwNz4uIEFsbCBSaWdodHMgUmVzZXJ2ZWRSZWd1bGFyTUlDUkNIRUNLOlZlcnNpb24gMS4wMFZlcnNpb24gMS4wMCBNYXkgMTEsIDIwMDcsIGluaXRpYWwgcmVsZWFzZVRoaXMgZm9udCB3YXMgY3JlYXRlZCB1c2luZyBGb250Q3JlYXRvciA1LjUgZnJvbSBIaWdoLUxvZ2ljLmNvbQBvAGIAeQENAGUAagBuAOkAbgBvAHIAbQBhAGwAUwB0AGEAbgBkAGEAcgBkA5oDsQO9A78DvQO5A7oDrABUAHkAcABlAGYAYQBjAGUAIACpACAAKAApAC4AIAA8ADIAMAAwADcAPgAuACAAQQBsAGwAIABSAGkAZwBoAHQAcwAgAFIAZQBzAGUAcgB2AGUAZABSAGUAZwB1AGwAYQByAE0ASQBDAFIAQwBIAEUAQwBLADoAVgBlAHIAcwBpAG8AbgAgADEALgAwADAAVgBlAHIAcwBpAG8AbgAgADEALgAwADAAIABNAGEAeQAgADEAMQAsACAAMgAwADAANwAsACAAaQBuAGkAdABpAGEAbAAgAHIAZQBsAGUAYQBzAGUAVABoAGkAcwAgAGYAbwBuAHQAIAB3AGEAcwAgAGMAcgBlAGEAdABlAGQAIAB1AHMAaQBuAGcAIABGAG8AbgB0AEMAcgBlAGEAdABvAHIAIAA1AC4ANQAgAGYAcgBvAG0AIABIAGkAZwBoAC0ATABvAGcAaQBjAC4AYwBvAG0ATgBvAHIAbQBhAGEAbABpAE4AbwByAG0AYQBsAGUAUwB0AGEAbgBkAGEAYQByAGQATgBvAHIAbQBhAGwAbgB5BB4EMQRLBEcEPQRLBDkATgBvAHIAbQDhAGwAbgBlAE4AYQB2AGEAZABuAG8AQQByAHIAdQBuAHQAYQACAAAAAAAA/ycAlgAAAAAAAAAAAAAAAAAAAAAAAAAAAEsAAAECAAMAEgATABQAFQAWABcAGAAZABoAGwAcAB0AHgAfACAAIQAiACMAJAAlACYAJwAoACkAKgArACwALQAuAC8AMAAxADIAMwA0ADUANgA3ADgAOQA6ADsAPAA9AD4APwBAAEEAQgBDAEQARQBGAEcASABJAEoASwBMAE0ATgBPAFAAUQBSAFMAVABVAFYAVwBYAFkFLm51bGwAAAAAAAH//wAC";
diff --git a/src/assets/icons8-gavel-20.png b/src/assets/icons8-gavel-20.png
new file mode 100644
index 0000000..6303ade
Binary files /dev/null and b/src/assets/icons8-gavel-20.png differ
diff --git a/src/components/ARCApplicationDialog.jsx b/src/components/ARCApplicationDialog.jsx
new file mode 100644
index 0000000..abfa1a8
--- /dev/null
+++ b/src/components/ARCApplicationDialog.jsx
@@ -0,0 +1,369 @@
+import React, { useState, useEffect } from 'react';
+import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
+import { Button } from '@/components/ui/button';
+import { Label } from '@/components/ui/label';
+import { Input } from '@/components/ui/input';
+import { Textarea } from '@/components/ui/textarea';
+import { supabase } from '@/integrations/supabase/client';
+import { notifyStaffOfArcSubmission } from '@/lib/arcSubmissionEmail';
+import { useToast } from '@/hooks/use-toast';
+import { Loader2, Upload, X, FileText, Trash2 } from 'lucide-react';
+import { v4 as uuidv4 } from 'uuid';
+
+function ARCApplicationDialog({ open, onOpenChange, application, onSuccess }) {
+ const { toast } = useToast();
+ const [loading, setLoading] = useState(false);
+ const [clients, setClients] = useState([]);
+ const [owners, setOwners] = useState([]);
+ const [ownersLoading, setOwnersLoading] = useState(false);
+ const [ownerSearch, setOwnerSearch] = useState('');
+
+ const [newFiles, setNewFiles] = useState([]);
+ const [existingFiles, setExistingFiles] = useState([]);
+
+ const [formData, setFormData] = useState({
+ title: '',
+ association_id: '',
+ description: '',
+ status: 'submitted',
+ owner_id: '',
+ unit_id: '',
+ });
+
+ useEffect(() => {
+ const fetchClients = async () => {
+ const { data } = await supabase.from('associations').select('id, name').eq('status', 'active').order('name');
+ setClients(data || []);
+ };
+ if (open) fetchClients();
+ }, [open]);
+
+ // Load owners for the selected association
+ useEffect(() => {
+ const fetchOwners = async () => {
+ if (!formData.association_id) {
+ setOwners([]);
+ return;
+ }
+ setOwnersLoading(true);
+ try {
+ const { data } = await supabase
+ .from('owners')
+ .select('id, first_name, last_name, property_address, unit_id, status')
+ .eq('association_id', formData.association_id)
+ .neq('status', 'archived')
+ .order('last_name', { nullsFirst: false })
+ .limit(2000);
+ setOwners(data || []);
+ } finally {
+ setOwnersLoading(false);
+ }
+ };
+ fetchOwners();
+ }, [formData.association_id]);
+
+ useEffect(() => {
+ if (application) {
+ setFormData({
+ title: application.title || '',
+ association_id: application.association_id || '',
+ description: application.description || '',
+ status: application.status || 'submitted',
+ owner_id: application.owner_id || '',
+ unit_id: application.unit_id || '',
+ });
+ setExistingFiles([]);
+ } else {
+ setFormData({ title: '', association_id: '', description: '', status: 'submitted', owner_id: '', unit_id: '' });
+ setNewFiles([]);
+ setExistingFiles([]);
+ }
+ setOwnerSearch('');
+ }, [application, open]);
+
+ const filteredOwners = React.useMemo(() => {
+ const q = ownerSearch.trim().toLowerCase();
+ if (!q) return owners.slice(0, 100);
+ return owners.filter(o => {
+ const name = `${o.first_name || ''} ${o.last_name || ''}`.toLowerCase();
+ const addr = (o.property_address || '').toLowerCase();
+ return name.includes(q) || addr.includes(q);
+ }).slice(0, 100);
+ }, [owners, ownerSearch]);
+
+ const selectedOwner = owners.find(o => o.id === formData.owner_id);
+
+ const handleFileChange = (e) => {
+ if (e.target.files) {
+ const filesArray = Array.from(e.target.files);
+ setNewFiles(prev => [...prev, ...filesArray]);
+ }
+ };
+
+ const removeNewFile = (index) => {
+ setNewFiles(prev => prev.filter((_, i) => i !== index));
+ };
+
+ const handleSubmit = async (e) => {
+ e.preventDefault();
+ setLoading(true);
+
+ try {
+ const { data: { user } } = await supabase.auth.getUser();
+
+ const payload = {
+ title: formData.title,
+ association_id: formData.association_id,
+ description: formData.description,
+ status: formData.status,
+ owner_id: formData.owner_id || null,
+ unit_id: formData.unit_id || (selectedOwner?.unit_id ?? null),
+ updated_at: new Date().toISOString()
+ };
+
+ let appId = application?.id;
+
+ if (application) {
+ const { error: err } = await supabase.from('arc_applications').update(payload).eq('id', application.id);
+ if (err) throw err;
+ } else {
+ const { data, error: err } = await supabase.from('arc_applications').insert([payload]).select().single();
+ if (err) throw err;
+ appId = data.id;
+
+ // Fire-and-forget staff notification email for new submissions only
+ const assocName = clients.find((c) => c.id === formData.association_id)?.name;
+ notifyStaffOfArcSubmission({
+ applicationId: data.id,
+ applicationTitle: formData.title || 'Architectural review request',
+ associationId: formData.association_id || null,
+ associationName: assocName,
+ description: formData.description || null,
+ fileCount: newFiles.length,
+ });
+ }
+
+ // Upload files to arc-files bucket and mirror into Documents under "ARC Applications"
+ if (newFiles.length > 0 && appId && formData.association_id) {
+ // Try to look up an address for context
+ let addressLabel = '';
+ try {
+ const { data: appRow } = await supabase
+ .from('arc_applications')
+ .select('unit_id, owner_id')
+ .eq('id', appId)
+ .maybeSingle();
+ if (appRow?.unit_id) {
+ const { data: u } = await supabase.from('units').select('address, unit_number').eq('id', appRow.unit_id).maybeSingle();
+ addressLabel = u?.address || u?.unit_number || '';
+ }
+ if (!addressLabel && appRow?.owner_id) {
+ const { data: o } = await supabase.from('owners').select('property_address').eq('id', appRow.owner_id).maybeSingle();
+ addressLabel = o?.property_address || '';
+ }
+ } catch {}
+ if (!addressLabel) addressLabel = 'Unknown Address';
+
+ for (const file of newFiles) {
+ try {
+ const storagePath = `${formData.association_id}/${appId}/${Date.now()}-${file.name}`;
+ const { error: upErr } = await supabase.storage.from('arc-files').upload(storagePath, file, { contentType: file.type });
+ if (upErr) continue;
+ const { data: urlData } = supabase.storage.from('arc-files').getPublicUrl(storagePath);
+ await supabase.from('documents').insert({
+ title: `${addressLabel} - ${formData.title || 'ARC Application'} - ${file.name}`,
+ file_name: file.name,
+ file_url: urlData.publicUrl,
+ file_size: file.size,
+ category: 'ARC Applications',
+ association_id: formData.association_id,
+ visibility: [],
+ is_public: false,
+ });
+ } catch (e) {
+ console.error('ARC file upload failed', e);
+ }
+ }
+ }
+
+ toast({
+ title: application ? "Application Updated" : "Application Submitted",
+ description: "ARC Application saved successfully."
+ });
+ setNewFiles([]);
+ onSuccess();
+ onOpenChange(false);
+
+ } catch (err) {
+ console.error(err);
+ toast({
+ variant: "destructive",
+ title: "Error",
+ description: err.message
+ });
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+
+
+
+ {application ? 'Edit ARC Application' : 'New ARC Application'}
+
+
+
+
+ );
+}
+
+export default ARCApplicationDialog;
diff --git a/src/components/ARCDetailsDialog.jsx b/src/components/ARCDetailsDialog.jsx
new file mode 100644
index 0000000..6c0c18d
--- /dev/null
+++ b/src/components/ARCDetailsDialog.jsx
@@ -0,0 +1,287 @@
+import React, { useState, useEffect, useRef } from 'react';
+import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
+import { Button } from '@/components/ui/button';
+import { Badge } from '@/components/ui/badge';
+import { Textarea } from '@/components/ui/textarea';
+import { ScrollArea } from '@/components/ui/scroll-area';
+import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
+import { Download, MessageSquare, Send, CheckCircle, XCircle, Clock, Vote, PenTool, FileText, Paperclip, ShieldAlert, FileText as SummaryIcon } from 'lucide-react';
+import { supabase } from '@/integrations/supabase/client';
+import { useAuth } from '@/contexts/AuthContext';
+import { useToast } from '@/hooks/use-toast';
+import { format } from 'date-fns';
+import { useCommentCount } from '@/hooks/useCommentCount';
+
+function ARCDetailsDialog({ open, onOpenChange, application, onUpdate }) {
+ const { user, isBoardMember, isAdmin } = useAuth();
+ const { toast } = useToast();
+ const [comments, setComments] = useState([]);
+ const [newComment, setNewComment] = useState('');
+ const [commentLoading, setCommentLoading] = useState(false);
+ const [voteLoading, setVoteLoading] = useState(false);
+ const [statusUpdating, setStatusUpdating] = useState(false);
+
+ const { count: commentCount, loading: countLoading } = useCommentCount(application?.id, 'arc_application');
+
+ const scrollRef = useRef(null);
+
+ const arcFiles = application?.arc_files || [];
+
+ useEffect(() => {
+ if (open && application) {
+ fetchComments();
+ }
+ }, [open, application]);
+
+ const fetchComments = async () => {
+ if (!application?.id) return;
+
+ const { data, error } = await supabase
+ .from('arc_comments')
+ .select('*')
+ .eq('application_id', application.id)
+ .order('created_at', { ascending: true });
+
+ if (!error) {
+ setComments(data || []);
+ setTimeout(() => {
+ if (scrollRef.current) {
+ scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
+ }
+ }, 100);
+ }
+ };
+
+ const handlePostComment = async (e) => {
+ e.preventDefault();
+ if (!newComment.trim()) return;
+ if (!user) {
+ toast({ variant: "destructive", title: "Error", description: "You must be logged in to comment." });
+ return;
+ }
+
+ setCommentLoading(true);
+ const { error } = await supabase.from('arc_comments').insert([{
+ application_id: application.id,
+ user_id: user.id,
+ content: newComment
+ }]);
+
+ if (!error) {
+ setNewComment('');
+ fetchComments();
+ } else {
+ toast({
+ variant: "destructive",
+ title: "Error posting comment",
+ description: error.message
+ });
+ }
+ setCommentLoading(false);
+ };
+
+ const handleStatusUpdate = async (newStatus) => {
+ if (!application?.id) return;
+ setStatusUpdating(true);
+ try {
+ const { error } = await supabase
+ .from('arc_applications')
+ .update({ status: newStatus })
+ .eq('id', application.id);
+
+ if (error) throw error;
+
+ toast({ title: "Status Updated", description: `Application status changed to ${newStatus}.` });
+ if (onUpdate) onUpdate();
+ } catch (err) {
+ console.error(err);
+ toast({ variant: "destructive", title: "Error", description: "Failed to update status." });
+ } finally {
+ setStatusUpdating(false);
+ }
+ };
+
+ const getStatusBadge = (status) => {
+ switch(status) {
+ case 'approved': return Approved ;
+ case 'rejected': return Rejected ;
+ default: return Pending ;
+ }
+ };
+
+ if (!application) return null;
+
+ return (
+
+
+
+
+
+
{application.title}
+
+ {getStatusBadge(application.status)}
+
+ {application.associations?.name}
+
+ {!countLoading && (
+
+
+ Comments ({commentCount})
+
+ )}
+
+
+
+
+
+
+ {/* Left Side: Details */}
+
+
+ {/* Admin Status Controls */}
+ {isAdmin && (
+
+
+ Admin Actions
+
+
+ Review the application and cast the final decision.
+
+
+ handleStatusUpdate('approved')}
+ disabled={application.status === 'approved' || statusUpdating}
+ >
+
+ Approve Application
+
+ handleStatusUpdate('rejected')}
+ disabled={application.status === 'rejected' || statusUpdating}
+ >
+
+ Reject Application
+
+ {application.status !== 'submitted' && (
+ handleStatusUpdate('submitted')}
+ disabled={statusUpdating}
+ >
+ Reset to Pending
+
+ )}
+
+
+ )}
+
+
+
Description
+
{application.description}
+
+
+ {/* Attachments Section */}
+
+
+
+ Attachments ({arcFiles.length})
+
+ {arcFiles.length > 0 ? (
+
+ ) : (
+
+
No attachments uploaded.
+
+ )}
+
+
+
+ {/* Right Side: Comments */}
+
+
+
+ Discussion
+
+ {!countLoading && (
+
+ {commentCount}
+
+ )}
+
+
+
+ {comments.length === 0 ? (
+
+ No comments yet. Start the discussion!
+
+ ) : (
+ comments.map((comment) => (
+
+
+
+ {format(new Date(comment.created_at), 'MMM d, h:mm a')}
+
+
+ ))
+ )}
+
+
+
+
+ setNewComment(e.target.value)}
+ placeholder="Type a comment..."
+ className="min-h-[40px] max-h-[100px] resize-none"
+ onKeyDown={(e) => {
+ if (e.key === 'Enter' && !e.shiftKey) {
+ e.preventDefault();
+ handlePostComment(e);
+ }
+ }}
+ />
+
+
+
+
+
+
+
+
+
+ );
+}
+
+export default ARCDetailsDialog;
diff --git a/src/components/AccountDropdown.tsx b/src/components/AccountDropdown.tsx
new file mode 100644
index 0000000..c036180
--- /dev/null
+++ b/src/components/AccountDropdown.tsx
@@ -0,0 +1,68 @@
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
+import { Loader2, AlertCircle } from "lucide-react";
+import { useCentralChartOfAccounts } from "@/hooks/useCentralChartOfAccounts";
+import { cn } from "@/lib/utils";
+
+interface AccountDropdownProps {
+ value?: string | null;
+ onChange?: (value: string) => void;
+ placeholder?: string;
+ disabled?: boolean;
+ className?: string;
+ required?: boolean;
+ /** Filters accounts to the association's accounting system (zoho vs buildium). */
+ associationId?: string | null;
+}
+
+export default function AccountDropdown({
+ value,
+ onChange,
+ placeholder = "Select account...",
+ disabled = false,
+ className,
+ required = false,
+ associationId,
+}: AccountDropdownProps) {
+ const { accounts, loading, error } = useCentralChartOfAccounts(associationId);
+
+ const safeValue = !value ? undefined : String(value);
+
+ return (
+ onChange?.(v)}
+ disabled={disabled || loading}
+ required={required}
+ >
+
+
+ {loading &&
}
+ {error &&
}
+
+
+
+
+
+ {accounts.map((acc) => (
+
+
+ {acc.account_number && (
+
+ {acc.account_number}
+
+ )}
+ {acc.account_name}
+
+ {acc.account_type}
+
+
+
+ ))}
+
+
+ );
+}
diff --git a/src/components/AddSubcategoryDialog.jsx b/src/components/AddSubcategoryDialog.jsx
new file mode 100644
index 0000000..ca5a63e
--- /dev/null
+++ b/src/components/AddSubcategoryDialog.jsx
@@ -0,0 +1,89 @@
+import React from 'react';
+import { useForm } from 'react-hook-form';
+import { zodResolver } from '@hookform/resolvers/zod';
+import * as z from 'zod';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form";
+import { Input } from "@/components/ui/input";
+import { Button } from "@/components/ui/button";
+import { Loader2 } from "lucide-react";
+
+const formSchema = z.object({
+ name: z.string()
+ .min(1, "Name is required")
+ .max(50, "Name must be less than 50 characters")
+ .regex(/^[a-zA-Z0-9\s-_]+$/, "Only letters, numbers, spaces, hyphens and underscores allowed"),
+});
+
+export function AddSubcategoryDialog({ open, onOpenChange, onAdd }) {
+ const form = useForm({
+ resolver: zodResolver(formSchema),
+ defaultValues: {
+ name: '',
+ },
+ });
+
+ const onSubmit = async (values) => {
+ try {
+ await onAdd(values.name);
+ form.reset();
+ onOpenChange(false);
+ } catch (error) {
+ // Error is handled in the hook's toast
+ }
+ };
+
+ return (
+
+
+
+ Add New Subcategory
+
+ Create a custom subcategory for fee schedules.
+
+
+
+
+ (
+
+ Subcategory Name
+
+
+
+
+
+ )}
+ />
+
+
+ onOpenChange(false)}>
+ Cancel
+
+
+ {form.formState.isSubmitting && }
+ Save
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/AnnouncementDialog.jsx b/src/components/AnnouncementDialog.jsx
new file mode 100644
index 0000000..b3660da
--- /dev/null
+++ b/src/components/AnnouncementDialog.jsx
@@ -0,0 +1,136 @@
+import React, { useState, useEffect } from 'react';
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
+import { Button } from '@/components/ui/button';
+import { Label } from '@/components/ui/label';
+import { Input } from '@/components/ui/input';
+import { supabase } from '@/integrations/supabase/client';
+import { useToast } from '@/hooks/use-toast';
+import { useAuth } from '@/contexts/AuthContext';
+import { Loader2 } from 'lucide-react';
+import { Textarea } from '@/components/ui/textarea';
+
+function AnnouncementDialog({ open, onOpenChange, announcement, onSuccess }) {
+ const { user } = useAuth();
+ const [title, setTitle] = useState('');
+ const [content, setContent] = useState('');
+ const [loading, setLoading] = useState(false);
+ const { toast } = useToast();
+
+ useEffect(() => {
+ if (announcement) {
+ setTitle(announcement.title);
+ setContent(announcement.content);
+ } else {
+ setTitle('');
+ setContent('');
+ }
+ }, [announcement, open]);
+
+ const handleSubmit = async (e) => {
+ e.preventDefault();
+ if (!title.trim() || !content.trim()) {
+ toast({
+ variant: "destructive",
+ title: "Validation Error",
+ description: "Title and content are required."
+ });
+ return;
+ }
+
+ setLoading(true);
+
+ try {
+ const announcementData = {
+ title,
+ content,
+ updated_at: new Date().toISOString(),
+ };
+
+ let error;
+ if (announcement) {
+ const { error: updateError } = await supabase
+ .from('announcements')
+ .update(announcementData)
+ .eq('id', announcement.id);
+ error = updateError;
+ } else {
+ const { error: insertError } = await supabase
+ .from('announcements')
+ .insert([{
+ ...announcementData,
+ created_by: user?.id,
+ created_at: new Date().toISOString()
+ }]);
+ error = insertError;
+ }
+
+ if (error) throw error;
+
+ toast({
+ title: announcement ? 'Announcement Updated' : 'Announcement Created',
+ description: 'The announcement has been successfully saved.',
+ });
+
+ if (onSuccess) onSuccess();
+ onOpenChange(false);
+
+ if (!announcement) {
+ setTitle('');
+ setContent('');
+ }
+ } catch (error) {
+ console.error('Error saving announcement:', error);
+ toast({
+ variant: 'destructive',
+ title: 'Error',
+ description: error.message || 'Failed to save announcement.',
+ });
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+
+
+
+ {announcement ? 'Edit Announcement' : 'Create Announcement'}
+
+
+
+ Title
+ setTitle(e.target.value)}
+ placeholder="Announcement Title"
+ required
+ />
+
+
+ Content
+ setContent(e.target.value)}
+ placeholder="Write your announcement content here..."
+ rows={10}
+ required
+ />
+
+
+ onOpenChange(false)}>
+ Cancel
+
+
+ {loading && }
+ {announcement ? 'Update' : 'Post Announcement'}
+
+
+
+
+
+ );
+}
+
+export default AnnouncementDialog;
diff --git a/src/components/AnnouncementManager.tsx b/src/components/AnnouncementManager.tsx
new file mode 100644
index 0000000..44c2022
--- /dev/null
+++ b/src/components/AnnouncementManager.tsx
@@ -0,0 +1,481 @@
+import { useState, useEffect, useMemo, useRef } from "react";
+import ReactQuill from "react-quill-new";
+import "react-quill-new/dist/quill.snow.css";
+import { supabase } from "@/integrations/supabase/client";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { formatDateTimeShortEST } from "@/lib/timezoneUtils";
+import { Megaphone, Plus, Archive, Trash2, Edit2, Users, AlertCircle, RefreshCw, Pin, PinOff } from "lucide-react";
+import {
+ Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
+} from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import {
+ Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
+} from "@/components/ui/select";
+import {
+ Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
+} from "@/components/ui/table";
+import { useAuth } from "@/contexts/AuthContext";
+import { useToast } from "@/components/ui/use-toast";
+import { htmlToPlainText } from "@/lib/htmlTextUtils";
+
+const ALL_ASSOCIATIONS_VALUE = "__all_associations__";
+
+interface Announcement {
+ id: string;
+ title: string;
+ content: string;
+ status: string;
+ visibility: string;
+ association_id: string | null;
+ created_by: string | null;
+ created_at: string;
+ updated_at: string;
+ pinned?: boolean;
+ expires_at?: string | null;
+}
+
+interface Association {
+ id: string;
+ name: string;
+}
+
+export default function AnnouncementManager({ boardAssociationIds }: { boardAssociationIds?: string[] } = {}) {
+ const { user, isAdmin, isStaff } = useAuth();
+ const [announcements, setAnnouncements] = useState([]);
+ const [associations, setAssociations] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [saving, setSaving] = useState(false);
+ const [error, setError] = useState(null);
+ const [modalOpen, setModalOpen] = useState(false);
+ const [editingId, setEditingId] = useState(null);
+ const [formData, setFormData] = useState({ title: "", content: "", visibility: "staff_board", association_id: "", pinned: false, expires_at: "" });
+ const { toast } = useToast();
+ const quillRef = useRef(null);
+
+ const isBoardView = !!boardAssociationIds?.length;
+ const canManage = (isAdmin || isStaff) && !isBoardView;
+
+ useEffect(() => {
+ fetchAnnouncements();
+ if (canManage) fetchAssociations();
+ }, []);
+
+ // Quill image handler — uploads to announcement-images bucket, embeds public URL
+ const imageHandler = () => {
+ const input = document.createElement("input");
+ input.setAttribute("type", "file");
+ input.setAttribute("accept", "image/*");
+ input.click();
+ input.onchange = async () => {
+ const file = input.files?.[0];
+ if (!file) return;
+ try {
+ const ext = file.name.split(".").pop() || "png";
+ const path = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${ext}`;
+ const { error: upErr } = await supabase.storage
+ .from("announcement-images")
+ .upload(path, file, { contentType: file.type, upsert: false });
+ if (upErr) throw upErr;
+ const { data: { publicUrl } } = supabase.storage.from("announcement-images").getPublicUrl(path);
+ const editor = quillRef.current?.getEditor?.();
+ const range = editor?.getSelection(true);
+ editor?.insertEmbed(range?.index ?? 0, "image", publicUrl, "user");
+ editor?.setSelection((range?.index ?? 0) + 1, 0);
+ } catch (err: any) {
+ toast({ variant: "destructive", title: "Image upload failed", description: err.message });
+ }
+ };
+ };
+
+ const quillModules = useMemo(() => ({
+ toolbar: {
+ container: [
+ [{ header: [1, 2, 3, false] }],
+ ["bold", "italic", "underline", "strike"],
+ [{ list: "ordered" }, { list: "bullet" }],
+ ["link", "image"],
+ ["clean"],
+ ],
+ handlers: { image: imageHandler },
+ },
+ }), []);
+
+ const fetchAssociations = async () => {
+ const { data } = await supabase.from("associations").select("id, name").order("name");
+ setAssociations((data as Association[]) || []);
+ };
+
+ const fetchAnnouncements = async () => {
+ setLoading(true);
+ setError(null);
+ try {
+ const nowIso = new Date().toISOString();
+ const { data, error: fetchError } = await supabase
+ .from("announcements")
+ .select("*")
+ .eq("status", "active")
+ .or(`expires_at.is.null,expires_at.gt.${nowIso}`)
+ .order("pinned", { ascending: false })
+ .order("created_at", { ascending: false })
+ .limit(50);
+
+ if (fetchError) throw fetchError;
+ setAnnouncements((data as Announcement[]) ?? []);
+ } catch (err: any) {
+ console.error("Error fetching announcements:", err);
+ setError("Failed to load announcements.");
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleSave = async () => {
+ if (!formData.title || !formData.content) {
+ toast({ variant: "destructive", title: "Title and Content required" });
+ return;
+ }
+
+ setSaving(true);
+
+ try {
+ const payload: any = {
+ title: formData.title,
+ content: formData.content,
+ visibility: formData.visibility,
+ association_id: formData.association_id || null,
+ status: "active",
+ pinned: formData.pinned,
+ expires_at: formData.expires_at ? new Date(formData.expires_at).toISOString() : null,
+ };
+
+ let savedId: string | null = editingId;
+ const isNew = !editingId;
+
+ if (editingId) {
+ const { error: updateError } = await supabase
+ .from("announcements")
+ .update({ ...payload, updated_at: new Date().toISOString() })
+ .eq("id", editingId);
+ if (updateError) throw updateError;
+ } else {
+ const { data: inserted, error: insertError } = await supabase
+ .from("announcements")
+ .insert([{ ...payload, created_by: user?.id }])
+ .select("id")
+ .single();
+ if (insertError) throw insertError;
+ savedId = inserted?.id ?? null;
+ }
+
+ // Send notification email for newly-posted announcements (skip community-page-only)
+ if (isNew && savedId && formData.visibility !== "public_only") {
+ supabase.functions
+ .invoke("notify-announcement", { body: { announcement_id: savedId } })
+ .then(({ error: notifyErr }) => {
+ if (notifyErr) console.error("notify-announcement failed:", notifyErr);
+ });
+ toast({ title: "Announcement posted", description: "Email notifications are being sent." });
+ } else {
+ toast({ title: editingId ? "Announcement updated" : "Announcement posted" });
+ }
+
+ setModalOpen(false);
+ setEditingId(null);
+ setFormData({ title: "", content: "", visibility: "staff_board", association_id: "", pinned: false, expires_at: "" });
+ fetchAnnouncements();
+ } catch (err: any) {
+ toast({ variant: "destructive", title: "Error saving announcement", description: err.message });
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ const handleArchive = async (id: string) => {
+ try {
+ const { error: archiveError } = await supabase.from("announcements").update({ status: "archived" }).eq("id", id);
+ if (archiveError) throw archiveError;
+ fetchAnnouncements();
+ toast({ title: "Announcement archived" });
+ } catch (err: any) {
+ toast({ variant: "destructive", title: "Error archiving announcement", description: err.message });
+ }
+ };
+
+ const handleDelete = async (id: string) => {
+ try {
+ const { error: deleteError } = await supabase.from("announcements").delete().eq("id", id);
+ if (deleteError) throw deleteError;
+ fetchAnnouncements();
+ toast({ title: "Announcement deleted" });
+ } catch (err: any) {
+ toast({ variant: "destructive", title: "Error deleting announcement", description: err.message });
+ }
+ };
+
+ const handleTogglePin = async (id: string, current: boolean) => {
+ try {
+ const { error: pinErr } = await supabase
+ .from("announcements")
+ .update({ pinned: !current, updated_at: new Date().toISOString() })
+ .eq("id", id);
+ if (pinErr) throw pinErr;
+ fetchAnnouncements();
+ toast({ title: !current ? "Announcement pinned" : "Announcement unpinned" });
+ } catch (err: any) {
+ toast({ variant: "destructive", title: "Error updating pin", description: err.message });
+ }
+ };
+
+ // Convert an ISO string to the value format expected by
+ const isoToLocalInput = (iso?: string | null) => {
+ if (!iso) return "";
+ const d = new Date(iso);
+ if (isNaN(d.getTime())) return "";
+ const pad = (n: number) => String(n).padStart(2, "0");
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
+ };
+
+ const openModal = (ann?: Announcement | null) => {
+ if (ann) {
+ setEditingId(ann.id);
+ setFormData({ title: ann.title, content: ann.content, visibility: ann.visibility || "all", association_id: ann.association_id || "", pinned: !!ann.pinned, expires_at: isoToLocalInput(ann.expires_at) });
+ } else {
+ setEditingId(null);
+ setFormData({ title: "", content: "", visibility: "staff_board", association_id: "", pinned: false, expires_at: "" });
+ }
+ setModalOpen(true);
+ };
+
+ const visibilityLabel = (v: string) => {
+ switch (v) {
+ case "staff_board": return "Staff & Board";
+ case "all": return "Staff, Board & Homeowners";
+ case "public": return "Public (Everyone)";
+ case "public_only": return "Public Only";
+ default: return "Staff & Board";
+ }
+ };
+
+ return (
+
+ {/* Header */}
+
+
+
+
+
Announcements
+
Post and manage team announcements visible across the platform.
+
+
+
+
+
+
+ {canManage && (
+
openModal()}>
+ Post Announcement
+
+ )}
+
+
+
+ {/* Table Card */}
+
+
+
Active Announcements
+
+ All active announcements are displayed on the dashboard and visible to relevant staff.
+
+
+
+
+ {loading ? (
+
Loading announcements...
+ ) : error ? (
+
+ ) : announcements.length === 0 ? (
+
No active announcements.
+ ) : (
+
+
+
+ Title
+ Content
+ Visibility
+ Posted
+ Expires
+ {canManage && Actions }
+
+
+
+ {announcements.map((ann) => (
+
+
+
+ {ann.pinned &&
}
+
{ann.title}
+
+
+
+
+ {htmlToPlainText(ann.content)}
+
+
+
+
+ {visibilityLabel(ann.visibility)}
+
+
+
+ {formatDateTimeShortEST(ann.created_at)}
+
+
+ {ann.expires_at ? formatDateTimeShortEST(ann.expires_at) : Never }
+
+ {canManage && (
+
+
+
handleTogglePin(ann.id, !!ann.pinned)}
+ >
+ {ann.pinned ? : }
+
+
openModal(ann)}>
+
+
+
handleArchive(ann.id)}>
+
+
+
handleDelete(ann.id)}>
+
+
+
+
+ )}
+
+ ))}
+
+
+ )}
+
+
+
+ {/* Modal */}
+
+
+
+ {editingId ? "Edit Announcement" : "Post Announcement"}
+
+
+
+ Title
+ setFormData({ ...formData, title: e.target.value })}
+ placeholder="Announcement title..."
+ />
+
+
+ setFormData({ ...formData, pinned: e.target.checked })}
+ className="h-4 w-4 rounded border-input accent-primary"
+ />
+
+ Pin to top
+ — Pinned announcements appear above all others.
+
+
+
Content
+
+ setFormData({ ...formData, content: val })}
+ modules={quillModules}
+ placeholder="Write your announcement... use the image button to embed pictures."
+ className="announcement-quill"
+ />
+
+
Click the image icon in the toolbar to upload and embed an image.
+
+
+
Visibility
+
setFormData({ ...formData, visibility: val })}>
+
+
+
+
+ Staff & Board Members
+ Staff, Board Members & Homeowners
+ Public (Everyone incl. Community Page)
+ Public Only (Community Page only)
+
+
+ {formData.visibility === "public" && (
+
Public announcements appear on the community page when the Announcements module is enabled.
+ )}
+
+
+
Association
+
setFormData({ ...formData, association_id: val === ALL_ASSOCIATIONS_VALUE ? "" : val })}
+ >
+
+
+
+
+ All / No Association
+ {associations.map(a => (
+ {a.name}
+ ))}
+
+
+
Required for Public visibility to appear on a community page.
+
+
+
Expiration date & time (optional)
+
+ setFormData({ ...formData, expires_at: e.target.value })}
+ />
+ {formData.expires_at && (
+ setFormData({ ...formData, expires_at: "" })}
+ >
+ Clear
+
+ )}
+
+
After this date and time the announcement will no longer be shown. Leave blank to keep it active indefinitely.
+
+
+
+ setModalOpen(false)}>Cancel
+ {saving ? "Saving..." : editingId ? "Save Changes" : "Post Announcement"}
+
+
+
+
+ );
+}
diff --git a/src/components/AssociationBoardMembersDialog.jsx b/src/components/AssociationBoardMembersDialog.jsx
new file mode 100644
index 0000000..a6fc0e6
--- /dev/null
+++ b/src/components/AssociationBoardMembersDialog.jsx
@@ -0,0 +1,151 @@
+import React, { useState, useEffect } from 'react';
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogDescription,
+ DialogFooter,
+} from '@/components/ui/dialog';
+import { Button } from '@/components/ui/button';
+import { Checkbox } from '@/components/ui/checkbox';
+import { Input } from '@/components/ui/input';
+import { Loader2, Search, User } from 'lucide-react';
+import { ScrollArea } from '@/components/ui/scroll-area';
+import { useAssociationBoardMembers } from '@/hooks/useAssociationBoardMembers';
+
+export default function AssociationBoardMembersDialog({ open, onOpenChange, client, onSuccess }) {
+ const { fetchAvailableUsers, fetchBoardMembers, saveBoardMembers, loading } = useAssociationBoardMembers();
+
+ const [users, setUsers] = useState([]);
+ const [selectedUserIds, setSelectedUserIds] = useState([]);
+ const [searchQuery, setSearchQuery] = useState('');
+ const [initLoading, setInitLoading] = useState(false);
+
+ useEffect(() => {
+ if (open && client) {
+ loadData();
+ } else {
+ setUsers([]);
+ setSelectedUserIds([]);
+ setSearchQuery('');
+ }
+ }, [open, client]);
+
+ const loadData = async () => {
+ setInitLoading(true);
+ const [allUsers, currentMembers] = await Promise.all([
+ fetchAvailableUsers(),
+ fetchBoardMembers(client.id)
+ ]);
+
+ setUsers(allUsers || []);
+ setSelectedUserIds(currentMembers.map(m => m.id) || []);
+ setInitLoading(false);
+ };
+
+ const handleToggle = (userId) => {
+ setSelectedUserIds(prev => {
+ if (prev.includes(userId)) {
+ return prev.filter(id => id !== userId);
+ } else {
+ return [...prev, userId];
+ }
+ });
+ };
+
+ const handleSave = async () => {
+ const success = await saveBoardMembers(client.id, selectedUserIds);
+ if (success) {
+ if (onSuccess) onSuccess();
+ onOpenChange(false);
+ }
+ };
+
+ const filteredUsers = users.filter(u =>
+ (u.email?.toLowerCase().includes(searchQuery.toLowerCase())) ||
+ (u.full_name?.toLowerCase().includes(searchQuery.toLowerCase()))
+ );
+
+ return (
+
+
+
+ Manage Board Members
+
+ Select users to assign as board members for {client?.name} .
+
+
+
+
+
+
+ setSearchQuery(e.target.value)}
+ className="pl-9"
+ />
+
+
+
+
+ User
+ {selectedUserIds.length} Selected
+
+
+ {initLoading ? (
+
+
+ Loading users...
+
+ ) : filteredUsers.length === 0 ? (
+
+ ) : (
+
+ {filteredUsers.map(user => {
+ const isSelected = selectedUserIds.includes(user.id);
+ return (
+
handleToggle(user.id)}
+ >
+
handleToggle(user.id)}
+ id={`user-${user.id}`}
+ />
+
+
+ {user.full_name || 'Unknown Name'}
+
+
{user.email}
+
+
+ );
+ })}
+
+ )}
+
+
+
+
+
+ onOpenChange(false)} disabled={loading}>
+ Cancel
+
+
+ {loading && }
+ Save Changes
+
+
+
+
+ );
+}
diff --git a/src/components/AssociationDetailsDialog.jsx b/src/components/AssociationDetailsDialog.jsx
new file mode 100644
index 0000000..ad2ac8b
--- /dev/null
+++ b/src/components/AssociationDetailsDialog.jsx
@@ -0,0 +1,259 @@
+import React, { useState, useEffect } from 'react';
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogDescription,
+ DialogFooter,
+} from '@/components/ui/dialog';
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@/components/ui/alert-dialog";
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import { Loader2, Save, Trash2, Mail, Phone, Building } from 'lucide-react';
+import { supabase } from '@/integrations/supabase/client';
+import { useToast } from '@/hooks/use-toast';
+
+export default function AssociationDetailsDialog({ open, onOpenChange, association, onSuccess }) {
+ const { toast } = useToast();
+ const [loading, setLoading] = useState(false);
+ const [isDeleting, setIsDeleting] = useState(false);
+ const [showDeleteAlert, setShowDeleteAlert] = useState(false);
+
+ const [formData, setFormData] = useState({
+ name: '',
+ email: '',
+ phone: '',
+ address: '',
+ city: '',
+ state: '',
+ zip: ''
+ });
+
+ useEffect(() => {
+ if (open && association) {
+ setFormData({
+ name: association.name || '',
+ email: association.email || '',
+ phone: association.phone || '',
+ address: association.address || '',
+ city: association.city || '',
+ state: association.state || '',
+ zip: association.zip || ''
+ });
+ }
+ }, [open, association]);
+
+ const handleChange = (field, value) => {
+ setFormData(prev => ({ ...prev, [field]: value }));
+ };
+
+ const handleSave = async () => {
+ if (!association) return;
+
+ if (!formData.name.trim()) {
+ toast({ variant: "destructive", title: "Validation Error", description: "Association name is required." });
+ return;
+ }
+
+ setLoading(true);
+ try {
+ const { error } = await supabase
+ .from('associations')
+ .update({
+ name: formData.name,
+ email: formData.email,
+ phone: formData.phone,
+ address: formData.address,
+ city: formData.city,
+ state: formData.state,
+ zip: formData.zip,
+ updated_at: new Date().toISOString()
+ })
+ .eq('id', association.id);
+
+ if (error) throw error;
+
+ toast({ title: "Success", description: "Association details updated successfully." });
+ if (onSuccess) onSuccess('update');
+ onOpenChange(false);
+
+ } catch (error) {
+ console.error('Error updating association:', error);
+ toast({ variant: "destructive", title: "Error", description: error.message || "Failed to update association." });
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleDelete = async () => {
+ if (!association) return;
+ setIsDeleting(true);
+ try {
+ const { error } = await supabase
+ .from('associations')
+ .delete()
+ .eq('id', association.id);
+
+ if (error) throw error;
+
+ toast({ title: "Success", description: "Association deleted successfully." });
+ if (onSuccess) onSuccess('delete');
+ setShowDeleteAlert(false);
+ onOpenChange(false);
+
+ } catch (error) {
+ console.error('Error deleting association:', error);
+ toast({ variant: "destructive", title: "Error", description: error.message || "Failed to delete association." });
+ } finally {
+ setIsDeleting(false);
+ }
+ };
+
+ return (
+ <>
+
+
+
+ Edit Association Details
+
+ Update basic information for {association?.name} .
+
+
+
+
+
+
+
+ Association Name
+
+ handleChange('name', e.target.value)}
+ placeholder="e.g. Sunset Valley HOA"
+ className="h-10"
+ />
+
+
+
+
+
+
+ Email Address
+
+ handleChange('email', e.target.value)}
+ placeholder="contact@example.com"
+ className="h-10"
+ />
+
+
+
+
+ Phone Number
+
+
handleChange('phone', e.target.value)}
+ placeholder="(555) 123-4567"
+ className="h-10"
+ />
+
+
+
+
+ Address
+ handleChange('address', e.target.value)}
+ placeholder="123 Main St"
+ className="h-10"
+ />
+
+
+
+
+
+
+
+ setShowDeleteAlert(true)}
+ type="button"
+ className="text-destructive hover:text-destructive w-full sm:w-auto"
+ >
+
+ Delete Association
+
+
+
+ onOpenChange(false)} disabled={loading}>
+ Cancel
+
+
+ {loading && }
+ {!loading && }
+ Save Changes
+
+
+
+
+
+
+
+
+
+ Are you absolutely sure?
+
+ This action cannot be undone. This will permanently delete the association
+ {association?.name} and remove all associated data.
+
+
+
+ Cancel
+ {
+ e.preventDefault();
+ handleDelete();
+ }}
+ className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
+ disabled={isDeleting}
+ >
+ {isDeleting ? : }
+ Delete Association
+
+
+
+
+ >
+ );
+}
diff --git a/src/components/AvriaSignSendDialog.tsx b/src/components/AvriaSignSendDialog.tsx
new file mode 100644
index 0000000..8d904db
--- /dev/null
+++ b/src/components/AvriaSignSendDialog.tsx
@@ -0,0 +1,287 @@
+import { useState, useEffect, useMemo } from "react";
+import { supabase } from "@/integrations/supabase/client";
+import { useToast } from "@/hooks/use-toast";
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Textarea } from "@/components/ui/textarea";
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import { Loader2, Send, X, FileSignature, UserPlus, Upload, FolderOpen, FileText, ChevronLeft, ChevronRight } from "lucide-react";
+import SignatureFieldPlacer, { PlacedField } from "@/components/SignatureFieldPlacer";
+
+interface Recipient { name: string; email: string }
+
+interface AvriaSignSendDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ documentUrl?: string;
+ documentName?: string;
+ associationId?: string;
+ onSuccess?: () => void;
+}
+
+function arrayBufferToBase64(buffer: ArrayBuffer) {
+ const bytes = new Uint8Array(buffer);
+ const chunkSize = 0x8000;
+ let binary = "";
+ for (let i = 0; i < bytes.length; i += chunkSize) {
+ binary += String.fromCharCode(...bytes.subarray(i, i + chunkSize));
+ }
+ return btoa(binary);
+}
+
+export default function AvriaSignSendDialog({
+ open, onOpenChange, documentUrl, documentName: initialDocName, associationId: initialAssocId, onSuccess,
+}: AvriaSignSendDialogProps) {
+ const { toast } = useToast();
+ const [step, setStep] = useState<1 | 2>(1);
+ const [sending, setSending] = useState(false);
+ const [associations, setAssociations] = useState([]);
+ const [associationId, setAssociationId] = useState(initialAssocId || "");
+ const [documentName, setDocumentName] = useState(initialDocName || "");
+ const [emailSubject, setEmailSubject] = useState("");
+ const [emailBody, setEmailBody] = useState("");
+ const [recipients, setRecipients] = useState([{ name: "", email: "" }]);
+ const [file, setFile] = useState(null);
+ const [sourceMode, setSourceMode] = useState<"upload" | "library">("upload");
+ const [libraryDocs, setLibraryDocs] = useState([]);
+ const [selectedDocUrl, setSelectedDocUrl] = useState(documentUrl || "");
+ const [fields, setFields] = useState([]);
+
+ // Local preview URL (created from File for the placer)
+ const [localFileUrl, setLocalFileUrl] = useState("");
+
+ useEffect(() => {
+ if (!open) return;
+ setStep(1);
+ setFields([]);
+ supabase.from("associations").select("id, name").eq("status", "active").order("name")
+ .then(({ data }) => setAssociations(data || []));
+ if (initialAssocId) setAssociationId(initialAssocId);
+ if (initialDocName) setDocumentName(initialDocName);
+ if (documentUrl) { setSelectedDocUrl(documentUrl); setSourceMode("upload"); }
+ }, [open, initialAssocId, initialDocName, documentUrl]);
+
+ useEffect(() => {
+ if (sourceMode === "library" && libraryDocs.length === 0) {
+ supabase.from("documents" as any).select("id, title, file_url").order("created_at", { ascending: false }).limit(50)
+ .then(({ data }) => setLibraryDocs((data as any[]) || []));
+ }
+ }, [sourceMode, libraryDocs.length]);
+
+ // Build a preview URL when a file is chosen
+ useEffect(() => {
+ if (file) {
+ const url = URL.createObjectURL(file);
+ setLocalFileUrl(url);
+ return () => URL.revokeObjectURL(url);
+ }
+ setLocalFileUrl("");
+ }, [file]);
+
+ const previewUrl = useMemo(() => {
+ if (sourceMode === "upload" && localFileUrl) return localFileUrl;
+ if (selectedDocUrl) return selectedDocUrl;
+ return "";
+ }, [sourceMode, localFileUrl, selectedDocUrl]);
+
+ const validRecipients = recipients.filter(r => r.name.trim() && r.email.trim());
+
+ const addRecipient = () => setRecipients([...recipients, { name: "", email: "" }]);
+ const removeRecipient = (i: number) => recipients.length > 1 && setRecipients(recipients.filter((_, idx) => idx !== i));
+ const updateRecipient = (i: number, k: keyof Recipient, v: string) => {
+ const u = [...recipients]; u[i] = { ...u[i], [k]: v }; setRecipients(u);
+ };
+
+ const goToStep2 = () => {
+ if (validRecipients.length === 0) {
+ toast({ variant: "destructive", title: "Add at least one recipient" }); return;
+ }
+ if (sourceMode === "upload" && !file && !selectedDocUrl) {
+ toast({ variant: "destructive", title: "Upload a document" }); return;
+ }
+ if (sourceMode === "library" && !selectedDocUrl) {
+ toast({ variant: "destructive", title: "Select a document from the library" }); return;
+ }
+ setStep(2);
+ };
+
+ const handleSend = async () => {
+ setSending(true);
+ try {
+ const payload: any = {
+ association_id: associationId || null,
+ document_name: documentName || file?.name || "Document",
+ recipients: validRecipients,
+ email_subject: emailSubject || `Please sign: ${documentName || "Document"}`,
+ email_body: emailBody || undefined,
+ // map field recipientIndex → recipient slot in same order as validRecipients
+ fields: fields.map(f => ({
+ recipient_index: f.recipientIndex,
+ field_type: f.field_type,
+ page_number: f.page_number,
+ x_ratio: f.x_ratio,
+ y_ratio: f.y_ratio,
+ width_ratio: f.width_ratio,
+ height_ratio: f.height_ratio,
+ })),
+ };
+
+ if (sourceMode === "upload" && file) {
+ payload.document_base64 = arrayBufferToBase64(await file.arrayBuffer());
+ payload.file_extension = file.name.split(".").pop() || "pdf";
+ } else if (selectedDocUrl) {
+ payload.document_url = selectedDocUrl;
+ }
+
+ const { data, error } = await supabase.functions.invoke("avria-sign-send", { body: payload });
+ if (error) throw error;
+ if (data?.error) throw new Error(data.error);
+
+ toast({ title: "Sent for Signature", description: `Envelope sent to ${validRecipients.length} recipient(s).` });
+ onSuccess?.();
+ onOpenChange(false);
+ setRecipients([{ name: "", email: "" }]); setFile(null); setDocumentName("");
+ setEmailSubject(""); setEmailBody(""); setSelectedDocUrl(""); setFields([]); setStep(1);
+ } catch (err: any) {
+ console.error("Avria Sign send error:", err);
+ toast({ variant: "destructive", title: "Failed to Send", description: err.message || "Error sending document." });
+ } finally {
+ setSending(false);
+ }
+ };
+
+ return (
+
+
+
+
+
+ {step === 1 ? "Send for Signature" : "Place Signature Fields"}
+
+
+ {step === 1
+ ? "Step 1 of 2 — Choose document & recipients."
+ : "Step 2 of 2 — Click on the document to place where each signer should sign or date."}
+
+
+
+ {step === 1 ? (
+
+
+ Association (optional)
+
+
+
+ {associations.map(a => {a.name} )}
+
+
+
+
+
+
Document Source
+
setSourceMode(v as any)}>
+
+ Upload
+ Library
+
+
+ {
+ const f = e.target.files?.[0] || null; setFile(f); setSelectedDocUrl("");
+ if (f && !documentName) setDocumentName(f.name);
+ }} />
+
+
+ {libraryDocs.length === 0 ? (
+ No documents in library.
+ ) : libraryDocs.map(d => (
+ { setSelectedDocUrl(d.file_url); setDocumentName(d.title); setFile(null); }}
+ className={`w-full text-left p-2 text-sm hover:bg-muted ${selectedDocUrl === d.file_url ? "bg-muted" : ""}`}>
+ {d.title}
+
+ ))}
+
+
+
+
+
+ Document Name
+ setDocumentName(e.target.value)} placeholder="e.g., Estoppel Certificate" />
+
+
+
+
+ Email Subject (optional)
+ setEmailSubject(e.target.value)} placeholder={`Please sign: ${documentName || "Document"}`} />
+
+
+ Message (optional)
+ setEmailBody(e.target.value)} rows={2} placeholder="Please review and sign." />
+
+
+
+
+
+ ) : (
+
+ {previewUrl ? (
+
+ ) : (
+
+ Document preview unavailable. You can still send without placed fields — signers will use the bottom of the document.
+
+ )}
+
+ Tip: Click anywhere on the page. Fields are anchored to the document, not the screen.
+ You can skip this step entirely and signers will sign at the bottom of the last page.
+
+
+ )}
+
+
+ {step === 2 && (
+ setStep(1)} disabled={sending} className="gap-1 mr-auto">
+ Back
+
+ )}
+ onOpenChange(false)} disabled={sending}>Cancel
+ {step === 1 ? (
+
+ Next: Place Fields
+
+ ) : (
+
+ {sending ? : }
+ {sending ? "Sending..." : "Send for Signature"}
+
+ )}
+
+
+
+ );
+}
diff --git a/src/components/BankAccountFormDialog.jsx b/src/components/BankAccountFormDialog.jsx
new file mode 100644
index 0000000..55354d6
--- /dev/null
+++ b/src/components/BankAccountFormDialog.jsx
@@ -0,0 +1,36 @@
+import React from 'react';
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogDescription,
+} from '@/components/ui/dialog';
+import { Landmark } from 'lucide-react';
+
+export default function BankAccountFormDialog({ open, onOpenChange, account, clientId, onSuccess, children }) {
+ const handleSuccess = () => {
+ if (onSuccess) onSuccess();
+ onOpenChange(false);
+ };
+
+ return (
+
+
+
+
+
+ {account ? 'Edit Bank Account' : 'Add New Bank Account'}
+
+
+ {account ? 'Update the details for this bank account.' : "Enter the details for this association's new bank account."}
+
+
+
+
+ {children}
+
+
+
+ );
+}
diff --git a/src/components/BankDepositDialog.jsx b/src/components/BankDepositDialog.jsx
new file mode 100644
index 0000000..5b8ce44
--- /dev/null
+++ b/src/components/BankDepositDialog.jsx
@@ -0,0 +1,187 @@
+import React, { useState, useEffect } from 'react';
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from '@/components/ui/dialog';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
+import { Checkbox } from '@/components/ui/checkbox';
+import { Textarea } from '@/components/ui/textarea';
+import { supabase } from '@/integrations/supabase/client';
+import { useToast } from '@/hooks/use-toast';
+import { Loader2, DollarSign } from 'lucide-react';
+import { format } from 'date-fns';
+
+export default function BankDepositDialog({ isOpen, onClose, onSuccess, bankAccount }) {
+ const { toast } = useToast();
+ const [loading, setLoading] = useState(false);
+
+ const [formData, setFormData] = useState({
+ deposit_date: format(new Date(), 'yyyy-MM-dd'),
+ amount: '',
+ deposit_type: '',
+ description: '',
+ reference: '',
+ notes: ''
+ });
+
+ const [errors, setErrors] = useState({});
+
+ useEffect(() => {
+ if (isOpen) {
+ setFormData({
+ deposit_date: format(new Date(), 'yyyy-MM-dd'),
+ amount: '',
+ deposit_type: '',
+ description: '',
+ reference: '',
+ notes: ''
+ });
+ setErrors({});
+ }
+ }, [isOpen, bankAccount]);
+
+ const validate = () => {
+ const newErrors = {};
+ if (!formData.deposit_date) newErrors.deposit_date = "Date is required";
+ if (!formData.deposit_type) newErrors.deposit_type = "Deposit type is required";
+
+ const amt = parseFloat(formData.amount);
+ if (!formData.amount || isNaN(amt) || amt <= 0) {
+ newErrors.amount = "Enter a valid positive amount";
+ }
+
+ setErrors(newErrors);
+ return Object.keys(newErrors).length === 0;
+ };
+
+ const handleSubmit = async (e) => {
+ e.preventDefault();
+ if (!validate()) return;
+ if (!bankAccount?.id) {
+ toast({ title: 'Error', description: 'No bank account selected.', variant: 'destructive' });
+ return;
+ }
+
+ setLoading(true);
+ try {
+ const { error: txError } = await supabase
+ .from('bank_transactions')
+ .insert([{
+ bank_account_id: bankAccount.id,
+ association_id: bankAccount.association_id,
+ date: formData.deposit_date,
+ description: formData.description || `Deposit: ${formData.deposit_type}`,
+ credit: parseFloat(formData.amount),
+ reference_number: formData.reference,
+ transaction_type: 'deposit'
+ }]);
+
+ if (txError) throw txError;
+
+ toast({ title: 'Deposit Recorded', description: 'The deposit has been successfully recorded.' });
+ if (onSuccess) onSuccess();
+ onClose();
+ } catch (err) {
+ console.error('Error recording deposit:', err);
+ toast({ title: 'Deposit Failed', description: err.message || 'An error occurred while saving.', variant: 'destructive' });
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+ !loading && onClose(val)}>
+
+
+
+
+ Record Bank Deposit
+
+
+ Record a new deposit for {bankAccount?.account_name} ({bankAccount?.bank_name}).
+
+
+
+
+
+
+
Deposit Date *
+
setFormData({...formData, deposit_date: e.target.value})}
+ className={errors.deposit_date ? "border-destructive" : ""}
+ />
+ {errors.deposit_date &&
{errors.deposit_date}
}
+
+
+
+
Deposit Type *
+
setFormData({...formData, deposit_type: v})}>
+
+
+
+
+ Manual Payment
+ Transfer In
+ Other
+
+
+ {errors.deposit_type &&
{errors.deposit_type}
}
+
+
+
+
Amount *
+
setFormData({...formData, amount: e.target.value})}
+ className={errors.amount ? "border-destructive" : ""}
+ />
+ {errors.amount &&
{errors.amount}
}
+
+
+
+ Reference #
+ setFormData({...formData, reference: e.target.value})}
+ />
+
+
+
+
+ Description
+ setFormData({...formData, description: e.target.value})}
+ />
+
+
+
+ Notes
+ setFormData({...formData, notes: e.target.value})}
+ className="resize-none h-20"
+ />
+
+
+
+ Cancel
+
+ {loading && }
+ Save Deposit
+
+
+
+
+
+ );
+}
diff --git a/src/components/BankFeeDialog.jsx b/src/components/BankFeeDialog.jsx
new file mode 100644
index 0000000..8a0a3bb
--- /dev/null
+++ b/src/components/BankFeeDialog.jsx
@@ -0,0 +1,71 @@
+import React, { useState } from 'react';
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Textarea } from "@/components/ui/textarea";
+import { AlertCircle } from 'lucide-react';
+
+export function BankFeeDialog({ open, onOpenChange, onAddBankFee }) {
+ const [amount, setAmount] = useState('');
+ const [description, setDescription] = useState('');
+
+ const handleSubmit = (e) => {
+ e.preventDefault();
+ if (!amount || parseFloat(amount) <= 0) return;
+
+ onAddBankFee({
+ amount: parseFloat(amount),
+ description
+ });
+
+ setAmount('');
+ setDescription('');
+ onOpenChange(false);
+ };
+
+ return (
+
+
+
+ Add Bank Fee
+
+
+
+
+
+
Priority Deduction
+
Bank fees are deducted FIRST from any payments received, before Assessments, Interest, or other fees.
+
+
+
+
+ Fee Amount ($)
+ setAmount(e.target.value)}
+ className="font-mono"
+ />
+
+
+
+ Description
+ setDescription(e.target.value)}
+ />
+
+
+
+ onOpenChange(false)}>Cancel
+ Add Bank Fee
+
+
+
+
+ );
+}
diff --git a/src/components/BidQuoteDetailsDialog.jsx b/src/components/BidQuoteDetailsDialog.jsx
new file mode 100644
index 0000000..6fb9e66
--- /dev/null
+++ b/src/components/BidQuoteDetailsDialog.jsx
@@ -0,0 +1,121 @@
+import React, { useState } from 'react';
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog';
+import { Button } from '@/components/ui/button';
+import { ScrollArea } from '@/components/ui/scroll-area';
+import { FileText, Download, Calendar, User, ExternalLink, Tag, Trash2 } from 'lucide-react';
+import { format } from 'date-fns';
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@/components/ui/alert-dialog";
+import { supabase } from '@/integrations/supabase/client';
+import { useToast } from '@/hooks/use-toast';
+import { useAuth } from '@/contexts/AuthContext';
+import { Separator } from '@/components/ui/separator';
+
+export function BidQuoteDetailsDialog({ open, onOpenChange, bid, onRefresh }) {
+ const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
+ const { toast } = useToast();
+ const { isAdmin } = useAuth();
+
+ if (!bid) return null;
+
+ const handleDelete = async () => {
+ try {
+ const { error } = await supabase.from('bids_quotes').delete().eq('id', bid.id);
+
+ if (error) throw error;
+
+ toast({ title: 'Deleted', description: 'Bid/Quote deleted successfully.' });
+ setDeleteDialogOpen(false);
+ onOpenChange(false);
+ if (onRefresh) onRefresh();
+ } catch (error) {
+ console.error("Delete failed:", error);
+ toast({ variant: 'destructive', title: 'Error', description: error.message || "Failed to delete bid." });
+ }
+ };
+
+ return (
+ <>
+
+
+
+
+
+
+
{bid.title || bid.vendor_name}
+
+
+
+ {format(new Date(bid.created_at), 'MMM d, yyyy')}
+
+
+
+
+ {isAdmin && (
+ setDeleteDialogOpen(true)}
+ >
+
+ Delete
+
+ )}
+
+
+
+
+
+
+
+
+
Description
+
{bid.description || "No description provided."}
+
+
+
+
Details
+
+
Vendor: {bid.vendor_name}
+
Amount: ${bid.amount?.toFixed(2)}
+
Status: {bid.status}
+ {bid.received_date &&
Received: {format(new Date(bid.received_date), 'MMM d, yyyy')}
}
+
+
+
+
+
+
+
+
+
+
+ Are you absolutely sure?
+
+ This action cannot be undone. This will permanently delete this bid/quote.
+
+
+
+ Cancel
+
+ Delete Bid/Quote
+
+
+
+
+ >
+ );
+}
diff --git a/src/components/BidQuoteDialog.jsx b/src/components/BidQuoteDialog.jsx
new file mode 100644
index 0000000..c9e10ce
--- /dev/null
+++ b/src/components/BidQuoteDialog.jsx
@@ -0,0 +1,251 @@
+import React, { useState, useEffect } from 'react';
+import { useForm } from 'react-hook-form';
+import { zodResolver } from '@hookform/resolvers/zod';
+import * as z from 'zod';
+import { supabase } from '@/integrations/supabase/client';
+import { useAuth } from '@/contexts/AuthContext';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+ DialogFooter,
+} from '@/components/ui/dialog';
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from '@/components/ui/form';
+import { Input } from '@/components/ui/input';
+import { Textarea } from '@/components/ui/textarea';
+import { Button } from '@/components/ui/button';
+import { Checkbox } from '@/components/ui/checkbox';
+import { useToast } from '@/hooks/use-toast';
+import { Loader2, Upload, X } from 'lucide-react';
+import { ScrollArea } from '@/components/ui/scroll-area';
+
+const formSchema = z.object({
+ vendor_name: z.string().min(2, 'Vendor name is required'),
+ description: z.string().optional(),
+ amount: z.coerce.number().min(0, 'Amount must be positive').optional(),
+});
+
+export function BidQuoteDialog({ open, onOpenChange, onSuccess }) {
+ const [associations, setAssociations] = useState([]);
+ const [selectedAssociations, setSelectedAssociations] = useState([]);
+ const [uploading, setUploading] = useState(false);
+ const { user } = useAuth();
+ const { toast } = useToast();
+
+ const form = useForm({
+ resolver: zodResolver(formSchema),
+ defaultValues: {
+ vendor_name: '',
+ description: '',
+ amount: '',
+ },
+ });
+
+ useEffect(() => {
+ if (open) {
+ fetchAssociations();
+ form.reset();
+ setSelectedAssociations([]);
+ }
+ }, [open, form]);
+
+ const fetchAssociations = async () => {
+ try {
+ const { data, error } = await supabase
+ .from('associations')
+ .select('id, name')
+ .order('name');
+
+ if (error) throw error;
+ setAssociations(data || []);
+ } catch (error) {
+ console.error('Error fetching associations:', error);
+ }
+ };
+
+ const onSubmit = async (values) => {
+ if (selectedAssociations.length === 0) {
+ toast({
+ variant: "destructive",
+ title: "Validation Error",
+ description: "Please select at least one association.",
+ });
+ return;
+ }
+
+ setUploading(true);
+ try {
+ // Create one bid per selected association
+ const inserts = selectedAssociations.map(assocId => ({
+ vendor_name: values.vendor_name,
+ description: values.description,
+ amount: values.amount || 0,
+ association_id: assocId,
+ created_by: user?.id,
+ status: 'pending'
+ }));
+
+ const { error } = await supabase.from('bids_quotes').insert(inserts);
+ if (error) throw error;
+
+ toast({
+ title: "Success",
+ description: "Bid/Quote created successfully.",
+ });
+
+ onSuccess?.();
+ onOpenChange(false);
+
+ } catch (error) {
+ console.error('Error creating bid:', error);
+ toast({
+ variant: "destructive",
+ title: "Error",
+ description: error.message || "Failed to create bid/quote.",
+ });
+ } finally {
+ setUploading(false);
+ }
+ };
+
+ const toggleAssociation = (id) => {
+ setSelectedAssociations(prev =>
+ prev.includes(id)
+ ? prev.filter(x => x !== id)
+ : [...prev, id]
+ );
+ };
+
+ const selectAll = () => {
+ if (selectedAssociations.length === associations.length) {
+ setSelectedAssociations([]);
+ } else {
+ setSelectedAssociations(associations.map(c => c.id));
+ }
+ };
+
+ return (
+
+
+
+ Create New Bid / Quote
+
+ Create a new bid or quote for review by association members.
+
+
+
+
+
+ (
+
+ Vendor Name
+
+
+
+
+
+ )}
+ />
+
+ (
+
+ Amount
+
+
+
+
+
+ )}
+ />
+
+ (
+
+ Description
+
+
+
+
+
+ )}
+ />
+
+
+
+ Assign to Associations
+
+ {selectedAssociations.length === associations.length ? 'Deselect All' : 'Select All'}
+
+
+
+
+ {associations.map((assoc) => (
+
+ toggleAssociation(assoc.id)}
+ />
+
+ {assoc.name}
+
+
+ ))}
+
+
+
+ Selected: {selectedAssociations.length} associations
+
+
+
+
+ onOpenChange(false)}>
+ Cancel
+
+
+ {uploading ? (
+ <>
+
+ Creating...
+ >
+ ) : (
+ 'Create Bid'
+ )}
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/BillApprovalDialog.jsx b/src/components/BillApprovalDialog.jsx
new file mode 100644
index 0000000..a10130f
--- /dev/null
+++ b/src/components/BillApprovalDialog.jsx
@@ -0,0 +1,256 @@
+import React, { useState, useEffect } from 'react';
+import { useForm, Controller } from 'react-hook-form';
+import { zodResolver } from '@hookform/resolvers/zod';
+import * as z from 'zod';
+import { supabase } from '@/integrations/supabase/client';
+import { useToast } from '@/hooks/use-toast';
+import { useAuth } from '@/contexts/AuthContext';
+import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import { Textarea } from '@/components/ui/textarea';
+import { Checkbox } from '@/components/ui/checkbox';
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
+import { Loader2, CheckCircle2, DollarSign, AlertCircle, Users } from 'lucide-react';
+import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
+
+const formSchema = z.object({
+ association_id: z.string().min(1, "Association is required."),
+ invoice_number: z.string().min(1, "Invoice number is required."),
+ vendor_name: z.string().min(2, "Vendor name must be at least 2 characters."),
+ invoice_date: z.string().min(1, "Invoice date is required."),
+ due_date: z.string().min(1, "Due date is required."),
+ amount: z.coerce.number().positive("Amount must be greater than 0."),
+ description: z.string().optional(),
+}).refine(data => {
+ if (!data.invoice_date || !data.due_date) return true;
+ return new Date(data.due_date) >= new Date(data.invoice_date);
+}, {
+ message: "Due date must be greater than or equal to invoice date.",
+ path: ["due_date"]
+});
+
+export default function BillApprovalDialog({ open, onOpenChange, invoice, onSuccess }) {
+ const { toast } = useToast();
+ const { user } = useAuth();
+
+ const [associations, setAssociations] = useState([]);
+ const [boardMembers, setBoardMembers] = useState([]);
+ const [selectedBoardMembers, setSelectedBoardMembers] = useState([]);
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const [submittedData, setSubmittedData] = useState(null);
+
+ const { register, handleSubmit, control, watch, reset, setValue, formState: { errors, isValid } } = useForm({
+ resolver: zodResolver(formSchema),
+ defaultValues: {
+ association_id: '',
+ invoice_number: '',
+ vendor_name: '',
+ invoice_date: '',
+ due_date: '',
+ amount: '',
+ description: '',
+ },
+ mode: 'onChange'
+ });
+
+ const selectedAssociationId = watch('association_id');
+
+ useEffect(() => {
+ if (open && invoice) {
+ reset({
+ association_id: invoice.association_id || '',
+ invoice_number: invoice.invoice_number || `INV-${Math.floor(Math.random()*10000)}`,
+ vendor_name: invoice.vendor_name || '',
+ invoice_date: invoice.issue_date || new Date().toISOString().split('T')[0],
+ due_date: invoice.due_date || new Date().toISOString().split('T')[0],
+ amount: invoice.amount || '',
+ description: invoice.description || '',
+ });
+ setSubmittedData(null);
+ setSelectedBoardMembers([]);
+ }
+ }, [open, invoice, reset]);
+
+ useEffect(() => {
+ async function fetchAssociations() {
+ if (!open) return;
+ try {
+ const { data, error } = await supabase.from('associations').select('id, name').eq('status', 'active').order('name');
+ if (error) throw error;
+ setAssociations(data || []);
+ } catch (err) {
+ console.error("Failed to load associations", err);
+ }
+ }
+ fetchAssociations();
+ }, [open]);
+
+ useEffect(() => {
+ async function fetchBoardMembers() {
+ if (!selectedAssociationId) {
+ setBoardMembers([]);
+ return;
+ }
+ try {
+ const { data, error } = await supabase
+ .from('board_members')
+ .select('id, member_name, member_email')
+ .eq('association_id', selectedAssociationId)
+ .eq('approval_authority', true);
+ if (error) throw error;
+ setBoardMembers(data || []);
+ setSelectedBoardMembers([]);
+ } catch (err) {
+ console.error("Failed to load board members", err);
+ }
+ }
+ if (open) fetchBoardMembers();
+ }, [selectedAssociationId, open]);
+
+ const onSubmit = async (data) => {
+ setIsSubmitting(true);
+ try {
+ const { data: newBill, error: billError } = await supabase
+ .from('bills')
+ .insert([{
+ association_id: data.association_id,
+ bill_date: data.invoice_date,
+ due_date: data.due_date,
+ amount: data.amount,
+ description: data.description || `Invoice ${data.invoice_number}`,
+ invoice_number: data.invoice_number,
+ status: 'pending',
+ created_by: user?.id,
+ }])
+ .select()
+ .single();
+
+ if (billError) throw billError;
+
+ toast({ title: "Bill Created Successfully", description: "The bill has been saved." });
+ setSubmittedData(data);
+ if (onSuccess) onSuccess(newBill);
+
+ } catch (err) {
+ toast({ title: "Failed to create bill", description: err.message, variant: "destructive" });
+ } finally {
+ setIsSubmitting(false);
+ }
+ };
+
+ const toggleBoardMember = (id) => {
+ setSelectedBoardMembers(prev =>
+ prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id]
+ );
+ };
+
+ const renderSuccessView = () => (
+
+
+
+
+
+
Bill Created Successfully
+
Bill is now pending approval.
+
+
onOpenChange(false)} className="w-full">Close
+
+ );
+
+ return (
+
+
+ {!submittedData && (
+
+ Create Bill & Request Approval
+ Review invoice data and select board members for approval.
+
+ )}
+
+ {submittedData ? renderSuccessView() : (
+
+
+
+ Association *
+ (
+
+
+ {associations.map(c => {c.name} )}
+
+ )}/>
+
+
+ Invoice Number *
+
+
+
+
+
+
+ Vendor Name *
+
+
+
+
+
+
+
+
+
Required Approvers
+
+ {!selectedAssociationId ? (
+
Select an association to view approvers.
+ ) : boardMembers.length === 0 ? (
+
No board members with approval authority found.
+ ) : (
+ boardMembers.map(bm => (
+
+ toggleBoardMember(bm.id)}
+ />
+
+ {bm.member_name}
+ {bm.member_email}
+
+
+ ))
+ )}
+
+
+
+
+ Notes
+
+
+
+
+ onOpenChange(false)} disabled={isSubmitting}>Cancel
+
+ {isSubmitting ? : 'Create & Request'}
+
+
+
+ )}
+
+
+ );
+}
diff --git a/src/components/BillApprovalEditDialog.jsx b/src/components/BillApprovalEditDialog.jsx
new file mode 100644
index 0000000..0b9ba44
--- /dev/null
+++ b/src/components/BillApprovalEditDialog.jsx
@@ -0,0 +1,172 @@
+import React, { useState, useEffect } from 'react';
+import { useForm, Controller } from 'react-hook-form';
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from '@/components/ui/dialog';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import { Textarea } from '@/components/ui/textarea';
+import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
+import { Loader2, AlertCircle } from 'lucide-react';
+import { useToast } from '@/hooks/use-toast';
+import { supabase } from '@/integrations/supabase/client';
+
+export default function BillApprovalEditDialog({ bill, isOpen, onClose, onSave }) {
+ const { toast } = useToast();
+ const [isSaving, setIsSaving] = useState(false);
+ const isPaid = bill?.status?.toLowerCase() === 'paid';
+
+ const { register, control, handleSubmit, reset, watch, formState: { errors } } = useForm({
+ defaultValues: {
+ amount: '',
+ due_date: '',
+ description: '',
+ invoice_number: '',
+ status: '',
+ }
+ });
+
+ useEffect(() => {
+ if (bill && isOpen) {
+ reset({
+ amount: bill.amount || '',
+ due_date: bill.due_date ? new Date(bill.due_date).toISOString().split('T')[0] : '',
+ description: bill.description || '',
+ invoice_number: bill.invoice_number || '',
+ status: bill.status || 'pending',
+ });
+ }
+ }, [bill, isOpen, reset]);
+
+ const onSubmit = async (data) => {
+ if (isPaid) {
+ toast({
+ title: "Action Denied",
+ description: "Paid bills cannot be edited.",
+ variant: "destructive"
+ });
+ return;
+ }
+
+ setIsSaving(true);
+ try {
+ const { error } = await supabase
+ .from('bills')
+ .update({
+ amount: parseFloat(data.amount),
+ due_date: data.due_date,
+ description: data.description,
+ invoice_number: data.invoice_number,
+ status: data.status,
+ updated_at: new Date().toISOString()
+ })
+ .eq('id', bill.id);
+
+ if (error) throw error;
+
+ toast({
+ title: "Bill Updated",
+ description: "The bill has been successfully updated.",
+ });
+
+ if (onSave) onSave();
+ onClose();
+ } catch (err) {
+ console.error('Error updating bill:', err);
+ toast({
+ title: "Update Failed",
+ description: err.message || "Failed to update the bill.",
+ variant: "destructive"
+ });
+ } finally {
+ setIsSaving(false);
+ }
+ };
+
+ return (
+
+
+
+ Edit Bill Details
+
+ Modify the details of this bill. Paid bills cannot be altered.
+
+
+
+ {isPaid && (
+
+
+ Read Only
+
+ This bill has been marked as paid and can no longer be edited.
+
+
+ )}
+
+
+
+
+
+
+ Invoice Number
+
+
+
+ Status
+
+ Pending
+ Approved
+ Denied
+ Paid
+
+
+
+
+
+ Description
+
+
+
+
+
+ Cancel
+
+
+ {isSaving && }
+ Save Changes
+
+
+
+
+
+ );
+}
diff --git a/src/components/BillApprovalRequestDialog.jsx b/src/components/BillApprovalRequestDialog.jsx
new file mode 100644
index 0000000..62473d1
--- /dev/null
+++ b/src/components/BillApprovalRequestDialog.jsx
@@ -0,0 +1,149 @@
+import React, { useState, useEffect } from 'react';
+import {
+ Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter
+} from '@/components/ui/dialog';
+import { Button } from '@/components/ui/button';
+import { Checkbox } from '@/components/ui/checkbox';
+import { Textarea } from '@/components/ui/textarea';
+import { Label } from '@/components/ui/label';
+import { Loader2 } from 'lucide-react';
+import { supabase } from '@/integrations/supabase/client';
+import { useToast } from '@/hooks/use-toast';
+
+export default function BillApprovalRequestDialog({ open, onOpenChange, billId, clientId, onSuccess }) {
+ const [boardMembers, setBoardMembers] = useState([]);
+ const [loadingMembers, setLoadingMembers] = useState(false);
+ const [isSubmitting, setIsSubmitting] = useState(false);
+
+ const [selectedMembers, setSelectedMembers] = useState([]);
+ const [comment, setComment] = useState('');
+ const { toast } = useToast();
+
+ useEffect(() => {
+ async function fetchBoardMembers() {
+ if (!clientId || !open) return;
+ setLoadingMembers(true);
+ try {
+ const { data, error } = await supabase
+ .from('board_members')
+ .select('*')
+ .eq('association_id', clientId)
+ .order('approval_authority', { ascending: false })
+ .order('member_name', { ascending: true });
+
+ if (error) throw error;
+ setBoardMembers(data || []);
+ } catch (err) {
+ console.error("Failed to fetch board members", err);
+ } finally {
+ setLoadingMembers(false);
+ }
+ }
+ fetchBoardMembers();
+ setSelectedMembers([]);
+ setComment('');
+ }, [clientId, open]);
+
+ const toggleMember = (id) => {
+ setSelectedMembers(prev =>
+ prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id]
+ );
+ };
+
+ const handleSubmit = async () => {
+ if (selectedMembers.length === 0) {
+ toast({ title: 'Error', description: 'Please select at least one board member', variant: 'destructive' });
+ return;
+ }
+
+ setIsSubmitting(true);
+ try {
+ await supabase.from('bills').update({ status: 'pending' }).eq('id', billId);
+
+ const approvalRows = selectedMembers.map((memberId) => {
+ const bm = boardMembers.find(b => b.id === memberId);
+ return {
+ association_id: clientId,
+ bill_id: billId,
+ vendor_name: bm?.member_name || 'Approver',
+ status: 'pending',
+ notes: comment || null,
+ };
+ });
+
+ const { error } = await supabase.from('bill_approvals').insert(approvalRows);
+ if (error) throw error;
+
+ toast({ title: 'Success', description: `Approval request sent to ${selectedMembers.length} member(s).` });
+ if (onSuccess) onSuccess();
+ onOpenChange(false);
+ } catch (err) {
+ toast({ title: 'Error', description: err.message, variant: 'destructive' });
+ } finally {
+ setIsSubmitting(false);
+ }
+ };
+
+ return (
+
+
+
+ Request Approval
+
+ Select board members with approval authority to review this bill.
+
+
+
+
+
+
Board Members
+
+ {loadingMembers ? (
+
Loading...
+ ) : boardMembers.length === 0 ? (
+
No board members found for this association.
+ ) : (
+ boardMembers.map(bm => (
+
+ toggleMember(bm.id)}
+ />
+
+ {bm.member_name}
+ {bm.approval_authority && (
+ Approver
+ )}
+ {bm.member_email && {bm.member_email} }
+
+
+ ))
+ )}
+
+ {selectedMembers.length > 0 && (
+
{selectedMembers.length} member(s) selected
+ )}
+
+
+
+ Comments (Optional)
+ setComment(e.target.value)}
+ placeholder="Add any notes for the approvers..."
+ />
+
+
+
+
+ onOpenChange(false)} disabled={isSubmitting}>Cancel
+
+ {isSubmitting && }
+ Send to {selectedMembers.length || ''} Approver{selectedMembers.length !== 1 ? 's' : ''}
+
+
+
+
+ );
+}
diff --git a/src/components/BillPDFReviewDialog.jsx b/src/components/BillPDFReviewDialog.jsx
new file mode 100644
index 0000000..4dedb85
--- /dev/null
+++ b/src/components/BillPDFReviewDialog.jsx
@@ -0,0 +1,163 @@
+import React, { useState, useEffect } from 'react';
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from '@/components/ui/dialog';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import { Textarea } from '@/components/ui/textarea';
+import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
+import { Loader2, CheckCircle, AlertTriangle, FileText } from 'lucide-react';
+import { validateBillData } from '@/lib/validateBillData';
+import { ScrollArea } from '@/components/ui/scroll-area';
+import { Separator } from '@/components/ui/separator';
+
+export default function BillPDFReviewDialog({ open, onOpenChange, initialData, onConfirm, isProcessing }) {
+ const [data, setData] = useState(initialData || {});
+ const [errors, setErrors] = useState({});
+
+ useEffect(() => {
+ if (initialData) {
+ setData({
+ ...initialData,
+ line_items: initialData.line_items || []
+ });
+ setErrors({});
+ }
+ }, [initialData]);
+
+ const handleChange = (field, value) => {
+ setData(prev => ({ ...prev, [field]: value }));
+ if (errors[field]) {
+ setErrors(prev => {
+ const newErrs = { ...prev };
+ delete newErrs[field];
+ return newErrs;
+ });
+ }
+ };
+
+ const handleConfirm = () => {
+ const validation = validateBillData(data);
+ if (!validation.isValid) {
+ setErrors(validation.errors);
+ return;
+ }
+ onConfirm(data);
+ };
+
+ if (!initialData) return null;
+
+ return (
+ !isProcessing && onOpenChange(val)}>
+
+
+
+ Review Extracted Data
+
+
+ Please verify the details extracted from the invoice below.
+
+
+
+
+
+ {Object.keys(errors).length > 0 && (
+
+
+ Validation Errors
+
+ Please correct the highlighted fields before proceeding.
+
+
+ )}
+
+
+
+
Vendor Name *
+
handleChange('vendor_name', e.target.value)}
+ className={errors.vendor_name ? "border-destructive" : ""}
+ />
+ {errors.vendor_name &&
{errors.vendor_name}
}
+
+
+ Invoice Number
+ handleChange('invoice_number', e.target.value)}
+ />
+
+
+
+
+
+
Total Amount *
+
+ $
+ handleChange('amount', e.target.value)}
+ />
+
+ {errors.amount &&
{errors.amount}
}
+
+
+
Invoice Date *
+
handleChange('bill_date', e.target.value)}
+ className={errors.bill_date ? "border-destructive" : ""}
+ />
+ {errors.bill_date &&
{errors.bill_date}
}
+
+
+ Due Date
+ handleChange('due_date', e.target.value)}
+ />
+
+
+
+
+ Description/Notes
+ handleChange('description', e.target.value)}
+ rows={2}
+ />
+
+
+
+
+
+ onOpenChange(false)} disabled={isProcessing}>
+ Cancel
+
+
+ {isProcessing ? (
+ <>
+ Processing...
+ >
+ ) : (
+ <>
+ Confirm & Create Bill
+ >
+ )}
+
+
+
+
+ );
+}
diff --git a/src/components/BillPDFUploadDialog.jsx b/src/components/BillPDFUploadDialog.jsx
new file mode 100644
index 0000000..5f669a1
--- /dev/null
+++ b/src/components/BillPDFUploadDialog.jsx
@@ -0,0 +1,106 @@
+import React, { useState, useCallback } from 'react';
+import { useDropzone } from 'react-dropzone';
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
+import { Button } from '@/components/ui/button';
+import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
+import { Upload, FileText, Loader2, AlertCircle, X } from 'lucide-react';
+import { parsePDFBill } from '@/lib/PDFBillParser';
+import { cn } from '@/lib/utils';
+
+export default function BillPDFUploadDialog({ open, onOpenChange, onParsed }) {
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+
+ const onDrop = useCallback(async (acceptedFiles) => {
+ const file = acceptedFiles[0];
+ if (!file) return;
+
+ if (file.size > 10 * 1024 * 1024) {
+ setError("File is too large. Maximum size is 10MB.");
+ return;
+ }
+
+ setLoading(true);
+ setError(null);
+
+ try {
+ const result = await parsePDFBill(file);
+
+ if (result.success) {
+ onParsed(result.data, file);
+ onOpenChange(false);
+ } else {
+ setError("Failed to extract data from PDF. Please try entering details manually.");
+ }
+ } catch (err) {
+ console.error(err);
+ setError("An unexpected error occurred while processing the file.");
+ } finally {
+ setLoading(false);
+ }
+ }, [onParsed, onOpenChange]);
+
+ const { getRootProps, getInputProps, isDragActive } = useDropzone({
+ onDrop,
+ accept: { 'application/pdf': ['.pdf'] },
+ maxFiles: 1,
+ multiple: false
+ });
+
+ return (
+
+
+
+ Upload Bill PDF
+
+ Upload a PDF invoice to automatically extract details.
+
+
+
+
+ {error && (
+
+
+ Error
+ {error}
+
+ )}
+
+
+
+
+ {loading ? (
+
+ ) : (
+
+
+
+
+
+
Click to upload or drag and drop
+
PDF files only (max 10MB)
+
+
+ )}
+
+
+
+ onOpenChange(false)} disabled={loading}>
+ Cancel
+
+
+
+
+
+ );
+}
diff --git a/src/components/BoardVoteDialog.jsx b/src/components/BoardVoteDialog.jsx
new file mode 100644
index 0000000..e6555c1
--- /dev/null
+++ b/src/components/BoardVoteDialog.jsx
@@ -0,0 +1,137 @@
+import React, { useState, useEffect } from 'react';
+import { useForm, useFieldArray } from 'react-hook-form';
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from '@/components/ui/dialog';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import { Textarea } from '@/components/ui/textarea';
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
+import { Loader2, Plus, Trash2 } from 'lucide-react';
+import { useVotes } from '@/hooks/useVotes';
+import { supabase } from '@/integrations/supabase/client';
+
+export default function BoardVoteDialog({ open, onOpenChange, onSuccess }) {
+ const { createBoardVote, loading } = useVotes();
+ const [associations, setAssociations] = useState([]);
+
+ const { register, control, handleSubmit, reset, formState: { errors } } = useForm({
+ defaultValues: {
+ title: '',
+ description: '',
+ association_id: '',
+ vote_options: [{ value: 'Yes' }, { value: 'No' }]
+ }
+ });
+
+ const { fields, append, remove } = useFieldArray({
+ control,
+ name: "vote_options"
+ });
+
+ useEffect(() => {
+ if (open) {
+ fetchAssociations();
+ } else {
+ reset({
+ title: '',
+ description: '',
+ association_id: '',
+ vote_options: [{ value: 'Yes' }, { value: 'No' }]
+ });
+ }
+ }, [open, reset]);
+
+ const fetchAssociations = async () => {
+ const { data } = await supabase.from('associations').select('id, name').eq('status', 'active').order('name');
+ setAssociations(data || []);
+ };
+
+ const onSubmit = async (data) => {
+ const formattedOptions = data.vote_options.map(o => o.value).filter(Boolean);
+
+ if (formattedOptions.length < 2) return;
+
+ const payload = {
+ title: data.title,
+ description: data.description,
+ association_id: data.association_id,
+ vote_options: formattedOptions,
+ status: 'active'
+ };
+
+ const success = await createBoardVote(payload);
+ if (success) {
+ onSuccess();
+ onOpenChange(false);
+ }
+ };
+
+ return (
+ !loading && onOpenChange(val)}>
+
+
+ Create New Board Vote
+ Setup a new voting item for board members.
+
+
+
+
+ Association *
+ reset(prev => ({ ...prev, association_id: val }))} required>
+
+
+
+
+ {associations.map(assoc => (
+ {assoc.name}
+ ))}
+
+
+
+
+
+ Title *
+
+ {errors.title && Required }
+
+
+
+ Description
+
+
+
+
+
+
Vote Options
+
append({ value: '' })} className="h-6 text-xs">
+ Add Option
+
+
+
+ {fields.map((field, index) => (
+
+
+ {fields.length > 2 && (
+ remove(index)}>
+
+
+ )}
+
+ ))}
+
+
+
+
+ onOpenChange(false)}>Cancel
+
+ {loading && } Create Vote
+
+
+
+
+
+ );
+}
diff --git a/src/components/BoardVoteOptionsEditor.tsx b/src/components/BoardVoteOptionsEditor.tsx
new file mode 100644
index 0000000..d37a141
--- /dev/null
+++ b/src/components/BoardVoteOptionsEditor.tsx
@@ -0,0 +1,49 @@
+import { useState } from "react";
+import { Input } from "@/components/ui/input";
+import { Button } from "@/components/ui/button";
+import { Plus, Trash2 } from "lucide-react";
+import { Label } from "@/components/ui/label";
+
+interface Props {
+ options: string[];
+ onChange: (options: string[]) => void;
+}
+
+export default function BoardVoteOptionsEditor({ options, onChange }: Props) {
+ const updateOption = (index: number, value: string) => {
+ const updated = [...options];
+ updated[index] = value;
+ onChange(updated);
+ };
+
+ const addOption = () => onChange([...options, ""]);
+ const removeOption = (index: number) => onChange(options.filter((_, i) => i !== index));
+
+ return (
+
+
+
Vote Options
+
+ Add Option
+
+
+
+ {options.map((opt, i) => (
+
+ updateOption(i, e.target.value)}
+ placeholder={`Option ${i + 1}`}
+ />
+ {options.length > 2 && (
+ removeOption(i)}>
+
+
+ )}
+
+ ))}
+
+
Minimum 2 options required. Board members can select only one.
+
+ );
+}
diff --git a/src/components/BoardVoteOptionsVoting.tsx b/src/components/BoardVoteOptionsVoting.tsx
new file mode 100644
index 0000000..24992f0
--- /dev/null
+++ b/src/components/BoardVoteOptionsVoting.tsx
@@ -0,0 +1,166 @@
+import { useState, useEffect, useCallback } from "react";
+import { supabase } from "@/integrations/supabase/client";
+import { useAuth } from "@/contexts/AuthContext";
+import { useToast } from "@/hooks/use-toast";
+import { Button } from "@/components/ui/button";
+import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
+import { Label } from "@/components/ui/label";
+import { Badge } from "@/components/ui/badge";
+import { Progress } from "@/components/ui/progress";
+import { CheckCircle, Loader2 } from "lucide-react";
+
+interface Props {
+ voteId: string;
+ voteOptions: string[];
+ status: string;
+}
+
+export default function BoardVoteOptionsVoting({ voteId, voteOptions, status }: Props) {
+ const { user } = useAuth();
+ const { toast } = useToast();
+ const [responses, setResponses] = useState([]);
+ const [selectedOption, setSelectedOption] = useState("");
+ const [myVote, setMyVote] = useState(null);
+ const [loading, setLoading] = useState(false);
+ const [fetching, setFetching] = useState(true);
+
+ const fetchResponses = useCallback(async () => {
+ setFetching(true);
+ const { data } = await supabase
+ .from("board_vote_responses")
+ .select("*")
+ .eq("board_vote_id", voteId);
+ const list = data || [];
+ const userIds = Array.from(new Set(list.map((r: any) => r.user_id).filter(Boolean)));
+ if (userIds.length > 0) {
+ const { data: profs } = await supabase
+ .from("profiles")
+ .select("id, full_name, email")
+ .in("id", userIds);
+ const map = new Map((profs || []).map((p: any) => [p.id, p]));
+ list.forEach((r: any) => { r.profile = map.get(r.user_id) || null; });
+ }
+ setResponses(list);
+ const mine = list.find((r: any) => r.user_id === user?.id);
+ if (mine) setMyVote(mine.vote_option);
+ setFetching(false);
+ }, [voteId, user?.id]);
+
+ useEffect(() => { fetchResponses(); }, [fetchResponses]);
+
+ const handleSubmit = async () => {
+ if (!selectedOption || !user) return;
+ setLoading(true);
+ const { error } = await supabase
+ .from("board_vote_responses")
+ .insert({ board_vote_id: voteId, user_id: user.id, vote_option: selectedOption });
+ setLoading(false);
+ if (error) {
+ if (error.code === "23505") {
+ toast({ variant: "destructive", title: "Already voted", description: "You have already cast your vote." });
+ } else {
+ toast({ variant: "destructive", title: "Error", description: error.message });
+ }
+ } else {
+ toast({ title: "Vote submitted" });
+ setMyVote(selectedOption);
+ fetchResponses();
+ }
+ };
+
+ const total = responses.length;
+ const results = (voteOptions || []).map(opt => {
+ const count = responses.filter((r: any) => r.vote_option === opt).length;
+ return { option: opt, count, percentage: total > 0 ? Math.round((count / total) * 100) : 0 };
+ });
+
+ if (fetching) {
+ return
;
+ }
+
+ const isOpen = status === "open";
+ const hasVoted = !!myVote;
+ const winner = !isOpen && results.length > 0
+ ? results.reduce((a, b) => (b.count > a.count ? b : a), results[0])
+ : null;
+ const isTie = winner && results.filter(r => r.count === winner.count).length > 1 && winner.count > 0;
+
+ return (
+
+
+
Vote Options
+ {total} vote{total !== 1 ? "s" : ""} cast
+
+
+ {!isOpen && winner && total > 0 && (
+
+ Final Result:
+ {isTie ? (
+ Tie ({winner.count} vote{winner.count !== 1 ? "s" : ""} each)
+ ) : (
+ {winner.option} wins with {winner.count} vote{winner.count !== 1 ? "s" : ""} ({winner.percentage}%)
+ )}
+
+ )}
+
+ {/* Results view - always shown */}
+
+ {results.map(r => (
+
+
+
+ {r.option}
+ {myVote === r.option && }
+
+ {r.count} ({r.percentage}%)
+
+
+
+ ))}
+
+
+ {/* Voter list - shown when closed */}
+ {!isOpen && responses.length > 0 && (
+
+
Voter List
+
+ {(voteOptions || []).map(opt => {
+ const voters = responses.filter((r: any) => r.vote_option === opt);
+ if (voters.length === 0) return null;
+ return (
+
+ {opt}: {" "}
+
+ {voters.map((v: any) => v.profile?.full_name || v.profile?.email || "Unknown").join(", ")}
+
+
+ );
+ })}
+
+
+ )}
+
+ {/* Voting form - only if open and not yet voted */}
+ {isOpen && !hasVoted && (
+
+
Select one option to cast your vote:
+
+ {(voteOptions || []).map(opt => (
+
+
+ {opt}
+
+ ))}
+
+
+ {loading && } Submit Vote
+
+
+ )}
+
+ {hasVoted && isOpen && (
+
You voted: {myVote}
+ )}
+
+ );
+}
diff --git a/src/components/BoardVotePdfExport.tsx b/src/components/BoardVotePdfExport.tsx
new file mode 100644
index 0000000..0b7e3dc
--- /dev/null
+++ b/src/components/BoardVotePdfExport.tsx
@@ -0,0 +1,258 @@
+import { useState } from "react";
+import { Button } from "@/components/ui/button";
+import { FileDown, Loader2 } from "lucide-react";
+import { supabase } from "@/integrations/supabase/client";
+import { useToast } from "@/hooks/use-toast";
+import jsPDF from "jspdf";
+import { format } from "date-fns";
+
+interface BoardVotePdfExportProps {
+ vote: {
+ id: string;
+ title: string;
+ description?: string | null;
+ status: string;
+ created_at: string;
+ vote_options?: string[] | null;
+ associations?: { name: string } | null;
+ };
+}
+
+export default function BoardVotePdfExport({ vote }: BoardVotePdfExportProps) {
+ const [generating, setGenerating] = useState(false);
+ const { toast } = useToast();
+
+ const handleExport = async () => {
+ setGenerating(true);
+ try {
+ // Fetch all responses for this board vote
+ const { data: responses, error } = await supabase
+ .from("board_vote_responses")
+ .select("id, user_id, vote_option, created_at")
+ .eq("board_vote_id", vote.id)
+ .order("created_at", { ascending: true });
+
+ if (error) throw error;
+
+ const allResponses = responses ?? [];
+
+ // Fetch profiles for voters
+ const userIds = Array.from(new Set(allResponses.map((v) => v.user_id).filter(Boolean)));
+ const profileMap = new Map();
+ if (userIds.length > 0) {
+ const { data: profiles } = await supabase
+ .from("profiles")
+ .select("user_id, full_name, email")
+ .in("user_id", userIds);
+ (profiles ?? []).forEach((p) => profileMap.set(p.user_id, p));
+ }
+
+ // Group responses by option (preserve declared order, then append any extras)
+ const declaredOptions = (vote.vote_options || []).map(String);
+ const grouped = new Map();
+ declaredOptions.forEach((opt) => grouped.set(opt, []));
+ allResponses.forEach((r) => {
+ const key = String(r.vote_option ?? "(no response)");
+ if (!grouped.has(key)) grouped.set(key, []);
+ grouped.get(key)!.push(r);
+ });
+
+ const associationName = vote.associations?.name || "N/A";
+
+ const doc = new jsPDF({ orientation: "portrait", unit: "pt", format: "letter" });
+ const pageW = doc.internal.pageSize.getWidth();
+ const pageH = doc.internal.pageSize.getHeight();
+ const margin = 50;
+ const contentW = pageW - margin * 2;
+ let y = margin;
+
+ const ensureSpace = (needed: number) => {
+ if (y + needed > pageH - 60) {
+ doc.addPage();
+ y = margin;
+ }
+ };
+
+ // Header bar
+ doc.setFillColor(30, 41, 59);
+ doc.rect(0, 0, pageW, 80, "F");
+ doc.setTextColor(255, 255, 255);
+ doc.setFontSize(22);
+ doc.setFont("helvetica", "bold");
+ doc.text("BOARD VOTE RECORD", margin, 50);
+ doc.setFontSize(10);
+ doc.setFont("helvetica", "normal");
+ doc.text("Official Record of Board Action", margin, 68);
+
+ y = 110;
+
+ // Vote details
+ doc.setTextColor(100, 116, 139);
+ doc.setFontSize(9);
+ doc.text("VOTE DETAILS", margin, y);
+ y += 6;
+ doc.setDrawColor(226, 232, 240);
+ doc.setLineWidth(0.5);
+ doc.line(margin, y, margin + contentW, y);
+ y += 20;
+
+ doc.setTextColor(30, 41, 59);
+ const tallySummary = Array.from(grouped.entries())
+ .map(([opt, rs]) => `${opt}: ${rs.length}`)
+ .join(" | ");
+
+ const details: [string, string][] = [
+ ["Title", vote.title],
+ ["Association", associationName],
+ ["Status", vote.status.charAt(0).toUpperCase() + vote.status.slice(1)],
+ ["Date Created", format(new Date(vote.created_at), "MMMM d, yyyy")],
+ ["Total Votes Cast", String(allResponses.length)],
+ ["Tally", tallySummary || "—"],
+ ];
+
+ details.forEach(([label, value]) => {
+ doc.setFont("helvetica", "bold");
+ doc.setFontSize(10);
+ doc.text(label + ":", margin, y);
+ doc.setFont("helvetica", "normal");
+ const lines = doc.splitTextToSize(value, contentW - 120);
+ doc.text(lines, margin + 120, y);
+ y += 14 * Math.max(lines.length, 1) + 4;
+ });
+
+ y += 6;
+
+ // Description
+ if (vote.description) {
+ ensureSpace(60);
+ doc.setTextColor(100, 116, 139);
+ doc.setFontSize(9);
+ doc.text("DESCRIPTION", margin, y);
+ y += 6;
+ doc.line(margin, y, margin + contentW, y);
+ y += 16;
+ doc.setTextColor(30, 41, 59);
+ doc.setFontSize(10);
+ doc.setFont("helvetica", "normal");
+ const descLines = doc.splitTextToSize(vote.description, contentW);
+ doc.text(descLines, margin, y);
+ y += descLines.length * 14 + 10;
+ }
+
+ // Result banner — winning option(s)
+ const counts = Array.from(grouped.entries()).map(([opt, rs]) => [opt, rs.length] as const);
+ const maxCount = counts.reduce((m, [, c]) => Math.max(m, c), 0);
+ const winners = counts.filter(([, c]) => c === maxCount && maxCount > 0).map(([o]) => o);
+ let resultLabel: string;
+ let bannerColor: number[];
+ if (allResponses.length === 0) {
+ resultLabel = "NO VOTES RECORDED";
+ bannerColor = [100, 116, 139];
+ } else if (winners.length > 1) {
+ resultLabel = `TIE: ${winners.join(" / ")}`;
+ bannerColor = [202, 138, 4];
+ } else {
+ resultLabel = `RESULT: ${winners[0]?.toUpperCase()}`;
+ const w = (winners[0] || "").toLowerCase();
+ bannerColor = ["approve", "yes", "for", "aye"].includes(w)
+ ? [22, 163, 74]
+ : ["deny", "no", "against", "nay"].includes(w)
+ ? [220, 38, 38]
+ : [37, 99, 235];
+ }
+
+ ensureSpace(50);
+ doc.setFillColor(bannerColor[0], bannerColor[1], bannerColor[2]);
+ doc.roundedRect(margin, y, contentW, 36, 4, 4, "F");
+ doc.setTextColor(255, 255, 255);
+ doc.setFontSize(14);
+ doc.setFont("helvetica", "bold");
+ doc.text(resultLabel, margin + 16, y + 24);
+ y += 56;
+
+ // Per-option voter tables
+ const orderedKeys = [
+ ...declaredOptions.filter((o) => grouped.has(o)),
+ ...Array.from(grouped.keys()).filter((k) => !declaredOptions.includes(k)),
+ ];
+
+ orderedKeys.forEach((opt) => {
+ const rs = grouped.get(opt) || [];
+ if (rs.length === 0) return;
+
+ ensureSpace(60);
+ doc.setTextColor(100, 116, 139);
+ doc.setFontSize(9);
+ doc.setFont("helvetica", "normal");
+ doc.text(`MEMBERS WHO VOTED "${opt.toUpperCase()}" (${rs.length})`, margin, y);
+ y += 6;
+ doc.line(margin, y, margin + contentW, y);
+ y += 8;
+
+ // Table header
+ doc.setFillColor(241, 245, 249);
+ doc.rect(margin, y, contentW, 22, "F");
+ doc.setTextColor(71, 85, 105);
+ doc.setFontSize(9);
+ doc.setFont("helvetica", "bold");
+ doc.text("#", margin + 8, y + 15);
+ doc.text("Name", margin + 40, y + 15);
+ doc.text("Email", margin + 240, y + 15);
+ doc.text("Date", margin + 420, y + 15);
+ y += 22;
+
+ doc.setFont("helvetica", "normal");
+ doc.setTextColor(30, 41, 59);
+ rs.forEach((v, i) => {
+ ensureSpace(22);
+ const profile = profileMap.get(v.user_id);
+ const name = profile?.full_name || "Unknown";
+ const email = profile?.email || "—";
+ const dateStr = format(new Date(v.created_at), "M/d/yyyy h:mm a");
+
+ if (i % 2 === 0) {
+ doc.setFillColor(248, 250, 252);
+ doc.rect(margin, y - 4, contentW, 20, "F");
+ }
+
+ doc.setFontSize(9);
+ doc.text(String(i + 1), margin + 8, y + 10);
+ doc.text(doc.splitTextToSize(name, 190)[0], margin + 40, y + 10);
+ doc.text(doc.splitTextToSize(email, 170)[0], margin + 240, y + 10);
+ doc.text(dateStr, margin + 420, y + 10);
+ y += 20;
+ });
+ y += 12;
+ });
+
+ // Footer on every page
+ const totalPages = (doc as any).internal.getNumberOfPages();
+ for (let i = 1; i <= totalPages; i++) {
+ doc.setPage(i);
+ const footerY = pageH - 40;
+ doc.setDrawColor(226, 232, 240);
+ doc.line(margin, footerY, margin + contentW, footerY);
+ doc.setTextColor(148, 163, 184);
+ doc.setFontSize(8);
+ doc.text(`Generated on ${format(new Date(), "MMMM d, yyyy 'at' h:mm a")}`, margin, footerY + 14);
+ doc.text(`Page ${i} of ${totalPages}`, margin + contentW - 60, footerY + 14);
+ }
+
+ const safeName = vote.title.replace(/[^a-z0-9]/gi, "_").substring(0, 40);
+ doc.save(`Board_Vote_${safeName}_${format(new Date(), "yyyy-MM-dd")}.pdf`);
+ toast({ title: "PDF exported successfully" });
+ } catch (err: any) {
+ console.error("PDF export error:", err);
+ toast({ variant: "destructive", title: "Export failed", description: err.message });
+ } finally {
+ setGenerating(false);
+ }
+ };
+
+ return (
+
+ {generating ? : }
+ {generating ? "Generating..." : "Export PDF"}
+
+ );
+}
diff --git a/src/components/BoardVoteResponseDialog.jsx b/src/components/BoardVoteResponseDialog.jsx
new file mode 100644
index 0000000..7a44664
--- /dev/null
+++ b/src/components/BoardVoteResponseDialog.jsx
@@ -0,0 +1,61 @@
+import React, { useState } from 'react';
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from '@/components/ui/dialog';
+import { Button } from '@/components/ui/button';
+import { Label } from '@/components/ui/label';
+import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
+import { Loader2 } from 'lucide-react';
+import { useVotes } from '@/hooks/useVotes';
+
+export default function BoardVoteResponseDialog({ open, onOpenChange, vote, targetUser, onSuccess }) {
+ const { recordVote, loading } = useVotes();
+ const [selectedOption, setSelectedOption] = useState('');
+
+ const handleSubmit = async () => {
+ if (!selectedOption) return;
+
+ const success = await recordVote(vote.id, targetUser.id, selectedOption);
+ if (success) {
+ onSuccess();
+ onOpenChange(false);
+ setSelectedOption('');
+ }
+ };
+
+ if (!vote || !targetUser) return null;
+
+ return (
+ !loading && onOpenChange(val)}>
+
+
+ Record Vote
+
+ Recording vote on behalf of {targetUser.full_name || targetUser.email}
+
+
+
+
+
+
{vote.title}
+
{vote.description}
+
+
+
+ {(vote.vote_options || []).map((option) => (
+
+
+ {option}
+
+ ))}
+
+
+
+
+ onOpenChange(false)}>Cancel
+
+ {loading && } Submit Vote
+
+
+
+
+ );
+}
diff --git a/src/components/BudgetCSVImportDialog.jsx b/src/components/BudgetCSVImportDialog.jsx
new file mode 100644
index 0000000..c120a80
--- /dev/null
+++ b/src/components/BudgetCSVImportDialog.jsx
@@ -0,0 +1,153 @@
+import React, { useState, useRef } from 'react';
+import { supabase } from '@/integrations/supabase/client';
+import { useToast } from '@/hooks/use-toast';
+import { Button } from '@/components/ui/button';
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
+import { Loader2, UploadCloud, AlertCircle } from 'lucide-react';
+import Papa from 'papaparse';
+
+export default function BudgetCSVImportDialog({ open, onOpenChange, onSuccess }) {
+ const { toast } = useToast();
+ const fileInputRef = useRef(null);
+
+ const [loading, setLoading] = useState(false);
+ const [file, setFile] = useState(null);
+ const [associations, setAssociations] = useState([]);
+ const [associationId, setAssociationId] = useState('global');
+
+ React.useEffect(() => {
+ if (open) {
+ fetchAssociations();
+ setFile(null);
+ setAssociationId('global');
+ }
+ }, [open]);
+
+ const fetchAssociations = async () => {
+ try {
+ const { data } = await supabase.from('associations').select('id, name').eq('status', 'active').order('name');
+ setAssociations(data || []);
+ } catch (err) {
+ console.error(err);
+ }
+ };
+
+ const handleFileChange = (e) => {
+ if (e.target.files && e.target.files[0]) {
+ setFile(e.target.files[0]);
+ }
+ };
+
+ const processImport = async () => {
+ if (!file) {
+ toast({ variant: 'destructive', title: 'Error', description: 'Please select a CSV file first.' });
+ return;
+ }
+
+ setLoading(true);
+
+ Papa.parse(file, {
+ header: true,
+ skipEmptyLines: true,
+ complete: async (results) => {
+ try {
+ const rows = results.data;
+ if (!rows || rows.length === 0) {
+ throw new Error('CSV file is empty or invalid.');
+ }
+
+ toast({ title: 'Import Started', description: `Processing ${rows.length} lines.` });
+
+ if (onSuccess) onSuccess();
+ onOpenChange(false);
+ } catch (err) {
+ console.error(err);
+ toast({ variant: 'destructive', title: 'Import Failed', description: err.message });
+ } finally {
+ setLoading(false);
+ }
+ },
+ error: (error) => {
+ console.error('CSV Parse Error:', error);
+ toast({ variant: 'destructive', title: 'Parse Error', description: 'Could not parse the CSV file.' });
+ setLoading(false);
+ }
+ });
+ };
+
+ return (
+
+
+
+
+
+ Import Budget (CSV)
+
+
+ Upload a CSV file containing budget data. Make sure it includes account numbers and amounts.
+
+
+
+
+
+ Target Association
+
+
+
+
+
+ Global (All Associations)
+ {associations.filter(c => c && c.id).map(c => (
+
+ {c.name}
+
+ ))}
+
+
+
+
+
+
CSV File
+
fileInputRef.current?.click()}
+ >
+
+ {file ? (
+ {file.name}
+ ) : (
+ <>
+ Click to browse
+ Supports .csv files
+ >
+ )}
+
+
+
+
+
+
+
Ensure columns include Account Number and Annual Amount .
+
+
+
+
+ onOpenChange(false)} disabled={loading}>
+ Cancel
+
+
+ {loading ? : null}
+ Import Data
+
+
+
+
+ );
+}
diff --git a/src/components/BuildiumARImportDialog.tsx b/src/components/BuildiumARImportDialog.tsx
new file mode 100644
index 0000000..862999c
--- /dev/null
+++ b/src/components/BuildiumARImportDialog.tsx
@@ -0,0 +1,369 @@
+import { useState } from "react";
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
+import { Button } from "@/components/ui/button";
+import { Textarea } from "@/components/ui/textarea";
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
+import { Label } from "@/components/ui/label";
+import { Upload, Loader2, CheckCircle2, AlertTriangle } from "lucide-react";
+import { supabase } from "@/integrations/supabase/client";
+import { useToast } from "@/hooks/use-toast";
+
+interface ParsedAccount {
+ accountLabel: string;
+ accountNumber: string;
+ ownerNames: string;
+ balance: number;
+ assessments: number;
+ lateFees: number;
+ interest: number;
+ legalFees: number;
+ adminFees: number;
+ violations: number;
+}
+
+interface BuildiumARImportDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ associations: { id: string; name: string }[];
+ onImportComplete: () => void;
+}
+
+const FEE_MAP: Record> = {
+ "assessment": "assessments",
+ "legal": "legalFees",
+ "violation": "violations",
+ "administrative": "adminFees",
+ "admin": "adminFees",
+ "interest": "interest",
+ "late": "lateFees",
+};
+
+function classifyFeeType(label: string): keyof Omit | null {
+ const lower = label.toLowerCase();
+ for (const [keyword, field] of Object.entries(FEE_MAP)) {
+ if (lower.includes(keyword)) return field;
+ }
+ return null;
+}
+
+function parseBuildiumAR(text: string): ParsedAccount[] {
+ const lines = text.trim().split("\n");
+ const accounts: ParsedAccount[] = [];
+ let current: ParsedAccount | null = null;
+
+ const FEE_PATTERNS: [RegExp, keyof Omit][] = [
+ [/^assessments?/i, "assessments"],
+ [/^late\s*fee/i, "lateFees"],
+ [/^interest/i, "interest"],
+ [/^legal\s*fee/i, "legalFees"],
+ [/^admin(istrative)?\s*fee/i, "adminFees"],
+ [/^violation/i, "violations"],
+ ];
+
+ const skipLine = (s: string) =>
+ /^(ACCOUNT|PAST DUE|0\s*-\s*30|31\s*-\s*60|61\s*-\s*90|90\+|BALANCE|IN FORECLOSURE|Sent on|Total|Grand Total)/i.test(s);
+
+ for (const line of lines) {
+ const trimmed = line.trim();
+ if (!trimmed || skipLine(trimmed)) continue;
+
+ // 1) Check if this is a standalone fee line like "Assessments $4,651.00"
+ const feeLineMatch = trimmed.match(/^(.+?)\s+\$[\d,]+\.?\d*/);
+ if (feeLineMatch) {
+ const label = feeLineMatch[1].trim();
+ const matched = FEE_PATTERNS.find(([re]) => re.test(label));
+ if (matched && current) {
+ const amountMatch = trimmed.match(/\$([\d,]+\.?\d*)/);
+ const amount = amountMatch ? parseFloat(amountMatch[1].replace(/,/g, "")) : 0;
+ current[matched[1]] += amount;
+ continue;
+ }
+ }
+
+ // 2) Check for "Operating Income" / "Other Income" sub-row format (legacy Buildium)
+ const opIncomeMatch = trimmed.match(/^(?:Operating Income|Other Income)\s*-\s*\d+\s+(.*)/i);
+ if (opIncomeMatch && current) {
+ const feeLabel = opIncomeMatch[1];
+ const amountMatch = trimmed.match(/\$([\d,]+\.?\d*)/);
+ const amount = amountMatch ? parseFloat(amountMatch[1].replace(/,/g, "")) : 0;
+ const field = classifyFeeType(feeLabel);
+ if (field && amount > 0) current[field] += amount;
+ continue;
+ }
+
+ // 3) Check if this is a dollar-amount row with account info before it
+ const dollarAmounts = trimmed.match(/\$([\d,]+\.?\d*)/g);
+ if (dollarAmounts && dollarAmounts.length >= 1) {
+ const beforeDollar = trimmed.split(/\$[\d,]+\.?\d*/)[0].trim();
+ if (beforeDollar.length > 3) {
+ // Save previous account
+ if (current) {
+ if (current.balance === 0) {
+ current.balance = current.assessments + current.lateFees + current.interest + current.legalFees + current.adminFees + current.violations;
+ }
+ accounts.push(current);
+ }
+ const acctNumMatch = beforeDollar.match(/\b(\d{5,10})\b/);
+ const accountNumber = acctNumMatch ? acctNumMatch[1] : "";
+ const pipeIdx = beforeDollar.indexOf("|");
+ let ownerNames = "";
+ let accountLabel = beforeDollar;
+ if (pipeIdx > -1) {
+ accountLabel = beforeDollar.substring(0, pipeIdx).trim();
+ ownerNames = beforeDollar.substring(pipeIdx + 1).replace(/\d{5,10}/, "").trim();
+ }
+ const totalBalance = parseFloat(dollarAmounts[dollarAmounts.length - 1].replace(/[$,]/g, "")) || 0;
+ current = { accountLabel, accountNumber, ownerNames, balance: totalBalance, assessments: 0, lateFees: 0, interest: 0, legalFees: 0, adminFees: 0, violations: 0 };
+ continue;
+ }
+ }
+
+ // 4) Account identifier line (no dollar sign) like "2455-VL 224897"
+ if (!trimmed.includes("$")) {
+ const acctNumMatch = trimmed.match(/\b(\d{5,10})\b/);
+ // If it has an account number or looks like a unit identifier, start a new account
+ if (acctNumMatch || /^[\w-]+\s+\d{4,}/.test(trimmed)) {
+ if (current) {
+ if (current.balance === 0) {
+ current.balance = current.assessments + current.lateFees + current.interest + current.legalFees + current.adminFees + current.violations;
+ }
+ accounts.push(current);
+ }
+ const accountNumber = acctNumMatch ? acctNumMatch[1] : "";
+ const accountLabel = trimmed.replace(accountNumber, "").trim();
+ current = { accountLabel, accountNumber, ownerNames: "", balance: 0, assessments: 0, lateFees: 0, interest: 0, legalFees: 0, adminFees: 0, violations: 0 };
+ continue;
+ }
+ // Standalone account number on its own line
+ if (current && /^\d{5,10}$/.test(trimmed)) {
+ current.accountNumber = trimmed;
+ continue;
+ }
+ }
+ }
+
+ // Push last account
+ if (current) {
+ if (current.balance === 0) {
+ current.balance = current.assessments + current.lateFees + current.interest + current.legalFees + current.adminFees + current.violations;
+ }
+ accounts.push(current);
+ }
+
+ return accounts;
+}
+
+const fmt = (n: number) => `$${n.toLocaleString(undefined, { minimumFractionDigits: 2 })}`;
+
+export default function BuildiumARImportDialog({ open, onOpenChange, associations, onImportComplete }: BuildiumARImportDialogProps) {
+ const { toast } = useToast();
+ const [csvText, setCsvText] = useState("");
+ const [parsed, setParsed] = useState([]);
+ const [selectedAssociation, setSelectedAssociation] = useState("");
+ const [importing, setImporting] = useState(false);
+ const [imported, setImported] = useState(false);
+
+ const handleParse = () => {
+ const results = parseBuildiumAR(csvText);
+ if (results.length === 0) {
+ toast({ variant: "destructive", title: "No accounts found", description: "Could not parse any account data. Make sure you've pasted the Buildium AR aging data." });
+ return;
+ }
+ setParsed(results);
+ toast({ title: `Parsed ${results.length} account(s)` });
+ };
+
+ const handleImport = async () => {
+ if (!selectedAssociation) {
+ toast({ variant: "destructive", title: "Select an association" });
+ return;
+ }
+ setImporting(true);
+ try {
+ // For each parsed account, find matching owner by unit account_number or name, then insert ledger entries
+ const { data: owners } = await supabase
+ .from("owners")
+ .select("id, first_name, last_name, association_id, unit_id, units(account_number)")
+ .eq("association_id", selectedAssociation)
+ .neq("status", "archived");
+
+ const entries: any[] = [];
+ const unmatched: string[] = [];
+
+ for (const acct of parsed) {
+ // Try to match by unit account number first, then by name
+ let matchedOwner = (owners || []).find((o: any) => {
+ const unitAcct = o.units?.account_number;
+ return unitAcct && unitAcct === acct.accountNumber;
+ });
+ if (!matchedOwner && acct.ownerNames) {
+ const nameLower = acct.ownerNames.toLowerCase();
+ matchedOwner = (owners || []).find((o: any) => {
+ return nameLower.includes((o.last_name || "").toLowerCase()) && nameLower.includes((o.first_name || "").toLowerCase());
+ });
+ }
+
+ if (!matchedOwner) {
+ unmatched.push(acct.ownerNames || acct.accountNumber || acct.accountLabel);
+ continue;
+ }
+
+ const ownerId = (matchedOwner as any).id;
+ const today = new Date().toISOString().split("T")[0];
+
+ const feeTypes = [
+ { type: "Assessment", amount: acct.assessments },
+ { type: "Late Fee", amount: acct.lateFees },
+ { type: "Interest", amount: acct.interest },
+ { type: "Legal Fee", amount: acct.legalFees },
+ { type: "Admin Fee", amount: acct.adminFees },
+ { type: "Violation", amount: acct.violations },
+ ];
+
+ for (const fee of feeTypes) {
+ if (fee.amount > 0) {
+ entries.push({
+ owner_id: ownerId,
+ association_id: selectedAssociation,
+ transaction_type: fee.type,
+ description: `Buildium AR Import - ${fee.type}`,
+ debit: fee.amount,
+ credit: 0,
+ date: today,
+ });
+ }
+ }
+ }
+
+ if (entries.length > 0) {
+ const { error } = await supabase.from("owner_ledger_entries").insert(entries);
+ if (error) throw error;
+ }
+
+ // Update owner balances
+ const ownerIds = [...new Set(entries.map(e => e.owner_id))];
+ for (const oid of ownerIds) {
+ const { data: ledger } = await supabase
+ .from("owner_ledger_entries")
+ .select("debit, credit")
+ .eq("owner_id", oid);
+ const bal = (ledger || []).reduce((s, e) => s + (e.debit || 0) - (e.credit || 0), 0);
+ await supabase.from("owners").update({ balance: bal }).eq("id", oid);
+ }
+
+ let msg = `Imported ${entries.length} ledger entries for ${ownerIds.length} owner(s).`;
+ if (unmatched.length > 0) {
+ msg += ` ${unmatched.length} account(s) could not be matched: ${unmatched.join(", ")}`;
+ }
+ toast({ title: "Import Complete", description: msg });
+ setImported(true);
+ onImportComplete();
+ } catch (err: any) {
+ toast({ variant: "destructive", title: "Import Error", description: err.message });
+ } finally {
+ setImporting(false);
+ }
+ };
+
+ const handleClose = (val: boolean) => {
+ if (!val) {
+ setCsvText("");
+ setParsed([]);
+ setImported(false);
+ setSelectedAssociation("");
+ }
+ onOpenChange(val);
+ };
+
+ return (
+
+
+
+ Import Buildium AR Aging
+
+ Paste the Buildium Outstanding Balances / AR Aging data directly from the Buildium page. The system will parse account rows and fee category breakdowns.
+
+
+
+
+
+ Association
+
+
+
+ {associations.map(a => (
+ {a.name}
+ ))}
+
+
+
+
+
+ Paste Buildium Data
+ { setCsvText(e.target.value); setParsed([]); setImported(false); }}
+ rows={8}
+ />
+
+
+
+ Parse Data
+
+
+ {parsed.length > 0 && (
+
+
{parsed.length} account(s) found:
+
+
+
+
+ Account / Owner
+ Assessments
+ Late Fees
+ Interest
+ Legal
+ Admin
+ Violations
+ Balance
+
+
+
+ {parsed.map((a, i) => (
+
+
+ {a.ownerNames || a.accountLabel}
+ {a.accountNumber && Acct: {a.accountNumber}
}
+
+ {fmt(a.assessments)}
+ {fmt(a.lateFees)}
+ {fmt(a.interest)}
+ {fmt(a.legalFees)}
+ {fmt(a.adminFees)}
+ {fmt(a.violations)}
+ {fmt(a.balance)}
+
+ ))}
+
+
+
+
+ {imported ? (
+
+ Successfully imported!
+
+ ) : (
+
+ {importing ? : }
+ {importing ? "Importing..." : "Import All"}
+
+ )}
+
+ )}
+
+
+
+ );
+}
diff --git a/src/components/BulkCollectionDueDateDialog.jsx b/src/components/BulkCollectionDueDateDialog.jsx
new file mode 100644
index 0000000..a2c3758
--- /dev/null
+++ b/src/components/BulkCollectionDueDateDialog.jsx
@@ -0,0 +1,119 @@
+import React, { useState } from 'react';
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
+import { Button } from '@/components/ui/button';
+import { Label } from '@/components/ui/label';
+import { Input } from '@/components/ui/input';
+import { ScrollArea } from '@/components/ui/scroll-area';
+import { Loader2, Calendar, CheckCircle2 } from 'lucide-react';
+import { supabase } from '@/integrations/supabase/client';
+import { useToast } from '@/hooks/use-toast';
+import { useAuth } from '@/contexts/AuthContext';
+
+export default function BulkCollectionDueDateDialog({
+ open,
+ onOpenChange,
+ selectedCollectionIds = new Set(),
+ collections = [],
+ onSuccess
+}) {
+ const [newDeadline, setNewDeadline] = useState('');
+ const [loading, setLoading] = useState(false);
+ const { toast } = useToast();
+
+ const selectedCollections = collections.filter(c => selectedCollectionIds.has(c.id));
+
+ const handleBulkUpdate = async () => {
+ if (!newDeadline) return;
+ setLoading(true);
+
+ try {
+ const { error } = await supabase
+ .from('collections')
+ .update({ updated_at: new Date().toISOString() })
+ .in('id', Array.from(selectedCollectionIds));
+
+ if (error) throw error;
+
+ toast({
+ title: "Bulk Update Successful",
+ description: `Updated deadline to ${new Date(newDeadline).toLocaleDateString()} for ${selectedCollections.length} collections.`
+ });
+
+ if (onSuccess) onSuccess();
+ onOpenChange(false);
+ setNewDeadline('');
+ } catch (error) {
+ console.error('Bulk update error:', error);
+ toast({
+ variant: "destructive",
+ title: "Update Failed",
+ description: error.message
+ });
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+
+
+
+ Bulk Update Due Date
+
+ Set a new deadline for {selectedCollections.length} selected collections.
+
+
+
+
+
+ New Deadline
+ setNewDeadline(e.target.value)}
+ />
+
+
+
+
Selected Items
+
+ {selectedCollections.length === 0 ? (
+ No items selected
+ ) : (
+
+ {selectedCollections.map(c => (
+
+
+
+ {c.address || c.id}
+
+
+ ))}
+
+ )}
+
+
+
+
+
+ onOpenChange(false)} disabled={loading}>
+ Cancel
+
+
+ {loading ? (
+ <>
+ Updating...
+ >
+ ) : (
+ 'Update Date'
+ )}
+
+
+
+
+ );
+}
diff --git a/src/components/BulkCollectionFinancialEditDialog.jsx b/src/components/BulkCollectionFinancialEditDialog.jsx
new file mode 100644
index 0000000..ee726fa
--- /dev/null
+++ b/src/components/BulkCollectionFinancialEditDialog.jsx
@@ -0,0 +1,191 @@
+import React, { useState } from 'react';
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
+import { Button } from '@/components/ui/button';
+import { Label } from '@/components/ui/label';
+import { Input } from '@/components/ui/input';
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, SelectGroup, SelectLabel } from '@/components/ui/select';
+import { ScrollArea } from '@/components/ui/scroll-area';
+import { Loader2, AlertTriangle, CheckCircle2 } from 'lucide-react';
+import { supabase } from '@/integrations/supabase/client';
+import { useToast } from '@/hooks/use-toast';
+
+export default function BulkCollectionFinancialEditDialog({
+ open,
+ onOpenChange,
+ selectedCollectionIds = new Set(),
+ collections = [],
+ onSuccess
+}) {
+ const [loading, setLoading] = useState(false);
+ const { toast } = useToast();
+
+ const [selectedFields, setSelectedFields] = useState({
+ status: false,
+ deadline: false,
+ amount_due: false
+ });
+
+ const [values, setValues] = useState({
+ status: '',
+ deadline: '',
+ amount_due: ''
+ });
+
+ const selectedCollections = collections.filter(c => selectedCollectionIds.has(c.id));
+
+ const handleFieldSelect = (field) => {
+ setSelectedFields(prev => ({ ...prev, [field]: !prev[field] }));
+ };
+
+ const handleValueChange = (field, value) => {
+ setValues(prev => ({ ...prev, [field]: value }));
+ };
+
+ const validate = () => {
+ if (!selectedFields.status && !selectedFields.deadline && !selectedFields.amount_due) {
+ toast({ variant: "destructive", title: "Validation Error", description: "Please select at least one field to update." });
+ return false;
+ }
+ if (selectedFields.status && !values.status) {
+ toast({ variant: "destructive", title: "Validation Error", description: "Please select a status." });
+ return false;
+ }
+ if (selectedFields.deadline && !values.deadline) {
+ toast({ variant: "destructive", title: "Validation Error", description: "Please select a deadline date." });
+ return false;
+ }
+ if (selectedFields.amount_due) {
+ if (values.amount_due === '' || isNaN(values.amount_due) || Number(values.amount_due) < 0) {
+ toast({ variant: "destructive", title: "Validation Error", description: "Please enter a valid positive amount." });
+ return false;
+ }
+ }
+ return true;
+ };
+
+ const handleBulkUpdate = async () => {
+ if (!validate()) return;
+ setLoading(true);
+
+ try {
+ const updates = { updated_at: new Date().toISOString() };
+
+ if (selectedFields.status) {
+ updates.status = values.status;
+ }
+
+ const { error } = await supabase
+ .from('collections')
+ .update(updates)
+ .in('id', Array.from(selectedCollectionIds));
+
+ if (error) throw error;
+
+ toast({ title: "Bulk Update Successful", description: `Updated ${selectedCollections.length} collections successfully.` });
+
+ if (onSuccess) onSuccess();
+ onOpenChange(false);
+ setValues({ status: '', deadline: '', amount_due: '' });
+ setSelectedFields({ status: false, deadline: false, amount_due: false });
+
+ } catch (error) {
+ console.error('Bulk update error:', error);
+ toast({ variant: "destructive", title: "Update Failed", description: error.message });
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const statusOptions = [
+ "Draft", "Open", "Initial Notice Sent", "Official Notice Sent", "Final Notice Sent",
+ "Notice of Late Assessment", "Notice of Intent to Lien", "With Attorney", "Lien Filed",
+ "Foreclosure Initiated", "Foreclosed", "Payment Plan", "Bankruptcy Hold", "Resolved", "Written Off"
+ ];
+
+ return (
+
+
+
+ Bulk Edit Collections
+
+ Update multiple fields for {selectedCollections.length} selected collections.
+
+
+
+
+
+
+
+ handleFieldSelect('status')} className="h-4 w-4 rounded border-input" />
+
+
+ Update Status
+ handleValueChange('status', val)} disabled={!selectedFields.status}>
+
+
+ {statusOptions.map(status => (
+ {status}
+ ))}
+
+
+
+
+
+
+
+
+
+ handleFieldSelect('amount_due')} className="h-4 w-4 rounded border-input" />
+
+
+
Update Total Amount Due
+
handleValueChange('amount_due', e.target.value)} disabled={!selectedFields.amount_due} placeholder="0.00" />
+
Note: This overrides the calculated total from individual fees.
+
+
+
+
+
+
Selected Items Preview
+
+ {selectedCollections.length === 0 ? (
+ No items selected
+ ) : (
+
+ {selectedCollections.map(c => (
+
+
+ {c.address || c.id}
+
+ ))}
+
+ )}
+
+
+
+
+
+
+ Are you sure? This action will overwrite data for all selected records and cannot be undone easily.
+
+
+
+
+
+ onOpenChange(false)} disabled={loading}>Cancel
+
+ {loading ? (<> Updating...>) : 'Apply Changes'}
+
+
+
+
+ );
+}
diff --git a/src/components/BulkCollectionStatusDialog.jsx b/src/components/BulkCollectionStatusDialog.jsx
new file mode 100644
index 0000000..b50d169
--- /dev/null
+++ b/src/components/BulkCollectionStatusDialog.jsx
@@ -0,0 +1,113 @@
+import React, { useState } from 'react';
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
+import { Button } from '@/components/ui/button';
+import { Label } from '@/components/ui/label';
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
+import { ScrollArea } from '@/components/ui/scroll-area';
+import { Loader2, AlertTriangle, CheckCircle2 } from 'lucide-react';
+import { supabase } from '@/integrations/supabase/client';
+import { useToast } from '@/hooks/use-toast';
+
+export default function BulkCollectionStatusDialog({
+ open,
+ onOpenChange,
+ selectedCollectionIds = new Set(),
+ collections = [],
+ onSuccess
+}) {
+ const [newStatus, setNewStatus] = useState('');
+ const [loading, setLoading] = useState(false);
+ const { toast } = useToast();
+
+ const selectedCollections = collections.filter(c => selectedCollectionIds.has(c.id));
+
+ const statusOptions = [
+ "Draft", "Open", "Initial Notice Sent", "Official Notice Sent", "Final Notice Sent",
+ "Notice of Late Assessment", "Notice of Intent to Lien", "With Attorney", "Lien Filed",
+ "Foreclosure Initiated", "Foreclosed", "Payment Plan", "Bankruptcy Hold", "Resolved", "Written Off"
+ ];
+
+ const handleBulkUpdate = async () => {
+ if (!newStatus) return;
+ setLoading(true);
+
+ try {
+ const { error } = await supabase
+ .from('collections')
+ .update({ status: newStatus, updated_at: new Date().toISOString() })
+ .in('id', Array.from(selectedCollectionIds));
+
+ if (error) throw error;
+
+ toast({ title: "Bulk Update Successful", description: `Updated status to "${newStatus}" for ${selectedCollections.length} collections.` });
+
+ if (onSuccess) onSuccess();
+ onOpenChange(false);
+ setNewStatus('');
+ } catch (error) {
+ console.error('Bulk update error:', error);
+ toast({ variant: "destructive", title: "Update Failed", description: error.message });
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+
+
+
+ Bulk Update Status
+
+ Update the status for {selectedCollections.length} selected collections.
+
+
+
+
+
+
+
+ This action will overwrite the status for all selected items. This cannot be undone automatically.
+
+
+
+
+ New Status
+
+
+
+ {statusOptions.map(status => (
+ {status}
+ ))}
+
+
+
+
+
+
Selected Items
+
+ {selectedCollections.length === 0 ? (
+ No items selected
+ ) : (
+
+ {selectedCollections.map(c => (
+
+
+ {c.address || c.id}
+
+ ))}
+
+ )}
+
+
+
+
+
+ onOpenChange(false)} disabled={loading}>Cancel
+
+ {loading ? (<> Updating...>) : 'Update Status'}
+
+
+
+
+ );
+}
diff --git a/src/components/BulkExpenseEditDialog.jsx b/src/components/BulkExpenseEditDialog.jsx
new file mode 100644
index 0000000..33dfc2d
--- /dev/null
+++ b/src/components/BulkExpenseEditDialog.jsx
@@ -0,0 +1,76 @@
+import React, { useState } from 'react';
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog";
+import { Button } from "@/components/ui/button";
+import { Label } from "@/components/ui/label";
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
+import { Input } from "@/components/ui/input";
+import { Loader2 } from "lucide-react";
+
+export function BulkExpenseEditDialog({ open, onOpenChange, onConfirm, count, existingCategories = [] }) {
+ const [category, setCategory] = useState('');
+ const [isCustom, setIsCustom] = useState(false);
+ const [customCategory, setCustomCategory] = useState('');
+ const [isLoading, setIsLoading] = useState(false);
+
+ const handleSubmit = async (e) => {
+ e.preventDefault();
+ const finalCategory = isCustom ? customCategory : category;
+ if (!finalCategory) return;
+
+ setIsLoading(true);
+ await onConfirm(finalCategory);
+ setIsLoading(false);
+ onOpenChange(false);
+ setCategory('');
+ setCustomCategory('');
+ setIsCustom(false);
+ };
+
+ return (
+
+
+
+ Bulk Edit Category
+
+ Update category for {count} selected expense{count !== 1 ? 's' : ''}.
+
+
+
+
+ Select Category
+ {
+ if (val === "custom") { setIsCustom(true); setCategory(""); }
+ else { setIsCustom(false); setCategory(val); }
+ }}
+ >
+
+
+ {existingCategories.map((cat) => (
+ {cat}
+ ))}
+ Create New Category...
+
+
+
+
+ {isCustom && (
+
+ New Category Name
+ setCustomCategory(e.target.value)} placeholder="Enter category name" autoFocus />
+
+ )}
+
+
+ onOpenChange(false)}>Cancel
+
+ {isLoading && }
+ Update Expenses
+
+
+
+
+
+ );
+}
diff --git a/src/components/BulkOwnerUpdateTagDialog.jsx b/src/components/BulkOwnerUpdateTagDialog.jsx
new file mode 100644
index 0000000..fecc5d1
--- /dev/null
+++ b/src/components/BulkOwnerUpdateTagDialog.jsx
@@ -0,0 +1,201 @@
+import React, { useState, useEffect } from 'react';
+import { supabase } from '@/integrations/supabase/client';
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from '@/components/ui/dialog';
+import { Button } from '@/components/ui/button';
+import { Badge } from '@/components/ui/badge';
+import { Checkbox } from '@/components/ui/checkbox';
+import { ScrollArea } from '@/components/ui/scroll-area';
+import { Label } from '@/components/ui/label';
+import { useToast } from '@/hooks/use-toast';
+import { Loader2, Tag, Search, X } from 'lucide-react';
+import { Input } from '@/components/ui/input';
+import { format } from 'date-fns';
+import { cn } from '@/lib/utils';
+
+export function BulkOwnerUpdateTagDialog({ open, onOpenChange, clientId, onSuccess }) {
+ const [updates, setUpdates] = useState([]);
+ const [availableTags, setAvailableTags] = useState([]);
+ const [selectedUpdates, setSelectedUpdates] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [processing, setProcessing] = useState(false);
+ const [searchQuery, setSearchQuery] = useState('');
+ const [tagsToAdd, setTagsToAdd] = useState([]);
+ const [tagsToRemove, setTagsToRemove] = useState([]);
+ const { toast } = useToast();
+
+ useEffect(() => {
+ if (open && clientId) {
+ loadData();
+ setSelectedUpdates([]);
+ setTagsToAdd([]);
+ setTagsToRemove([]);
+ setSearchQuery('');
+ }
+ }, [open, clientId]);
+
+ const loadData = async () => {
+ setLoading(true);
+ try {
+ const { data: tagsData, error: tagsError } = await supabase
+ .from('owner_update_tags')
+ .select('*')
+ .eq('client_id', clientId)
+ .order('name');
+ if (tagsError) throw tagsError;
+ setAvailableTags(tagsData || []);
+
+ // Fetch recent updates
+ setUpdates([]);
+ } catch (error) {
+ console.error('Error loading data:', error);
+ toast({ variant: 'destructive', title: 'Error', description: 'Failed to load data.' });
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleSelectAll = () => {
+ if (selectedUpdates.length === filteredUpdates.length) {
+ setSelectedUpdates([]);
+ } else {
+ setSelectedUpdates(filteredUpdates.map(u => u.id));
+ }
+ };
+
+ const handleSelectUpdate = (id) => {
+ setSelectedUpdates(prev => prev.includes(id) ? prev.filter(uid => uid !== id) : [...prev, id]);
+ };
+
+ const toggleTagToAdd = (tag) => {
+ if (tagsToRemove.find(t => t.id === tag.id)) setTagsToRemove(prev => prev.filter(t => t.id !== tag.id));
+ if (tagsToAdd.find(t => t.id === tag.id)) setTagsToAdd(prev => prev.filter(t => t.id !== tag.id));
+ else setTagsToAdd(prev => [...prev, tag]);
+ };
+
+ const toggleTagToRemove = (tag) => {
+ if (tagsToAdd.find(t => t.id === tag.id)) setTagsToAdd(prev => prev.filter(t => t.id !== tag.id));
+ if (tagsToRemove.find(t => t.id === tag.id)) setTagsToRemove(prev => prev.filter(t => t.id !== tag.id));
+ else setTagsToRemove(prev => [...prev, tag]);
+ };
+
+ const handleSubmit = async () => {
+ if (selectedUpdates.length === 0) return;
+ if (tagsToAdd.length === 0 && tagsToRemove.length === 0) { onOpenChange(false); return; }
+
+ setProcessing(true);
+ try {
+ const updatesToProcess = updates.filter(u => selectedUpdates.includes(u.id));
+ const promises = updatesToProcess.map(async (update) => {
+ let currentTags = Array.isArray(update.tags) ? [...update.tags] : [];
+ if (tagsToRemove.length > 0) {
+ const removeIds = tagsToRemove.map(t => t.id);
+ currentTags = currentTags.filter(t => !removeIds.includes(t.id));
+ }
+ if (tagsToAdd.length > 0) {
+ tagsToAdd.forEach(tag => {
+ if (!currentTags.some(t => t.id === tag.id)) currentTags.push(tag);
+ });
+ }
+ return supabase.from('owner_updates').update({ tags: currentTags }).eq('id', update.id);
+ });
+ await Promise.all(promises);
+ toast({ title: "Bulk Update Complete", description: `Updated tags for ${selectedUpdates.length} items.` });
+ if (onSuccess) onSuccess();
+ onOpenChange(false);
+ } catch (error) {
+ console.error("Bulk update error:", error);
+ toast({ variant: "destructive", title: "Error", description: "Failed to process bulk updates." });
+ } finally {
+ setProcessing(false);
+ }
+ };
+
+ const filteredUpdates = updates.filter(u =>
+ u.content?.toLowerCase().includes(searchQuery.toLowerCase())
+ );
+
+ return (
+
+
+
+ Bulk Tag Editor
+ Select updates to apply or remove tags in bulk.
+
+
+
+
+
+
Add Tags
+
+ {availableTags.map(tag => (
+ t.id === tag.id) ? "default" : "outline"}
+ className={cn("cursor-pointer transition-all", tagsToAdd.some(t => t.id === tag.id) ? "bg-green-600 hover:bg-green-700 border-green-600 text-white" : "hover:bg-green-50")}
+ onClick={() => toggleTagToAdd(tag)}>
+ {tagsToAdd.some(t => t.id === tag.id) && }{tag.name}
+
+ ))}
+ {availableTags.length === 0 && No tags available. }
+
+
+
+
Remove Tags
+
+ {availableTags.map(tag => (
+ t.id === tag.id) ? "default" : "outline"}
+ className={cn("cursor-pointer transition-all", tagsToRemove.some(t => t.id === tag.id) ? "bg-red-600 hover:bg-red-700 border-red-600 text-white" : "hover:bg-red-50")}
+ onClick={() => toggleTagToRemove(tag)}>
+ {tagsToRemove.some(t => t.id === tag.id) && }{tag.name}
+
+ ))}
+ {availableTags.length === 0 && No tags available. }
+
+
+
+
+
+
+
+
+ setSearchQuery(e.target.value)} />
+
+
{selectedUpdates.length} selected
+
+
+
+ 0 && selectedUpdates.length === filteredUpdates.length} onCheckedChange={handleSelectAll} />
+ Select All Visible
+
+
+
+ {loading ? (
+
+ ) : (
+
+ {filteredUpdates.length === 0 ? (
+
No updates found.
+ ) : (
+ filteredUpdates.map(update => (
+
handleSelectUpdate(update.id)}>
+
handleSelectUpdate(update.id)} className="mt-1" />
+
+
+ ))
+ )}
+
+ )}
+
+
+
+
+
+ onOpenChange(false)}>Cancel
+
+ {processing ? (<> Updating...>) : `Update ${selectedUpdates.length} Items`}
+
+
+
+
+ );
+}
diff --git a/src/components/BulkProxyTextDialog.jsx b/src/components/BulkProxyTextDialog.jsx
new file mode 100644
index 0000000..4607d89
--- /dev/null
+++ b/src/components/BulkProxyTextDialog.jsx
@@ -0,0 +1,24 @@
+import React from 'react';
+import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
+import { Button } from '@/components/ui/button';
+
+export default function BulkProxyTextDialog({ open, onOpenChange }) {
+ const handleClose = () => { onOpenChange(false); };
+
+ return (
+
+
+
+ Manage Proxy Text
+ This feature is currently unavailable.
+
+
+
The proxy text configuration is not supported in the current database schema.
+
+
+ Close
+
+
+
+ );
+}
diff --git a/src/components/BulkViolationUpdateDialog.jsx b/src/components/BulkViolationUpdateDialog.jsx
new file mode 100644
index 0000000..ed60c4b
--- /dev/null
+++ b/src/components/BulkViolationUpdateDialog.jsx
@@ -0,0 +1,160 @@
+import React, { useState, useEffect } from 'react';
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
+import { Button } from '@/components/ui/button';
+import { Label } from '@/components/ui/label';
+import { Input } from '@/components/ui/input';
+import { Textarea } from '@/components/ui/textarea';
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
+import { Loader2, Calendar as CalendarIcon, StickyNote } from 'lucide-react';
+import { supabase } from '@/integrations/supabase/client';
+import { useAuth } from '@/contexts/AuthContext';
+import { logBulkUpdate, logStatusChange, logStageChange } from '@/lib/violationTimelineLogger';
+
+export function BulkViolationUpdateDialog({ open, onOpenChange, selectedIds = [], onSuccess }) {
+ const { user } = useAuth();
+ const [loading, setLoading] = useState(false);
+ const [updates, setUpdates] = useState({
+ status: null, stage: null, priority: null, assigned_to: null,
+ violation_date: '', due_date: '', notes: ''
+ });
+
+ const handleSubmit = async () => {
+ setLoading(true);
+ try {
+ const updatePayload = {};
+ const timestamp = new Date().toISOString();
+
+ if (updates.status && updates.status !== 'no_change') {
+ // If status is "fined", mark as "closed" instead
+ updatePayload.status = updates.status.toLowerCase() === 'fined' ? 'closed' : updates.status;
+ }
+ if (updates.priority && updates.priority !== 'no_change') updatePayload.priority = updates.priority;
+ if (updates.assigned_to && updates.assigned_to !== 'no_change') updatePayload.assigned_to = updates.assigned_to;
+ if (updates.violation_date) updatePayload.violation_date = updates.violation_date;
+ if (updates.due_date) updatePayload.due_date = updates.due_date;
+ if (updates.notes && updates.notes.trim() !== '') updatePayload.notes = updates.notes;
+ if (updates.stage && updates.stage !== 'no_change') {
+ updatePayload.stage = updates.stage;
+ }
+
+ if (Object.keys(updatePayload).length === 0) { onOpenChange(false); return; }
+
+ // Fetch current state of all selected violations for change tracking
+ const { data: currentViolations } = await supabase.from('violations').select('id, status, stage, notice_level').in('id', selectedIds);
+
+ const { error } = await supabase.from('violations').update(updatePayload).in('id', selectedIds);
+ if (error) throw error;
+
+ // Auto-log timeline for each affected violation
+ const logPromises = selectedIds.map(async (id) => {
+ const current = currentViolations?.find(v => v.id === id);
+ await logBulkUpdate(id, updatePayload);
+ if (updatePayload.status && current && current.status !== updatePayload.status) {
+ await logStatusChange(id, current.status, updatePayload.status);
+ }
+ if (updatePayload.stage && current && (current.stage || current.notice_level) !== updatePayload.stage) {
+ await logStageChange(id, current.stage || current.notice_level, updatePayload.stage);
+ }
+ });
+ await Promise.allSettled(logPromises);
+
+ onSuccess(selectedIds.length);
+ onOpenChange(false);
+ setUpdates({ status: null, stage: null, priority: null, assigned_to: null, violation_date: '', due_date: '', notes: '' });
+ } catch (error) {
+ console.error('Bulk update error:', error);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+
+
+
+ Bulk Update Violations
+
+ Updating {selectedIds.length} selected violation(s).
+
+
+
+
+
+
+
+
Other Properties
+
+ Status
+ setUpdates(prev => ({ ...prev, status: val }))}>
+
+
+ No Change
+ Open
+ Resolved
+ Recommended for Fining
+ Fined
+ Closed
+
+
+
+
+
+ Stage
+ setUpdates(prev => ({ ...prev, stage: val }))}>
+
+
+ No Change
+ First Notice
+ Second Notice
+ Third & Final Notice
+
+
+
+
+
+ Priority
+ setUpdates(prev => ({ ...prev, priority: val }))}>
+
+
+ No Change
+ Low
+ Medium
+ High
+
+
+
+
+
+
+ Optional Notes (Overwrites existing)
+
+ setUpdates(prev => ({ ...prev, notes: e.target.value }))} placeholder="Add a note to all selected violations..." className="h-20 text-xs" />
+
+
+
+
+
+ onOpenChange(false)}>Cancel
+
+ {loading ? : null}
+ Confirm Update
+
+
+
+
+ );
+}
diff --git a/src/components/CalendarBlockedDateDialog.jsx b/src/components/CalendarBlockedDateDialog.jsx
new file mode 100644
index 0000000..c7fa6a2
--- /dev/null
+++ b/src/components/CalendarBlockedDateDialog.jsx
@@ -0,0 +1,180 @@
+import React, { useState, useEffect } from 'react';
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Textarea } from "@/components/ui/textarea";
+import { Switch } from "@/components/ui/switch";
+import { useToast } from "@/hooks/use-toast";
+import { useAuth } from '@/contexts/AuthContext';
+import { Lock, Loader2, Trash2, Clock } from 'lucide-react';
+import { format } from 'date-fns';
+import { supabase } from '@/integrations/supabase/client';
+
+export default function CalendarBlockedDateDialog({
+ open,
+ onOpenChange,
+ eventToEdit,
+ onSuccess
+}) {
+ const { user } = useAuth();
+ const { toast } = useToast();
+ const [loading, setLoading] = useState(false);
+
+ const [formData, setFormData] = useState({
+ title: 'Blocked Date',
+ description: '',
+ date: format(new Date(), 'yyyy-MM-dd'),
+ allDay: true,
+ startTime: '09:00',
+ endTime: '17:00',
+ });
+
+ useEffect(() => {
+ if (open) {
+ if (eventToEdit) {
+ setFormData({
+ title: eventToEdit.title || 'Blocked Date',
+ description: eventToEdit.reason || eventToEdit.description || '',
+ date: eventToEdit.start_date || format(new Date(), 'yyyy-MM-dd'),
+ allDay: true,
+ startTime: '09:00',
+ endTime: '17:00',
+ });
+ } else {
+ setFormData({
+ title: 'Blocked Date',
+ description: '',
+ date: format(new Date(), 'yyyy-MM-dd'),
+ allDay: true,
+ startTime: '09:00',
+ endTime: '17:00',
+ });
+ }
+ }
+ }, [open, eventToEdit]);
+
+ const handleSubmit = async (e) => {
+ e.preventDefault();
+ setLoading(true);
+
+ try {
+ if (!formData.date) throw new Error("Date is required");
+
+ if (eventToEdit) {
+ const { error } = await supabase.from('blocked_dates').update({
+ title: formData.title,
+ reason: formData.description,
+ start_date: formData.date,
+ end_date: formData.date,
+ }).eq('id', eventToEdit.id);
+ if (error) throw error;
+ } else {
+ // Need an association_id - we'll require it or use a default
+ const { data: assocs } = await supabase.from('associations').select('id').eq('status', 'active').limit(1);
+ const assocId = assocs?.[0]?.id;
+ if (!assocId) throw new Error("No association found");
+
+ const { error } = await supabase.from('blocked_dates').insert({
+ title: formData.title,
+ reason: formData.description,
+ start_date: formData.date,
+ end_date: formData.date,
+ association_id: assocId,
+ created_by: user?.id,
+ });
+ if (error) throw error;
+ }
+
+ toast({ title: 'Date Blocked', description: `Date ${formData.date} has been blocked.` });
+ if (onSuccess) onSuccess();
+ onOpenChange(false);
+
+ } catch (error) {
+ console.error("Error saving blocked date:", error);
+ toast({ variant: "destructive", title: "Error", description: error.message || "Failed to save blocked date" });
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleUnblock = async () => {
+ if (!eventToEdit) return;
+ setLoading(true);
+ try {
+ const { error } = await supabase.from('blocked_dates').delete().eq('id', eventToEdit.id);
+ if (error) throw error;
+ toast({ title: "Date Unblocked", description: "The blocked event has been removed." });
+ if (onSuccess) onSuccess();
+ onOpenChange(false);
+ } catch (error) {
+ toast({ variant: "destructive", title: "Error", description: error.message });
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+
+
+
+
+
+ {eventToEdit ? 'Edit Blocked Date' : 'Block a Date'}
+
+ Blocking a date prevents scheduling on this day.
+
+
+
+
+ Date to Block
+ setFormData({ ...formData, date: e.target.value })} required />
+
+
+
+ setFormData({ ...formData, allDay: checked })} />
+ Block Entire Day
+
+
+ {!formData.allDay && (
+
+ )}
+
+
+ Label
+ setFormData({ ...formData, title: e.target.value })} placeholder="e.g. Holiday, Closed" />
+
+
+
+ Internal Note (Optional)
+ setFormData({ ...formData, description: e.target.value })} placeholder="Why is this blocked?" rows={3} />
+
+
+
+ {eventToEdit ? (
+
+ Unblock
+
+ ) :
}
+
+ onOpenChange(false)}>Cancel
+
+ {loading && }
+ {eventToEdit ? 'Update Block' : 'Block Date'}
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/CalendarEventDialog.jsx b/src/components/CalendarEventDialog.jsx
new file mode 100644
index 0000000..a863ef0
--- /dev/null
+++ b/src/components/CalendarEventDialog.jsx
@@ -0,0 +1,270 @@
+import React, { useState, useEffect } from 'react';
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
+import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Textarea } from "@/components/ui/textarea";
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
+import { Switch } from "@/components/ui/switch";
+import { Checkbox } from "@/components/ui/checkbox";
+import { Badge } from "@/components/ui/badge";
+import { useToast } from "@/hooks/use-toast";
+import { supabase } from '@/integrations/supabase/client';
+import { useAuth } from '@/contexts/AuthContext';
+import { format } from 'date-fns';
+import { Loader2, Trash2, Lock, Search, Users, Globe, Clock } from 'lucide-react';
+import { cn } from '@/lib/utils';
+
+const isValidUUID = (uuid) => {
+ const regex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
+ return typeof uuid === 'string' && regex.test(uuid);
+};
+
+export default function CalendarEventDialog({
+ open,
+ onOpenChange,
+ selectedDate,
+ eventToEdit,
+ event,
+ onSuccess
+}) {
+ const { user, isAdmin } = useAuth();
+ const { toast } = useToast();
+
+ const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
+ const [associations, setAssociations] = useState([]);
+ const [searchTerm, setSearchTerm] = useState('');
+ const [loading, setLoading] = useState(false);
+
+ const activeEvent = eventToEdit || event;
+
+ const [formData, setFormData] = useState({
+ title: '',
+ description: '',
+ date: '',
+ startTime: '09:00',
+ endTime: '10:00',
+ type: 'meeting',
+ allDay: false,
+ associationId: '',
+ });
+
+ useEffect(() => {
+ if (isAdmin && open) {
+ const fetchAssociations = async () => {
+ const { data, error } = await supabase.from('associations').select('id, name').eq('status', 'active').order('name');
+ if (!error && data) setAssociations(data || []);
+ };
+ fetchAssociations();
+ }
+ }, [isAdmin, open]);
+
+ useEffect(() => {
+ if (open) {
+ if (activeEvent) {
+ const startDate = activeEvent.start_date ? new Date(activeEvent.start_date) : new Date();
+ setFormData({
+ title: activeEvent.title || '',
+ description: activeEvent.description || '',
+ date: format(startDate, 'yyyy-MM-dd'),
+ startTime: '09:00',
+ endTime: '10:00',
+ type: activeEvent.event_type || 'meeting',
+ allDay: activeEvent.all_day || false,
+ associationId: activeEvent.association_id || '',
+ });
+ } else {
+ const initialDate = selectedDate ? new Date(selectedDate) : new Date();
+ setFormData({
+ title: '',
+ description: '',
+ date: format(initialDate, 'yyyy-MM-dd'),
+ startTime: '09:00',
+ endTime: '10:00',
+ type: 'meeting',
+ allDay: false,
+ associationId: '',
+ });
+ }
+ }
+ }, [open, selectedDate, activeEvent, isAdmin]);
+
+ const handleSubmit = async (e) => {
+ e.preventDefault();
+ setLoading(true);
+
+ try {
+ if (!formData.date) throw new Error("Date is required.");
+ if (!formData.title) throw new Error("Title is required.");
+
+ const assocId = formData.associationId;
+ if (!assocId) throw new Error("Please select an association.");
+
+ let startDate = formData.date;
+ let endDate = formData.date;
+
+ const eventData = {
+ title: formData.title,
+ description: formData.description,
+ start_date: startDate,
+ end_date: endDate,
+ event_type: formData.type,
+ all_day: formData.allDay,
+ association_id: assocId,
+ created_by: user?.id,
+ };
+
+ if (activeEvent) {
+ const { error } = await supabase.from('calendar_events').update(eventData).eq('id', activeEvent.id);
+ if (error) throw error;
+ } else {
+ const { error } = await supabase.from('calendar_events').insert(eventData);
+ if (error) throw error;
+ }
+
+ toast({
+ title: activeEvent ? 'Event Updated' : 'Event Created',
+ description: `Event successfully ${activeEvent ? 'updated' : 'created'}.`,
+ });
+
+ onSuccess?.();
+ onOpenChange(false);
+
+ } catch (error) {
+ console.error("Error in CalendarEventDialog:", error);
+ toast({ variant: 'destructive', title: 'Error', description: error.message || "Failed to save event" });
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleDelete = async () => {
+ if (!activeEvent) return;
+ setLoading(true);
+ try {
+ const { error } = await supabase.from('calendar_events').delete().eq('id', activeEvent.id);
+ if (error) throw error;
+ toast({ title: "Event deleted" });
+ onSuccess?.();
+ onOpenChange(false);
+ } catch (err) {
+ toast({ variant: "destructive", title: "Error", description: "Failed to delete event" });
+ } finally {
+ setShowDeleteConfirm(false);
+ setLoading(false);
+ }
+ };
+
+ const filteredAssociations = associations.filter(c =>
+ c.name.toLowerCase().includes(searchTerm.toLowerCase())
+ );
+
+ return (
+ <>
+
+
+
+
+ {activeEvent ? 'Edit Event' : 'New Event'}
+
+
+
+
+
+ Association
+ setFormData({...formData, associationId: val})}>
+
+
+ {associations.map(c => (
+ {c.name}
+ ))}
+
+
+
+
+
+ Event Title
+ setFormData({ ...formData, title: e.target.value })} placeholder="e.g. Annual Meeting" required />
+
+
+
+
+ Date
+ setFormData({ ...formData, date: e.target.value })} required />
+
+
+ Type
+ setFormData({...formData, type: val})}>
+
+
+ Meeting
+ Deadline
+ Reminder
+ Inspection
+ Other
+
+
+
+
+
+
+ setFormData({ ...formData, allDay: checked })} />
+ All Day Event
+
+
+ {!formData.allDay && (
+
+ )}
+
+
+ Description
+ setFormData({ ...formData, description: e.target.value })} placeholder="Add details..." rows={3} />
+
+
+
+
+ {activeEvent && (
+ setShowDeleteConfirm(true)} size="sm">
+ Delete
+
+ )}
+
+
+ onOpenChange(false)}>Close
+
+ {loading ? : null}
+ {activeEvent ? 'Save Changes' : 'Create Event'}
+
+
+
+
+
+
+
+
+
+
+ Delete Event
+ Are you sure you want to delete this event? This action cannot be undone.
+
+
+
Cancel
+
+ {loading ? : null} Delete
+
+
+
+
+ >
+ );
+}
diff --git a/src/components/CallLogDialog.tsx b/src/components/CallLogDialog.tsx
new file mode 100644
index 0000000..8931ade
--- /dev/null
+++ b/src/components/CallLogDialog.tsx
@@ -0,0 +1,212 @@
+import { useState, useEffect } from "react";
+import { supabase } from "@/integrations/supabase/client";
+import { useToast } from "@/hooks/use-toast";
+import { useAuth } from "@/contexts/AuthContext";
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
+import { Label } from "@/components/ui/label";
+import { Input } from "@/components/ui/input";
+import { Textarea } from "@/components/ui/textarea";
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
+import { Button } from "@/components/ui/button";
+import { Loader2 } from "lucide-react";
+
+const SPECIAL_OPTIONS = ["NON-CLIENT", "Inquiry", "General", "Vendor"];
+
+interface CallLogDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ associations?: { id: string; name: string }[];
+ callLog?: any;
+ onSaved?: () => void;
+ onSuccess?: () => void;
+}
+
+export function CallLogDialog({ open, onOpenChange, associations: propAssociations, callLog, onSaved, onSuccess }: CallLogDialogProps) {
+ const { toast } = useToast();
+ const { user } = useAuth();
+ const [associations, setAssociations] = useState<{ id: string; name: string }[]>(propAssociations || []);
+ const [loading, setLoading] = useState(false);
+ const [form, setForm] = useState({
+ association_id: "",
+ caller_name: "",
+ caller_phone: "",
+ call_type: "inbound",
+ status: "pending",
+ duration: "",
+ notes: "",
+ });
+
+ useEffect(() => {
+ if (open && !propAssociations) {
+ const fetchAssociations = async () => {
+ try {
+ const { data, error } = await supabase.from('associations').select('id, name').eq('status', 'active').order('name');
+ if (error) throw error;
+ setAssociations(data || []);
+ } catch (error) {
+ console.error('Error fetching associations:', error);
+ }
+ };
+ fetchAssociations();
+ } else if (propAssociations) {
+ setAssociations(propAssociations);
+ }
+ }, [open, propAssociations]);
+
+ useEffect(() => {
+ if (callLog) {
+ setForm({
+ association_id: callLog.association_id || '',
+ caller_name: callLog.caller_name || '',
+ caller_phone: callLog.caller_phone || '',
+ call_type: callLog.call_type || 'inbound',
+ status: callLog.status || 'pending',
+ duration: callLog.duration || '',
+ notes: callLog.notes || '',
+ });
+ } else {
+ setForm({
+ association_id: "",
+ caller_name: "",
+ caller_phone: "",
+ call_type: "inbound",
+ status: "pending",
+ duration: "",
+ notes: "",
+ });
+ }
+ }, [callLog, open]);
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ setLoading(true);
+
+ try {
+ if (!user) throw new Error('You must be logged in.');
+
+ let finalAssocId = form.association_id;
+ let finalNotes = form.notes;
+
+ if (SPECIAL_OPTIONS.includes(form.association_id)) {
+ finalAssocId = "";
+ finalNotes = `[${form.association_id}] ${form.notes}`;
+ }
+
+ // Need a valid association_id for the FK constraint
+ const assocId = finalAssocId || (associations.length ? associations[0].id : null);
+ if (!assocId || SPECIAL_OPTIONS.includes(form.association_id)) {
+ // For special options, still need an association
+ if (!associations.length) {
+ toast({ title: "Error", description: "No associations available", variant: "destructive" });
+ return;
+ }
+ }
+
+ const dataToSubmit: any = {
+ association_id: SPECIAL_OPTIONS.includes(form.association_id) ? associations[0]?.id : assocId,
+ caller_name: form.caller_name || "Unknown",
+ caller_phone: form.caller_phone || null,
+ call_type: form.call_type,
+ notes: finalNotes || null,
+ follow_up_required: form.status === "pending",
+ taken_by: user.id,
+ };
+
+ const { error } = callLog
+ ? await supabase.from("call_logs").update(dataToSubmit).eq("id", callLog.id)
+ : await supabase.from("call_logs").insert(dataToSubmit);
+
+ if (error) throw error;
+
+ toast({ title: callLog ? "Log Updated" : "Communication log created" });
+ setForm({ association_id: "", caller_name: "", caller_phone: "", call_type: "inbound", status: "pending", duration: "", notes: "" });
+ onOpenChange(false);
+ onSaved?.();
+ onSuccess?.();
+ } catch (error: any) {
+ console.error('CallLogDialog error:', error);
+ toast({ title: "Error", description: error.message, variant: "destructive" });
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+
+
+
+ {callLog ? 'Edit Communication Log' : 'Add New Communication Log'}
+
+ {callLog ? 'Update the details for this communication.' : 'Fill out the form to add a new communication record.'}
+
+
+
+
+ Client / Category *
+ setForm({ ...form, association_id: v })}>
+
+
+ {SPECIAL_OPTIONS.map((opt) => (
+ {opt}
+ ))}
+ {associations.map(a => {a.name} )}
+
+
+
+
+
+
+ Type *
+ setForm({ ...form, call_type: v })}>
+
+
+ Inbound
+ Outbound
+ Email
+ Voicemail
+
+
+
+
+ Status
+ setForm({ ...form, status: v })}>
+
+
+ Pending
+ Responded
+ Resolved
+
+
+
+
+ Duration (mins)
+ setForm({ ...form, duration: e.target.value })} />
+
+
+
+ Notes
+ setForm({ ...form, notes: e.target.value })} placeholder="Enter call notes..." rows={4} />
+
+
+ onOpenChange(false)}>Cancel
+
+ {loading && }
+ {loading ? 'Saving...' : callLog ? 'Update' : 'Create'}
+
+
+
+
+
+ );
+}
+
+export default CallLogDialog;
diff --git a/src/components/CallLogImportDialog.jsx b/src/components/CallLogImportDialog.jsx
new file mode 100644
index 0000000..e6257e2
--- /dev/null
+++ b/src/components/CallLogImportDialog.jsx
@@ -0,0 +1,193 @@
+import React, { useState, useRef } from 'react';
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
+import { Button } from '@/components/ui/button';
+import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
+import { Progress } from '@/components/ui/progress';
+import { ScrollArea } from '@/components/ui/scroll-area';
+import { Upload, FileText, AlertTriangle, CheckCircle, Shield, X, Download, Loader2 } from 'lucide-react';
+import { useAuth } from '@/contexts/AuthContext';
+import { useToast } from '@/hooks/use-toast';
+
+export function CallLogImportDialog({ open, onOpenChange, onSuccess }) {
+ const { user } = useAuth();
+ const { toast } = useToast();
+ const fileInputRef = useRef(null);
+
+ const [file, setFile] = useState(null);
+ const [importing, setImporting] = useState(false);
+ const [progress, setProgress] = useState(0);
+ const [validationResult, setValidationResult] = useState(null);
+ const [analyzing, setAnalyzing] = useState(false);
+
+ const resetValidation = () => {
+ setValidationResult(null);
+ };
+
+ const handleFileChange = (e) => {
+ const selectedFile = e.target.files[0];
+ if (selectedFile) {
+ if (selectedFile.size > 5 * 1024 * 1024) {
+ toast({ variant: "destructive", title: "File Too Large", description: "Max file size is 5MB." });
+ return;
+ }
+ setFile(selectedFile);
+ resetValidation();
+ }
+ };
+
+ const handleAnalyze = async () => {
+ if (!file) return;
+ setAnalyzing(true);
+ try {
+ // Placeholder: In production, use a real validation hook/service
+ setValidationResult({ valid: true, sanitizedRecords: [], errors: [] });
+ toast({ title: "Analysis Complete", description: "File validated successfully." });
+ } catch (err) {
+ toast({ variant: "destructive", title: "Analysis Failed", description: err.message });
+ } finally {
+ setAnalyzing(false);
+ }
+ };
+
+ const executeImport = async () => {
+ if (!validationResult || !validationResult.valid) return;
+
+ setImporting(true);
+ setProgress(10);
+
+ try {
+ // Placeholder: In production, use processCallLogImport
+ setProgress(100);
+ toast({ title: "Import Successful", description: `Imported ${validationResult.sanitizedRecords.length} logs.` });
+ if (onSuccess) onSuccess();
+ onOpenChange(false);
+ } catch (err) {
+ toast({ variant: "destructive", title: "Import Failed", description: err.message });
+ } finally {
+ setImporting(false);
+ setProgress(0);
+ }
+ };
+
+ const downloadTemplate = () => {
+ toast({ title: "Template", description: "Template download not yet configured." });
+ };
+
+ const handleClose = () => {
+ if (importing) return;
+ setFile(null);
+ resetValidation();
+ onOpenChange(false);
+ };
+
+ return (
+
+
+
+
+
+ Secure Call Log Import
+
+
+ Import call logs from CSV, Excel, or JSON. Data is validated for security and integrity.
+
+
+
+
+ {!file ? (
+
+
+
Upload Log File
+
CSV, Excel, JSON (max 5MB)
+
+ fileInputRef.current?.click()}>
+ Select File
+
+
+
+
+
+
+
+ ) : (
+
+
+
+
+ {file.name}
+
+
{ setFile(null); resetValidation(); }} disabled={importing || analyzing}>
+
+
+
+
+ {!validationResult && (
+
+ {analyzing ? : "Validate & Preview"}
+ {analyzing ? "Analyzing..." : ""}
+
+ )}
+
+ {validationResult && (
+
+ {validationResult.valid ? (
+
+
+ Validation Passed
+
+ Ready to import {validationResult.sanitizedRecords.length} records.
+
+
+ ) : (
+
+
+ Validation Failed
+
+ Found {validationResult.errors.length} issues. Import blocked.
+
+
+ )}
+
+ {!validationResult.valid && (
+
+ {validationResult.errors.map((err, i) => (
+
+ Row {err.row}: {err.messages.join(', ')}
+
+ ))}
+
+ )}
+
+ )}
+
+ {importing && (
+
+
+ Importing...
+ {progress}%
+
+
+
+ )}
+
+ )}
+
+
+
+ Close
+ {validationResult?.valid && (
+
+ {importing ? : "Confirm Import"}
+
+ )}
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/components/CallLogRestoreDialog.jsx b/src/components/CallLogRestoreDialog.jsx
new file mode 100644
index 0000000..5a629cc
--- /dev/null
+++ b/src/components/CallLogRestoreDialog.jsx
@@ -0,0 +1,149 @@
+import React, { useState, useEffect } from 'react';
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
+import { Button } from '@/components/ui/button';
+import { ScrollArea } from '@/components/ui/scroll-area';
+import { Loader2, AlertTriangle, CheckCircle, RotateCcw } from 'lucide-react';
+import { useAuth } from '@/contexts/AuthContext';
+import { useToast } from '@/hooks/use-toast';
+
+export function CallLogRestoreDialog({ open, onOpenChange, onSuccess }) {
+ const { user } = useAuth();
+ const { toast } = useToast();
+
+ const [step, setStep] = useState('analyze');
+ const [analysis, setAnalysis] = useState(null);
+ const [loading, setLoading] = useState(false);
+ const [result, setResult] = useState(null);
+
+ useEffect(() => {
+ if (open && step === 'analyze') {
+ performAnalysis();
+ }
+ }, [open]);
+
+ const performAnalysis = async () => {
+ setLoading(true);
+ try {
+ // Placeholder: In production, use fetchCallLogsForRestoration
+ setAnalysis({ count: 0, records: [], issues: [] });
+ setStep('review');
+ } catch (err) {
+ toast({ variant: "destructive", title: "Analysis Failed", description: err.message });
+ onOpenChange(false);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleRestore = async () => {
+ if (!analysis) return;
+ setStep('restoring');
+
+ try {
+ // Placeholder: In production, use restoreCallLogs
+ const res = { successCount: analysis.records.length, errors: [] };
+ setResult(res);
+ setStep('result');
+ if (onSuccess) onSuccess();
+ } catch (err) {
+ toast({ variant: "destructive", title: "Restoration Failed", description: err.message });
+ setStep('review');
+ }
+ };
+
+ const handleClose = () => {
+ setStep('analyze');
+ setAnalysis(null);
+ setResult(null);
+ onOpenChange(false);
+ };
+
+ return (
+
+
+
+
+
+ Call Log Restoration
+
+
+ Scan and verify integrity of recent call logs.
+
+
+
+
+ {step === 'analyze' && (
+
+
+
Scanning call logs...
+
+ )}
+
+ {step === 'review' && analysis && (
+
+
+
+
+
Review Findings
+
+ Found {analysis.count} recent call logs.
+ {analysis.issues.length > 0
+ ? ` Detected ${analysis.issues.length} potential integrity issues.`
+ : " No critical issues detected."}
+
+
+
+
+
+ Proceeding will attempt to correct any metadata issues and verify record consistency.
+
+
+ )}
+
+ {step === 'restoring' && (
+
+
+
Processing records...
+
+ )}
+
+ {step === 'result' && result && (
+
+
+
+
+
Process Complete
+
+ Successfully processed {result.successCount} records.
+
+
+
+
+ {result.errors.length > 0 && (
+
+ {result.errors.map((e, i) => (
+ Error with ID {e.id}: {e.message}
+ ))}
+
+ )}
+
+ )}
+
+
+
+ {step === 'review' && (
+ <>
+ Cancel
+
+ Proceed
+
+ >
+ )}
+ {step === 'result' && (
+ Close
+ )}
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/components/ChartOfAccountsDropdown.jsx b/src/components/ChartOfAccountsDropdown.jsx
new file mode 100644
index 0000000..7ad7846
--- /dev/null
+++ b/src/components/ChartOfAccountsDropdown.jsx
@@ -0,0 +1,118 @@
+import React, { useState, useEffect, useMemo } from 'react';
+import { supabase } from '@/integrations/supabase/client';
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
+import { cn } from '@/lib/utils';
+
+const NONE_VALUE = '__none__';
+const PAGE_SIZE = 1000;
+
+const normalizeType = (type) => String(type || '').trim().toLowerCase();
+
+const accountTypeMatches = (accountType, filterType) => {
+ if (!filterType) return true;
+ const normalizedAccountType = normalizeType(accountType).replace(/[\s-]+/g, '_');
+ const normalizedFilterType = normalizeType(filterType).replace(/[\s-]+/g, '_');
+ if (normalizedAccountType === normalizedFilterType) return true;
+ if (normalizedFilterType === 'expense') return normalizedAccountType.includes('expense') || normalizedAccountType === 'cost_of_goods_sold';
+ return false;
+};
+
+const isAssignedToAssociation = (account, associationId) => {
+ if (!associationId) return true;
+ const associationIds = Array.isArray(account.association_ids) ? account.association_ids : [];
+ return account.association_id === associationId || associationIds.includes(associationId);
+};
+
+const isUnassignedGlobalAccount = (account) => {
+ const associationIds = Array.isArray(account.association_ids) ? account.association_ids : [];
+ return !account.association_id && associationIds.length === 0;
+};
+
+export default function ChartOfAccountsDropdown({ value, onChange, className, placeholder = "Account", accountType = null, associationId = null, disabled = false }) {
+ const [accounts, setAccounts] = useState([]);
+
+ useEffect(() => {
+ const fetch = async () => {
+ // Resolve which accounting system this association uses
+ let system = 'buildium';
+ if (associationId) {
+ const { data: assoc } = await supabase
+ .from('associations')
+ .select('zoho_organization_id')
+ .eq('id', associationId)
+ .maybeSingle();
+ if (assoc?.zoho_organization_id && String(assoc.zoho_organization_id).trim() !== '') {
+ system = 'zoho';
+ }
+ }
+
+ const allAccounts = [];
+ for (let from = 0; ; from += PAGE_SIZE) {
+ const { data, error } = await supabase
+ .from('chart_of_accounts')
+ .select('id, account_name, account_number, account_type, parent_account_id, association_id, association_ids, accounting_system')
+ .eq('is_active', true)
+ .order('account_number', { ascending: true })
+ .range(from, from + PAGE_SIZE - 1);
+
+ if (error || !data) break;
+ allAccounts.push(...data);
+ if (data.length < PAGE_SIZE) break;
+ }
+
+ const filtered = allAccounts.filter((account) => {
+ const matchesType = accountTypeMatches(account.account_type, accountType);
+ const matchesSystem = normalizeType(account.accounting_system || 'buildium') === system;
+ const matchesAssociation = isAssignedToAssociation(account, associationId);
+
+ return matchesType && (matchesAssociation || (matchesSystem && isUnassignedGlobalAccount(account)));
+ });
+
+ setAccounts(filtered);
+ };
+ fetch();
+ }, [accountType, associationId]);
+
+ // Sort accounts hierarchically: parents first, then their children (by account_number)
+ const orderedAccounts = useMemo(() => {
+ const byId = new Map(accounts.map(a => [a.id, a]));
+ const childrenByParent = new Map();
+ const roots = [];
+ accounts.forEach(a => {
+ if (a.parent_account_id && byId.has(a.parent_account_id)) {
+ if (!childrenByParent.has(a.parent_account_id)) childrenByParent.set(a.parent_account_id, []);
+ childrenByParent.get(a.parent_account_id).push(a);
+ } else {
+ roots.push(a);
+ }
+ });
+ const sortFn = (a, b) => String(a.account_number || '').localeCompare(String(b.account_number || ''));
+ roots.sort(sortFn);
+ const out = [];
+ const visit = (node, depth) => {
+ out.push({ ...node, _depth: depth });
+ const kids = (childrenByParent.get(node.id) || []).sort(sortFn);
+ kids.forEach(k => visit(k, depth + 1));
+ };
+ roots.forEach(r => visit(r, 0));
+ return out;
+ }, [accounts]);
+
+ return (
+ onChange(nextValue === NONE_VALUE ? '' : nextValue)} disabled={disabled}>
+
+
+
+
+ None
+ {orderedAccounts.map(a => (
+
+
+ {a._depth > 0 ? '↳ ' : ''}{a.account_number} - {a.account_name}
+
+
+ ))}
+
+
+ );
+}
diff --git a/src/components/CheckLayoutEditor.tsx b/src/components/CheckLayoutEditor.tsx
new file mode 100644
index 0000000..54ad83b
--- /dev/null
+++ b/src/components/CheckLayoutEditor.tsx
@@ -0,0 +1,613 @@
+import { useEffect, useState } from "react";
+import { supabase } from "@/integrations/supabase/client";
+import { useToast } from "@/hooks/use-toast";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Label } from "@/components/ui/label";
+import { Input } from "@/components/ui/input";
+import { Textarea } from "@/components/ui/textarea";
+import { Switch } from "@/components/ui/switch";
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
+import { Loader2, Save, Printer, Upload, Trash2, Copy } from "lucide-react";
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@/components/ui/alert-dialog";
+import {
+ downloadChecksPdf,
+ type CheckLayout,
+ type CheckFieldKey,
+ type CheckFieldPosition,
+ DEFAULT_FIELD_POSITIONS,
+ FIELD_LABELS,
+ AVAILABLE_FONTS,
+} from "@/utils/checkPdfGenerator";
+
+interface Props {
+ associationId: string;
+ associationName?: string;
+ /** When 'company', reads/writes company_check_layouts (no association_id required, associationId may be any string identifier). */
+ mode?: "association" | "company";
+}
+
+const DEFAULTS: CheckLayout = {
+ check_position: "top",
+ offset_x: 0,
+ offset_y: 0,
+ show_payer_block: true,
+ show_logo: true,
+ payer_name: "",
+ payer_address: "",
+ show_signature_line: true,
+ signature_image_url: "",
+ signature_label: "Authorized Signature",
+ memo_prefix: "",
+ footer_text: "",
+ show_field_labels: false,
+ font_family: "helvetica",
+ field_positions: {},
+ logo_url: "",
+};
+
+const FIELD_ORDER: CheckFieldKey[] = [
+ "payer", "logo", "date", "check_number",
+ "payee_label", "payee", "payee_address",
+ "amount_box_label", "amount_box", "amount_words",
+ "memo_label", "memo",
+ "signature_line", "signature_image", "signature_label",
+ "footer", "micr",
+];
+
+export default function CheckLayoutEditor({ associationId, associationName, mode = "association" }: Props) {
+ const isCompany = mode === "company";
+ const tableName = isCompany ? "company_check_layouts" : "check_layouts";
+ const { toast } = useToast();
+ const [loading, setLoading] = useState(true);
+ const [saving, setSaving] = useState(false);
+ const [uploading, setUploading] = useState(false);
+ const [uploadingLogo, setUploadingLogo] = useState(false);
+ const [layoutId, setLayoutId] = useState(null);
+ const [layout, setLayout] = useState(DEFAULTS);
+ const [otherLayouts, setOtherLayouts] = useState>([]);
+ const [copyFromId, setCopyFromId] = useState("");
+ const [confirmCopyOpen, setConfirmCopyOpen] = useState(false);
+ const [copying, setCopying] = useState(false);
+
+ useEffect(() => {
+ let cancelled = false;
+ (async () => {
+ setLoading(true);
+ const query = supabase.from(tableName as any).select("*");
+ const { data } = isCompany
+ ? await query.maybeSingle()
+ : await query.eq("association_id", associationId).maybeSingle();
+ if (cancelled) return;
+ if (data) {
+ setLayoutId((data as any).id);
+ setLayout({
+ check_position: ((data as any).check_position as CheckLayout["check_position"]) || "top",
+ offset_x: Number((data as any).offset_x) || 0,
+ offset_y: Number((data as any).offset_y) || 0,
+ show_payer_block: (data as any).show_payer_block ?? true,
+ show_logo: (data as any).show_logo ?? true,
+ payer_name: (data as any).payer_name || "",
+ payer_address: (data as any).payer_address || "",
+ show_signature_line: (data as any).show_signature_line ?? true,
+ signature_image_url: (data as any).signature_image_url || "",
+ signature_label: (data as any).signature_label || "Authorized Signature",
+ memo_prefix: (data as any).memo_prefix || "",
+ footer_text: (data as any).footer_text || "",
+ show_field_labels: (data as any).show_field_labels ?? false,
+ font_family: (data as any).font_family || "helvetica",
+ field_positions: ((data as any).field_positions as CheckLayout["field_positions"]) || {},
+ logo_url: (data as any).logo_url || "",
+ });
+ } else {
+ setLayoutId(null);
+ setLayout({ ...DEFAULTS, payer_name: associationName || "" });
+ }
+ setLoading(false);
+ })();
+ return () => { cancelled = true; };
+ }, [associationId, associationName, tableName, isCompany]);
+
+ // Load other associations' layouts for "Copy from" (association mode only)
+ useEffect(() => {
+ if (isCompany) { setOtherLayouts([]); return; }
+ let cancelled = false;
+ (async () => {
+ const { data: layouts } = await supabase
+ .from("check_layouts")
+ .select("id, association_id")
+ .neq("association_id", associationId);
+ if (cancelled || !layouts || layouts.length === 0) {
+ if (!cancelled) setOtherLayouts([]);
+ return;
+ }
+ const ids = Array.from(new Set(layouts.map((l: any) => l.association_id)));
+ const { data: assocs } = await supabase
+ .from("associations")
+ .select("id, name")
+ .in("id", ids);
+ if (cancelled) return;
+ const nameMap = new Map((assocs || []).map((a: any) => [a.id, a.name]));
+ const merged = layouts
+ .map((l: any) => ({
+ id: l.id,
+ association_id: l.association_id,
+ association_name: nameMap.get(l.association_id) || "Unknown",
+ }))
+ .sort((a, b) => a.association_name.localeCompare(b.association_name));
+ setOtherLayouts(merged);
+ })();
+ return () => { cancelled = true; };
+ }, [associationId, isCompany]);
+
+ const handleCopyFrom = async () => {
+ if (!copyFromId) return;
+ setCopying(true);
+ try {
+ const { data, error } = await supabase
+ .from("check_layouts")
+ .select("*")
+ .eq("id", copyFromId)
+ .maybeSingle();
+ if (error) throw error;
+ if (!data) throw new Error("Source layout not found");
+ setLayout({
+ check_position: (data.check_position as CheckLayout["check_position"]) || "top",
+ offset_x: Number(data.offset_x) || 0,
+ offset_y: Number(data.offset_y) || 0,
+ show_payer_block: data.show_payer_block ?? true,
+ show_logo: data.show_logo ?? true,
+ // Keep this association's own payer name/address — only copy formatting/positions/images
+ payer_name: layout.payer_name || associationName || "",
+ payer_address: layout.payer_address || "",
+ show_signature_line: data.show_signature_line ?? true,
+ signature_image_url: data.signature_image_url || "",
+ signature_label: data.signature_label || "Authorized Signature",
+ memo_prefix: data.memo_prefix || "",
+ footer_text: data.footer_text || "",
+ show_field_labels: (data as any).show_field_labels ?? false,
+ font_family: (data as any).font_family || "helvetica",
+ field_positions: ((data as any).field_positions as CheckLayout["field_positions"]) || {},
+ logo_url: (data as any).logo_url || "",
+ });
+ toast({
+ title: "Settings copied",
+ description: "Review and click Save Layout to apply to this association.",
+ });
+ setConfirmCopyOpen(false);
+ } catch (err: any) {
+ toast({ variant: "destructive", title: "Copy failed", description: err.message });
+ } finally {
+ setCopying(false);
+ }
+ };
+
+ const update = (key: K, value: CheckLayout[K]) => {
+ setLayout((prev) => ({ ...prev, [key]: value }));
+ };
+
+ const updateField = (key: CheckFieldKey, patch: Partial) => {
+ setLayout((prev) => {
+ const current = (prev.field_positions || {})[key] || {};
+ return {
+ ...prev,
+ field_positions: { ...(prev.field_positions || {}), [key]: { ...current, ...patch } },
+ };
+ });
+ };
+
+ const resetField = (key: CheckFieldKey) => {
+ setLayout((prev) => {
+ const next = { ...(prev.field_positions || {}) };
+ delete next[key];
+ return { ...prev, field_positions: next };
+ });
+ };
+
+ const handleImageUpload = async (
+ file: File,
+ bucket: "check-signatures" | "check-logos",
+ target: "signature_image_url" | "logo_url"
+ ) => {
+ const setter = target === "signature_image_url" ? setUploading : setUploadingLogo;
+ setter(true);
+ try {
+ const ext = file.name.split(".").pop() || "png";
+ const path = `${associationId}/${Date.now()}.${ext}`;
+ const { error: upErr } = await supabase.storage.from(bucket).upload(path, file, { upsert: true });
+ if (upErr) throw upErr;
+ const { data } = supabase.storage.from(bucket).getPublicUrl(path);
+ update(target, data.publicUrl);
+ toast({ title: target === "logo_url" ? "Logo uploaded" : "Signature uploaded" });
+ } catch (err: any) {
+ toast({ variant: "destructive", title: "Upload failed", description: err.message });
+ } finally {
+ setter(false);
+ }
+ };
+
+ const handleSave = async () => {
+ setSaving(true);
+ try {
+ const basePayload = {
+ check_position: layout.check_position || "top",
+ offset_x: Number(layout.offset_x) || 0,
+ offset_y: Number(layout.offset_y) || 0,
+ show_payer_block: !!layout.show_payer_block,
+ show_logo: !!layout.show_logo,
+ payer_name: layout.payer_name || null,
+ payer_address: layout.payer_address || null,
+ show_signature_line: !!layout.show_signature_line,
+ signature_image_url: layout.signature_image_url || null,
+ signature_label: layout.signature_label || null,
+ memo_prefix: layout.memo_prefix || null,
+ footer_text: layout.footer_text || null,
+ show_field_labels: !!layout.show_field_labels,
+ font_family: layout.font_family || "helvetica",
+ field_positions: (layout.field_positions || {}) as any,
+ logo_url: layout.logo_url || null,
+ };
+ const payload: any = isCompany
+ ? basePayload
+ : { ...basePayload, association_id: associationId };
+
+ if (layoutId) {
+ const { error } = await supabase.from(tableName as any).update(payload).eq("id", layoutId);
+ if (error) throw error;
+ } else {
+ const { data, error } = await supabase.from(tableName as any).insert(payload).select("id").single();
+ if (error) throw error;
+ setLayoutId((data as any).id);
+ }
+ toast({ title: "Layout saved", description: isCompany ? "Future company checks will use this layout." : "Future checks for this association will use this layout." });
+ } catch (err: any) {
+ toast({ variant: "destructive", title: "Save failed", description: err.message });
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ const handlePreview = async () => {
+ await downloadChecksPdf(
+ [{
+ check_number: "1001",
+ check_date: new Date().toISOString().slice(0, 10),
+ payee: "Sample Vendor, Inc.",
+ amount: 1234.56,
+ memo: "Sample invoice #INV-001",
+ bank_account_name: "Operating Account",
+ bank_routing_number: "123456789",
+ bank_account_number: "9876543210",
+ association_name: associationName,
+ layout,
+ }],
+ `check-preview-${associationId}.pdf`,
+ { includeMicr: false }
+ );
+ };
+
+ if (loading) {
+ return
;
+ }
+
+ return (
+
+
+
+
Check Layout
+
+ Customize how printed checks look for this association. Applied automatically when printing checks.
+
+
+
+ {otherLayouts.length > 0 && (
+
+
+
+
+
+
+ {otherLayouts.map((l) => (
+ {l.association_name}
+ ))}
+
+
+ setConfirmCopyOpen(true)}
+ className="gap-2"
+ >
+ Copy
+
+
+ )}
+
+ Preview PDF
+
+
+ {saving ? : }
+ {saving ? "Saving…" : "Save Layout"}
+
+
+
+
+
+
+
+ Copy check layout settings?
+
+ This will replace the current layout, fonts, positions, logo, and signature with the
+ selected association's settings. Your payer name and address will be kept. Nothing is
+ saved until you click "Save Layout".
+
+
+
+ Cancel
+
+ {copying ? "Copying…" : "Copy settings"}
+
+
+
+
+
+
+ Page & Typography
+
+
+
Check Position
+
update("check_position", v as CheckLayout["check_position"])}
+ >
+
+
+ Top of page
+ Middle of page
+ Bottom of page
+
+
+
Match your check stock layout.
+
+
+ Font Family
+ update("font_family", v)}
+ >
+
+
+ {AVAILABLE_FONTS.map(f => (
+ {f.label}
+ ))}
+
+
+
+
+
+ update("show_field_labels", v)}
+ />
+ Print field labels (Payee, Memo, Date…)
+
+
+
+ Global Horizontal offset (in)
+ update("offset_x", Number(e.target.value))} />
+
+
+ Global Vertical offset (in)
+ update("offset_y", Number(e.target.value))} />
+
+
+
+
+
+ Logo
+
+
+ update("show_logo", v)} />
+ Print logo on the check
+
+ {layout.logo_url ? (
+
+
+
update("logo_url", "")} className="gap-1 text-destructive">
+ Remove
+
+
+ ) : (
+
+ {uploadingLogo ? : }
+ {uploadingLogo ? "Uploading…" : "Upload logo (PNG)"}
+ {
+ const f = e.target.files?.[0];
+ if (f) handleImageUpload(f, "check-logos", "logo_url");
+ }} />
+
+ )}
+
+ Adjust logo size and position in the per-field editor below (under "Logo").
+
+
+
+
+
+ Payer Block
+
+
+ update("show_payer_block", v)} />
+ Show payer name & address
+
+
+ Payer Name
+ update("payer_name", e.target.value)}
+ placeholder={associationName || "Association Name"} />
+
+
+ Payer Address
+ update("payer_address", e.target.value)}
+ placeholder={"123 Main Street\nCity, ST 12345"} />
+
+
+
+
+
+ Signature
+
+
+ update("show_signature_line", v)} />
+ Show signature line
+
+
+ Signature Label
+ update("signature_label", e.target.value)}
+ placeholder="Authorized Signature" />
+
+
+
Signature Image (optional)
+
+ Upload a PNG of the signature. Adjust its position and size freely in the per-field editor below (under "Signature Image") — no constraints.
+
+ {layout.signature_image_url ? (
+
+
+
update("signature_image_url", "")} className="gap-1 text-destructive">
+ Remove
+
+
+ ) : (
+
+ {uploading ? : }
+ {uploading ? "Uploading…" : "Upload signature image"}
+ {
+ const f = e.target.files?.[0];
+ if (f) handleImageUpload(f, "check-signatures", "signature_image_url");
+ }} />
+
+ )}
+
+
+
+
+
+ Memo & Footer
+
+
+ Memo Prefix
+ update("memo_prefix", e.target.value)}
+ placeholder="e.g. HOA Dues —" />
+
+
+ Footer Text
+ update("footer_text", e.target.value)}
+ placeholder="e.g. Void after 90 days" />
+
+
+
+
+
+
+ Per-Field Position, Size & Labels
+
+ Adjust X/Y in inches from the check's top-left corner. Set font size in points.
+ For Payee/Memo/Date/etc., the printed label shows when "Print field labels" is on above.
+
+
+
+
+
Field
+
Show
+
X (in)
+
Y (in)
+
Size
+
Label
+
+
+ {FIELD_ORDER.map((key) => {
+ const def = DEFAULT_FIELD_POSITIONS[key];
+ const cur = (layout.field_positions || {})[key] || {};
+ const isImage = key === "logo" || key === "signature_line" || key === "signature_image";
+ return (
+
+ );
+ })}
+
+
+
+ );
+}
diff --git a/src/components/ChecklistCSVImportDialog.jsx b/src/components/ChecklistCSVImportDialog.jsx
new file mode 100644
index 0000000..b4f3fb7
--- /dev/null
+++ b/src/components/ChecklistCSVImportDialog.jsx
@@ -0,0 +1,277 @@
+import React, { useState, useRef } from 'react';
+import Papa from 'papaparse';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+ DialogFooter,
+} from '@/components/ui/dialog';
+import { Button } from '@/components/ui/button';
+import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
+import { ScrollArea } from '@/components/ui/scroll-area';
+import { HelpCircle, Upload, AlertTriangle, CheckCircle, Loader2, X } from 'lucide-react';
+import { supabase } from '@/integrations/supabase/client';
+import { useAuth } from '@/contexts/AuthContext';
+import { useToast } from '@/hooks/use-toast';
+
+export default function ChecklistCSVImportDialog({ open, onOpenChange, onSuccess }) {
+ const { user } = useAuth();
+ const { toast } = useToast();
+ const fileInputRef = useRef(null);
+ const db = supabase;
+
+ const [file, setFile] = useState(null);
+ const [parsedData, setParsedData] = useState([]);
+ const [error, setError] = useState(null);
+ const [importing, setImporting] = useState(false);
+ const [step, setStep] = useState('upload');
+
+ const handleFileChange = (e) => {
+ const selectedFile = e.target.files[0];
+ if (selectedFile) {
+ if (selectedFile.type !== 'text/csv' && !selectedFile.name.endsWith('.csv')) {
+ setError('Please upload a valid CSV file.');
+ return;
+ }
+ setFile(selectedFile);
+ parseCSV(selectedFile);
+ }
+ };
+
+ const parseCSV = (file) => {
+ setError(null);
+ Papa.parse(file, {
+ header: true,
+ skipEmptyLines: true,
+ complete: (results) => {
+ if (results.errors.length > 0) {
+ setError(`Error parsing CSV: ${results.errors[0].message}`);
+ return;
+ }
+
+ const headers = results.meta.fields;
+ const requiredHeaders = ['Checklist Name', 'Items'];
+ const missingHeaders = requiredHeaders.filter(h => !headers.includes(h));
+
+ if (missingHeaders.length > 0) {
+ setError(`Missing required columns: ${missingHeaders.join(', ')}. Please check the tooltip for format.`);
+ return;
+ }
+
+ const processed = results.data.map((row, index) => ({
+ id: index,
+ title: row['Checklist Name'] || '',
+ itemsRaw: row['Items'] || '',
+ description: row['Description'] || '',
+ valid: !!row['Checklist Name'] && !!row['Items']
+ }));
+
+ setParsedData(processed);
+ setStep('preview');
+ },
+ error: (err) => {
+ setError(`Failed to read file: ${err.message}`);
+ }
+ });
+ };
+
+ const handleCellEdit = (index, field, value) => {
+ const newData = [...parsedData];
+ newData[index][field] = value;
+ newData[index].valid = !!newData[index].title && !!newData[index].itemsRaw;
+ setParsedData(newData);
+ };
+
+ const removeRow = (index) => {
+ setParsedData(prev => prev.filter((_, i) => i !== index));
+ };
+
+ const handleImport = async () => {
+ const validRows = parsedData.filter(r => r.valid);
+ if (validRows.length === 0) {
+ setError("No valid data to import.");
+ return;
+ }
+
+ setImporting(true);
+ try {
+ const records = validRows.map(row => {
+ const itemsArray = row.itemsRaw.split('|').map(itemText => ({
+ text: itemText.trim(),
+ required: false,
+ id: Math.random().toString(36).substr(2, 9)
+ })).filter(i => i.text.length > 0);
+
+ return {
+ title: row.title,
+ description: row.description,
+ items: itemsArray,
+ created_by: user?.id,
+ };
+ });
+
+ const { error: insertError } = await db
+ .from('checklists')
+ .insert(records);
+
+ if (insertError) throw insertError;
+
+ toast({
+ title: 'Import Successful',
+ description: `Successfully imported ${records.length} checklist templates.`,
+ });
+
+ if (onSuccess) onSuccess();
+ handleClose();
+
+ } catch (err) {
+ console.error('Import error:', err);
+ setError(`Database error: ${err.message}`);
+ } finally {
+ setImporting(false);
+ }
+ };
+
+ const handleClose = () => {
+ onOpenChange(false);
+ setFile(null);
+ setParsedData([]);
+ setError(null);
+ setStep('upload');
+ if (fileInputRef.current) fileInputRef.current.value = '';
+ };
+
+ return (
+
+
+
+ Import Checklists from CSV
+
+ Upload a CSV file to bulk create checklist templates.
+
+
+
+ {error && (
+
+
+ Error
+ {error}
+
+ )}
+
+ {step === 'upload' && (
+
+
fileInputRef.current?.click()}
+ >
+
+
Click to upload CSV
+
or drag and drop file here
+
+
+
+
+
+
+
CSV Format Requirements:
+
+ Headers must be exactly: Checklist Name, Items, Description
+ Items should be separated by a pipe character (|) e.g. "Task 1|Task 2|Task 3"
+ Description is optional.
+
+
+ Checklist Name,Items,Description
+ "Weekly Audit","Check Lights|Check Doors|Empty Trash","Weekly facility check"
+ "Safety Insp","Fire Extinguishers|Exits Clear","Monthly safety"
+
+
+
+
+ )}
+
+ {step === 'preview' && (
+
+
+
Preview Data ({parsedData.length} rows)
+ setStep('upload')}>Upload Different File
+
+
+
+
+
+
+
+ * Required fields
+ {parsedData.filter(r => !r.valid).length} invalid rows will be skipped
+
+
+ )}
+
+
+ Cancel
+ {step === 'preview' && (
+ r.valid).length === 0}>
+ {importing && }
+ Import {parsedData.filter(r => r.valid).length} Templates
+
+ )}
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/components/ChecklistImportDialog.jsx b/src/components/ChecklistImportDialog.jsx
new file mode 100644
index 0000000..0ca5df8
--- /dev/null
+++ b/src/components/ChecklistImportDialog.jsx
@@ -0,0 +1,209 @@
+import React, { useState, useRef } from 'react';
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogDescription,
+ DialogFooter
+} from '@/components/ui/dialog';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import { useToast } from '@/hooks/use-toast';
+import { AlertCircle, Loader2 } from 'lucide-react';
+import { Alert, AlertDescription } from '@/components/ui/alert';
+import { ScrollArea } from '@/components/ui/scroll-area';
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
+import Papa from 'papaparse';
+
+const LOG_PREFIX = '[ChecklistImportDialog]';
+
+// Simple built-in parsers instead of external service dependency
+const parseJSON = async (file) => {
+ const text = await file.text();
+ const data = JSON.parse(text);
+ const items = Array.isArray(data) ? data : [data];
+ const headers = items.length > 0 ? Object.keys(items[0]) : [];
+ return { items, headers };
+};
+
+const parseCSV = async (file) => {
+ return new Promise((resolve, reject) => {
+ Papa.parse(file, {
+ header: true,
+ skipEmptyLines: true,
+ complete: (results) => {
+ resolve({ items: results.data, headers: results.meta.fields || [] });
+ },
+ error: (err) => reject(err),
+ });
+ });
+};
+
+const parseText = async (file) => {
+ const text = await file.text();
+ const lines = text.split('\n').filter(l => l.trim());
+ const items = lines.map((line, i) => ({ item: line.trim(), index: i + 1 }));
+ return { items, headers: ['item', 'index'] };
+};
+
+export default function ChecklistImportDialog({ open, onOpenChange, onNext }) {
+ const [file, setFile] = useState(null);
+ const [items, setItems] = useState([]);
+ const [headers, setHeaders] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+ const fileInputRef = useRef(null);
+ const { toast } = useToast();
+
+ const handleFileChange = async (e) => {
+ const selectedFile = e.target.files?.[0];
+ if (!selectedFile) return;
+
+ setFile(selectedFile);
+ setLoading(true);
+ setError(null);
+ setItems([]);
+ setHeaders([]);
+
+ console.log(`${LOG_PREFIX} File selected`, selectedFile.name);
+
+ try {
+ let result = { items: [], headers: [] };
+ const type = selectedFile.type;
+ const name = selectedFile.name.toLowerCase();
+
+ if (name.endsWith('.json') || type === 'application/json') {
+ result = await parseJSON(selectedFile);
+ } else if (name.endsWith('.csv') || type === 'text/csv') {
+ result = await parseCSV(selectedFile);
+ } else if (name.endsWith('.txt') || type === 'text/plain') {
+ result = await parseText(selectedFile);
+ } else {
+ throw new Error("Unsupported file format. Please use JSON, CSV, or TXT.");
+ }
+
+ console.log(`${LOG_PREFIX} Parse Result`, result);
+
+ if (result.items.length === 0) {
+ throw new Error("No valid items found in file.");
+ }
+
+ setItems(result.items);
+ setHeaders(result.headers);
+ } catch (err) {
+ console.error(`${LOG_PREFIX} Error`, err);
+ setError(err.message);
+ setFile(null);
+ if (fileInputRef.current) fileInputRef.current.value = '';
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleNext = () => {
+ if (items.length === 0) return;
+ console.log(`${LOG_PREFIX} Proceeding with`, { itemCount: items.length, headers });
+
+ if (onNext) {
+ onNext(items, headers, file?.name?.split('.')[0] || "Imported Checklist");
+ }
+
+ setFile(null);
+ setItems([]);
+ setHeaders([]);
+ setError(null);
+ if (fileInputRef.current) fileInputRef.current.value = '';
+ };
+
+ return (
+
+
+
+ Import Checklist
+
+ Upload a file (CSV, JSON, TXT) to import checklist items. First row/key will be used as headers.
+
+
+
+
+
+ Checklist File
+
+
+
+ {loading && (
+
+
+ Parsing file contents...
+
+ )}
+
+ {error && (
+
+
+ {error}
+
+ )}
+
+ {items.length > 0 && !loading && (
+
+
+ Preview ({items.length} items)
+ {
+ setFile(null);
+ setItems([]);
+ setHeaders([]);
+ if(fileInputRef.current) fileInputRef.current.value = '';
+ }}>Clear
+
+
+
+
+
+
+
+ {headers.map((h, i) => (
+ {h}
+ ))}
+
+
+
+ {items.slice(0, 10).map((item, idx) => (
+
+ {headers.map((h, i) => (
+ {item[h] || '-'}
+ ))}
+
+ ))}
+
+
+
+ {items.length > 10 && (
+
+ And {items.length - 10} more items...
+
+ )}
+
+
+ )}
+
+
+
+ onOpenChange(false)}>Cancel
+
+ Next
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/components/ClientDialog.jsx b/src/components/ClientDialog.jsx
new file mode 100644
index 0000000..7fd4ccf
--- /dev/null
+++ b/src/components/ClientDialog.jsx
@@ -0,0 +1,222 @@
+import React, { useState, useEffect } from 'react';
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogDescription,
+ DialogFooter,
+} from '@/components/ui/dialog';
+import { Button } from '@/components/ui/button';
+import { supabase } from '@/integrations/supabase/client';
+import { useToast } from '@/hooks/use-toast';
+import { Upload, X, Loader2, FileImage as ImageIcon } from 'lucide-react';
+import { Label } from '@/components/ui/label';
+import { Input } from '@/components/ui/input';
+import { Textarea } from '@/components/ui/textarea';
+
+function ClientDialog({ open, onOpenChange, onSuccess, client }) {
+ const db = supabase;
+ const [formData, setFormData] = useState({
+ name: '',
+ email: '',
+ phone: '',
+ address: '',
+ city: '',
+ state: '',
+ zip: '',
+ logo_url: '',
+ });
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const [uploadingLogo, setUploadingLogo] = useState(false);
+ const { toast } = useToast();
+
+ useEffect(() => {
+ if (client) {
+ setFormData({
+ name: client.name || '',
+ email: client.email || '',
+ phone: client.phone || '',
+ address: client.address || '',
+ city: client.city || '',
+ state: client.state || '',
+ zip: client.zip || '',
+ logo_url: client.logo_url || '',
+ });
+ } else {
+ setFormData({
+ name: '', email: '', phone: '', address: '', city: '', state: '', zip: '', logo_url: ''
+ });
+ }
+ }, [client, open]);
+
+ const handleChange = (e) => {
+ const { name, value } = e.target;
+ setFormData((prev) => ({ ...prev, [name]: value }));
+ };
+
+ const handleFileUpload = async (e) => {
+ const file = e.target.files[0];
+ if (!file) return;
+
+ setUploadingLogo(true);
+ try {
+ const fileExt = file.name.split('.').pop();
+ const fileName = `${Date.now()}_${Math.random().toString(36).substring(7)}.${fileExt}`;
+ const filePath = `associations/${fileName}`;
+
+ const { error: uploadError } = await supabase.storage
+ .from('logos')
+ .upload(filePath, file);
+
+ if (uploadError) throw uploadError;
+
+ const { data: { publicUrl } } = supabase.storage
+ .from('logos')
+ .getPublicUrl(filePath);
+
+ setFormData(prev => ({ ...prev, logo_url: publicUrl }));
+ toast({ title: "Upload Successful", description: "Logo uploaded successfully." });
+
+ } catch (error) {
+ console.error('Error uploading logo:', error);
+ toast({ variant: "destructive", title: "Upload Failed", description: error.message });
+ } finally {
+ setUploadingLogo(false);
+ }
+ };
+
+ const removeLogo = () => {
+ setFormData(prev => ({ ...prev, logo_url: '' }));
+ };
+
+ const handleSubmit = async (e) => {
+ e.preventDefault();
+ setIsSubmitting(true);
+
+ const dataToSubmit = { ...formData };
+
+ let error;
+ if (client) {
+ const { error: updateError } = await db
+ .from('associations')
+ .update(dataToSubmit)
+ .eq('id', client.id);
+ error = updateError;
+ } else {
+ const { error: insertError } = await db
+ .from('associations')
+ .insert([dataToSubmit]);
+ error = insertError;
+ }
+
+ if (!error) {
+ toast({
+ title: `Association ${client ? 'Updated' : 'Added'}`,
+ description: `Association information has been successfully ${client ? 'updated' : 'added'}.`,
+ });
+ onSuccess();
+ onOpenChange(false);
+ } else {
+ toast({
+ variant: 'destructive',
+ title: `Error ${client ? 'updating' : 'adding'} association`,
+ description: error.message,
+ });
+ }
+ setIsSubmitting(false);
+ };
+
+ const isEditMode = !!client;
+
+ return (
+
+
+
+ {isEditMode ? 'Edit Association' : 'Add New Association'}
+
+ {isEditMode ? 'Update the association\'s details below.' : 'Fill in the details for the new association.'}
+
+
+
+ {/* Logo Upload Section */}
+
+
+
Association Logo
+
+ {formData.logo_url ? (
+
+
+
+
+
+
+ ) : (
+
+
+ No logo uploaded
+
+ )}
+
+
+ {uploadingLogo && }
+
+
+
+
+
+
+
+ onOpenChange(false)} disabled={isSubmitting}>
+ Cancel
+
+
+ {isSubmitting ? 'Saving...' : (isEditMode ? 'Save Changes' : 'Add Association')}
+
+
+
+
+
+ );
+}
+
+export default ClientDialog;
\ No newline at end of file
diff --git a/src/components/ClientEmailDialog.jsx b/src/components/ClientEmailDialog.jsx
new file mode 100644
index 0000000..29282da
--- /dev/null
+++ b/src/components/ClientEmailDialog.jsx
@@ -0,0 +1,178 @@
+import React, { useState, useEffect } from 'react';
+import { supabase } from '@/integrations/supabase/client';
+import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger, DialogFooter } from "@/components/ui/dialog";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Loader2, Mail, Copy, Check, RefreshCw, AlertTriangle, ShieldCheck } from "lucide-react";
+import { useToast } from "@/hooks/use-toast";
+import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
+
+export default function ClientEmailDialog({ clientId, clientName, trigger }) {
+ const [isOpen, setIsOpen] = useState(false);
+ const [loading, setLoading] = useState(false);
+ const [emailData, setEmailData] = useState(null);
+ const [copied, setCopied] = useState(false);
+ const { toast } = useToast();
+ const db = supabase;
+
+ useEffect(() => {
+ if (isOpen && clientId) {
+ fetchEmail();
+ }
+ }, [isOpen, clientId]);
+
+ const fetchEmail = async () => {
+ setLoading(true);
+ try {
+ // Note: client_email_addresses table may need to be created if not present
+ const { data, error } = await db
+ .from('associations')
+ .select('email')
+ .eq('id', clientId)
+ .maybeSingle();
+
+ if (error) {
+ console.error("ClientEmailDialog: Error fetching email:", error);
+ toast({ variant: "destructive", title: "Fetch Error", description: "Could not retrieve email settings." });
+ }
+
+ setEmailData(data ? { email_address: data.email, created_at: new Date().toISOString() } : null);
+ } catch (err) {
+ console.error(err);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const generateEmail = async () => {
+ setLoading(true);
+ try {
+ const { data, error } = await supabase.functions.invoke('generate-client-email', {
+ body: {
+ clientId: clientId,
+ clientName: clientName
+ },
+ });
+
+ if (error) {
+ throw new Error(data?.error || error.message || "Function invocation failed");
+ }
+
+ await fetchEmail();
+
+ toast({
+ title: "Email Generated",
+ description: `Successfully assigned email`,
+ });
+ } catch (err) {
+ console.error("ClientEmailDialog: Generation error", err);
+ toast({
+ variant: "destructive",
+ title: "Generation Failed",
+ description: err.message || "Failed to generate email address."
+ });
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleCopy = () => {
+ if (emailData?.email_address) {
+ navigator.clipboard.writeText(emailData.email_address);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ toast({ title: "Copied", description: "Email address copied to clipboard." });
+ }
+ };
+
+ return (
+
+
+ {trigger || (
+
+
+ Manage Inbound Email
+
+ )}
+
+
+
+ Inbound Email Configuration
+
+ Configuration for {clientName} .
+
+
+
+
+ {loading && !emailData ? (
+
+
+
Communicating with server...
+
+ ) : emailData ? (
+
+
+
Active Inbound Address
+
+
+
+
+
+
+ {copied ? : }
+
+
+
+ Created on {new Date(emailData.created_at).toLocaleDateString()}
+
+
+
+
+
+ How it works
+
+ Emails sent to this address are automatically processed.
+ The content is extracted and added as a Status Update for this association.
+
+
+
+
+
+
+
Regenerate Address
+
Create a new random address if spam occurs.
+
+
+
+ Regenerate
+
+
+
+
+ ) : (
+
+
+
+
+
+
No Email Address Assigned
+
Generate a unique inbound address for {clientName} to enable email-to-status features.
+
+
+ {loading ? : }
+ Generate Email Address
+
+
+ )}
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/components/CollectionDetailsDialog.jsx b/src/components/CollectionDetailsDialog.jsx
new file mode 100644
index 0000000..d39ced9
--- /dev/null
+++ b/src/components/CollectionDetailsDialog.jsx
@@ -0,0 +1,131 @@
+import React, { useState } from 'react';
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogDescription,
+ DialogFooter,
+} from "@/components/ui/dialog";
+import { Button } from "@/components/ui/button";
+import { FileText, Calendar, Clock, MapPin, User, Edit2, Lock, Flag } from "lucide-react";
+import { supabase } from '@/integrations/supabase/client';
+import { useToast } from '@/hooks/use-toast';
+import { useAuth } from '@/contexts/AuthContext';
+
+export default function CollectionDetailsDialog({ open, onOpenChange, collection }) {
+ const { toast } = useToast();
+ const { user } = useAuth();
+
+ if (!collection) return null;
+
+ const formatCurrency = (amount) => {
+ if (!amount) return '-';
+ return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(amount);
+ };
+
+ const getStatusColor = (status) => {
+ switch (status) {
+ case 'Resolved': return 'bg-green-100 text-green-800 border-green-200';
+ case 'open': return 'bg-yellow-100 text-yellow-800 border-yellow-200';
+ case 'With Attorney': return 'bg-purple-100 text-purple-800 border-purple-200';
+ default: return 'bg-slate-100 text-slate-800 border-slate-200';
+ }
+ };
+
+ const formatDate = (dateStr) => {
+ if (!dateStr) return 'N/A';
+ return new Date(dateStr).toLocaleDateString('en-US', { timeZone: 'America/New_York' });
+ };
+
+ return (
+
+
+
+
+
+ Collection Details
+
+ Review the complete information for this collection record.
+
+
+
+ {collection.status || 'Pending'}
+
+
+
+
+
+
+
+
Owner
+
{collection.owners?.first_name} {collection.owners?.last_name}
+
+
+
Total Outstanding
+
{formatCurrency(collection.amount_owed)}
+
+
+
+
+
+
+
+
+
+
+
Association
+
{collection.associations?.name || 'N/A'}
+
+
+
+
+
+
+
+
+
+
+
Last Notice Date
+
{formatDate(collection.last_notice_date)}
+
+
+
+
+
+
+
+
+
Date Created
+
{formatDate(collection.created_at)}
+
+
+
+
+
+ {collection.notes && (
+
+
Notes & History
+
+ {collection.notes}
+
+
+ )}
+
+
+
+
+ Last updated: {formatDate(collection.updated_at)}
+
+ onOpenChange(false)}
+ className="w-full sm:w-auto bg-slate-900 hover:bg-slate-800"
+ >
+ Close Details
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/components/CollectionDialog.jsx b/src/components/CollectionDialog.jsx
new file mode 100644
index 0000000..83a7560
--- /dev/null
+++ b/src/components/CollectionDialog.jsx
@@ -0,0 +1,231 @@
+import React, { useState, useEffect } from 'react';
+import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
+import { Button } from '@/components/ui/button';
+import { Label } from '@/components/ui/label';
+import { supabase } from '@/integrations/supabase/client';
+import { useToast } from '@/hooks/use-toast';
+
+const STATUS_OPTIONS = [
+ "open",
+ "in_progress",
+ "resolved",
+ "closed"
+];
+
+const normalizeCollectionStatus = (status) => status === 'resolved' ? 'closed' : status;
+
+function CollectionDialog({ open, onOpenChange, collection, onSuccess }) {
+ const { toast } = useToast();
+ const db = supabase;
+ const [associations, setAssociations] = useState([]);
+ const [owners, setOwners] = useState([]);
+ const [units, setUnits] = useState([]);
+ const [formData, setFormData] = useState({
+ association_id: '',
+ owner_id: '',
+ unit_id: '',
+ notes: '',
+ status: 'open',
+ last_notice_date: '',
+ amount_owed: ''
+ });
+ const [loading, setLoading] = useState(false);
+
+ useEffect(() => {
+ const fetchAssociations = async () => {
+ const { data, error } = await db.from('associations').select('id, name').eq('status', 'active').order('name');
+ if (!error) setAssociations(data || []);
+ };
+ if (open) fetchAssociations();
+ }, [open]);
+
+ useEffect(() => {
+ if (formData.association_id) {
+ const fetchOwnersAndUnits = async () => {
+ const [ownersRes, unitsRes] = await Promise.all([
+ db.from('owners').select('id, first_name, last_name').eq('association_id', formData.association_id).eq('status', 'active').order('last_name'),
+ db.from('units').select('id, unit_number, address').eq('association_id', formData.association_id).order('unit_number'),
+ ]);
+ if (!ownersRes.error) setOwners(ownersRes.data || []);
+ if (!unitsRes.error) setUnits(unitsRes.data || []);
+ };
+ fetchOwnersAndUnits();
+ } else {
+ setOwners([]);
+ setUnits([]);
+ }
+ }, [formData.association_id]);
+
+ useEffect(() => {
+ if (collection) {
+ setFormData({
+ association_id: collection.association_id || '',
+ owner_id: collection.owner_id || '',
+ unit_id: collection.unit_id || '',
+ notes: collection.notes || '',
+ status: collection.status || 'open',
+ last_notice_date: collection.last_notice_date || '',
+ amount_owed: collection.amount_owed || ''
+ });
+ } else {
+ setFormData({
+ association_id: '', owner_id: '', unit_id: '', notes: '', status: 'open', last_notice_date: '', amount_owed: ''
+ });
+ }
+ }, [collection, open]);
+
+ const handleSubmit = async (e) => {
+ e.preventDefault();
+ setLoading(true);
+
+ if (!formData.association_id) {
+ toast({ variant: 'destructive', title: 'Missing Field', description: 'Please select an association.' });
+ setLoading(false);
+ return;
+ }
+
+ const dataToSubmit = {
+ ...formData,
+ status: normalizeCollectionStatus(formData.status),
+ last_notice_date: formData.last_notice_date || null,
+ amount_owed: formData.amount_owed || 0,
+ owner_id: formData.owner_id || null,
+ unit_id: formData.unit_id || null,
+ };
+
+ const { error } = collection
+ ? await db.from('collections').update(dataToSubmit).eq('id', collection.id)
+ : await db.from('collections').insert([dataToSubmit]);
+
+ if (!error) {
+ toast({
+ title: collection ? 'Collection Updated' : 'Collection Created',
+ description: `Collection has been successfully ${collection ? 'updated' : 'created'}.`,
+ });
+ onSuccess();
+ onOpenChange(false);
+ } else {
+ toast({ variant: 'destructive', title: 'Error', description: error.message });
+ }
+ setLoading(false);
+ };
+
+ return (
+
+
+
+ {collection ? 'Edit Collection' : 'Add New Collection'}
+
+
+
+ Association *
+ setFormData({ ...formData, association_id: e.target.value, owner_id: '' })}
+ required
+ className="w-full mt-1 px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-teal-500 focus:border-transparent"
+ >
+ Select an association...
+ {associations.map((a) => (
+ {a.name}
+ ))}
+
+
+
+
+ Unit
+ setFormData({ ...formData, unit_id: e.target.value })}
+ className="w-full mt-1 px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-teal-500 focus:border-transparent"
+ disabled={!formData.association_id}
+ >
+ Select a unit...
+ {units.map((u) => (
+ {u.unit_number}{u.address ? ` - ${u.address}` : ''}
+ ))}
+
+
+
+
+ Owner
+ setFormData({ ...formData, owner_id: e.target.value })}
+ className="w-full mt-1 px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-teal-500 focus:border-transparent"
+ disabled={!formData.association_id}
+ >
+ Select an owner...
+ {owners.map((o) => (
+ {o.first_name} {o.last_name}
+ ))}
+
+
+
+
+
+ Status *
+ setFormData({ ...formData, status: e.target.value })}
+ required
+ className="w-full mt-1 px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-teal-500 focus:border-transparent"
+ >
+ {STATUS_OPTIONS.map((status) => (
+ {status}
+ ))}
+
+
+
+ Last Notice Date
+ setFormData({ ...formData, last_notice_date: e.target.value })}
+ className="w-full mt-1 px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-teal-500 focus:border-transparent"
+ />
+
+
+
+
+ Amount Owed ($)
+ setFormData({ ...formData, amount_owed: e.target.value })}
+ className="w-full mt-1 px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-teal-500 focus:border-transparent"
+ />
+
+
+
+ Notes
+ setFormData({ ...formData, notes: e.target.value })}
+ rows={3}
+ className="w-full mt-1 px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-teal-500 focus:border-transparent"
+ />
+
+
+ onOpenChange(false)}>Cancel
+
+ {loading ? 'Saving...' : collection ? 'Update' : 'Create'}
+
+
+
+
+
+ );
+}
+
+export default CollectionDialog;
\ No newline at end of file
diff --git a/src/components/CollectionFinancialDialog.jsx b/src/components/CollectionFinancialDialog.jsx
new file mode 100644
index 0000000..db49408
--- /dev/null
+++ b/src/components/CollectionFinancialDialog.jsx
@@ -0,0 +1,199 @@
+import React, { useEffect } from 'react';
+import { useForm } from 'react-hook-form';
+import { zodResolver } from '@hookform/resolvers/zod';
+import * as z from 'zod';
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from '@/components/ui/dialog';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, SelectGroup, SelectLabel as SLabel } from '@/components/ui/select';
+import { useToast } from '@/hooks/use-toast';
+import { supabase } from '@/integrations/supabase/client';
+import { useAuth } from '@/contexts/AuthContext';
+import { Lock } from 'lucide-react';
+
+const schema = z.object({
+ association_id: z.string().min(1, 'Association is required'),
+ status: z.string().min(1, 'Status is required'),
+ amount_owed: z.coerce.number().min(0, 'Total must be positive'),
+ last_notice_date: z.string().optional().nullable(),
+ notes: z.string().optional().nullable(),
+});
+
+const normalizeCollectionStatus = (status) => status === 'resolved' ? 'closed' : status;
+
+export default function CollectionFinancialDialog({ open, onOpenChange, collection, onSuccess, associations }) {
+ const { toast } = useToast();
+ const { user } = useAuth();
+ const db = supabase;
+
+ const { register, handleSubmit, reset, setValue, watch, formState: { errors, isSubmitting, isValid } } = useForm({
+ resolver: zodResolver(schema),
+ mode: 'onChange',
+ defaultValues: {
+ amount_owed: 0,
+ status: 'open',
+ }
+ });
+
+ useEffect(() => {
+ if (open) {
+ if (collection) {
+ reset({
+ association_id: collection.association_id,
+ status: collection.status || 'open',
+ amount_owed: collection.amount_owed || 0,
+ last_notice_date: collection.last_notice_date || '',
+ notes: collection.notes || '',
+ });
+ } else {
+ reset({
+ association_id: '',
+ status: 'open',
+ amount_owed: 0,
+ last_notice_date: '',
+ notes: '',
+ });
+ }
+ }
+ }, [collection, reset, open]);
+
+ const handleSave = async (data) => {
+ try {
+ const payload = {
+ ...data,
+ status: normalizeCollectionStatus(data.status),
+ last_notice_date: data.last_notice_date || null,
+ };
+
+ let error;
+
+ if (collection?.id) {
+ const { error: updateError } = await db
+ .from('collections')
+ .update(payload)
+ .eq('id', collection.id);
+ error = updateError;
+ } else {
+ const { error: insertError } = await db
+ .from('collections')
+ .insert([payload]);
+ error = insertError;
+ }
+
+ if (error) throw error;
+
+ toast({
+ title: "Success",
+ description: "Collection financial information updated successfully",
+ });
+
+ if (onSuccess) onSuccess();
+ onOpenChange(false);
+
+ } catch (error) {
+ console.error("Submission error:", error);
+ toast({
+ variant: 'destructive',
+ title: 'Error',
+ description: error.message || "Failed to save record",
+ });
+ }
+ };
+
+ return (
+
+
+
+ {collection ? 'Financial Record' : 'Add Financial Record'}
+
+ Update the financial breakdown and status for this collection case.
+
+
+
+
+
+
+
Association *
+
setValue('association_id', val)}
+ defaultValue={collection?.association_id}
+ disabled={!!collection}
+ >
+
+
+
+
+ {associations?.map(a => (
+ {a.name}
+ ))}
+
+
+ {errors.association_id &&
{errors.association_id.message}
}
+
+
+
+
Status *
+
setValue('status', val)}
+ defaultValue={collection?.status || 'open'}
+ >
+
+
+
+
+ Open
+ In Progress
+ Resolved
+ Closed
+
+
+ {errors.status &&
{errors.status.message}
}
+
+
+
+
Amount Owed ($) *
+
+ {errors.amount_owed &&
{errors.amount_owed.message}
}
+
+
+
+ Last Notice Date
+
+
+
+
+
+ Notes
+
+
+
+
+ onOpenChange(false)}>
+ Cancel
+
+
+ {isSubmitting ? 'Saving...' : (collection ? 'Update Record' : 'Create Record')}
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/components/CollectionReportDialog.jsx b/src/components/CollectionReportDialog.jsx
new file mode 100644
index 0000000..791358d
--- /dev/null
+++ b/src/components/CollectionReportDialog.jsx
@@ -0,0 +1,89 @@
+import React, { useState } from 'react';
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogDescription,
+ DialogFooter,
+} from "@/components/ui/dialog";
+import { Button } from '@/components/ui/button';
+import { FileDown, Loader2 } from 'lucide-react';
+
+export default function CollectionReportDialog({ open, onOpenChange, collections, clientName, statusFilter }) {
+ const [isGenerating, setIsGenerating] = useState(false);
+
+ const formatDate = (date) => {
+ return new Date(date).toLocaleDateString('en-US', { timeZone: 'America/New_York' });
+ };
+
+ const handleExport = async () => {
+ setIsGenerating(true);
+ try {
+ await new Promise(resolve => setTimeout(resolve, 100));
+
+ // Placeholder: In production, use generateCollectionReport
+ console.log('Generating report for', collections.length, 'collections');
+
+ onOpenChange(false);
+ } catch (error) {
+ console.error("Export failed", error);
+ } finally {
+ setIsGenerating(false);
+ }
+ };
+
+ const getStatusLabel = () => {
+ if (statusFilter === 'active') return 'Active';
+ if (statusFilter === 'resolved') return 'Resolved';
+ return 'All';
+ };
+
+ const todayEST = formatDate(new Date());
+
+ return (
+
+
+
+ Export {getStatusLabel()} Financial Report
+
+ Generate a detailed financial breakdown PDF for your {getStatusLabel().toLowerCase()} collections.
+
+
+
+
+
This report includes financial details for {collections?.length || 0} visible records.
+
+
Included Columns:
+
+ Owner Name
+ Total Amount Owed
+ Last Notice Date
+ Status
+
+
Report Date: {todayEST} (EST)
+
+
+
+
+ onOpenChange(false)}>
+ Cancel
+
+
+ {isGenerating ? (
+ <>
+
+ Generating...
+ >
+ ) : (
+ <>
+
+ Download Report
+ >
+ )}
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/components/Combobox.tsx b/src/components/Combobox.tsx
new file mode 100644
index 0000000..bc621aa
--- /dev/null
+++ b/src/components/Combobox.tsx
@@ -0,0 +1,63 @@
+import React, { useState } from 'react';
+import { Check, ChevronsUpDown } from 'lucide-react';
+import { Button } from '@/components/ui/button';
+import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command';
+import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
+import { cn } from '@/lib/utils';
+
+interface ComboboxOption {
+ value: string;
+ label: string;
+}
+
+interface ComboboxProps {
+ options: ComboboxOption[];
+ value: string;
+ onChange: (value: string) => void;
+ placeholder?: string;
+ emptyText?: string;
+ disabled?: boolean;
+ className?: string;
+}
+
+export function Combobox({ options, value, onChange, placeholder = "Select...", emptyText = "No results.", disabled, className }: ComboboxProps) {
+ const [open, setOpen] = useState(false);
+ const selected = options.find(o => o.value === value);
+
+ return (
+
+
+
+ {selected?.label || placeholder}
+
+
+
+
+
+
+
+ {emptyText}
+
+ {options.map(option => (
+ { onChange(option.value); setOpen(false); }}
+ >
+
+ {option.label}
+
+ ))}
+
+
+
+
+
+ );
+}
diff --git a/src/components/CustomVariablesInserter.jsx b/src/components/CustomVariablesInserter.jsx
new file mode 100644
index 0000000..91d8fe0
--- /dev/null
+++ b/src/components/CustomVariablesInserter.jsx
@@ -0,0 +1,53 @@
+import React from 'react';
+import { useCustomVariables } from '@/hooks/useCustomVariables';
+import { Button } from '@/components/ui/button';
+import { ScrollArea } from '@/components/ui/scroll-area';
+
+export function CustomVariablesInserter({ clientId, onSelect, onClose }) {
+ const { variables = [] } = useCustomVariables(clientId);
+
+ if (!variables.length) {
+ return (
+
+ No custom variables available for this association.
+
+ );
+ }
+
+ // Group by category
+ const grouped = variables.reduce((acc, v) => {
+ const cat = v.category || 'general';
+ if (!acc[cat]) acc[cat] = [];
+ acc[cat].push(v);
+ return acc;
+ }, {});
+
+ return (
+
+
+ {Object.entries(grouped).map(([category, vars]) => (
+
+
+ {category}
+
+ {vars.map((v) => (
+
{
+ onSelect?.(v.variable_name);
+ onClose?.();
+ }}
+ >
+ {`{{${v.variable_name}}}`}
+ {v.display_label}
+
+ ))}
+
+ ))}
+
+
+ );
+}
diff --git a/src/components/CustomVariablesManager.tsx b/src/components/CustomVariablesManager.tsx
new file mode 100644
index 0000000..0595207
--- /dev/null
+++ b/src/components/CustomVariablesManager.tsx
@@ -0,0 +1,264 @@
+import { useState, useEffect } from "react";
+import { supabase } from "@/integrations/supabase/client";
+import { useToast } from "@/hooks/use-toast";
+import { useAssociationCustomVariables, type AssociationCustomVariable as CustomVariable } from "@/hooks/useAssociationCustomVariables";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
+import { Plus, Edit2, Trash2, Search, Variable, Copy } from "lucide-react";
+import { Skeleton } from "@/components/ui/skeleton";
+
+const CATEGORIES = ["general", "financial", "legal", "contact", "property", "other"];
+
+export default function CustomVariablesManager() {
+ const { toast } = useToast();
+ const [associations, setAssociations] = useState([]);
+ const [selectedAssocId, setSelectedAssocId] = useState("");
+ const { variables, loading, fetchVariables, createVariable, updateVariable, deleteVariable } = useAssociationCustomVariables(selectedAssocId);
+ const [search, setSearch] = useState("");
+ const [dialogOpen, setDialogOpen] = useState(false);
+ const [editing, setEditing] = useState(null);
+ const [form, setForm] = useState({ variable_name: "", display_label: "", default_value: "", description: "", category: "general" });
+ const [saving, setSaving] = useState(false);
+
+ useEffect(() => {
+ supabase.from("associations").select("id, name").eq("status", "active").order("name").then(({ data }) => {
+ setAssociations(data || []);
+ if (data?.length && !selectedAssocId) setSelectedAssocId(data[0].id);
+ });
+ }, []);
+
+ const filtered = variables.filter(v =>
+ v.variable_name.toLowerCase().includes(search.toLowerCase()) ||
+ v.display_label.toLowerCase().includes(search.toLowerCase()) ||
+ (v.description || "").toLowerCase().includes(search.toLowerCase())
+ );
+
+ const grouped = CATEGORIES.reduce>((acc, cat) => {
+ const items = filtered.filter(v => v.category === cat);
+ if (items.length > 0) acc[cat] = items;
+ return acc;
+ }, {});
+
+ const openCreate = () => {
+ setEditing(null);
+ setForm({ variable_name: "", display_label: "", default_value: "", description: "", category: "general" });
+ setDialogOpen(true);
+ };
+
+ const openEdit = (v: CustomVariable) => {
+ setEditing(v);
+ setForm({
+ variable_name: v.variable_name,
+ display_label: v.display_label,
+ default_value: v.default_value || "",
+ description: v.description || "",
+ category: v.category || "general",
+ });
+ setDialogOpen(true);
+ };
+
+ const handleSave = async () => {
+ console.log("[CustomVariablesManager] handleSave called", { form, selectedAssocId, editing });
+ if (!form.variable_name.trim() || !form.display_label.trim()) {
+ toast({ title: "Variable name and label are required", variant: "destructive" });
+ return;
+ }
+ if (!selectedAssocId) {
+ toast({ title: "Please select an association first", variant: "destructive" });
+ return;
+ }
+ const cleanName = form.variable_name.replace(/[{}]/g, "").replace(/\s+/g, "_").toLowerCase();
+
+ setSaving(true);
+ try {
+ if (editing) {
+ console.log("[CustomVariablesManager] Calling updateVariable...");
+ await updateVariable(editing.id, {
+ variable_name: cleanName,
+ display_label: form.display_label.trim(),
+ default_value: form.default_value,
+ description: form.description || null,
+ category: form.category,
+ });
+ } else {
+ console.log("[CustomVariablesManager] Calling createVariable...", { selectedAssocId, cleanName });
+ const result = await createVariable({
+ association_id: selectedAssocId,
+ variable_name: cleanName,
+ display_label: form.display_label.trim(),
+ default_value: form.default_value || "",
+ description: form.description || null,
+ category: form.category || "general",
+ });
+ console.log("[CustomVariablesManager] createVariable returned:", result);
+ }
+ setDialogOpen(false);
+ } catch (err: any) {
+ console.error("[CustomVariablesManager] Save error:", err);
+ toast({ title: "Error saving variable", description: err.message, variant: "destructive" });
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ const handleDelete = async (v: CustomVariable) => {
+ try {
+ await deleteVariable(v.id);
+ } catch (err: any) {
+ toast({ title: "Error", description: err.message, variant: "destructive" });
+ }
+ };
+
+ const copyTag = (name: string) => {
+ navigator.clipboard.writeText(`{{${name}}}`);
+ toast({ title: "Copied!", description: `{{${name}}} copied to clipboard` });
+ };
+
+ return (
+
+
+
+
+ Custom Variables
+
+
+ Create reusable variables for letters, forms, and notices. Use {"{{variable_name}}"} in your templates.
+
+
+
+
+
+
+
+
+ {associations.map(a => {a.name} )}
+
+
+
+ Add Variable
+
+
+
+
+ {/* Search */}
+
+
+ setSearch(e.target.value)} />
+
+
+ {/* Variables Table */}
+ {loading ? (
+
{Array.from({ length: 4 }).map((_, i) => )}
+ ) : !selectedAssocId ? (
+
Select an association to manage its custom variables.
+ ) : filtered.length === 0 ? (
+
+ {search ? "No variables match your search." : "No custom variables yet. Click \"Add Variable\" to create one."}
+
+ ) : (
+ Object.entries(grouped).map(([category, vars]) => (
+
+
+ {category}
+
+
+
+
+ Variable Tag
+ Label
+ Default Value
+ Description
+ Actions
+
+
+
+ {vars.map(v => (
+
+
+ copyTag(v.variable_name)} className="flex items-center gap-1.5 group" title="Click to copy">
+
+ {`{{${v.variable_name}}}`}
+
+
+
+
+ {v.display_label}
+ {v.default_value || "—"}
+ {v.description || "—"}
+
+
+ openEdit(v)}>
+
+
+ handleDelete(v)}>
+
+
+
+
+
+ ))}
+
+
+
+ ))
+ )}
+
+ {/* Create/Edit Dialog */}
+
+
+
+ {editing ? "Edit Variable" : "Create Variable"}
+
+ {editing ? "Update this custom variable." : "Define a new variable to use in your templates."}
+
+
+
+
+
Variable Name
+
setForm({ ...form, variable_name: e.target.value })}
+ placeholder="e.g. late_fee_amount"
+ />
+
+ Use in templates as: {`{{${form.variable_name.replace(/[{}]/g, "").replace(/\s+/g, "_").toLowerCase() || "variable_name"}}}`}
+
+
+
+ Display Label
+ setForm({ ...form, display_label: e.target.value })} placeholder="e.g. Late Fee Amount" />
+
+
+ Default Value
+ setForm({ ...form, default_value: e.target.value })} placeholder="e.g. $25.00" />
+
+
+ Category
+ setForm({ ...form, category: v })}>
+
+
+ {CATEGORIES.map(c => {c} )}
+
+
+
+
+ Description
+ setForm({ ...form, description: e.target.value })} placeholder="What this variable represents..." />
+
+
+
+ setDialogOpen(false)}>Cancel
+
+ {saving ? "Saving..." : editing ? "Save Changes" : "Create Variable"}
+
+
+
+
+
+ );
+}
diff --git a/src/components/DateRequestDetailsDialog.jsx b/src/components/DateRequestDetailsDialog.jsx
new file mode 100644
index 0000000..956c9de
--- /dev/null
+++ b/src/components/DateRequestDetailsDialog.jsx
@@ -0,0 +1,244 @@
+import React, { useState } from 'react';
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogDescription,
+ DialogFooter,
+} from "@/components/ui/dialog";
+import { Button } from "@/components/ui/button";
+import { Textarea } from "@/components/ui/textarea";
+import { Badge } from '@/components/ui/badge';
+import { Calendar, Clock, User, FileText, CheckCircle, XCircle, AlertTriangle, Loader2 } from 'lucide-react';
+import { format, parseISO } from 'date-fns';
+import { supabase } from '@/integrations/supabase/client';
+import { useToast } from "@/hooks/use-toast";
+import { useAuth } from '@/contexts/AuthContext';
+
+export default function DateRequestDetailsDialog({ open, onOpenChange, request, onRefresh }) {
+ const { user } = useAuth();
+ const { toast } = useToast();
+ const [loading, setLoading] = useState(false);
+ const [rejectReason, setRejectReason] = useState('');
+ const [showRejectInput, setShowRejectInput] = useState(false);
+
+ if (!request) return null;
+
+ const handleApprove = async () => {
+ setLoading(true);
+ try {
+ const timeStr = request.requested_time || '09:00:00';
+ const startDateTime = new Date(`${request.requested_date}T${timeStr}`);
+ const endDateTime = new Date(startDateTime.getTime() + 60 * 60 * 1000);
+
+ const { error: eventError } = await supabase
+ .from('calendar_events')
+ .insert({
+ title: `Client Request: ${request.associations?.name || 'Unknown'}`,
+ description: `Auto-generated from Date Request.\n\nClient Note: ${request.task_description}\n\nAdditional Notes: ${request.notes || 'None'}`,
+ start_date: startDateTime.toISOString(),
+ end_date: endDateTime.toISOString(),
+ association_id: request.association_id,
+ event_type: 'meeting',
+ created_by: user.id,
+ });
+
+ if (eventError) throw eventError;
+
+ const { error: reqError } = await supabase
+ .from('client_requests')
+ .update({
+ status: 'approved',
+ assigned_to: user.id,
+ })
+ .eq('id', request.id);
+
+ if (reqError) throw reqError;
+
+ toast({
+ title: "Request Approved",
+ description: "The request has been approved and a calendar event created.",
+ });
+
+ onRefresh?.();
+ onOpenChange(false);
+
+ } catch (error) {
+ console.error('Approval Error:', error);
+ toast({
+ variant: "destructive",
+ title: "Error",
+ description: "Failed to approve request. " + error.message,
+ });
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleReject = async () => {
+ if (!rejectReason.trim()) {
+ toast({
+ variant: "destructive",
+ title: "Reason Required",
+ description: "Please provide a reason for rejection.",
+ });
+ return;
+ }
+
+ setLoading(true);
+ try {
+ const { error } = await supabase
+ .from('client_requests')
+ .update({
+ status: 'rejected',
+ assigned_to: user.id,
+ description: rejectReason
+ })
+ .eq('id', request.id);
+
+ if (error) throw error;
+
+ toast({
+ title: "Request Rejected",
+ description: "The request has been rejected.",
+ });
+
+ onRefresh?.();
+ onOpenChange(false);
+ setShowRejectInput(false);
+ setRejectReason('');
+
+ } catch (error) {
+ console.error('Rejection Error:', error);
+ toast({
+ variant: "destructive",
+ title: "Error",
+ description: "Failed to reject request. " + error.message,
+ });
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+
+
+
+
+ Request Details
+
+ {request.status?.toUpperCase()}
+
+
+
+ Review the details submitted by the client.
+
+
+
+
+
+
+
+
+
+
{request.associations?.name || request.requester_name}
+
Request ID: {request.id?.slice(0, 8)}
+
+
+
+
+
+
+ Requested Date
+
+
+ {request.created_at ? format(parseISO(request.created_at), 'MMMM d, yyyy') : 'N/A'}
+
+
+
+
+ Priority
+
+
+ {request.priority || 'Medium'}
+
+
+
+
+
+
+
+ Title
+
+
+ {request.title || 'General'}
+
+
+
+ {request.description && (
+
+
+ Description
+
+
+ {request.description}
+
+
+ )}
+
+
+ {request.status === 'pending' || request.status === 'open' ? (
+
+ {!showRejectInput ? (
+
+ setShowRejectInput(true)}
+ disabled={loading}
+ >
+ Reject Request
+
+
+ {loading ? : }
+ Approve & Schedule
+
+
+ ) : (
+
+
+
setRejectReason(e.target.value)}
+ />
+
+ setShowRejectInput(false)} disabled={loading}>
+ Cancel
+
+
+ {loading ? : "Confirm Rejection"}
+
+
+
+ )}
+
+ ) : null}
+
+
+
+ onOpenChange(false)}>Close
+
+
+
+ );
+}
diff --git a/src/components/DateRequestDialog.jsx b/src/components/DateRequestDialog.jsx
new file mode 100644
index 0000000..2cb16db
--- /dev/null
+++ b/src/components/DateRequestDialog.jsx
@@ -0,0 +1,52 @@
+import React, { useState } from 'react';
+import { Dialog, DialogContent } from '@/components/ui/dialog';
+import { Calendar } from '@/components/ui/calendar';
+import { Loader2 } from 'lucide-react';
+
+export default function DateRequestDialog({ open, onOpenChange, onSuccess }) {
+ const [selectedDate, setSelectedDate] = useState(new Date());
+
+ const disabledDays = [
+ { before: new Date() },
+ ];
+
+ return (
+
+
+
+
+
+
Select a Date
+
+
+
+
+
+
+
+
+
Request Details
+
+ Date request form placeholder. Selected date: {selectedDate?.toLocaleDateString()}
+
+
+
+
+
+ );
+}
diff --git a/src/components/DeleteAssociationDialog.jsx b/src/components/DeleteAssociationDialog.jsx
new file mode 100644
index 0000000..154f898
--- /dev/null
+++ b/src/components/DeleteAssociationDialog.jsx
@@ -0,0 +1,74 @@
+import React, { useState } from 'react';
+import {
+ AlertDialog,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@/components/ui/alert-dialog";
+import { Loader2, Trash2, AlertTriangle } from 'lucide-react';
+import { Button } from '@/components/ui/button';
+
+export default function DeleteAssociationDialog({ open, onOpenChange, association, onDelete }) {
+ const [isDeleting, setIsDeleting] = useState(false);
+
+ const handleDelete = async (e) => {
+ e.preventDefault();
+ if (!association) return;
+
+ setIsDeleting(true);
+ try {
+ await onDelete(association.id);
+ onOpenChange(false);
+ } catch (error) {
+ // Error handling in parent
+ } finally {
+ setIsDeleting(false);
+ }
+ };
+
+ if (!association) return null;
+
+ return (
+
+
+
+
+
+ Delete Association
+
+
+
+
+
+ You are about to delete {association.name} . This action is permanent and cannot be undone.
+
+
+
+ Deleting this association will remove the client record from the system. Linked data such as requests, documents, and settings may also be deleted or orphaned depending on database constraints.
+
+
+
+
+ Cancel
+
+ {isDeleting ? (
+ <>
+
+ Deleting...
+ >
+ ) : (
+ 'Delete Association'
+ )}
+
+
+
+
+ );
+}
diff --git a/src/components/DeleteChartOfAccountDialog.tsx b/src/components/DeleteChartOfAccountDialog.tsx
new file mode 100644
index 0000000..2b0ce4b
--- /dev/null
+++ b/src/components/DeleteChartOfAccountDialog.tsx
@@ -0,0 +1,168 @@
+import { useState, useEffect } from "react";
+import {
+ AlertDialog, AlertDialogContent, AlertDialogHeader, AlertDialogTitle,
+ AlertDialogDescription, AlertDialogFooter, AlertDialogCancel,
+} from "@/components/ui/alert-dialog";
+import { Button } from "@/components/ui/button";
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
+import { AlertTriangle, Loader2, ArrowRight } from "lucide-react";
+import { supabase } from "@/integrations/supabase/client";
+import { useToast } from "@/components/ui/use-toast";
+import type { Tables } from "@/integrations/supabase/types";
+
+type ChartOfAccount = Tables<"chart_of_accounts">;
+
+interface DeleteChartOfAccountDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ account: ChartOfAccount | null;
+ allAccounts?: ChartOfAccount[];
+ onSuccess: () => void;
+}
+
+export default function DeleteChartOfAccountDialog({
+ open,
+ onOpenChange,
+ account,
+ allAccounts = [],
+ onSuccess,
+}: DeleteChartOfAccountDialogProps) {
+ const { toast } = useToast();
+ const [checking, setChecking] = useState(false);
+ const [isDeleting, setIsDeleting] = useState(false);
+
+ // Check for journal entries referencing this account
+ const [journalCount, setJournalCount] = useState(0);
+
+ useEffect(() => {
+ if (open && account) {
+ checkDependencies();
+ } else {
+ setJournalCount(0);
+ }
+ }, [open, account]);
+
+ const checkDependencies = async () => {
+ if (!account) return;
+ setChecking(true);
+ try {
+ const { count, error } = await supabase
+ .from("journal_entries")
+ .select("*", { count: "exact", head: true })
+ .eq("chart_of_account_id", account.id);
+
+ if (error) throw error;
+ setJournalCount(count ?? 0);
+ } catch (err) {
+ console.error("Error checking dependencies:", err);
+ toast({ variant: "destructive", title: "Error", description: "Failed to check account dependencies." });
+ } finally {
+ setChecking(false);
+ }
+ };
+
+ const handleConfirm = async () => {
+ if (!account) return;
+ setIsDeleting(true);
+
+ try {
+ if (journalCount > 0) {
+ throw new Error(
+ `Cannot delete this account because it is referenced by ${journalCount} journal entries. Remove those references first.`
+ );
+ }
+
+ const { error: delErr } = await supabase
+ .from("chart_of_accounts")
+ .delete()
+ .eq("id", account.id);
+
+ if (delErr) {
+ if (delErr.code === "23503") {
+ throw new Error(
+ "Cannot delete this account because it is still referenced by other financial records."
+ );
+ }
+ throw delErr;
+ }
+
+ toast({ title: "Account Deleted", description: "The account was successfully deleted." });
+ onSuccess();
+ onOpenChange(false);
+ } catch (err: any) {
+ console.error("Delete failed:", err);
+ toast({ variant: "destructive", title: "Deletion Failed", description: err.message });
+ } finally {
+ setIsDeleting(false);
+ }
+ };
+
+ if (!account) return null;
+
+ const canSubmit = !isDeleting && journalCount === 0;
+
+ return (
+
+
+
+
+
+ Delete Account: {account.account_name}
+
+
+
+
+ You are about to delete{" "}
+
+ {account.account_number} - {account.account_name}
+
+ . This action is permanent.
+
+
+ {checking ? (
+
+ Checking account dependencies...
+
+ ) : (
+ <>
+ {journalCount > 0 && (
+
+
+
+
+
Cannot Delete
+
+ This account is referenced by{" "}
+ {journalCount} journal entries . Remove those references
+ before deleting.
+
+
+
+
+ )}
+
+ {journalCount === 0 && (
+
+ No dependencies found. This action cannot be undone.
+
+ )}
+ >
+ )}
+
+
+
+
+ Cancel
+
+ {isDeleting && }
+ Confirm Delete
+
+
+
+
+ );
+}
diff --git a/src/components/DeleteChartOfAccountDialogFull.jsx b/src/components/DeleteChartOfAccountDialogFull.jsx
new file mode 100644
index 0000000..93c5608
--- /dev/null
+++ b/src/components/DeleteChartOfAccountDialogFull.jsx
@@ -0,0 +1,245 @@
+import React, { useState, useEffect } from 'react';
+import {
+ AlertDialog,
+ AlertDialogContent,
+ AlertDialogHeader,
+ AlertDialogTitle,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogCancel,
+} from "@/components/ui/alert-dialog";
+import { Button } from '@/components/ui/button';
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
+import { AlertTriangle, Loader2, ArrowRight } from 'lucide-react';
+import { supabase } from '@/integrations/supabase/client';
+import { useToast } from '@/hooks/use-toast';
+
+export default function DeleteChartOfAccountDialogFull({
+ open,
+ onOpenChange,
+ account,
+ allAccounts = [],
+ onSuccess
+}) {
+ const { toast } = useToast();
+
+ const [checking, setChecking] = useState(false);
+ const [journalCount, setJournalCount] = useState(0);
+
+ const [hasSubAccounts, setHasSubAccounts] = useState(false);
+ const [deleteMode, setDeleteMode] = useState('cancel');
+ const [newParentId, setNewParentId] = useState('none');
+
+ const [isDeleting, setIsDeleting] = useState(false);
+
+ useEffect(() => {
+ if (open && account) {
+ checkDependencies();
+ } else {
+ setJournalCount(0);
+ setHasSubAccounts(false);
+ setDeleteMode('cancel');
+ setNewParentId('none');
+ }
+ }, [open, account]);
+
+ const checkDependencies = async () => {
+ setChecking(true);
+ try {
+ const subs = allAccounts.filter(a => a.parent_account_id === account.id);
+ setHasSubAccounts(subs.length > 0);
+
+ const { count, error } = await supabase
+ .from('journal_entries')
+ .select('*', { count: 'exact', head: true })
+ .eq('chart_of_account_id', account.id);
+
+ if (error) throw error;
+ setJournalCount(count ?? 0);
+ } catch (err) {
+ console.error("Error checking dependencies:", err);
+ toast({ variant: "destructive", title: "Error", description: "Failed to check account dependencies." });
+ } finally {
+ setChecking(false);
+ }
+ };
+
+ const handleConfirm = async () => {
+ if (!account) return;
+ setIsDeleting(true);
+
+ try {
+ if (journalCount > 0) {
+ throw new Error(`Cannot delete: ${journalCount} journal entries reference this account. Remove them first.`);
+ }
+
+ if (hasSubAccounts) {
+ if (deleteMode === 'delete-all') {
+ const childIds = allAccounts.filter(a => a.parent_account_id === account.id).map(a => a.id);
+ if (childIds.length > 0) {
+ const { error: childErr } = await supabase.from('chart_of_accounts').delete().in('id', childIds);
+ if (childErr) throw childErr;
+ }
+ } else if (deleteMode === 'move') {
+ const target = newParentId === 'none' ? null : newParentId;
+ const { error: moveErr } = await supabase
+ .from('chart_of_accounts')
+ .update({ parent_account_id: target })
+ .eq('parent_account_id', account.id);
+ if (moveErr) throw moveErr;
+ } else {
+ setIsDeleting(false);
+ return;
+ }
+ }
+
+ const { error: delErr } = await supabase
+ .from('chart_of_accounts')
+ .delete()
+ .eq('id', account.id);
+
+ if (delErr) {
+ if (delErr.code === '23503') {
+ throw new Error("Cannot delete: still referenced by other financial records.");
+ }
+ throw delErr;
+ }
+
+ toast({ title: "Account Deleted", description: "The account was successfully deleted." });
+ onSuccess();
+ onOpenChange(false);
+ } catch (err) {
+ console.error("Delete failed:", err);
+ toast({ variant: "destructive", title: "Deletion Failed", description: err.message });
+ } finally {
+ setIsDeleting(false);
+ }
+ };
+
+ if (!account) return null;
+
+ const canSubmit = !isDeleting &&
+ (!hasSubAccounts || deleteMode !== 'cancel') &&
+ journalCount === 0;
+
+ return (
+
+
+
+
+
+ Delete Account: {account.account_name}
+
+
+
+
+ You are about to delete {account.account_number} - {account.account_name} .
+ This action is permanent.
+
+
+ {checking ? (
+
+ Checking account dependencies...
+
+ ) : (
+ <>
+ {journalCount > 0 && (
+
+
+
+
+
Cannot Delete
+
+ This account is referenced by {journalCount} journal entries . Remove those references before deleting.
+
+
+
+
+ )}
+
+ {hasSubAccounts && journalCount === 0 && (
+
+
Warning: This account has nested sub-accounts.
+
Choose how to handle the child accounts:
+
+
+
+ setDeleteMode('delete-all')}
+ />
+
+ Delete All
+ Remove this account AND all its sub-accounts permanently.
+
+
+
+
+
setDeleteMode('move')}
+ />
+
+
+ Move Sub-accounts
+ Reassign sub-accounts to a new parent.
+
+ {deleteMode === 'move' && (
+
+
+
+
+
+
+ -- Make Top Level (No Parent) --
+ {allAccounts
+ .filter(a => a.id !== account.id && a.parent_account_id !== account.id)
+ .map(a => (
+
+ {a.account_name} {a.account_number ? `(${a.account_number})` : ''}
+
+ ))
+ }
+
+
+
+ )}
+
+
+
+
+ )}
+
+ {journalCount === 0 && !hasSubAccounts && (
+
+ No dependencies found. This action is permanent and cannot be undone.
+
+ )}
+ >
+ )}
+
+
+
+
+ Cancel
+
+ {isDeleting && }
+ Confirm Delete
+
+
+
+
+ );
+}
diff --git a/src/components/DeleteReportDialog.jsx b/src/components/DeleteReportDialog.jsx
new file mode 100644
index 0000000..df3d0c7
--- /dev/null
+++ b/src/components/DeleteReportDialog.jsx
@@ -0,0 +1,49 @@
+import React from 'react';
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@/components/ui/alert-dialog";
+import { Loader2 } from 'lucide-react';
+
+export default function DeleteReportDialog({ open, onOpenChange, report, onConfirm, isDeleting }) {
+ if (!report) return null;
+
+ return (
+
+
+
+ Are you sure?
+
+ This will permanently delete the saved report "{report.report_name}". This action cannot be undone.
+
+
+
+ Cancel
+ {
+ e.preventDefault();
+ onConfirm(report.id);
+ }}
+ className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
+ disabled={isDeleting}
+ >
+ {isDeleting ? (
+ <>
+
+ Deleting...
+ >
+ ) : (
+ 'Delete'
+ )}
+
+
+
+
+ );
+}
diff --git a/src/components/DeleteUserDialog.jsx b/src/components/DeleteUserDialog.jsx
new file mode 100644
index 0000000..e800478
--- /dev/null
+++ b/src/components/DeleteUserDialog.jsx
@@ -0,0 +1,82 @@
+import React, { useState } from 'react';
+import { supabase } from '@/integrations/supabase/client';
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from '@/components/ui/alert-dialog';
+import { useToast } from '@/hooks/use-toast';
+import { Loader2 } from 'lucide-react';
+
+export default function DeleteUserDialog({ open, onOpenChange, user: targetUser, onSuccess }) {
+ const [loading, setLoading] = useState(false);
+ const { toast } = useToast();
+
+ const handleDelete = async () => {
+ if (!targetUser) return;
+
+ setLoading(true);
+ try {
+ const { data, error } = await supabase.functions.invoke('admin-auth-actions', {
+ body: JSON.stringify({
+ action: 'deleteUser',
+ userId: targetUser.id
+ })
+ });
+
+ if (error) throw error;
+ if (data?.error) throw new Error(data.error);
+
+ toast({
+ title: "User Deleted",
+ description: `${targetUser.email} has been removed successfully.`,
+ });
+
+ if (onSuccess) onSuccess();
+ onOpenChange(false);
+ } catch (error) {
+ console.error('Error deleting user:', error);
+ toast({
+ variant: "destructive",
+ title: "Delete Failed",
+ description: error.message || "Could not delete user. Check permissions.",
+ });
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+
+
+
+ Are you absolutely sure?
+
+ This action cannot be undone. This will permanently delete the user account for{' '}
+ {targetUser?.email}
+ {targetUser?.full_name && ({targetUser.full_name}) }.
+
+
+
+ Cancel
+ {
+ e.preventDefault();
+ handleDelete();
+ }}
+ disabled={loading}
+ className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
+ >
+ {loading ? : null}
+ Delete User
+
+
+
+
+ );
+}
diff --git a/src/components/DeleteWithReassignDialog.jsx b/src/components/DeleteWithReassignDialog.jsx
new file mode 100644
index 0000000..f035d2c
--- /dev/null
+++ b/src/components/DeleteWithReassignDialog.jsx
@@ -0,0 +1,133 @@
+import React, { useState, useEffect } from 'react';
+import { supabase } from '@/integrations/supabase/client';
+import { useToast } from '@/hooks/use-toast';
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
+import { Button } from '@/components/ui/button';
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
+import { AlertCircle, Loader2 } from 'lucide-react';
+
+export default function DeleteWithReassignDialog({ open, onOpenChange, itemToDelete, itemType, entityName, availableReplacements, associationId, onSuccess }) {
+ const { toast } = useToast();
+ const [loading, setLoading] = useState(false);
+ const [usageCount, setUsageCount] = useState(0);
+ const [replacementName, setReplacementName] = useState('');
+ const [checkingUsage, setCheckingUsage] = useState(false);
+
+ useEffect(() => {
+ if (open && itemToDelete && associationId) {
+ checkUsage();
+ setReplacementName('');
+ }
+ }, [open, itemToDelete, associationId]);
+
+ const checkUsage = async () => {
+ setCheckingUsage(true);
+ try {
+ let field = '';
+ if (itemType === 'type') field = 'account_type';
+ else if (itemType === 'category') field = 'account_type';
+ else if (itemType === 'subcategory') field = 'account_type';
+
+ const { count, error } = await supabase
+ .from('chart_of_accounts')
+ .select('id', { count: 'exact', head: true })
+ .eq('association_id', associationId)
+ .eq(field, itemToDelete.name);
+
+ if (error) throw error;
+ setUsageCount(count || 0);
+ } catch (err) {
+ console.error('Error checking usage:', err);
+ } finally {
+ setCheckingUsage(false);
+ }
+ };
+
+ const handleDelete = async () => {
+ if (usageCount > 0 && !replacementName) {
+ toast({ variant: 'destructive', title: 'Action Required', description: 'Please select a replacement to reassign existing accounts.' });
+ return;
+ }
+
+ setLoading(true);
+ try {
+ let field = '';
+ if (itemType === 'type') field = 'account_type';
+ else if (itemType === 'category') field = 'account_type';
+ else if (itemType === 'subcategory') field = 'account_type';
+
+ if (usageCount > 0) {
+ const { error: updateError } = await supabase
+ .from('chart_of_accounts')
+ .update({ [field]: replacementName })
+ .eq('association_id', associationId)
+ .eq(field, itemToDelete.name);
+
+ if (updateError) throw updateError;
+ }
+
+ toast({ title: 'Deleted Successfully', description: `Deleted ${entityName} and reassigned accounts if needed.` });
+ onSuccess();
+ onOpenChange(false);
+ } catch (err) {
+ console.error('Error deleting:', err);
+ toast({ variant: 'destructive', title: 'Error', description: err.message });
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const replacements = (availableReplacements || []).filter(r => r.name !== itemToDelete?.name);
+
+ return (
+
+
+
+
+ Delete {entityName}
+
+
+ Are you sure you want to delete the {entityName} "{itemToDelete?.name}"?
+
+
+
+
+ {checkingUsage ? (
+
Checking usage...
+ ) : usageCount > 0 ? (
+
+
+ This {entityName} is currently used by {usageCount} account(s). You must select a replacement to reassign these accounts before deleting.
+
+
+ Reassign to:
+
+
+
+
+
+ {replacements.map(r => (
+ {r.name}
+ ))}
+
+
+
+
+ ) : (
+
+ This {entityName} is not currently in use by any accounts. It is safe to delete.
+
+ )}
+
+
+
+ onOpenChange(false)} disabled={loading}>Cancel
+ 0 && !replacementName)}>
+ {loading ? : null}
+ Confirm Delete
+
+
+
+
+ );
+}
diff --git a/src/components/DocuSignSendDialog.tsx b/src/components/DocuSignSendDialog.tsx
new file mode 100644
index 0000000..d6452c9
--- /dev/null
+++ b/src/components/DocuSignSendDialog.tsx
@@ -0,0 +1,332 @@
+import { useState, useEffect } from "react";
+import { supabase } from "@/integrations/supabase/client";
+import { useToast } from "@/hooks/use-toast";
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Textarea } from "@/components/ui/textarea";
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
+import { Badge } from "@/components/ui/badge";
+import { Loader2, Send, Plus, X, FileSignature, UserPlus } from "lucide-react";
+import { extractDocuSignFunctionError } from "@/lib/docusign";
+
+interface Recipient {
+ name: string;
+ email: string;
+}
+
+function arrayBufferToBase64(buffer: ArrayBuffer) {
+ const bytes = new Uint8Array(buffer);
+ const chunkSize = 0x8000;
+ let binary = "";
+
+ for (let index = 0; index < bytes.length; index += chunkSize) {
+ const chunk = bytes.subarray(index, index + chunkSize);
+ binary += String.fromCharCode(...chunk);
+ }
+
+ return btoa(binary);
+}
+
+interface DocuSignSendDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ /** Pre-fill with a document URL from storage */
+ documentUrl?: string;
+ documentName?: string;
+ associationId?: string;
+ onSuccess?: () => void;
+}
+
+export default function DocuSignSendDialog({
+ open,
+ onOpenChange,
+ documentUrl,
+ documentName: initialDocName,
+ associationId: initialAssocId,
+ onSuccess,
+}: DocuSignSendDialogProps) {
+ const { toast } = useToast();
+ const [sending, setSending] = useState(false);
+ const [associations, setAssociations] = useState([]);
+ const [associationId, setAssociationId] = useState(initialAssocId || "");
+ const [documentName, setDocumentName] = useState(initialDocName || "");
+ const [emailSubject, setEmailSubject] = useState("");
+ const [emailBody, setEmailBody] = useState("");
+ const [recipients, setRecipients] = useState([{ name: "", email: "" }]);
+ const [file, setFile] = useState(null);
+ const [consentUrl, setConsentUrl] = useState(null);
+
+ useEffect(() => {
+ if (open) {
+ setConsentUrl(null);
+ supabase
+ .from("associations")
+ .select("id, name")
+ .eq("status", "active")
+ .order("name")
+ .then(({ data }) => setAssociations(data || []));
+
+ if (initialAssocId) setAssociationId(initialAssocId);
+ if (initialDocName) setDocumentName(initialDocName);
+ }
+ }, [open, initialAssocId, initialDocName]);
+
+ const addRecipient = () => setRecipients([...recipients, { name: "", email: "" }]);
+
+ const removeRecipient = (idx: number) => {
+ if (recipients.length <= 1) return;
+ setRecipients(recipients.filter((_, i) => i !== idx));
+ };
+
+ const updateRecipient = (idx: number, field: keyof Recipient, value: string) => {
+ const updated = [...recipients];
+ updated[idx] = { ...updated[idx], [field]: value };
+ setRecipients(updated);
+ };
+
+ const handleSend = async () => {
+ // Validate
+ if (!associationId) {
+ toast({ variant: "destructive", title: "Select an association" });
+ return;
+ }
+ const validRecipients = recipients.filter((r) => r.name.trim() && r.email.trim());
+ if (validRecipients.length === 0) {
+ toast({ variant: "destructive", title: "Add at least one recipient with name and email" });
+ return;
+ }
+ if (!file && !documentUrl) {
+ toast({ variant: "destructive", title: "Upload a document or provide a document URL" });
+ return;
+ }
+
+ setSending(true);
+ try {
+ let base64Doc: string;
+ let ext = "pdf";
+
+ if (file) {
+ // Read file as base64
+ const buffer = await file.arrayBuffer();
+ base64Doc = arrayBufferToBase64(buffer);
+ ext = file.name.split(".").pop() || "pdf";
+ if (!documentName) setDocumentName(file.name);
+ } else if (documentUrl) {
+ // Fetch from storage URL
+ const res = await fetch(documentUrl);
+ const blob = await res.blob();
+ const buffer = await blob.arrayBuffer();
+ base64Doc = arrayBufferToBase64(buffer);
+ ext = documentUrl.split(".").pop()?.split("?")[0] || "pdf";
+ } else {
+ throw new Error("No document provided");
+ }
+
+ const { data, error } = await supabase.functions.invoke("docusign-send", {
+ body: {
+ action: "send",
+ association_id: associationId,
+ document_name: documentName || file?.name || "Document",
+ document_base64: base64Doc,
+ file_extension: ext,
+ recipients: validRecipients,
+ email_subject: emailSubject || `Please sign: ${documentName || "Document"}`,
+ email_body: emailBody || undefined,
+ },
+ });
+
+ if (error) throw error;
+ if (data?.error) throw new Error(data.error);
+
+ setConsentUrl(null);
+
+ toast({
+ title: "Document Sent for Signing",
+ description: `Envelope ${data.envelope_id} sent to ${validRecipients.length} recipient(s).`,
+ });
+
+ onSuccess?.();
+ onOpenChange(false);
+
+ // Reset form
+ setRecipients([{ name: "", email: "" }]);
+ setFile(null);
+ setDocumentName("");
+ setEmailSubject("");
+ setEmailBody("");
+ } catch (err: any) {
+ console.error("DocuSign send error:", err);
+ const parsedError = await extractDocuSignFunctionError(err);
+
+ if (parsedError.code === "consent_required") {
+ setConsentUrl(parsedError.consentUrl ?? null);
+ toast({
+ variant: "destructive",
+ title: "DocuSign consent required",
+ description: "Open the consent link, approve access, then try sending again.",
+ });
+ return;
+ }
+
+ toast({
+ variant: "destructive",
+ title: "Failed to Send",
+ description: parsedError.message || "An error occurred while sending the document.",
+ });
+ } finally {
+ setSending(false);
+ }
+ };
+
+ return (
+
+
+
+
+
+ Send for Signature
+
+
+ Send a document via DocuSign for electronic signature.
+
+
+
+
+ {/* Association */}
+
+ Association
+
+
+
+
+
+ {associations.map((a) => (
+ {a.name}
+ ))}
+
+
+
+
+ {/* Document Upload */}
+
+
Document
+ {documentUrl ? (
+
+
+ {documentName || "Linked document"}
+ From storage
+
+ ) : (
+
{
+ const f = e.target.files?.[0] || null;
+ setFile(f);
+ if (f && !documentName) setDocumentName(f.name);
+ }}
+ />
+ )}
+
+
+ {/* Document Name */}
+
+ Document Name
+ setDocumentName(e.target.value)}
+ />
+
+
+ {/* Email Subject */}
+
+ Email Subject (optional)
+ setEmailSubject(e.target.value)}
+ />
+
+
+ {/* Email Body */}
+
+ Message (optional)
+ setEmailBody(e.target.value)}
+ rows={2}
+ />
+
+
+ {/* Recipients */}
+
+
+
+
Signature placement tips:
+
• Add /sig/ in your document where signatures should appear
+
• Add /date/ where the signed date should appear
+
• DocuSign will automatically detect these anchors
+
+
+ {consentUrl && (
+
+
DocuSign access needs one-time consent
+
Open the consent page, approve access, then come back and resend the document.
+
window.open(consentUrl, "_blank", "noopener,noreferrer")}
+ >
+ Grant DocuSign Consent
+
+
+ )}
+
+
+
+ onOpenChange(false)} disabled={sending}>
+ Cancel
+
+
+ {sending ? : }
+ {sending ? "Sending..." : "Send for Signature"}
+
+
+
+
+ );
+}
diff --git a/src/components/DocumentDialog.jsx b/src/components/DocumentDialog.jsx
new file mode 100644
index 0000000..31cf8fd
--- /dev/null
+++ b/src/components/DocumentDialog.jsx
@@ -0,0 +1,346 @@
+import React, { useState, useEffect, useRef } from 'react';
+import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
+import { Button } from '@/components/ui/button';
+import { Label } from '@/components/ui/label';
+import { supabase } from '@/integrations/supabase/client';
+import { useToast } from '@/hooks/use-toast';
+import { useAuth } from '@/contexts/AuthContext';
+import { Plus, Trash2, FileText, UploadCloud, X, Loader2 } from 'lucide-react';
+import { cn } from '@/lib/utils';
+
+function DocumentDialog({ open, onOpenChange, onSuccess }) {
+ const { toast } = useToast();
+ const { user } = useAuth();
+ const [associations, setAssociations] = useState([]);
+
+ const [isDragging, setIsDragging] = useState(false);
+ const fileInputRef = useRef(null);
+
+ const [files, setFiles] = useState([
+ { id: Date.now(), title: '', description: '', file_url: '', fileObject: null }
+ ]);
+
+ const [commonData, setCommonData] = useState({
+ association_id: '',
+ category: '',
+ });
+
+ const [loading, setLoading] = useState(false);
+ const [uploadProgress, setUploadProgress] = useState(0);
+
+ useEffect(() => {
+ const fetchData = async () => {
+ const { data } = await supabase.from('associations').select('id, name').eq('status', 'active').order('name');
+ setAssociations(data || []);
+ };
+
+ if (open) {
+ fetchData();
+ setFiles([{ id: Date.now(), title: '', description: '', file_url: '', fileObject: null }]);
+ setCommonData({ association_id: '', category: '' });
+ setUploadProgress(0);
+ }
+ }, [open]);
+
+ const handleDragOver = (e) => { e.preventDefault(); setIsDragging(true); };
+ const handleDragLeave = (e) => { e.preventDefault(); setIsDragging(false); };
+
+ const processDroppedFiles = (droppedFiles) => {
+ const newFiles = Array.from(droppedFiles).map(file => ({
+ id: Math.random().toString(36).substr(2, 9),
+ title: file.name,
+ description: '',
+ file_url: '',
+ fileObject: file
+ }));
+
+ if (files.length === 1 && !files[0].title && !files[0].file_url && !files[0].fileObject) {
+ setFiles(newFiles);
+ } else {
+ setFiles(prev => [...prev, ...newFiles]);
+ }
+ };
+
+ const handleDrop = (e) => {
+ e.preventDefault();
+ setIsDragging(false);
+ if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
+ processDroppedFiles(e.dataTransfer.files);
+ }
+ };
+
+ const handleFileInputChange = (e) => {
+ if (e.target.files && e.target.files.length > 0) {
+ processDroppedFiles(e.target.files);
+ }
+ if (fileInputRef.current) fileInputRef.current.value = '';
+ };
+
+ const handleAddEmptyRow = () => {
+ setFiles([...files, { id: Date.now(), title: '', description: '', file_url: '', fileObject: null }]);
+ };
+
+ const handleRemoveFile = (id) => {
+ if (files.length > 1) {
+ setFiles(files.filter(f => f.id !== id));
+ } else {
+ setFiles([{ id: Date.now(), title: '', description: '', file_url: '', fileObject: null }]);
+ }
+ };
+
+ const handleFileChange = (id, field, value) => {
+ setFiles(files.map(f => f.id === id ? { ...f, [field]: value } : f));
+ };
+
+ const uploadFileToStorage = async (fileObj) => {
+ if (!user) throw new Error("User not authenticated");
+ const fileExt = fileObj.name.split('.').pop();
+ const fileName = `${Date.now()}-${Math.random().toString(36).substring(2)}.${fileExt}`;
+ const filePath = `${user.id}/${fileName}`;
+
+ const { error: uploadError } = await supabase.storage
+ .from('files')
+ .upload(filePath, fileObj);
+
+ if (uploadError) throw uploadError;
+
+ const { data: { publicUrl } } = supabase.storage
+ .from('files')
+ .getPublicUrl(filePath);
+
+ return publicUrl;
+ };
+
+ const handleSubmit = async (e) => {
+ e.preventDefault();
+ setLoading(true);
+ setUploadProgress(0);
+
+ if (!user) {
+ toast({ variant: "destructive", title: "Error", description: "You must be logged in to upload documents." });
+ setLoading(false);
+ return;
+ }
+
+ if (!commonData.association_id) {
+ toast({ variant: "destructive", title: "Error", description: "Please select an association." });
+ setLoading(false);
+ return;
+ }
+
+ const validFiles = files.filter(f => f.title.trim() !== '');
+ if (validFiles.length === 0) {
+ toast({ variant: "destructive", title: "Error", description: "Please add at least one document with a title." });
+ setLoading(false);
+ return;
+ }
+
+ try {
+ const documentsToInsert = [];
+ let completedCount = 0;
+
+ for (const file of validFiles) {
+ let finalUrl = file.file_url;
+ if (file.fileObject) {
+ finalUrl = await uploadFileToStorage(file.fileObject);
+ }
+
+ documentsToInsert.push({
+ title: file.title,
+ file_url: finalUrl,
+ file_name: file.fileObject?.name || file.title,
+ file_size: file.fileObject?.size || null,
+ association_id: commonData.association_id,
+ category: commonData.category || 'general',
+ uploaded_by: user.id,
+ });
+
+ completedCount++;
+ setUploadProgress(Math.round((completedCount / validFiles.length) * 100));
+ }
+
+ const { error } = await supabase.from('documents').insert(documentsToInsert);
+ if (error) throw error;
+
+ toast({
+ title: "Success",
+ description: `Successfully uploaded ${validFiles.length} document(s).`,
+ });
+ onSuccess();
+ onOpenChange(false);
+ } catch (error) {
+ console.error('Upload error:', error);
+ toast({
+ variant: "destructive",
+ title: "Upload Failed",
+ description: error.message || "An error occurred while uploading documents.",
+ });
+ } finally {
+ setLoading(false);
+ setUploadProgress(0);
+ }
+ };
+
+ return (
+
+
+
+ Upload Documents
+
+
+
+
+
+ Association *
+ setCommonData({ ...commonData, association_id: e.target.value })}
+ required
+ className="w-full mt-1.5 px-3 py-2 text-sm border rounded-md focus:ring-2 focus:ring-primary bg-background"
+ >
+ Select an association
+ {associations.map((a) => (
+ {a.name}
+ ))}
+
+
+
+ Category (Optional)
+ setCommonData({ ...commonData, category: e.target.value })}
+ className="w-full mt-1.5 px-3 py-2 text-sm border rounded-md focus:ring-2 focus:ring-primary bg-background"
+ >
+ General
+ Financial
+ Legal
+ Insurance
+ Meeting Minutes
+
+
+
+
+ fileInputRef.current?.click()}
+ >
+
+
+
+
+
+
+ Click to upload or drag and drop
+
+
+ PDF, DOCX, XLSX, Images (max 10MB)
+
+
+
+
+
+
+
Files to Upload ({files.length})
+
+ Add Manual Link
+
+
+
+
+ {files.map((file) => (
+
+
handleRemoveFile(file.id)}
+ className="absolute top-2 right-2 h-6 w-6 text-muted-foreground hover:text-destructive opacity-0 group-hover:opacity-100 transition-opacity"
+ >
+
+
+
+
+
+ ))}
+
+
+
+
+ onOpenChange(false)} disabled={loading}>
+ Cancel
+
+
+ {loading ? (
+ <>
+
+ {uploadProgress > 0 ? `${uploadProgress}%` : 'Processing...'}
+ >
+ ) : (
+ `Upload ${files.length} File${files.length !== 1 ? 's' : ''}`
+ )}
+
+
+
+
+
+ );
+}
+
+export default DocumentDialog;
diff --git a/src/components/DropdownElementDialog.jsx b/src/components/DropdownElementDialog.jsx
new file mode 100644
index 0000000..57f349f
--- /dev/null
+++ b/src/components/DropdownElementDialog.jsx
@@ -0,0 +1,8 @@
+import React from 'react';
+
+const DropdownElementDialog = () => {
+ return null;
+};
+
+export { DropdownElementDialog };
+export default DropdownElementDialog;
diff --git a/src/components/EditBillDialog.jsx b/src/components/EditBillDialog.jsx
new file mode 100644
index 0000000..7693bab
--- /dev/null
+++ b/src/components/EditBillDialog.jsx
@@ -0,0 +1,409 @@
+import React, { useState, useEffect } from 'react';
+import { useForm, Controller, useWatch } from 'react-hook-form';
+import { zodResolver } from '@hookform/resolvers/zod';
+import * as z from 'zod';
+import { supabase } from '@/integrations/supabase/client';
+import { useToast } from '@/hooks/use-toast';
+import { useAuth } from '@/contexts/AuthContext';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import { Textarea } from '@/components/ui/textarea';
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select';
+import { Loader2, DollarSign, AlertCircle, Upload, X } from 'lucide-react';
+import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
+
+const formSchema = z.object({
+ association_id: z.string().min(1, "Association is required."),
+ expense_account_id: z.string().min(1, "GL Account is required."),
+ invoice_number: z.string().min(1, "Invoice number is required."),
+ vendor_name: z.string().min(2, "Vendor name must be at least 2 characters."),
+ bill_date: z.string().min(1, "Bill date is required."),
+ due_date: z.string().min(1, "Due date is required."),
+ amount: z.coerce.number().positive("Amount must be greater than 0."),
+ description: z.string().optional(),
+}).refine(data => {
+ if (!data.bill_date || !data.due_date) return true;
+ return new Date(data.due_date) >= new Date(data.bill_date);
+}, {
+ message: "Due date must be greater than or equal to bill date.",
+ path: ["due_date"]
+});
+
+export default function EditBillDialog({ open, onOpenChange, bill, onSuccess }) {
+ const { toast } = useToast();
+ const { user } = useAuth();
+
+ const [associations, setAssociations] = useState([]);
+ const [coas, setCoas] = useState([]);
+ const [loadingAssociations, setLoadingAssociations] = useState(false);
+ const [loadingCoas, setLoadingCoas] = useState(false);
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const [fetchError, setFetchError] = useState(null);
+ const [pdfFile, setPdfFile] = useState(null);
+
+ const { register, handleSubmit, control, reset, formState: { errors, isValid } } = useForm({
+ resolver: zodResolver(formSchema),
+ defaultValues: {
+ association_id: '',
+ expense_account_id: '',
+ invoice_number: '',
+ vendor_name: '',
+ bill_date: '',
+ due_date: '',
+ amount: '',
+ description: '',
+ },
+ mode: 'onChange'
+ });
+
+ useEffect(() => {
+ if (open && bill) {
+ reset({
+ association_id: bill.association_id || '',
+ expense_account_id: bill.expense_account_id || '',
+ invoice_number: bill.invoice_number || '',
+ vendor_name: bill.vendor_name || '',
+ bill_date: bill.bill_date || new Date().toISOString().split('T')[0],
+ due_date: bill.due_date || new Date().toISOString().split('T')[0],
+ amount: bill.amount || '',
+ description: bill.description || '',
+ });
+ setPdfFile(null);
+ setFetchError(null);
+ }
+ }, [open, bill, reset]);
+
+ // Watch the selected association so we can re-load the COA
+ // scoped to its accounting system (zoho vs buildium).
+ const watchedAssociationId = useWatch({ control, name: 'association_id' });
+
+ useEffect(() => {
+ if (!open) return;
+
+ async function fetchAssociations() {
+ setLoadingAssociations(true);
+ try {
+ const { data: aData, error: aErr } = await supabase
+ .from('associations')
+ .select('id, name, zoho_organization_id')
+ .eq('status', 'active')
+ .order('name');
+ if (aErr) throw aErr;
+ setAssociations(aData || []);
+ } catch (err) {
+ console.error('Error fetching associations:', err);
+ setFetchError('Failed to load associations.');
+ } finally {
+ setLoadingAssociations(false);
+ }
+ }
+
+ fetchAssociations();
+ }, [open]);
+
+ useEffect(() => {
+ if (!open) return;
+ if (!watchedAssociationId) { setCoas([]); return; }
+
+ async function fetchCoas() {
+ setLoadingCoas(true);
+ try {
+ const assoc = associations.find(a => a.id === watchedAssociationId);
+ const system = assoc?.zoho_organization_id ? 'zoho' : 'buildium';
+ const { data: coaData, error: coaErr } = await supabase
+ .from('chart_of_accounts')
+ .select('id, account_name, account_number, account_type')
+ .eq('is_active', true)
+ .eq('accounting_system', system)
+ .order('account_number');
+ if (coaErr) throw coaErr;
+ setCoas(coaData || []);
+ } catch (err) {
+ console.error('Error fetching chart of accounts:', err);
+ setFetchError('Failed to load chart of accounts.');
+ } finally {
+ setLoadingCoas(false);
+ }
+ }
+
+ fetchCoas();
+ }, [open, watchedAssociationId, associations]);
+
+ const uploadPdf = async () => {
+ if (!pdfFile) return null;
+ const fileExt = pdfFile.name.split('.').pop();
+ const fileName = `${crypto.randomUUID()}.${fileExt}`;
+ const filePath = `bills/${fileName}`;
+
+ const { error: uploadError } = await supabase.storage
+ .from('files')
+ .upload(filePath, pdfFile);
+
+ if (uploadError) throw uploadError;
+
+ const { data } = supabase.storage.from('files').getPublicUrl(filePath);
+ return data.publicUrl;
+ };
+
+ const onSubmit = async (data) => {
+ setIsSubmitting(true);
+ try {
+ let pdfUrl = bill.attachment_url;
+ if (pdfFile) {
+ pdfUrl = await uploadPdf();
+ }
+
+ const { error: billError } = await supabase
+ .from('bills')
+ .update({
+ association_id: data.association_id,
+ expense_account_id: data.expense_account_id,
+ bill_date: data.bill_date,
+ due_date: data.due_date,
+ amount: data.amount,
+ description: data.description || `Invoice ${data.invoice_number} from ${data.vendor_name}`,
+ invoice_number: data.invoice_number,
+ attachment_url: pdfUrl,
+ })
+ .eq('id', bill.id);
+
+ if (billError) throw billError;
+
+ toast({
+ title: "Bill Updated Successfully",
+ description: "The bill has been updated.",
+ });
+
+ onSuccess();
+ onOpenChange(false);
+ } catch (err) {
+ console.error('Update error:', err);
+ toast({
+ title: "Failed to update bill",
+ description: err.message || "An unexpected error occurred.",
+ variant: "destructive"
+ });
+ } finally {
+ setIsSubmitting(false);
+ }
+ };
+
+ return (
+
+
+
+ Edit Bill {bill?.invoice_number ? `(${bill.invoice_number})` : ''}
+
+ Update bill details and ensure accounting records remain balanced.
+
+
+
+ {fetchError && (
+
+
+ Error
+ {fetchError}
+
+ )}
+
+
+
+
+
Association *
+
(
+
+
+
+
+
+ {associations.map(a => (
+ {a.name}
+ ))}
+
+
+ )}
+ />
+ {errors.association_id && {errors.association_id.message}
}
+
+
+
+
GL Account (Expense) *
+
(
+
+
+
+
+
+ {coas.map(acc => (
+
+ {acc.account_number} - {acc.account_name}
+
+ ))}
+ {coas.length === 0 && !loadingCoas && (
+ No expense accounts found.
+ )}
+
+
+ )}
+ />
+ {errors.expense_account_id && {errors.expense_account_id.message}
}
+
+
+
+
+
Bill/Invoice Number *
+
+ {errors.invoice_number &&
{errors.invoice_number.message}
}
+
+
+
+
Vendor Name *
+
+ {errors.vendor_name &&
{errors.vendor_name.message}
}
+
+
+
+
+
+
Bill Date *
+
+ {errors.bill_date &&
{errors.bill_date.message}
}
+
+
+
+
Due Date *
+
+ {errors.due_date &&
{errors.due_date.message}
}
+
+
+
+
+
Amount *
+
+
+
+
+ {errors.amount &&
{errors.amount.message}
}
+
+
+
+ Description / Notes
+
+
+
+
+
Supporting Document (PDF)
+
+
setPdfFile(e.target.files?.[0] || null)}
+ className="absolute inset-0 w-full h-full opacity-0 cursor-pointer z-10"
+ accept=".pdf,.png,.jpg,.jpeg"
+ />
+ {pdfFile ? (
+
+
{pdfFile.name}
+
{
+ e.stopPropagation();
+ setPdfFile(null);
+ }}
+ >
+ Remove
+
+
+ ) : (
+
+
+
+
+ {bill?.attachment_url ? (
+
Existing PDF attached. Click to replace.
+ ) : (
+
Click to upload or drag and drop
+ )}
+
+ )}
+
+
+
+
+ onOpenChange(false)}
+ disabled={isSubmitting}
+ >
+ Cancel
+
+
+ {isSubmitting ? (
+ <> Saving...>
+ ) : (
+ 'Save Changes'
+ )}
+
+
+
+
+
+ );
+}
diff --git a/src/components/EmailAddressDialog.jsx b/src/components/EmailAddressDialog.jsx
new file mode 100644
index 0000000..51890d3
--- /dev/null
+++ b/src/components/EmailAddressDialog.jsx
@@ -0,0 +1,189 @@
+import React, { useState, useEffect } from 'react';
+import { useForm } from 'react-hook-form';
+import { zodResolver } from '@hookform/resolvers/zod';
+import * as z from 'zod';
+import {
+ Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
+} from "@/components/ui/dialog";
+import {
+ Form, FormControl, FormField, FormItem, FormLabel, FormMessage,
+} from "@/components/ui/form";
+import {
+ Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
+} from "@/components/ui/select";
+import { Input } from "@/components/ui/input";
+import { Button } from "@/components/ui/button";
+import { useToast } from "@/hooks/use-toast";
+import { supabase } from '@/integrations/supabase/client';
+import { Loader2 } from "lucide-react";
+
+const formSchema = z.object({
+ email_address: z.string().email("Invalid email address format").min(5, "Email is too short"),
+ association_id: z.string().min(1, "Please select an association"),
+});
+
+export default function EmailAddressDialog({ open, onOpenChange, onSuccess, preSelectedAssociationId = null }) {
+ const [associations, setAssociations] = useState([]);
+ const [loadingAssociations, setLoadingAssociations] = useState(false);
+ const { toast } = useToast();
+
+ const form = useForm({
+ resolver: zodResolver(formSchema),
+ defaultValues: {
+ email_address: '',
+ association_id: preSelectedAssociationId || '',
+ },
+ });
+
+ useEffect(() => {
+ if (preSelectedAssociationId) {
+ form.setValue('association_id', preSelectedAssociationId);
+ }
+ }, [preSelectedAssociationId, form]);
+
+ useEffect(() => {
+ if (open) {
+ fetchAssociations();
+ form.reset({
+ email_address: '',
+ association_id: preSelectedAssociationId || '',
+ });
+ }
+ }, [open]);
+
+ const fetchAssociations = async () => {
+ setLoadingAssociations(true);
+ try {
+ const { data, error } = await supabase
+ .from('associations')
+ .select('id, name')
+ .order('name');
+
+ if (error) throw error;
+ setAssociations(data || []);
+ } catch (error) {
+ console.error('Error fetching associations:', error);
+ toast({
+ variant: "destructive",
+ title: "Error",
+ description: "Failed to load associations list.",
+ });
+ } finally {
+ setLoadingAssociations(false);
+ }
+ };
+
+ const onSubmit = async (values) => {
+ try {
+ // Check if email already exists
+ const { data: existing } = await supabase
+ .from('client_email_addresses')
+ .select('id')
+ .eq('email_address', values.email_address)
+ .maybeSingle();
+
+ if (existing) {
+ toast({
+ variant: "destructive",
+ title: "Duplicate Email",
+ description: "This email address is already assigned to an association.",
+ });
+ return;
+ }
+
+ const { error } = await supabase
+ .from('client_email_addresses')
+ .insert({
+ client_id: values.association_id,
+ email_address: values.email_address,
+ });
+
+ if (error) throw error;
+
+ toast({
+ title: "Success",
+ description: "Email routing rule created successfully.",
+ });
+
+ onSuccess?.();
+ onOpenChange(false);
+ } catch (error) {
+ console.error('Error creating email rule:', error);
+ toast({
+ variant: "destructive",
+ title: "Error",
+ description: error.message || "Failed to create email routing rule.",
+ });
+ }
+ };
+
+ return (
+
+
+
+ Add Email Routing Rule
+
+ Assign an email address to an association. All emails sent to this address will be routed to the selected association.
+
+
+
+
+
+ (
+
+ Association
+
+
+
+
+
+
+
+ {associations.map((assoc) => (
+
+ {assoc.name}
+
+ ))}
+
+
+
+
+ )}
+ />
+
+ (
+
+ Email Address
+
+
+
+
+
+ )}
+ />
+
+
+ onOpenChange(false)}>
+ Cancel
+
+
+ {form.formState.isSubmitting && }
+ Save Rule
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/EstoppelDialog.jsx b/src/components/EstoppelDialog.jsx
new file mode 100644
index 0000000..391e363
--- /dev/null
+++ b/src/components/EstoppelDialog.jsx
@@ -0,0 +1,243 @@
+import React, { useState, useEffect } from 'react';
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
+import { Button } from '@/components/ui/button';
+import { Label } from '@/components/ui/label';
+import { supabase } from '@/integrations/supabase/client';
+import { useToast } from '@/hooks/use-toast';
+import { useAuth } from '@/contexts/AuthContext';
+import { Loader2 } from 'lucide-react';
+
+const ESTOPPEL_STAGES = [
+ "Estoppel Requested",
+ "Estoppel Received",
+ "Estoppel Issued",
+ "Closing Documents Received"
+];
+
+function EstoppelDialog({ open, onOpenChange, onSuccess, estoppel = null }) {
+ const { toast } = useToast();
+ const { user } = useAuth();
+ const [associations, setAssociations] = useState([]);
+ const [loading, setLoading] = useState(false);
+
+ const [scopeType, setScopeType] = useState('property');
+
+ const [formData, setFormData] = useState({
+ association_id: '',
+ address: '',
+ status: 'Estoppel Requested',
+ notes: '',
+ });
+
+ useEffect(() => {
+ const fetchAssociations = async () => {
+ try {
+ const { data, error } = await supabase.from('associations').select('id, name').eq('status', 'active').order('name');
+ if (error) throw error;
+ setAssociations(data || []);
+ } catch (error) {
+ console.error('EstoppelDialog.fetchAssociations', error);
+ }
+ };
+
+ if (open) {
+ fetchAssociations();
+ if (estoppel) {
+ const isAssociationLevel = estoppel.address === 'Association-level';
+ setScopeType(isAssociationLevel ? 'association' : 'property');
+
+ setFormData({
+ association_id: estoppel.association_id,
+ address: estoppel.address,
+ status: estoppel.status,
+ notes: estoppel.notes || '',
+ });
+ } else {
+ setScopeType('property');
+ setFormData({
+ association_id: '',
+ address: '',
+ status: 'Estoppel Requested',
+ notes: '',
+ });
+ }
+ }
+ }, [open, estoppel]);
+
+ const handleSubmit = async (e) => {
+ e.preventDefault();
+ setLoading(true);
+
+ try {
+ const finalAddress = scopeType === 'association' ? 'Association-level' : formData.address;
+
+ if (!formData.association_id) {
+ throw new Error("Association is required.");
+ }
+ if (scopeType === 'property' && !finalAddress.trim()) {
+ throw new Error("Please enter a property address.");
+ }
+
+ const dataToSubmit = {
+ association_id: formData.association_id,
+ address: finalAddress,
+ status: formData.status,
+ notes: formData.notes,
+ created_by: user.id,
+ updated_at: new Date().toISOString()
+ };
+
+ if (!estoppel) {
+ dataToSubmit.created_at = new Date().toISOString();
+ }
+
+ let error;
+ if (estoppel) {
+ const { error: updateError } = await supabase
+ .from('estoppels')
+ .update(dataToSubmit)
+ .eq('id', estoppel.id);
+ error = updateError;
+ } else {
+ const { error: insertError } = await supabase
+ .from('estoppels')
+ .insert([dataToSubmit]);
+ error = insertError;
+ }
+
+ if (error) throw error;
+
+ toast({
+ title: estoppel ? "Estoppel Updated" : "Estoppel Created",
+ description: estoppel ? "Estoppel has been successfully updated." : "Estoppel has been successfully created.",
+ });
+ onSuccess();
+ onOpenChange(false);
+ } catch (error) {
+ console.error('EstoppelDialog.handleSubmit', error);
+ toast({
+ variant: "destructive",
+ title: "Error",
+ description: error.message,
+ });
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+
+
+
+ {estoppel ? 'Edit Estoppel' : 'New Estoppel Request'}
+
+ {estoppel ? 'Update estoppel details and status.' : 'Create a new estoppel record.'}
+
+
+
+
+ Association *
+ setFormData({ ...formData, association_id: e.target.value })}
+ required
+ className="w-full mt-1 px-3 py-2 border border-border rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent bg-background"
+ >
+ Select an association
+ {associations.map((assoc) => (
+ {assoc.name}
+ ))}
+
+
+
+
+
+ {scopeType === 'property' && (
+
+ Address *
+ setFormData({ ...formData, address: e.target.value })}
+ required={scopeType === 'property'}
+ placeholder="Enter property address"
+ className="w-full mt-1 px-3 py-2 border border-border rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent bg-background"
+ />
+
+ )}
+
+
+ Current Stage *
+ setFormData({ ...formData, status: e.target.value })}
+ required
+ className="w-full mt-1 px-3 py-2 border border-border rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent bg-background"
+ >
+ {ESTOPPEL_STAGES.map((stage) => (
+ {stage}
+ ))}
+
+
+
+
+ Notes
+ setFormData({ ...formData, notes: e.target.value })}
+ rows={3}
+ placeholder="Add any additional details..."
+ className="w-full mt-1 px-3 py-2 border border-border rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent bg-background"
+ />
+
+
+
+ onOpenChange(false)}>
+ Cancel
+
+
+ {loading && }
+ {loading ? (estoppel ? 'Updating...' : 'Creating...') : (estoppel ? 'Save Changes' : 'Create Record')}
+
+
+
+
+
+ );
+}
+
+export default EstoppelDialog;
diff --git a/src/components/ExpenseBundleDialog.jsx b/src/components/ExpenseBundleDialog.jsx
new file mode 100644
index 0000000..3beb1cb
--- /dev/null
+++ b/src/components/ExpenseBundleDialog.jsx
@@ -0,0 +1,208 @@
+import React, { useState, useEffect } from 'react';
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
+import { Button } from '@/components/ui/button';
+import { Label } from '@/components/ui/label';
+import { Input } from '@/components/ui/input';
+import { Textarea } from '@/components/ui/textarea';
+import { supabase } from '@/integrations/supabase/client';
+import { useToast } from '@/hooks/use-toast';
+import { useAuth } from '@/contexts/AuthContext';
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
+import ExpenseBundleSelector from '@/components/ExpenseBundleSelector';
+
+export default function ExpenseBundleDialog({ open, onOpenChange, onSuccess, bundle, preselectedIds }) {
+ const { user } = useAuth();
+ const { toast } = useToast();
+ const [loading, setLoading] = useState(false);
+ const [associations, setAssociations] = useState([]);
+
+ const [formData, setFormData] = useState({
+ association_id: '',
+ name: '',
+ description: '',
+ });
+
+ const [selectedExpenseIds, setSelectedExpenseIds] = useState(new Set());
+
+ useEffect(() => {
+ if (open) {
+ fetchAssociations();
+ if (bundle) {
+ setFormData({
+ association_id: bundle.client_id || '',
+ name: bundle.name,
+ description: bundle.description || '',
+ });
+ fetchBundleExpenses(bundle.id);
+ } else if (preselectedIds && preselectedIds.size > 0) {
+ setFormData({ association_id: '', name: '', description: '' });
+ setSelectedExpenseIds(new Set(preselectedIds));
+ } else {
+ setFormData({ association_id: '', name: '', description: '' });
+ setSelectedExpenseIds(new Set());
+ }
+ }
+ }, [open, bundle]);
+
+ const fetchAssociations = async () => {
+ const { data } = await supabase.from('associations').select('id, name').eq('status', 'active').order('name');
+ setAssociations(data || []);
+ };
+
+ const fetchBundleExpenses = async (bundleId) => {
+ const { data, error } = await supabase
+ .from('bundle_expenses')
+ .select('expense_id, fee_schedule_id')
+ .eq('bundle_id', bundleId);
+ if (data && !error) {
+ setSelectedExpenseIds(
+ new Set(
+ data
+ .map((item) => item.expense_id || (item.fee_schedule_id ? `fee:${item.fee_schedule_id}` : null))
+ .filter(Boolean)
+ )
+ );
+ }
+ };
+
+ const handleAssociationChange = (associationId) => {
+ if (associationId !== formData.association_id) {
+ setFormData(prev => ({ ...prev, association_id: associationId }));
+ setSelectedExpenseIds(new Set());
+ }
+ };
+
+ const handleSubmit = async () => {
+ if (!formData.name || !formData.association_id) {
+ toast({ variant: "destructive", title: "Missing fields", description: "Name and Association are required." });
+ return;
+ }
+
+ setLoading(true);
+ try {
+ let bundleId = bundle?.id;
+
+ const bundlePayload = {
+ name: formData.name,
+ description: formData.description,
+ client_id: formData.association_id,
+ updated_at: new Date().toISOString()
+ };
+
+ if (bundleId) {
+ const { error } = await supabase.from('expense_bundles').update(bundlePayload).eq('id', bundleId);
+ if (error) throw error;
+ } else {
+ const { data, error } = await supabase
+ .from('expense_bundles')
+ .insert([{ ...bundlePayload, created_by: user?.id }])
+ .select()
+ .single();
+ if (error) throw error;
+ bundleId = data.id;
+ }
+
+ // Sync bundle expenses
+ await supabase.from('bundle_expenses').delete().eq('bundle_id', bundleId);
+
+ if (selectedExpenseIds.size > 0) {
+ const links = Array.from(selectedExpenseIds).map((itemId) => {
+ if (String(itemId).startsWith('fee:')) {
+ return {
+ bundle_id: bundleId,
+ fee_schedule_id: String(itemId).replace('fee:', ''),
+ };
+ }
+
+ return {
+ bundle_id: bundleId,
+ expense_id: itemId,
+ };
+ });
+
+ const { error: linkError } = await supabase.from('bundle_expenses').insert(links);
+ if (linkError) throw linkError;
+ }
+
+ toast({ title: "Success", description: `Bundle ${bundle ? 'updated' : 'created'} with ${selectedExpenseIds.size} items.` });
+ onSuccess?.();
+ onOpenChange(false);
+ } catch (error) {
+ console.error('Bundle save error:', error);
+ toast({ variant: "destructive", title: "Error", description: error.message || "Failed to save bundle." });
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+
+
+
+ {bundle ? 'Edit Expense Bundle' : 'Create Expense Bundle'}
+ Group related expenses together for organized billing.
+
+
+
+
+
+
+ Association
+
+
+
+
+
+ {associations.map(a => (
+ {a.name}
+ ))}
+
+
+
+
+ Bundle Name
+ setFormData(prev => ({ ...prev, name: e.target.value }))}
+ placeholder="e.g. July 2024 Legal Fees"
+ className="bg-background"
+ />
+
+
+
+ Description
+ setFormData(prev => ({ ...prev, description: e.target.value }))}
+ placeholder="Optional details about this bundle..."
+ className="h-[108px] bg-background resize-none"
+ />
+
+
+
+
+
Select Fees to Include
+ {formData.association_id ? (
+
+ ) : (
+
+ Please select an association to view existing billable expenses and universal fee templates.
+
+ )}
+
+
+
+
+ onOpenChange(false)}>Cancel
+
+ {loading ? 'Saving...' : (bundle ? 'Update Bundle' : 'Create Bundle')}
+
+
+
+
+ );
+}
diff --git a/src/components/ExpenseBundleSelector.jsx b/src/components/ExpenseBundleSelector.jsx
new file mode 100644
index 0000000..24b16c4
--- /dev/null
+++ b/src/components/ExpenseBundleSelector.jsx
@@ -0,0 +1,224 @@
+import React, { useState, useEffect, useMemo } from 'react';
+import { supabase } from '@/integrations/supabase/client';
+import { Badge } from '@/components/ui/badge';
+import { Button } from '@/components/ui/button';
+import { Combobox } from '@/components/Combobox';
+import { Loader2, Plus, X } from 'lucide-react';
+import { format } from 'date-fns';
+
+export default function ExpenseBundleSelector({ associationId, selectedIds, onSelectionChange }) {
+ const [expenses, setExpenses] = useState([]);
+ const [feeSchedules, setFeeSchedules] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [rows, setRows] = useState(['']);
+
+ useEffect(() => {
+ if (associationId) {
+ fetchExpenses();
+ } else {
+ setExpenses([]);
+ setFeeSchedules([]);
+ setRows(['']);
+ }
+ }, [associationId]);
+
+ useEffect(() => {
+ const ids = Array.from(selectedIds);
+ setRows((currentRows) => {
+ const filledRows = currentRows.filter(Boolean);
+ const hasSameSelection =
+ filledRows.length === ids.length &&
+ filledRows.every((id) => ids.includes(id));
+
+ if (hasSameSelection) {
+ return currentRows.length ? currentRows : [''];
+ }
+
+ return ids.length ? ids : [''];
+ });
+ }, [selectedIds, associationId]);
+
+ const fetchExpenses = async () => {
+ setLoading(true);
+ const [expensesRes, feeRes] = await Promise.all([
+ supabase
+ .from('billable_expenses')
+ .select('*')
+ .eq('association_id', associationId)
+ .order('date', { ascending: false }),
+ supabase
+ .from('fee_schedules')
+ .select('*')
+ .order('description')
+ ]);
+
+ if (!expensesRes.error) setExpenses(expensesRes.data || []);
+ if (!feeRes.error) setFeeSchedules(feeRes.data || []);
+ setLoading(false);
+ };
+
+ const allRows = useMemo(() => {
+ const existingKeys = new Set(
+ expenses.map((expense) => `${expense.description || ''}::${expense.category || ''}::${expense.billable_type || ''}`)
+ );
+
+ const universalFees = feeSchedules
+ .filter((fee) => !existingKeys.has(`${fee.description || ''}::${fee.category || ''}::${fee.account || ''}`))
+ .map((fee) => ({
+ id: `fee:${fee.id}`,
+ date: null,
+ description: fee.description,
+ category: fee.category,
+ vendor_name: null,
+ amount: Number(fee.fee || 0),
+ is_credit: false,
+ billable_type: fee.account || fee.subcategory || null,
+ source: 'fee_schedule',
+ }));
+
+ return [
+ ...expenses.map((expense) => ({ ...expense, source: 'billable_expense' })),
+ ...universalFees,
+ ];
+
+ }, [expenses, feeSchedules]);
+
+ const optionMap = useMemo(
+ () => new Map(allRows.map((row) => [row.id, row])),
+ [allRows]
+ );
+
+ const options = useMemo(
+ () =>
+ allRows.map((row) => ({
+ value: row.id,
+ label: [
+ row.description || 'Untitled fee',
+ row.category,
+ `$${Math.abs(Number(row.amount || 0)).toFixed(2)}`,
+ row.source === 'fee_schedule' ? 'Universal Fee' : 'Existing Fee',
+ ]
+ .filter(Boolean)
+ .join(' • '),
+ })),
+ [allRows]
+ );
+
+ const syncSelection = (nextRows) => {
+ onSelectionChange(new Set(nextRows.filter(Boolean)));
+ };
+
+ const handleAddRow = () => {
+ if (rows.length >= allRows.length) return;
+ setRows([...rows, '']);
+ };
+
+ const handleRemoveRow = (rowIndex) => {
+ const nextRows = rows.filter((_, index) => index !== rowIndex);
+ const normalizedRows = nextRows.length ? nextRows : [''];
+ setRows(normalizedRows);
+ syncSelection(normalizedRows);
+ };
+
+ const handleRowChange = (rowIndex, nextValue) => {
+ const nextRows = [...rows];
+ nextRows[rowIndex] = nextValue;
+ setRows(nextRows);
+ syncSelection(nextRows);
+ };
+
+ const selectedTotal = allRows
+ .filter(e => selectedIds.has(e.id))
+ .reduce((s, e) => s + (e.is_credit ? -e.amount : e.amount), 0);
+
+ if (loading) {
+ return (
+
+ Loading expenses...
+
+ );
+ }
+
+ if (!allRows.length) {
+ return (
+
+ No billable expenses or fee templates found for this association.
+
+ );
+ }
+
+ return (
+
+
+
+
Bundle fees
+
Add rows and choose any existing or universal fee.
+
+
+
+ {selectedIds.size} selected
+
+ = 0 ? "default" : "secondary"}>
+ Total: ${Math.abs(selectedTotal).toFixed(2)}{selectedTotal < 0 ? ' CR' : ''}
+
+
+
+
+
+ {rows.map((rowId, index) => {
+ const unavailableIds = new Set(rows.filter((value, valueIndex) => value && valueIndex !== index));
+ const rowOptions = options.filter((option) => !unavailableIds.has(option.value));
+ const selectedItem = rowId ? optionMap.get(rowId) : null;
+
+ return (
+
+
+ handleRowChange(index, value)}
+ placeholder="Select a fee"
+ emptyText="No fees available"
+ className="h-10 flex-1"
+ />
+ handleRemoveRow(index)}
+ disabled={!rowId && rows.length === 1}
+ aria-label="Remove fee row"
+ >
+
+
+
+
+ {selectedItem && (
+
+ {selectedItem.source === 'fee_schedule' ? 'Universal Fee' : 'Existing Fee'}
+ {selectedItem.category || 'Uncategorized'}
+ {selectedItem.vendor_name && {selectedItem.vendor_name} }
+ {selectedItem.date && {format(new Date(`${selectedItem.date}T12:00:00`), 'MM/dd/yy')} }
+
+ {selectedItem.is_credit ? '-' : ''}${Math.abs(Number(selectedItem.amount || 0)).toFixed(2)}
+
+
+ )}
+
+ );
+ })}
+
+
+
= allRows.length}
+ className="w-full"
+ >
+
+ Add row
+
+
+ );
+}
diff --git a/src/components/ExpenseDialog.jsx b/src/components/ExpenseDialog.jsx
new file mode 100644
index 0000000..2432cd5
--- /dev/null
+++ b/src/components/ExpenseDialog.jsx
@@ -0,0 +1,500 @@
+import React, { useState, useEffect, useRef } from 'react';
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
+import { Button } from '@/components/ui/button';
+import { Label } from '@/components/ui/label';
+import { Input } from '@/components/ui/input';
+import { Textarea } from '@/components/ui/textarea';
+import { Badge } from '@/components/ui/badge';
+import { supabase } from '@/integrations/supabase/client';
+import { useToast } from '@/hooks/use-toast';
+import { useAuth } from '@/contexts/AuthContext';
+import { Upload, FileText, X, PlusCircle, Trash2, Plus, RefreshCw, Loader2 } from 'lucide-react';
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
+import { Switch } from '@/components/ui/switch';
+
+let lastUsedContext = {
+ association_id: '',
+ address: 'Association-level',
+ date: new Date().toISOString().split('T')[0],
+ receipt_url: ''
+};
+
+function ExpenseDialog({ open, onOpenChange, onSuccess, expense }) {
+ const { user } = useAuth();
+ const { toast } = useToast();
+
+ const [loading, setLoading] = useState(false);
+ const [uploading, setUploading] = useState(false);
+ const [associations, setAssociations] = useState([]);
+
+ const saveAndAddRef = useRef(false);
+
+ const [headerData, setHeaderData] = useState({
+ association_id: '',
+ date: new Date().toISOString().split('T')[0],
+ address: 'Association-level',
+ receipt_url: '',
+ });
+
+ const [isCredit, setIsCredit] = useState(false);
+ const [creditReason, setCreditReason] = useState('');
+
+ const [lineItems, setLineItems] = useState([]);
+
+ const fetchInitialData = async () => {
+ try {
+ const { data: assocData } = await supabase.from('associations').select('id, name').eq('status', 'active').order('name');
+ setAssociations(assocData || []);
+ } catch (err) {
+ console.error('Failed to fetch initial data:', err);
+ }
+ };
+
+ useEffect(() => {
+ if (open) {
+ fetchInitialData();
+
+ if (expense) {
+ setHeaderData({
+ association_id: expense.association_id,
+ date: expense.date ? expense.date.split('T')[0] : new Date().toISOString().split('T')[0],
+ address: expense.address || 'Association-level',
+ receipt_url: expense.receipt_url || '',
+ });
+
+ setIsCredit(expense.is_credit || false);
+ setCreditReason(expense.credit_reason || '');
+
+ setLineItems([{
+ id: expense.id,
+ category: expense.category || 'General',
+ description: expense.description || '',
+ billable_type: expense.billable_type || 'Expense',
+ unit_price: Math.abs(expense.unit_price || 0),
+ quantity: expense.quantity || 1,
+ amount: Math.abs(expense.amount || 0)
+ }]);
+ } else {
+ setHeaderData({
+ association_id: lastUsedContext.association_id || '',
+ date: lastUsedContext.date || new Date().toISOString().split('T')[0],
+ address: lastUsedContext.address || 'Association-level',
+ receipt_url: '',
+ });
+
+ setIsCredit(false);
+ setCreditReason('');
+
+ setLineItems([{
+ id: crypto.randomUUID(),
+ category: 'General',
+ description: '',
+ billable_type: 'Expense',
+ quantity: 1,
+ unit_price: 0,
+ amount: 0
+ }]);
+ }
+ }
+ }, [open, expense]);
+
+ const updateLineItem = (id, field, value) => {
+ setLineItems(prev => prev.map(item => {
+ if (item.id === id) {
+ let updated = { ...item, [field]: value };
+ if (field === 'quantity' || field === 'unit_price') {
+ const q = field === 'quantity' ? parseFloat(value) : parseFloat(item.quantity);
+ const p = field === 'unit_price' ? parseFloat(value) : parseFloat(item.unit_price);
+ updated.amount = (isNaN(q) ? 0 : q) * (isNaN(p) ? 0 : p);
+ }
+ if (field === 'amount') {
+ updated.amount = parseFloat(value);
+ }
+ return updated;
+ }
+ return item;
+ }));
+ };
+
+ const addLineItem = () => {
+ setLineItems(prev => [...prev, {
+ id: crypto.randomUUID(),
+ category: 'General',
+ description: '',
+ billable_type: 'Expense',
+ quantity: 1,
+ unit_price: 0,
+ amount: 0
+ }]);
+ };
+
+ const removeLineItem = (id) => {
+ if (lineItems.length <= 1) {
+ setLineItems([{
+ id: crypto.randomUUID(),
+ category: 'General',
+ description: '',
+ billable_type: 'Expense',
+ quantity: 1,
+ unit_price: 0,
+ amount: 0
+ }]);
+ return;
+ }
+ setLineItems(prev => prev.filter(item => item.id !== id));
+ };
+
+ const handleFileUpload = async (e) => {
+ if (!e.target.files || !e.target.files[0]) return;
+ const file = e.target.files[0];
+ const fileExt = file.name.split('.').pop();
+ const fileName = `${Math.random().toString(36).substring(2)}-${Date.now()}.${fileExt}`;
+
+ setUploading(true);
+ try {
+ const { error: uploadError } = await supabase.storage
+ .from('files')
+ .upload(fileName, file);
+ if (uploadError) throw uploadError;
+
+ const { data: urlData } = supabase.storage
+ .from('files')
+ .getPublicUrl(fileName);
+
+ setHeaderData(prev => ({ ...prev, receipt_url: urlData.publicUrl }));
+ toast({ title: "Receipt attached successfully" });
+ } catch (error) {
+ toast({ variant: "destructive", title: "Upload failed", description: error.message });
+ } finally {
+ setUploading(false);
+ }
+ };
+
+ const handleRemoveReceipt = () => {
+ setHeaderData(prev => ({ ...prev, receipt_url: '' }));
+ };
+
+ const handleSubmit = async (e) => {
+ e.preventDefault();
+ setLoading(true);
+
+ if (!headerData.association_id) {
+ toast({ variant: "destructive", title: "Error", description: "Please select an association." });
+ setLoading(false);
+ return;
+ }
+
+ if (lineItems.length === 0) {
+ toast({ variant: "destructive", title: "Error", description: "Add at least one expense item." });
+ setLoading(false);
+ return;
+ }
+
+ try {
+ const baseData = {
+ association_id: headerData.association_id,
+ date: headerData.date,
+ address: headerData.address || 'Association-level',
+ receipt_url: headerData.receipt_url,
+ created_by: user?.id,
+ is_credit: isCredit,
+ credit_reason: isCredit ? creditReason : null,
+ };
+
+ const processedItems = lineItems.map(item => {
+ let finalAmount = Math.abs(parseFloat(item.amount));
+ if (isCredit) finalAmount = -finalAmount;
+ let finalUnitPrice = Math.abs(parseFloat(item.unit_price));
+ if (isCredit) finalUnitPrice = -finalUnitPrice;
+
+ return {
+ ...item,
+ amount: finalAmount,
+ unit_price: finalUnitPrice,
+ quantity: Math.abs(parseFloat(item.quantity))
+ };
+ });
+
+ if (expense) {
+ const item = processedItems[0];
+ const { error } = await supabase
+ .from('billable_expenses')
+ .update({
+ ...baseData,
+ category: item.category || 'General',
+ description: item.description,
+ amount: item.amount,
+ quantity: item.quantity,
+ unit_price: item.unit_price,
+ billable_type: item.billable_type,
+ })
+ .eq('id', expense.id);
+ if (error) throw error;
+ } else {
+ const rowsToInsert = processedItems.map(item => ({
+ ...baseData,
+ category: item.category || 'General',
+ description: item.description,
+ amount: item.amount,
+ quantity: item.quantity,
+ unit_price: item.unit_price,
+ billable_type: item.billable_type,
+ status: 'pending'
+ }));
+
+ const { error } = await supabase.from('billable_expenses').insert(rowsToInsert);
+ if (error) throw error;
+
+ lastUsedContext = {
+ association_id: headerData.association_id,
+ address: headerData.address,
+ date: headerData.date,
+ };
+ }
+
+ toast({
+ title: expense ? "Expense Updated" : "Expenses Saved",
+ description: saveAndAddRef.current ? "Saved! Ready for the next entry." : "Expense records have been successfully saved.",
+ });
+
+ onSuccess();
+
+ if (saveAndAddRef.current) {
+ setLineItems([{
+ id: crypto.randomUUID(),
+ category: 'General',
+ description: '',
+ billable_type: 'Expense',
+ quantity: 1,
+ unit_price: 0,
+ amount: 0
+ }]);
+ setIsCredit(false);
+ setCreditReason('');
+ setHeaderData(prev => ({ ...prev, receipt_url: '' }));
+ saveAndAddRef.current = false;
+ } else {
+ onOpenChange(false);
+ }
+ } catch (error) {
+ console.error("Submit error:", error);
+ toast({ variant: "destructive", title: "Error", description: error.message });
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const formatCurrency = (amount) => {
+ return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(amount);
+ };
+
+ return (
+
+
+
+ {expense ? 'Edit Expense' : 'Add Expenses'}
+
+ {expense ? 'Update the details for this expense record.' : 'Create one or more billable expense records.'}
+
+
+
+
+
+
+ Association
+ {
+ setHeaderData({ ...headerData, association_id: val, address: 'Association-level' });
+ }}
+ disabled={!!expense}
+ >
+
+
+
+
+ {(associations || []).map(a => (
+ {a.name}
+ ))}
+
+
+
+
+ Date
+ setHeaderData({ ...headerData, date: e.target.value })}
+ required
+ className="bg-background"
+ />
+
+
+
+
+
+
+
+
+
+ Mark as Credit/Refund
+
+
+ {isCredit && (
+
Amounts will be negative
+ )}
+
+
+ {isCredit && (
+
+ Credit Reason / Notes
+ setCreditReason(e.target.value)}
+ placeholder="Reason for refund..."
+ className="h-[38px] min-h-[38px] py-2 text-xs bg-background resize-none"
+ />
+
+ )}
+
+
+
+
+
Line Items
+ {!expense && (
+
+ Add Row
+
+ )}
+
+
+
+ {(lineItems || []).map((item, index) => (
+
+
+ Item {index + 1}
+ {!expense && (
+ removeLineItem(item.id)}
+ >
+
+
+ )}
+
+
+
+
+ Qty
+ updateLineItem(item.id, 'quantity', e.target.value)}
+ />
+
+
+ Rate
+ updateLineItem(item.id, 'unit_price', e.target.value)}
+ />
+
+
+
Amount
+
+ {isCredit && '-'}${parseFloat(item.amount || 0).toFixed(2)}
+
+
+
+
+ ))}
+
+
+
+
+
Receipt Attachment
+ {!headerData.receipt_url ? (
+
+
+
+ {uploading ? 'Uploading...' : 'Click to upload receipt'}
+ PDF, PNG, JPG up to 5MB
+
+ ) : (
+
+ )}
+
+
+
+ onOpenChange(false)}>Cancel
+
+ {!expense && (
+
{ saveAndAddRef.current = true; }}
+ >
+
+ Save & Add Another
+
+ )}
+
{ saveAndAddRef.current = false; }}
+ className="min-w-[120px]"
+ >
+ {loading ? 'Saving...' : (expense ? 'Update Expense' : `Save ${lineItems.length} Expenses`)}
+
+
+
+
+
+
+ );
+}
+
+export default ExpenseDialog;
diff --git a/src/components/ExpenseRestorationDialog.jsx b/src/components/ExpenseRestorationDialog.jsx
new file mode 100644
index 0000000..988086d
--- /dev/null
+++ b/src/components/ExpenseRestorationDialog.jsx
@@ -0,0 +1,151 @@
+import React, { useState, useEffect } from 'react';
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
+import { Button } from '@/components/ui/button';
+import { ScrollArea } from '@/components/ui/scroll-area';
+import { Loader2, AlertTriangle, CheckCircle, RotateCcw } from 'lucide-react';
+import { useAuth } from '@/contexts/AuthContext';
+import { useToast } from '@/hooks/use-toast';
+
+export function ExpenseRestorationDialog({ open, onOpenChange, onSuccess, analyzeRestorationCandidates, processExpenseRestoration }) {
+ const { user } = useAuth();
+ const { toast } = useToast();
+
+ const [step, setStep] = useState('analyze');
+ const [analysis, setAnalysis] = useState(null);
+ const [loading, setLoading] = useState(false);
+ const [result, setResult] = useState(null);
+
+ useEffect(() => {
+ if (open && step === 'analyze') {
+ performAnalysis();
+ }
+ }, [open]);
+
+ const performAnalysis = async () => {
+ if (!analyzeRestorationCandidates) return;
+ setLoading(true);
+ try {
+ const data = await analyzeRestorationCandidates();
+ setAnalysis(data);
+ setStep('review');
+ } catch (err) {
+ toast({ variant: "destructive", title: "Analysis Failed", description: err.message });
+ onOpenChange(false);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleRestore = async () => {
+ if (!analysis || !processExpenseRestoration) return;
+ setStep('restoring');
+
+ const ids = analysis.allRecords.map(r => r.id);
+
+ try {
+ const res = await processExpenseRestoration(ids);
+ setResult(res);
+ setStep('result');
+ if (onSuccess) onSuccess();
+ } catch (err) {
+ toast({ variant: "destructive", title: "Restoration Failed", description: err.message });
+ setStep('review');
+ }
+ };
+
+ const handleClose = () => {
+ setStep('analyze');
+ setAnalysis(null);
+ setResult(null);
+ onOpenChange(false);
+ };
+
+ return (
+
+
+
+
+
+ Expense Data Restoration
+
+
+ Verify and restore integrity of billable expenses.
+
+
+
+
+ {step === 'analyze' && (
+
+
+
Analyzing expense records...
+
+ )}
+
+ {step === 'review' && analysis && (
+
+
+
+
+
Review Findings
+
+ Found {analysis.totalFound} billable expenses in the database.
+ {analysis.potentialIssues.length > 0
+ ? ` Detected ${analysis.potentialIssues.length} records with potential integrity issues.`
+ : " No critical integrity issues detected."}
+
+
+
+
+ Proceeding will re-verify these records and update their system status timestamps.
+ This action is logged for audit purposes.
+
+
+ )}
+
+ {step === 'restoring' && (
+
+
+
Processing records...
+
+ )}
+
+ {step === 'result' && result && (
+
+
+
+
+
Process Complete
+
+ Successfully processed {result.successCount} records.
+
+
+
+
+ {result.errors.length > 0 && (
+
+ {result.errors.map((e, i) => (
+ Error with ID {e.id}: {e.message}
+ ))}
+
+ )}
+
+ )}
+
+
+
+ {step === 'review' && (
+ <>
+ Cancel
+
+ Proceed with Restoration
+
+ >
+ )}
+ {step === 'result' && (
+ Close
+ )}
+
+
+
+ );
+}
diff --git a/src/components/ExpenseSettingsPanel.tsx b/src/components/ExpenseSettingsPanel.tsx
new file mode 100644
index 0000000..3412c18
--- /dev/null
+++ b/src/components/ExpenseSettingsPanel.tsx
@@ -0,0 +1,350 @@
+import { useState, useEffect, useCallback } from "react";
+import { supabase } from "@/integrations/supabase/client";
+import { useToast } from "@/hooks/use-toast";
+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 { Switch } from "@/components/ui/switch";
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog";
+import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog";
+import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
+import { Plus, Trash2, Edit, Loader2, GripVertical, ChevronRight, FolderTree } from "lucide-react";
+
+interface Category {
+ id: string;
+ name: string;
+ description: string | null;
+ color: string | null;
+ is_active: boolean;
+ sort_order: number;
+}
+
+interface Subcategory {
+ id: string;
+ category_id: string;
+ name: string;
+ description: string | null;
+ is_active: boolean;
+ sort_order: number;
+}
+
+export default function ExpenseSettingsPanel() {
+ const { toast } = useToast();
+ const [categories, setCategories] = useState([]);
+ const [subcategories, setSubcategories] = useState([]);
+ const [loading, setLoading] = useState(true);
+
+ // Category dialog
+ const [catDialogOpen, setCatDialogOpen] = useState(false);
+ const [editingCat, setEditingCat] = useState(null);
+ const [catForm, setCatForm] = useState({ name: "", description: "", color: "#6366f1" });
+ const [catSaving, setCatSaving] = useState(false);
+
+ // Subcategory dialog
+ const [subDialogOpen, setSubDialogOpen] = useState(false);
+ const [editingSub, setEditingSub] = useState(null);
+ const [subForm, setSubForm] = useState({ name: "", description: "", category_id: "" });
+ const [subSaving, setSubSaving] = useState(false);
+
+ // Delete
+ const [deleteTarget, setDeleteTarget] = useState<{ type: "category" | "subcategory"; id: string; name: string } | null>(null);
+
+ const fetchAll = useCallback(async () => {
+ setLoading(true);
+ const [catRes, subRes] = await Promise.all([
+ supabase.from("expense_categories").select("*").order("sort_order").order("name"),
+ supabase.from("expense_subcategories").select("*").order("sort_order").order("name"),
+ ]);
+ if (catRes.data) setCategories(catRes.data as Category[]);
+ if (subRes.data) setSubcategories(subRes.data as Subcategory[]);
+ setLoading(false);
+ }, []);
+
+ useEffect(() => { fetchAll(); }, [fetchAll]);
+
+ // Category CRUD
+ const openAddCat = () => {
+ setEditingCat(null);
+ setCatForm({ name: "", description: "", color: "#6366f1" });
+ setCatDialogOpen(true);
+ };
+
+ const openEditCat = (cat: Category) => {
+ setEditingCat(cat);
+ setCatForm({ name: cat.name, description: cat.description || "", color: cat.color || "#6366f1" });
+ setCatDialogOpen(true);
+ };
+
+ const saveCat = async () => {
+ if (!catForm.name.trim()) {
+ toast({ variant: "destructive", title: "Name is required" }); return;
+ }
+ setCatSaving(true);
+ const payload = { name: catForm.name.trim(), description: catForm.description || null, color: catForm.color || null };
+
+ if (editingCat) {
+ const { error } = await supabase.from("expense_categories").update({ ...payload, updated_at: new Date().toISOString() }).eq("id", editingCat.id);
+ if (error) { toast({ variant: "destructive", title: "Error", description: error.message }); setCatSaving(false); return; }
+ toast({ title: "Category updated" });
+ } else {
+ const maxOrder = categories.length > 0 ? Math.max(...categories.map(c => c.sort_order)) + 1 : 0;
+ const { error } = await supabase.from("expense_categories").insert({ ...payload, sort_order: maxOrder });
+ if (error) { toast({ variant: "destructive", title: "Error", description: error.message }); setCatSaving(false); return; }
+ toast({ title: "Category created" });
+ }
+ setCatDialogOpen(false);
+ setCatSaving(false);
+ fetchAll();
+ };
+
+ const toggleCatActive = async (cat: Category) => {
+ await supabase.from("expense_categories").update({ is_active: !cat.is_active, updated_at: new Date().toISOString() }).eq("id", cat.id);
+ fetchAll();
+ };
+
+ // Subcategory CRUD
+ const openAddSub = (categoryId: string) => {
+ setEditingSub(null);
+ setSubForm({ name: "", description: "", category_id: categoryId });
+ setSubDialogOpen(true);
+ };
+
+ const openEditSub = (sub: Subcategory) => {
+ setEditingSub(sub);
+ setSubForm({ name: sub.name, description: sub.description || "", category_id: sub.category_id });
+ setSubDialogOpen(true);
+ };
+
+ const saveSub = async () => {
+ if (!subForm.name.trim()) {
+ toast({ variant: "destructive", title: "Name is required" }); return;
+ }
+ setSubSaving(true);
+ const payload = { name: subForm.name.trim(), description: subForm.description || null, category_id: subForm.category_id };
+
+ if (editingSub) {
+ const { error } = await supabase.from("expense_subcategories").update({ ...payload, updated_at: new Date().toISOString() }).eq("id", editingSub.id);
+ if (error) { toast({ variant: "destructive", title: "Error", description: error.message }); setSubSaving(false); return; }
+ toast({ title: "Subcategory updated" });
+ } else {
+ const catSubs = subcategories.filter(s => s.category_id === subForm.category_id);
+ const maxOrder = catSubs.length > 0 ? Math.max(...catSubs.map(s => s.sort_order)) + 1 : 0;
+ const { error } = await supabase.from("expense_subcategories").insert({ ...payload, sort_order: maxOrder });
+ if (error) { toast({ variant: "destructive", title: "Error", description: error.message }); setSubSaving(false); return; }
+ toast({ title: "Subcategory created" });
+ }
+ setSubDialogOpen(false);
+ setSubSaving(false);
+ fetchAll();
+ };
+
+ const toggleSubActive = async (sub: Subcategory) => {
+ await supabase.from("expense_subcategories").update({ is_active: !sub.is_active, updated_at: new Date().toISOString() }).eq("id", sub.id);
+ fetchAll();
+ };
+
+ // Delete
+ const confirmDelete = async () => {
+ if (!deleteTarget) return;
+ const table = deleteTarget.type === "category" ? "expense_categories" : "expense_subcategories";
+ const { error } = await supabase.from(table).delete().eq("id", deleteTarget.id);
+ if (error) {
+ toast({ variant: "destructive", title: "Error", description: error.message });
+ } else {
+ toast({ title: `${deleteTarget.type === "category" ? "Category" : "Subcategory"} deleted` });
+ }
+ setDeleteTarget(null);
+ fetchAll();
+ };
+
+ const getSubsForCategory = (catId: string) => subcategories.filter(s => s.category_id === catId);
+
+ if (loading) {
+ return
;
+ }
+
+ return (
+
+
+
+
Expense Categories & Subcategories
+
Manage categories used across billable expenses and fee schedules.
+
+
Add Category
+
+
+ {categories.length === 0 ? (
+
+
+
+ No categories yet. Click "Add Category" to get started.
+
+
+ ) : (
+
c.id)} className="space-y-3">
+ {categories.map(cat => {
+ const subs = getSubsForCategory(cat.id);
+ return (
+
+
+
+
+
{cat.name}
+
{subs.length} sub{subs.length !== 1 ? "s" : ""}
+ {!cat.is_active &&
Inactive }
+
+
+
+
+
+
+ toggleCatActive(cat)} />
+ Active
+
+ {cat.description &&
{cat.description} }
+
+
+
openAddSub(cat.id)}>
+ Add Subcategory
+
+
openEditCat(cat)}>
+
+
+
setDeleteTarget({ type: "category", id: cat.id, name: cat.name })}>
+
+
+
+
+
+ {subs.length === 0 ? (
+
+ No subcategories. Click "+ Add Subcategory" to create one.
+
+ ) : (
+
+
+
+
+ Subcategory
+ Description
+ Active
+ Actions
+
+
+
+ {subs.map(sub => (
+
+ {sub.name}
+ {sub.description || "—"}
+
+ toggleSubActive(sub)} />
+
+
+
+ openEditSub(sub)}>
+
+
+ setDeleteTarget({ type: "subcategory", id: sub.id, name: sub.name })}>
+
+
+
+
+
+ ))}
+
+
+
+ )}
+
+
+ );
+ })}
+
+ )}
+
+ {/* Category Dialog */}
+
+
+
+ {editingCat ? "Edit Category" : "Add Category"}
+ Define a category for organizing expenses.
+
+
+
+ Name *
+ setCatForm(p => ({ ...p, name: e.target.value }))} placeholder="e.g. Maintenance" />
+
+
+ Description
+ setCatForm(p => ({ ...p, description: e.target.value }))} placeholder="Optional description" />
+
+
+
Color
+
+ setCatForm(p => ({ ...p, color: e.target.value }))} className="w-10 h-10 rounded cursor-pointer border" />
+ {catForm.color}
+
+
+
+
+ setCatDialogOpen(false)}>Cancel
+
+ {catSaving && }
+ {editingCat ? "Update" : "Create"}
+
+
+
+
+
+ {/* Subcategory Dialog */}
+
+
+
+ {editingSub ? "Edit Subcategory" : "Add Subcategory"}
+
+ Under: {categories.find(c => c.id === subForm.category_id)?.name || "—"}
+
+
+
+
+ setSubDialogOpen(false)}>Cancel
+
+ {subSaving && }
+ {editingSub ? "Update" : "Create"}
+
+
+
+
+
+ {/* Delete Confirmation */}
+
{ if (!open) setDeleteTarget(null); }}>
+
+
+ Delete {deleteTarget?.type === "category" ? "Category" : "Subcategory"}
+
+ Are you sure you want to delete "{deleteTarget?.name}"?
+ {deleteTarget?.type === "category" && " This will also delete all its subcategories."}
+ {" "}This cannot be undone.
+
+
+
+ Cancel
+ Delete
+
+
+
+
+ );
+}
diff --git a/src/components/ExportConfirmationDialog.jsx b/src/components/ExportConfirmationDialog.jsx
new file mode 100644
index 0000000..f15663b
--- /dev/null
+++ b/src/components/ExportConfirmationDialog.jsx
@@ -0,0 +1,72 @@
+import React from 'react';
+import {
+ AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
+ AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
+} from "@/components/ui/alert-dialog";
+import { ScrollArea } from "@/components/ui/scroll-area";
+
+export function ExportConfirmationDialog({ open, onOpenChange, totalCount, excludedOwners, onConfirm }) {
+ const includedCount = totalCount - excludedOwners.length;
+
+ return (
+
+
+
+ Confirm Sign-in Sheet Export
+
+ Review the roster details before generating the PDF.
+
+
+
+
+
+
+
+
{excludedOwners.length}
+
Excluded
+
+
+
{includedCount}
+
Included
+
+
+
+ {excludedOwners.length > 0 ? (
+
+
+ Excluded Owners ({excludedOwners.length})
+
+
+
+ {excludedOwners.map(owner => (
+
+ {owner.owner_name}
+ {owner.property_address}
+
+ ))}
+
+
+
+ ) : (
+
+ No owners excluded. All {totalCount} owners will be listed.
+
+ )}
+
+
+
+ Cancel
+
+ {includedCount === 0 ? "No Owners to Export" : "Confirm Export"}
+
+
+
+
+ );
+}
diff --git a/src/components/FeeScheduleDialog.jsx b/src/components/FeeScheduleDialog.jsx
new file mode 100644
index 0000000..906fd26
--- /dev/null
+++ b/src/components/FeeScheduleDialog.jsx
@@ -0,0 +1,248 @@
+import React, { useEffect, useState } from 'react';
+import { useForm } from 'react-hook-form';
+import { zodResolver } from '@hookform/resolvers/zod';
+import * as z from 'zod';
+import { supabase } from '@/integrations/supabase/client';
+import {
+ Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
+} from "@/components/ui/dialog";
+import {
+ Form, FormControl, FormField, FormItem, FormLabel, FormMessage,
+} from "@/components/ui/form";
+import {
+ Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
+} from "@/components/ui/select";
+import { Input } from "@/components/ui/input";
+import { Button } from "@/components/ui/button";
+import { Loader2 } from "lucide-react";
+import { useToast } from "@/hooks/use-toast";
+
+const formSchema = z.object({
+ description: z.string().min(1, "Description is required"),
+ fee: z.coerce.number().min(0, "Fee must be 0 or greater"),
+ category: z.string().min(1, "Category is required"),
+ subcategory: z.string().min(1, "Subcategory is required"),
+ account: z.string().optional(),
+});
+
+
+export function FeeScheduleDialog({ open, onOpenChange, onSuccess, item = null }) {
+ const { toast } = useToast();
+ const [subcategories, setSubcategories] = useState([]);
+ const [categories, setCategories] = useState([]);
+
+ const form = useForm({
+ resolver: zodResolver(formSchema),
+ defaultValues: {
+ description: '',
+ fee: '',
+ category: '',
+ subcategory: '',
+ account: '',
+ },
+ });
+
+ useEffect(() => {
+ if (open) {
+ fetchSubcategories();
+ fetchCategories();
+ if (item) {
+ form.reset({
+ description: item.description,
+ fee: item.fee,
+ category: item.category || '',
+ subcategory: item.subcategory || '',
+ account: item.account || '',
+ });
+ } else {
+ form.reset({
+ description: '',
+ fee: '',
+ category: '',
+ subcategory: '',
+ account: '',
+ });
+ }
+ }
+ }, [open, item, form]);
+
+ const fetchSubcategories = async () => {
+ const { data } = await supabase
+ .from('expense_subcategories')
+ .select('*')
+ .eq('is_active', true)
+ .order('name');
+ setSubcategories(data || []);
+ };
+
+ const fetchCategories = async () => {
+ const { data } = await supabase
+ .from('expense_categories')
+ .select('*')
+ .eq('is_active', true)
+ .order('name');
+ setCategories(data || []);
+ };
+
+ const onSubmit = async (values) => {
+ try {
+ const payload = {
+ description: values.description,
+ fee: values.fee,
+ category: values.category,
+ subcategory: values.subcategory,
+ account: values.account || null,
+ updated_at: new Date().toISOString(),
+ };
+
+ let error;
+ if (item) {
+ const result = await supabase
+ .from('fee_schedules')
+ .update(payload)
+ .eq('id', item.id);
+ error = result.error;
+ } else {
+ const result = await supabase
+ .from('fee_schedules')
+ .insert([payload]);
+ error = result.error;
+ }
+
+ if (error) throw error;
+
+ toast({
+ title: item ? "Updated" : "Created",
+ description: `Fee schedule item has been ${item ? "updated" : "created"} successfully.`,
+ });
+
+ window.dispatchEvent(new Event('feeScheduleUpdated'));
+ onSuccess();
+ onOpenChange(false);
+ } catch (error) {
+ console.error('Error saving fee schedule:', error);
+ toast({
+ variant: "destructive",
+ title: "Error",
+ description: error.message || "Failed to save fee schedule item.",
+ });
+ }
+ };
+
+ return (
+
+
+
+ {item ? "Edit Fee Item" : "Add Fee Item"}
+
+ {item ? "Edit the details of this fee schedule item." : "Add a new item to the fee schedule."}
+
+
+
+
+ (
+
+ Service / Item Description *
+
+
+
+
+
+ )}
+ />
+
+
+ (
+
+ Category *
+
+
+
+
+
+ {categories.map((cat) => (
+ {cat.name}
+ ))}
+
+
+
+
+ )}
+ />
+
+ (
+
+ Subcategory *
+
+
+
+
+
+ {subcategories.map((sub) => (
+ {sub.name}
+ ))}
+
+
+
+
+ )}
+ />
+
+
+ (
+
+ Account
+
+
+
+
+
+ {categories.map((cat) => (
+ {cat.name}
+ ))}
+
+
+
+
+ )}
+ />
+
+ (
+
+ Fee Amount ($) *
+
+
+
+
+
+ )}
+ />
+
+
+ onOpenChange(false)}>Cancel
+
+ {form.formState.isSubmitting && }
+ Save
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/FileUploadDropzone.tsx b/src/components/FileUploadDropzone.tsx
new file mode 100644
index 0000000..e08eb60
--- /dev/null
+++ b/src/components/FileUploadDropzone.tsx
@@ -0,0 +1,123 @@
+import React, { useCallback, useState } from 'react';
+import { useDropzone } from 'react-dropzone';
+import { supabase } from '@/integrations/supabase/client';
+import { useToast } from '@/hooks/use-toast';
+import { Upload, X, FileText, Loader2 } from 'lucide-react';
+import { Button } from '@/components/ui/button';
+import { cn } from '@/lib/utils';
+
+interface UploadedFile {
+ file_name: string;
+ file_url: string;
+ file_size: number;
+}
+
+interface FileUploadDropzoneProps {
+ bucketName: string;
+ onFilesUploaded: (files: UploadedFile[]) => void;
+ maxFiles?: number;
+ className?: string;
+}
+
+export function FileUploadDropzone({ bucketName, onFilesUploaded, maxFiles = 5, className }: FileUploadDropzoneProps) {
+ const { toast } = useToast();
+ const [uploading, setUploading] = useState(false);
+ const [uploadedFiles, setUploadedFiles] = useState([]);
+
+ const onDrop = useCallback(async (acceptedFiles: File[]) => {
+ if (acceptedFiles.length === 0) return;
+
+ setUploading(true);
+ const newFiles: UploadedFile[] = [];
+
+ try {
+ for (const file of acceptedFiles) {
+ const fileExt = file.name.split('.').pop();
+ const filePath = `${Date.now()}-${Math.random().toString(36).substring(7)}.${fileExt}`;
+
+ const { error: uploadError } = await supabase.storage
+ .from(bucketName)
+ .upload(filePath, file);
+
+ if (uploadError) throw uploadError;
+
+ const { data: { publicUrl } } = supabase.storage
+ .from(bucketName)
+ .getPublicUrl(filePath);
+
+ newFiles.push({
+ file_name: file.name,
+ file_url: publicUrl,
+ file_size: file.size,
+ });
+ }
+
+ const allFiles = [...uploadedFiles, ...newFiles];
+ setUploadedFiles(allFiles);
+ onFilesUploaded(allFiles);
+ } catch (err: any) {
+ console.error('Upload error:', err);
+ toast({ variant: 'destructive', title: 'Upload failed', description: err.message });
+ } finally {
+ setUploading(false);
+ }
+ }, [bucketName, uploadedFiles, onFilesUploaded, toast]);
+
+ const removeFile = (index: number) => {
+ const updated = uploadedFiles.filter((_, i) => i !== index);
+ setUploadedFiles(updated);
+ onFilesUploaded(updated);
+ };
+
+ const { getRootProps, getInputProps, isDragActive } = useDropzone({
+ onDrop,
+ maxFiles: maxFiles - uploadedFiles.length,
+ disabled: uploading || uploadedFiles.length >= maxFiles,
+ });
+
+ return (
+
+
= maxFiles) && "opacity-50 cursor-not-allowed"
+ )}
+ >
+
+ {uploading ? (
+
+ ) : (
+
+
+
+ {isDragActive ? "Drop files here..." : "Drag & drop files, or click to browse"}
+
+
+ Max {maxFiles} files • {uploadedFiles.length}/{maxFiles} uploaded
+
+
+ )}
+
+
+ {uploadedFiles.length > 0 && (
+
+ {uploadedFiles.map((file, idx) => (
+
+
+ {file.file_name}
+ {(file.file_size / 1024).toFixed(1)} KB
+ removeFile(idx)}>
+
+
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/src/components/FinancialRuleDialog.jsx b/src/components/FinancialRuleDialog.jsx
new file mode 100644
index 0000000..055de52
--- /dev/null
+++ b/src/components/FinancialRuleDialog.jsx
@@ -0,0 +1,274 @@
+import React, { useState, useEffect } from 'react';
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import { Textarea } from '@/components/ui/textarea';
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
+import { Plus, Trash2, Loader2, PlayCircle } from 'lucide-react';
+import { motion, AnimatePresence } from 'framer-motion';
+
+export default function FinancialRuleDialog({ isOpen, onClose, onSave, initialData }) {
+ const [loading, setLoading] = useState(false);
+ const [formData, setFormData] = useState({
+ name: '', description: '', rule_type: 'Charge',
+ parameters: [], conditions: [], actions: [],
+ });
+ const [errors, setErrors] = useState({});
+
+ useEffect(() => {
+ if (isOpen) {
+ if (initialData) {
+ setFormData({
+ name: initialData.name || '',
+ description: initialData.description || '',
+ rule_type: initialData.rule_type || 'Charge',
+ parameters: initialData.parameters || [],
+ conditions: initialData.conditions || [],
+ actions: initialData.actions || [],
+ });
+ } else {
+ setFormData({ name: '', description: '', rule_type: 'Charge', parameters: [], conditions: [], actions: [] });
+ }
+ setErrors({});
+ }
+ }, [isOpen, initialData]);
+
+ const handleAddParam = () => {
+ setFormData(prev => ({
+ ...prev,
+ parameters: [...prev.parameters, { id: crypto.randomUUID(), name: '', type: 'Text', defaultValue: '' }]
+ }));
+ };
+
+ const handleUpdateParam = (id, field, value) => {
+ setFormData(prev => ({
+ ...prev,
+ parameters: prev.parameters.map(p => p.id === id ? { ...p, [field]: value } : p)
+ }));
+ };
+
+ const handleRemoveParam = (id) => {
+ setFormData(prev => ({ ...prev, parameters: prev.parameters.filter(p => p.id !== id) }));
+ };
+
+ const handleAddCondition = () => {
+ setFormData(prev => ({
+ ...prev,
+ conditions: [...prev.conditions, { id: crypto.randomUUID(), field: '', operator: 'equals', value: '', logic: 'AND' }]
+ }));
+ };
+
+ const handleUpdateCondition = (id, field, value) => {
+ setFormData(prev => ({
+ ...prev,
+ conditions: prev.conditions.map(c => c.id === id ? { ...c, [field]: value } : c)
+ }));
+ };
+
+ const handleRemoveCondition = (id) => {
+ setFormData(prev => ({ ...prev, conditions: prev.conditions.filter(c => c.id !== id) }));
+ };
+
+ const handleAddAction = () => {
+ setFormData(prev => ({
+ ...prev,
+ actions: [...prev.actions, { id: crypto.randomUUID(), type: 'Apply Charge', target: '', amount: '' }]
+ }));
+ };
+
+ const handleUpdateAction = (id, field, value) => {
+ setFormData(prev => ({
+ ...prev,
+ actions: prev.actions.map(a => a.id === id ? { ...a, [field]: value } : a)
+ }));
+ };
+
+ const handleRemoveAction = (id) => {
+ setFormData(prev => ({ ...prev, actions: prev.actions.filter(a => a.id !== id) }));
+ };
+
+ const validate = () => {
+ const newErrors = {};
+ if (!formData.name.trim()) newErrors.name = 'Rule name is required';
+ if (!formData.rule_type) newErrors.rule_type = 'Rule type is required';
+ setErrors(newErrors);
+ return Object.keys(newErrors).length === 0;
+ };
+
+ const handleSubmit = async (e) => {
+ e.preventDefault();
+ if (!validate()) return;
+ setLoading(true);
+ try {
+ await onSave(formData);
+ onClose();
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+
+
+
+
+ {initialData ? 'Edit Financial Rule' : 'Create New Rule'}
+
+
+
+
+
+
+
Rule Name *
+
setFormData({...formData, name: e.target.value})}
+ placeholder="e.g., Late Fee on Overdue Balances"
+ className={`mt-1 ${errors.name ? 'border-destructive' : ''}`}
+ />
+ {errors.name &&
{errors.name}
}
+
+
+
+
+ Rule Type *
+ setFormData({...formData, rule_type: v})}>
+
+
+ Charge Rule
+ Payment Rule
+ Adjustment
+ Fee Rule
+
+
+
+
+
+
+ Description
+ setFormData({...formData, description: e.target.value})}
+ placeholder="Optional description of what this rule does..."
+ className="mt-1" rows={2}
+ />
+
+
+
+ {/* Parameters Section */}
+
+
+
Custom Parameters
+
+ Add Parameter
+
+
+
+ {formData.parameters.map((param) => (
+
+ handleUpdateParam(param.id, 'name', e.target.value)} className="flex-1" />
+ handleUpdateParam(param.id, 'type', v)}>
+
+
+ Text
+ Number
+ Date
+ Percentage
+
+
+ handleUpdateParam(param.id, 'defaultValue', e.target.value)} className="w-[120px]" />
+ handleRemoveParam(param.id)} className="text-muted-foreground hover:text-destructive">
+
+ ))}
+
+ {formData.parameters.length === 0 &&
No custom parameters defined.
}
+
+
+ {/* Conditions Section */}
+
+
+
Conditions
+
+ Add Condition
+
+
+
+ {formData.conditions.map((cond, idx) => (
+
+ {idx > 0 && (
+ handleUpdateCondition(cond.id, 'logic', v)}>
+
+ AND OR
+
+ )}
+ handleUpdateCondition(cond.id, 'field', e.target.value)} className="flex-1" />
+ handleUpdateCondition(cond.id, 'operator', v)}>
+
+
+ Equals
+ {'>'}
+ {'<'}
+ Contains
+
+
+ handleUpdateCondition(cond.id, 'value', e.target.value)} className="flex-1" />
+ handleRemoveCondition(cond.id)} className="text-muted-foreground hover:text-destructive">
+
+ ))}
+
+ {formData.conditions.length === 0 &&
Rule applies unconditionally if empty.
}
+
+
+ {/* Actions Section */}
+
+
+
Actions
+
+ Add Action
+
+
+
+ {formData.actions.map((act) => (
+
+ handleUpdateAction(act.id, 'type', v)}>
+
+
+ Apply Charge
+ Waive Fee
+ Send Notice
+
+
+ handleUpdateAction(act.id, 'target', e.target.value)} className="flex-1" />
+ handleUpdateAction(act.id, 'amount', e.target.value)} className="w-[120px]" />
+ handleRemoveAction(act.id)} className="text-muted-foreground hover:text-destructive">
+
+ ))}
+
+ {formData.actions.length === 0 &&
Add actions to execute when conditions are met.
}
+
+
+ {/* Preview */}
+
+
+
Preview Summary
+
+ {formData.name ? `Rule: ${formData.name}` : 'Unnamed Rule'} ({formData.rule_type})
+
+
+ If {formData.conditions.length} condition(s) met, execute {formData.actions.length} action(s).
+
+
+
+
+ Cancel
+
+ {loading && }
+ Save Rule
+
+
+
+
+
+ );
+}
diff --git a/src/components/FolderShareDialog.jsx b/src/components/FolderShareDialog.jsx
new file mode 100644
index 0000000..70215c0
--- /dev/null
+++ b/src/components/FolderShareDialog.jsx
@@ -0,0 +1,198 @@
+import React, { useState, useEffect } from 'react';
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import { Switch } from '@/components/ui/switch';
+import { supabase } from '@/integrations/supabase/client';
+import { useToast } from '@/hooks/use-toast';
+import { Copy, RefreshCw, Globe, Lock, ExternalLink, ShieldAlert } from 'lucide-react';
+import { v4 as uuidv4 } from 'uuid';
+
+export function FolderShareDialog({ open, onOpenChange, folder, onSuccess }) {
+ const { toast } = useToast();
+ const [loading, setLoading] = useState(false);
+ const [isShared, setIsShared] = useState(false);
+ const [password, setPassword] = useState('');
+ const [shareToken, setShareToken] = useState('');
+
+ useEffect(() => {
+ if (folder) {
+ setIsShared(folder.is_shared || false);
+ setPassword(folder.share_password || '');
+ setShareToken(folder.share_token || '');
+ }
+ }, [folder]);
+
+ const handleShareToggle = (checked) => {
+ setIsShared(checked);
+ if (checked && !shareToken) {
+ setShareToken(uuidv4());
+ }
+ if (checked && !password) {
+ generateRandomPassword();
+ }
+ };
+
+ const generateRandomPassword = () => {
+ const chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
+ let pass = "";
+ for(let i=0; i<8; i++) {
+ pass += chars.charAt(Math.floor(Math.random() * chars.length));
+ }
+ setPassword(pass);
+ };
+
+ const regenerateToken = () => {
+ if (window.confirm("Are you sure? This will invalidate any existing links sent to users.")) {
+ setShareToken(uuidv4());
+ }
+ };
+
+ const handleSave = async () => {
+ setLoading(true);
+ try {
+ if (isShared && !password.trim()) {
+ throw new Error("An access code is required to enable secure sharing.");
+ }
+
+ const updates = {
+ is_shared: isShared,
+ share_password: isShared ? password : null,
+ share_token: isShared ? shareToken : folder.share_token
+ };
+
+ const { error } = await supabase
+ .from('document_categories')
+ .update(updates)
+ .eq('id', folder.id);
+
+ if (error) throw error;
+
+ toast({ title: "Settings Saved", description: isShared ? "Folder is now publicly accessible via the link." : "Folder sharing disabled." });
+ if (onSuccess) onSuccess();
+ onOpenChange(false);
+ } catch (err) {
+ toast({ variant: "destructive", title: "Error", description: err.message });
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const shareLink = shareToken ? `${window.location.origin}/shared/${shareToken}` : '';
+
+ const copyLink = () => {
+ navigator.clipboard.writeText(shareLink);
+ toast({ title: "Link Copied!", description: "Share link copied to clipboard." });
+ };
+
+ const copyPassword = () => {
+ navigator.clipboard.writeText(password);
+ toast({ title: "Code Copied!", description: "Access code copied to clipboard." });
+ };
+
+ return (
+
+
+
+
+
+ Share Folder: {folder?.name}
+
+
+ Generate a secure link for external users.
+
+
+
+
+
+
+
Public Sharing
+
+ {isShared ? 'Enabled - Folder is accessible with link & code' : 'Disabled - Folder is private'}
+
+
+
+
+
+ {isShared && (
+
+
+
1. Share Link
+
+
+
+ Regenerate Link
+
+
+
+
+
+
+ 2. Access Code
+
+ New Code
+
+
+
+
+
+
+
+
+
+
+ Authentication Required
+
+
+ setPassword(e.target.value)}
+ placeholder="Enter secure code"
+ className="font-mono text-xl font-bold tracking-widest text-center bg-background border-amber-300"
+ />
+
+
+
+
+
+ External users must enter this code to view files.
+
+
+
+
+
+ )}
+
+
+
+ onOpenChange(false)}>Cancel
+
+ {loading ? 'Saving...' : 'Save Settings'}
+
+
+
+
+ );
+}
diff --git a/src/components/GenerateInvoiceDialog.jsx b/src/components/GenerateInvoiceDialog.jsx
new file mode 100644
index 0000000..01c5ec5
--- /dev/null
+++ b/src/components/GenerateInvoiceDialog.jsx
@@ -0,0 +1,60 @@
+import React, { useState } from 'react';
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
+import { Button } from '@/components/ui/button';
+import { Label } from '@/components/ui/label';
+import { Input } from '@/components/ui/input';
+
+function GenerateInvoiceDialog({ open, onOpenChange, onGenerate, count }) {
+ const [invoiceNumber, setInvoiceNumber] = useState('');
+ const [loading, setLoading] = useState(false);
+
+ const handleSubmit = async (e) => {
+ e.preventDefault();
+ if (!invoiceNumber || invoiceNumber.trim() === '') return;
+
+ setLoading(true);
+ await onGenerate(invoiceNumber);
+ setLoading(false);
+ setInvoiceNumber('');
+ onOpenChange(false);
+ };
+
+ return (
+
+
+
+ Generate Invoice
+
+ Generate a PDF invoice for {count} selected item{count !== 1 ? 's' : ''}.
+
+
+
+
+
Invoice Number
+
setInvoiceNumber(e.target.value)}
+ placeholder="e.g. INV-2024-001"
+ required
+ autoFocus
+ />
+
+ This number will be used for the invoice and set as the tracking ID.
+
+
+
+ onOpenChange(false)}>
+ Cancel
+
+
+ {loading ? 'Generating...' : 'Generate PDF'}
+
+
+
+
+
+ );
+}
+
+export default GenerateInvoiceDialog;
diff --git a/src/components/HomeownerRequestDetailsDialog.jsx b/src/components/HomeownerRequestDetailsDialog.jsx
new file mode 100644
index 0000000..8e6d9c5
--- /dev/null
+++ b/src/components/HomeownerRequestDetailsDialog.jsx
@@ -0,0 +1,154 @@
+import React, { useState, useEffect } from 'react';
+import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
+import { ScrollArea } from '@/components/ui/scroll-area';
+import { Button } from '@/components/ui/button';
+import { Separator } from '@/components/ui/separator';
+import { Badge } from '@/components/ui/badge';
+import { Calendar, Trash2, Edit, Mail, CheckCircle2 } from 'lucide-react';
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@/components/ui/alert-dialog";
+import { useAuth } from '@/contexts/AuthContext';
+import { supabase } from '@/integrations/supabase/client';
+import { useToast } from '@/hooks/use-toast';
+
+// NOTE: These sub-components are referenced but may need to be created separately:
+// HomeownerRequestCommentsSection, HomeownerRequestVotingPanel,
+// HomeownerRequestSummarySection, HomeownerRequestNotificationHistory, HomeownerRequestNotifyDialog
+
+export default function HomeownerRequestDetailsDialog({ open, onOpenChange, request, onRefresh, onEdit }) {
+ const { userRole } = useAuth();
+ const { toast } = useToast();
+ const [refreshTrigger, setRefreshTrigger] = useState(0);
+ const [notifyDialogOpen, setNotifyDialogOpen] = useState(false);
+ const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
+ const [localRequest, setLocalRequest] = useState(request);
+
+ useEffect(() => {
+ setLocalRequest(request);
+ }, [request]);
+
+ const fetchDetails = async () => {
+ if (!request?.id) return;
+
+ const { data: requestData, error: requestError } = await supabase
+ .from('homeowner_requests')
+ .select('*')
+ .eq('id', request.id)
+ .single();
+
+ if (requestError) {
+ console.error("Error fetching request details:", requestError);
+ return;
+ }
+
+ if (requestData) {
+ setLocalRequest(prev => ({
+ ...prev,
+ ...requestData,
+ }));
+ }
+ };
+
+ useEffect(() => {
+ if (open) {
+ fetchDetails();
+ }
+ }, [open, request?.id, refreshTrigger]);
+
+ if (!localRequest) return null;
+
+ const isAdmin = userRole?.role === 'admin' || userRole?.role === 'manager';
+
+ const handleDelete = async () => {
+ try {
+ const { error } = await supabase.from('homeowner_requests').delete().eq('id', localRequest.id);
+
+ if (error) throw error;
+
+ toast({ title: 'Deleted', description: 'Homeowner request deleted successfully.' });
+ setDeleteDialogOpen(false);
+ onOpenChange(false);
+ if (onRefresh) onRefresh();
+ } catch (error) {
+ console.error("Delete failed:", error);
+ toast({ variant: 'destructive', title: 'Error', description: error.message || "Failed to delete request." });
+ }
+ };
+
+ const handleRefresh = () => {
+ setRefreshTrigger(prev => prev + 1);
+ fetchDetails();
+ if (onRefresh) onRefresh();
+ };
+
+ return (
+ <>
+
+
+
+
+
+
+ {localRequest.status}
+ {new Date(localRequest.created_at).toLocaleDateString()}
+
+
{localRequest.title}
+
+ {isAdmin && (
+
+ onEdit(localRequest)}> Edit
+ setDeleteDialogOpen(true)}
+ >
+
+
+
+ )}
+
+
+
+
+
+
+ {localRequest.description && (
+
+ {localRequest.description}
+
+ )}
+
+
+
+
+
+
+
+
+
+ Are you absolutely sure?
+
+ This action cannot be undone. This will permanently delete the homeowner request
+ and all associated data.
+
+
+
+ Cancel
+
+ Delete Request
+
+
+
+
+ >
+ );
+}
diff --git a/src/components/HomeownerRequestDialog.jsx b/src/components/HomeownerRequestDialog.jsx
new file mode 100644
index 0000000..516c31e
--- /dev/null
+++ b/src/components/HomeownerRequestDialog.jsx
@@ -0,0 +1,210 @@
+import React, { useState, useEffect } from 'react';
+import {
+ Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription
+} from '@/components/ui/dialog';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import { Textarea } from '@/components/ui/textarea';
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
+import { Checkbox } from '@/components/ui/checkbox';
+import { ScrollArea } from '@/components/ui/scroll-area';
+import { useToast } from '@/hooks/use-toast';
+import { supabase } from '@/integrations/supabase/client';
+import { useAuth } from '@/contexts/AuthContext';
+import { Loader2, CheckCircle2 } from 'lucide-react';
+import { Badge } from '@/components/ui/badge';
+
+export default function HomeownerRequestDialog({ open, onOpenChange, request, onSuccess, isReadOnly = false }) {
+ const { user } = useAuth();
+ const { toast } = useToast();
+
+ const [title, setTitle] = useState('');
+ const [description, setDescription] = useState('');
+ const [status, setStatus] = useState('open');
+ const [category, setCategory] = useState('general');
+ const [priority, setPriority] = useState('medium');
+ const [selectedAssociations, setSelectedAssociations] = useState([]);
+
+ const [associations, setAssociations] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [submitting, setSubmitting] = useState(false);
+
+ useEffect(() => {
+ const fetchAssociations = async () => {
+ setLoading(true);
+ const { data } = await supabase.from('associations').select('id, name').eq('status', 'active').order('name');
+ setAssociations(data || []);
+ setLoading(false);
+ };
+ if (open) fetchAssociations();
+ }, [open]);
+
+ useEffect(() => {
+ if (request) {
+ setTitle(request.title || '');
+ setDescription(request.description || '');
+ setStatus(request.status || 'open');
+ setCategory(request.category || 'general');
+ setPriority(request.priority || 'medium');
+ } else {
+ setTitle('');
+ setDescription('');
+ setStatus('open');
+ setCategory('general');
+ setPriority('medium');
+ setSelectedAssociations([]);
+ }
+ }, [request, open]);
+
+ const handleSubmit = async (e) => {
+ e.preventDefault();
+ if (!title.trim()) return toast({ variant: 'destructive', title: 'Title required' });
+ if (selectedAssociations.length === 0) return toast({ variant: 'destructive', title: 'Select at least one association' });
+
+ setSubmitting(true);
+ try {
+ const payload = {
+ title,
+ description,
+ status,
+ category,
+ priority,
+ updated_at: new Date().toISOString()
+ };
+
+ if (request) {
+ const { error } = await supabase.from('homeowner_requests').update(payload).eq('id', request.id);
+ if (error) throw error;
+ } else {
+ const { error } = await supabase.from('homeowner_requests')
+ .insert({ ...payload, association_id: selectedAssociations[0] });
+ if (error) throw error;
+ }
+
+ toast({ title: 'Success', description: 'Request saved successfully.' });
+ if (onSuccess) onSuccess();
+ onOpenChange(false);
+
+ } catch (error) {
+ console.error(error);
+ toast({ variant: 'destructive', title: 'Error', description: error.message });
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ const toggleAssociation = (id) => {
+ if (isReadOnly) return;
+ setSelectedAssociations(prev =>
+ prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id]
+ );
+ };
+
+ const handleSelectAll = () => {
+ if (isReadOnly) return;
+ if (selectedAssociations.length === associations.length) {
+ setSelectedAssociations([]);
+ } else {
+ setSelectedAssociations(associations.map(c => c.id));
+ }
+ };
+
+ if (isReadOnly) {
+ return null;
+ }
+
+ return (
+
+
+
+
+
+ {request ? 'Edit Homeowner Request' : 'New Homeowner Request'}
+ Create or edit a homeowner request.
+
+
+
+
+
+
+
+ Title *
+ setTitle(e.target.value)} placeholder="e.g. Maintenance Request" />
+
+
+
+ Description
+ setDescription(e.target.value)} placeholder="Brief summary..." rows={3} />
+
+
+
+
+ Status
+
+
+
+ Open
+ In Progress
+ Resolved
+ Closed
+
+
+
+
+ Category
+
+
+
+ General
+ Maintenance
+ Complaint
+ Architectural
+
+
+
+
+ Priority
+
+
+
+ Low
+ Medium
+ High
+
+
+
+
+
+
+
+ Assigned Associations *
+
+ {selectedAssociations.length === associations.length ? 'Deselect All' : 'Select All'}
+
+
+
+ {loading ?
: associations.map(assoc => (
+
+ toggleAssociation(assoc.id)} />
+
+ {assoc.name}
+
+
+ ))}
+
+
+
+
+
+
+ onOpenChange(false)}>Cancel
+
+ {submitting && }
+ Save Request
+
+
+
+
+ );
+}
diff --git a/src/components/HomeownerRequestExportDialog.jsx b/src/components/HomeownerRequestExportDialog.jsx
new file mode 100644
index 0000000..aad43fe
--- /dev/null
+++ b/src/components/HomeownerRequestExportDialog.jsx
@@ -0,0 +1,161 @@
+import React, { useState } from 'react';
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from '@/components/ui/dialog';
+import { Button } from '@/components/ui/button';
+import { Loader2, FileDown } from 'lucide-react';
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
+import { Label } from '@/components/ui/label';
+import { format } from 'date-fns';
+
+export default function HomeownerRequestExportDialog({
+ open,
+ onOpenChange,
+ requests = [],
+ singleRequest = null,
+ associations = []
+}) {
+ const [isExporting, setIsExporting] = useState(false);
+ const [selectedAssociationFilter, setSelectedAssociationFilter] = useState('all');
+
+ const handleExport = async () => {
+ setIsExporting(true);
+ try {
+ let dataToExport = singleRequest ? [singleRequest] : requests;
+
+ if (!singleRequest && selectedAssociationFilter !== 'all') {
+ dataToExport = dataToExport.filter(req => req.association_id === selectedAssociationFilter);
+ }
+
+ if (dataToExport.length === 0) {
+ alert("No data to export with current filters.");
+ setIsExporting(false);
+ return;
+ }
+
+ // Dynamic import for jsPDF to avoid bundle bloat
+ const jsPDF = (await import('jspdf')).default;
+ await import('jspdf-autotable');
+
+ const doc = new jsPDF();
+ let yPos = 20;
+
+ doc.setFontSize(22);
+ doc.setTextColor(44, 62, 80);
+ doc.text("Homeowner Requests Report", 14, 20);
+
+ doc.setDrawColor(200);
+ doc.setLineWidth(0.5);
+ doc.line(14, 25, 196, 25);
+
+ yPos = 35;
+
+ doc.setFontSize(10);
+ doc.setTextColor(100);
+ doc.text(`Generated: ${format(new Date(), 'MMM dd, yyyy HH:mm')}`, 14, yPos);
+ yPos += 5;
+
+ if (singleRequest) {
+ doc.text(`Type: Detail Report`, 14, yPos);
+ } else {
+ const assocName = associations.find(c => c.id === selectedAssociationFilter)?.name || "All Associations";
+ doc.text(`Association Filter: ${assocName}`, 14, yPos);
+ }
+ yPos += 15;
+
+ for (const req of dataToExport) {
+ if (yPos > 240) {
+ doc.addPage();
+ yPos = 20;
+ }
+
+ doc.setFillColor(240, 242, 245);
+ doc.setDrawColor(210);
+ doc.rect(14, yPos - 6, 182, 12, 'FD');
+
+ doc.setFontSize(14);
+ doc.setTextColor(0);
+ doc.setFont("helvetica", "bold");
+ const title = req.title || "Untitled Request";
+ const displayTitle = title.length > 50 ? title.substring(0, 50) + "..." : title;
+ doc.text(displayTitle, 17, yPos + 2);
+
+ doc.setFontSize(10);
+ const statusText = (req.status || 'open').toUpperCase();
+ const statusWidth = doc.getTextWidth(statusText);
+ doc.setTextColor(100);
+ doc.text(statusText, 190 - statusWidth, yPos + 2);
+
+ yPos += 15;
+
+ doc.setTextColor(0);
+ doc.setFontSize(10);
+ doc.setFont("helvetica", "normal");
+ const createdDate = format(new Date(req.created_at), 'MMM dd, yyyy');
+ doc.text(`Submitted: ${createdDate}`, 17, yPos);
+ yPos += 8;
+
+ if (req.description) {
+ doc.setFont("helvetica", "bold");
+ doc.text("Description:", 17, yPos);
+ yPos += 5;
+
+ doc.setFont("helvetica", "normal");
+ const splitDesc = doc.splitTextToSize(req.description, 175);
+ doc.text(splitDesc, 17, yPos);
+ yPos += (splitDesc.length * 5) + 10;
+ }
+ }
+
+ const filename = singleRequest
+ ? `Request_${singleRequest.title.substring(0, 10).replace(/[^a-zA-Z0-9]/g, '_')}.pdf`
+ : `Requests_Report_${format(new Date(), 'yyyy-MM-dd')}.pdf`;
+
+ doc.save(filename);
+ onOpenChange(false);
+ } catch (error) {
+ console.error("Export failed:", error);
+ alert("Export failed. See console for details.");
+ } finally {
+ setIsExporting(false);
+ }
+ };
+
+ return (
+
+
+
+ {singleRequest ? 'Export Request Details' : 'Export Requests Report'}
+
+ Generate a detailed PDF report.
+
+
+
+ {!singleRequest && (
+
+
+ Filter by Association
+
+
+
+
+
+ All Associations
+ {associations.map(assoc => (
+ {assoc.name}
+ ))}
+
+
+
+
+ )}
+
+
+ onOpenChange(false)}>Cancel
+
+ {isExporting ? : }
+ Download PDF
+
+
+
+
+ );
+}
diff --git a/src/components/HomeownerRequestNotifyDialog.jsx b/src/components/HomeownerRequestNotifyDialog.jsx
new file mode 100644
index 0000000..0063c75
--- /dev/null
+++ b/src/components/HomeownerRequestNotifyDialog.jsx
@@ -0,0 +1,134 @@
+import React, { useState, useEffect } from 'react';
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from '@/components/ui/dialog';
+import { Button } from '@/components/ui/button';
+import { Loader2, Mail, AlertTriangle, CheckCircle2 } from 'lucide-react';
+import { useToast } from '@/hooks/use-toast';
+import { supabase } from '@/integrations/supabase/client';
+import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
+import { ScrollArea } from '@/components/ui/scroll-area';
+import { useAuth } from '@/contexts/AuthContext';
+
+export default function HomeownerRequestNotifyDialog({ open, onOpenChange, requestId, onSuccess }) {
+ const { toast } = useToast();
+ const { user } = useAuth();
+
+ const [loading, setLoading] = useState(false);
+ const [sending, setSending] = useState(false);
+ const [request, setRequest] = useState(null);
+ const [previewTab, setPreviewTab] = useState('recipients');
+
+ const [sendResults, setSendResults] = useState(null);
+
+ useEffect(() => {
+ if (open && requestId) {
+ loadData();
+ } else {
+ resetState();
+ }
+ }, [open, requestId]);
+
+ const resetState = () => {
+ setRequest(null);
+ setSendResults(null);
+ };
+
+ const loadData = async () => {
+ setLoading(true);
+ try {
+ const { data: reqData, error: reqError } = await supabase
+ .from('homeowner_requests')
+ .select('*')
+ .eq('id', requestId)
+ .single();
+
+ if (reqError) throw reqError;
+ setRequest(reqData);
+ } catch (error) {
+ console.error("Error loading notify dialog data:", error);
+ toast({ variant: 'destructive', title: 'Data access error', description: 'Failed to retrieve homeowner request.' });
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleSend = async () => {
+ setSending(true);
+ setSendResults(null);
+
+ try {
+ // Placeholder: In production, this would call an edge function or email service
+ toast({ title: 'Notification', description: 'Email notification feature requires email service configuration.' });
+ setSendResults({ success: true });
+ if (onSuccess) onSuccess();
+ } catch (error) {
+ console.error("Send failed:", error);
+ toast({ variant: 'destructive', title: 'Failed to send', description: error.message });
+ setSendResults({ success: false, error: error.message });
+ } finally {
+ setSending(false);
+ }
+ };
+
+ return (
+
+
+
+ Notify Homeowners
+ Send email notification for "{request?.title}"
+
+
+
+ {sendResults ? (
+
+
+ {sendResults.success ? (
+
+
+
+ ) : (
+
+ )}
+
+ {sendResults.success ? 'Notifications Sent' : 'Sending Failed'}
+
+
+
+ ) : (
+
+ {loading ? (
+
+
+
Loading request details...
+
+ ) : (
+
+
+
Email notification functionality requires email service configuration.
+
+ )}
+
+ )}
+
+
+
+ {sendResults ? (
+ onOpenChange(false)}>Close
+ ) : (
+ <>
+ onOpenChange(false)}>Cancel
+
+ {sending ? : }
+ {sending ? 'Sending...' : 'Send Notifications'}
+
+ >
+ )}
+
+
+
+ );
+}
diff --git a/src/components/IcsImportDialog.jsx b/src/components/IcsImportDialog.jsx
new file mode 100644
index 0000000..aa84fb3
--- /dev/null
+++ b/src/components/IcsImportDialog.jsx
@@ -0,0 +1,131 @@
+import React, { useState, useCallback } from 'react';
+import { useDropzone } from 'react-dropzone';
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
+import { Button } from '@/components/ui/button';
+import { ScrollArea } from '@/components/ui/scroll-area';
+import { Loader2, Upload, FileText, CheckCircle, AlertTriangle } from 'lucide-react';
+import { useToast } from '@/hooks/use-toast';
+
+export function IcsImportDialog({ open, onOpenChange, onImport }) {
+ const [file, setFile] = useState(null);
+ const [parsedEvents, setParsedEvents] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const { toast } = useToast();
+
+ const onDrop = useCallback((acceptedFiles) => {
+ const uploadedFile = acceptedFiles[0];
+ if (uploadedFile) {
+ setFile(uploadedFile);
+ parseIcs(uploadedFile);
+ }
+ }, []);
+
+ const { getRootProps, getInputProps, isDragActive } = useDropzone({
+ onDrop,
+ accept: { 'text/calendar': ['.ics'] },
+ maxFiles: 1
+ });
+
+ const parseIcs = async (file) => {
+ const reader = new FileReader();
+ reader.onload = async () => {
+ try {
+ // Dynamic import for ical.js
+ const ICAL = (await import('ical.js')).default;
+ const jcalData = ICAL.parse(reader.result);
+ const comp = new ICAL.Component(jcalData);
+ const vevents = comp.getAllSubcomponents('vevent');
+
+ const events = vevents.map(vevent => {
+ const event = new ICAL.Event(vevent);
+ return {
+ title: event.summary,
+ description: event.description,
+ start_time: event.startDate.toJSDate().toISOString(),
+ end_time: event.endDate.toJSDate().toISOString(),
+ location: event.location,
+ source: 'ics_import'
+ };
+ });
+ setParsedEvents(events);
+ } catch (err) {
+ console.error(err);
+ toast({ variant: "destructive", title: "Parse Error", description: "Invalid ICS file format." });
+ setFile(null);
+ }
+ };
+ reader.readAsText(file);
+ };
+
+ const handleImport = async () => {
+ setLoading(true);
+ try {
+ await onImport(parsedEvents);
+ toast({ title: "Import Successful", description: `Imported ${parsedEvents.length} events.` });
+ onOpenChange(false);
+ setFile(null);
+ setParsedEvents([]);
+ } catch (err) {
+ toast({ variant: "destructive", title: "Import Failed", description: err.message });
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+
+
+
+ Import Calendar (.ics)
+ Import events from external calendar files.
+
+
+ {!file ? (
+
+
+
+
Drag & drop .ics file here, or click to select
+
Maximum file size 5MB
+
+ ) : (
+
+
+
+
+ {file.name}
+
+
{ setFile(null); setParsedEvents([]); }}>Change
+
+
+
+
+
+
Ready to Import
+
Found {parsedEvents.length} events in file.
+
+
+
+
+ {parsedEvents.map((ev, i) => (
+
+ {ev.title} - {new Date(ev.start_time).toLocaleDateString()}
+
+ ))}
+
+
+ )}
+
+
+ onOpenChange(false)}>Cancel
+
+ {loading && }
+ Import Events
+
+
+
+
+ );
+}
diff --git a/src/components/ImageElement.tsx b/src/components/ImageElement.tsx
new file mode 100644
index 0000000..c88d421
--- /dev/null
+++ b/src/components/ImageElement.tsx
@@ -0,0 +1,215 @@
+import { useState } from "react";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import {
+ Trash2, GripVertical, Image as ImageIcon, Upload,
+ AlignLeft, AlignCenter, AlignRight,
+} from "lucide-react";
+import { supabase } from "@/integrations/supabase/client";
+import { useToast } from "@/components/ui/use-toast";
+
+interface ImageStyles {
+ width?: string;
+ widthUnit?: string;
+ height?: string;
+ alignment?: string;
+}
+
+interface ImageContent {
+ url?: string;
+ altText?: string;
+}
+
+interface ImageBlock {
+ styles?: ImageStyles;
+ content?: ImageContent;
+}
+
+interface ImageElementProps {
+ block: ImageBlock;
+ onChange: (updates: Partial) => void;
+ onDelete: () => void;
+ dragHandleProps?: Record;
+}
+
+export default function ImageElement({ block, onChange, onDelete, dragHandleProps }: ImageElementProps) {
+ const { toast } = useToast();
+ const [uploading, setUploading] = useState(false);
+ const styles = block.styles || {};
+ const content = block.content || {};
+
+ const width = styles.width || "100";
+ const widthUnit = styles.widthUnit || "%";
+ const height = styles.height || "auto";
+ const alignment = styles.alignment || "center";
+ const imageUrl = content.url || "";
+ const altText = content.altText || "";
+
+ const updateStyle = (key: string, value: string) => {
+ onChange({ styles: { ...styles, [key]: value } });
+ };
+
+ const updateContent = (key: string, value: string) => {
+ onChange({ content: { ...content, [key]: value } });
+ };
+
+ const handleFileUpload = async (event: React.ChangeEvent) => {
+ try {
+ setUploading(true);
+ const file = event.target.files?.[0];
+ if (!file) return;
+
+ const fileExt = file.name.split(".").pop();
+ const fileName = `${Math.random().toString(36).substring(2)}.${fileExt}`;
+ const filePath = `form-assets/${fileName}`;
+
+ const { error: uploadError } = await supabase.storage
+ .from("company-assets")
+ .upload(filePath, file);
+
+ if (uploadError) throw uploadError;
+
+ const {
+ data: { publicUrl },
+ } = supabase.storage.from("company-assets").getPublicUrl(filePath);
+
+ updateContent("url", publicUrl);
+ toast({ title: "Success", description: "Image uploaded successfully" });
+ } catch (error) {
+ console.error("Error uploading image:", error);
+ toast({ variant: "destructive", title: "Error", description: "Failed to upload image." });
+ } finally {
+ setUploading(false);
+ }
+ };
+
+ return (
+
+
+
+
+
+
+ Image
+
+
+
+
+
+
+
+
+ {!imageUrl ? (
+
+
+
+
Upload an image or enter URL
+
+
+
+
+ {uploading ? "Uploading..." : <> Choose File>}
+
+
+
+
+ updateContent("url", e.target.value)}
+ />
+
+
+
+ ) : (
+
+
+
+
+
+ updateContent("url", "")}>
+ Change
+
+
+
+
+
+
+
+
Width
+
+ updateStyle("width", e.target.value)} />
+ updateStyle("widthUnit", e.target.value)}
+ >
+ %
+ px
+
+
+
+
+ Height (px)
+ updateStyle("height", e.target.value)} placeholder="auto" />
+
+
+
+
+ Alt Text
+ updateContent("altText", e.target.value)} placeholder="Description for accessibility" />
+
+
+
+
Alignment
+
+ {(["left", "center", "right"] as const).map((align) => (
+
updateStyle("alignment", align)}
+ >
+ {align === "left" && }
+ {align === "center" && }
+ {align === "right" && }
+
+ ))}
+
+
+
+
+
+ {/* Preview */}
+
+
Preview
+
+
+
+
+
+
+
+ )}
+
+
+ );
+}
diff --git a/src/components/ImageElementDialog.jsx b/src/components/ImageElementDialog.jsx
new file mode 100644
index 0000000..14b4b8d
--- /dev/null
+++ b/src/components/ImageElementDialog.jsx
@@ -0,0 +1,8 @@
+import React from 'react';
+
+const ImageElementDialog = () => {
+ return null;
+};
+
+export { ImageElementDialog };
+export default ImageElementDialog;
diff --git a/src/components/ImageUploadField.tsx b/src/components/ImageUploadField.tsx
new file mode 100644
index 0000000..6ce5aba
--- /dev/null
+++ b/src/components/ImageUploadField.tsx
@@ -0,0 +1,145 @@
+import { useState, useCallback } from "react";
+import { useDropzone } from "react-dropzone";
+import { Upload, X, Loader2 } from "lucide-react";
+import { supabase } from "@/integrations/supabase/client";
+import { cn } from "@/lib/utils";
+import { useToast } from "@/components/ui/use-toast";
+
+interface ImageUploadFieldProps {
+ images?: string[];
+ onChange: (images: string[]) => void;
+ disabled?: boolean;
+ bucket?: string;
+}
+
+export function ImageUploadField({
+ images = [],
+ onChange,
+ disabled = false,
+ bucket = "status-update-images",
+}: ImageUploadFieldProps) {
+ const [uploading, setUploading] = useState(false);
+ const { toast } = useToast();
+ const safeImages = Array.isArray(images) ? images : [];
+
+ const onDrop = useCallback(
+ async (acceptedFiles: File[]) => {
+ if (disabled) return;
+ setUploading(true);
+ const newImages: string[] = [];
+ const errors: string[] = [];
+
+ for (const file of acceptedFiles) {
+ try {
+ const fileExt = file.name.split(".").pop();
+ const fileName = `${Math.random().toString(36).substring(2)}_${Date.now()}.${fileExt}`;
+
+ const { error: uploadError } = await supabase.storage
+ .from(bucket)
+ .upload(fileName, file);
+
+ if (uploadError) throw uploadError;
+
+ const {
+ data: { publicUrl },
+ } = supabase.storage.from(bucket).getPublicUrl(fileName);
+
+ newImages.push(publicUrl);
+ } catch (error) {
+ console.error("Error uploading image:", error);
+ errors.push(file.name);
+ }
+ }
+
+ if (errors.length > 0) {
+ toast({
+ variant: "destructive",
+ title: "Upload Failed",
+ description: `Failed to upload: ${errors.join(", ")}`,
+ });
+ }
+
+ if (newImages.length > 0) {
+ onChange([...safeImages, ...newImages]);
+ }
+
+ setUploading(false);
+ },
+ [safeImages, onChange, disabled, toast, bucket]
+ );
+
+ const removeImage = (indexToRemove: number) => {
+ if (disabled) return;
+ onChange(safeImages.filter((_, index) => index !== indexToRemove));
+ };
+
+ const { getRootProps, getInputProps, isDragActive } = useDropzone({
+ onDrop,
+ accept: { "image/*": [".png", ".jpg", ".jpeg", ".gif", ".webp"] },
+ disabled: disabled || uploading,
+ maxSize: 5242880,
+ });
+
+ return (
+
+
+
+
+ {uploading ? (
+
+ ) : (
+
+ )}
+
+ {uploading ? (
+
Uploading images...
+ ) : isDragActive ? (
+
Drop images here
+ ) : (
+
+ Click to upload {" "}
+ or drag and drop
+
+ )}
+
+
PNG, JPG, GIF up to 5MB
+
+
+
+ {safeImages.length > 0 && (
+
+ {safeImages.map((url, index) => (
+
+
+ {!disabled && (
+
removeImage(index)}
+ className="absolute top-1 right-1 p-1 bg-background/90 rounded-full shadow-sm opacity-0 group-hover:opacity-100 transition-opacity hover:bg-destructive/10 text-muted-foreground hover:text-destructive"
+ >
+
+
+ )}
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/src/components/ImportDelinquencyDialog.jsx b/src/components/ImportDelinquencyDialog.jsx
new file mode 100644
index 0000000..d354edc
--- /dev/null
+++ b/src/components/ImportDelinquencyDialog.jsx
@@ -0,0 +1,208 @@
+import React, { useState, useCallback } from 'react';
+import { useDropzone } from 'react-dropzone';
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
+import { Button } from '@/components/ui/button';
+import { ScrollArea } from '@/components/ui/scroll-area';
+import { Alert, AlertTitle, AlertDescription } from '@/components/ui/alert';
+import { useToast } from '@/hooks/use-toast';
+import { Upload, FileText, AlertTriangle, CheckCircle, XCircle, Loader2 } from 'lucide-react';
+import { validateCSVHeaders, validateRow, parseCSVFile, consolidateRows } from '@/lib/delinquencyImportUtils';
+import { supabase } from '@/integrations/supabase/client';
+
+export default function ImportDelinquencyDialog({ open, onOpenChange, associationId, onSuccess }) {
+ const [file, setFile] = useState(null);
+ const [analyzing, setAnalyzing] = useState(false);
+ const [uploading, setUploading] = useState(false);
+ const [validationResult, setValidationResult] = useState(null);
+ const [parsedRows, setParsedRows] = useState([]);
+ const { toast } = useToast();
+
+ const onDrop = useCallback(async (acceptedFiles) => {
+ const selectedFile = acceptedFiles[0];
+ if (!selectedFile) return;
+
+ setFile(selectedFile);
+ setAnalyzing(true);
+ setValidationResult(null);
+
+ try {
+ const rows = await parseCSVFile(selectedFile);
+
+ if (rows.length === 0) throw new Error("File is empty");
+ const headers = Object.keys(rows[0]);
+ const headerCheck = validateCSVHeaders(headers);
+
+ if (!headerCheck.valid) {
+ setValidationResult({ valid: false, error: headerCheck.error });
+ setAnalyzing(false);
+ return;
+ }
+
+ const errors = [];
+ let validRows = 0;
+
+ rows.forEach((row, index) => {
+ const rowErrors = validateRow(row, index);
+ if (rowErrors.length > 0) {
+ errors.push(...rowErrors);
+ } else {
+ validRows++;
+ }
+ });
+
+ setParsedRows(rows);
+ setValidationResult({
+ valid: errors.length === 0,
+ totalRows: rows.length,
+ validRows,
+ errors
+ });
+
+ } catch (error) {
+ setValidationResult({ valid: false, error: error.message });
+ } finally {
+ setAnalyzing(false);
+ }
+ }, []);
+
+ const { getRootProps, getInputProps, isDragActive } = useDropzone({
+ onDrop,
+ accept: { 'text/csv': ['.csv'] },
+ maxFiles: 1
+ });
+
+ const handleImport = async () => {
+ if (!parsedRows.length || !associationId) return;
+ setUploading(true);
+
+ try {
+ const consolidated = consolidateRows(parsedRows);
+
+ const { data, error } = await supabase.functions.invoke('process-delinquency-import', {
+ body: { rows: consolidated, association_id: associationId }
+ });
+
+ if (error) throw new Error(error.message);
+ if (!data.success) throw new Error(data.error);
+
+ toast({
+ title: "Import Successful",
+ description: `Processed ${data.processed} records with ${data.errors} errors.`
+ });
+
+ if (onSuccess) onSuccess();
+ onOpenChange(false);
+ setFile(null);
+ setValidationResult(null);
+
+ } catch (err) {
+ toast({
+ variant: "destructive",
+ title: "Import Failed",
+ description: err.message
+ });
+ } finally {
+ setUploading(false);
+ }
+ };
+
+ return (
+
+
+
+ Import Delinquency Records
+
+ Upload a CSV file with columns: unit_id, account_number, amount, fee_type.
+
+
+
+ {!file ? (
+
+
+
+
+ Drag & drop CSV file here, or click to select
+
+
+ Valid fee types: Assessments, Late Fees, Interest, Legal Fees, Admin Fees
+
+
+ ) : (
+
+
+
+
+
+
{file.name}
+
{(file.size / 1024).toFixed(1)} KB
+
+
+
{ setFile(null); setValidationResult(null); }}>Change
+
+
+ {analyzing && (
+
+
+ Validating file...
+
+ )}
+
+ {!analyzing && validationResult && (
+
+ {validationResult.valid ? (
+
+
+ Validation Successful
+
+ Ready to import {validationResult.totalRows} records.
+
+
+ ) : (
+
+
+ Validation Errors
+
+ Found {validationResult.errors?.length || 1} issues in the file.
+
+
+ )}
+
+ {validationResult.errors && validationResult.errors.length > 0 && (
+
+
+ Error Log
+
+
+
+ {validationResult.errors.map((err, i) => (
+
+
+ {err}
+
+ ))}
+
+
+
+ )}
+
+ )}
+
+ )}
+
+
+ onOpenChange(false)}>Cancel
+
+ {uploading ? <> Importing...> : 'Import Records'}
+
+
+
+
+ );
+}
diff --git a/src/components/ImportDialog.jsx b/src/components/ImportDialog.jsx
new file mode 100644
index 0000000..bdbc260
--- /dev/null
+++ b/src/components/ImportDialog.jsx
@@ -0,0 +1,771 @@
+import React, { useState, useEffect } from 'react';
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
+import { Button } from '@/components/ui/button';
+import { useToast } from '@/hooks/use-toast';
+import { supabase } from '@/integrations/supabase/client';
+import * as XLSX from 'xlsx';
+import { Label } from '@/components/ui/label';
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
+import { Input } from '@/components/ui/input';
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
+import { AlertCircle, CheckCircle2, AlertTriangle, ArrowRight, HelpCircle } from 'lucide-react';
+import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
+import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
+
+/**
+ * ImportDialog handles the parsing and mapping of bank/lockbox files.
+ * Maps imported data to associations, GL accounts, units, and vendors.
+ */
+const ImportDialog = ({ open, onOpenChange, requiredFields = [], onSuccess, additionalData = {}, defaultFileType = 'bank' }) => {
+ const { toast } = useToast();
+ const LOCKBOX_ACCEPT = '.txt,text/plain,text/*,.asc,.dat';
+
+ const [step, setStep] = useState(1);
+ const [isLoading, setIsLoading] = useState(false);
+ const [fileType, setFileType] = useState(defaultFileType);
+ const [file, setFile] = useState(null);
+
+ const [fileHeaders, setFileHeaders] = useState([]);
+ const [mapping, setMapping] = useState({});
+ const [bankData, setBankData] = useState([]);
+ const [bankPreviewData, setBankPreviewData] = useState([]);
+
+ const [lockboxRows, setLockboxRows] = useState([]);
+ const [importSummary, setImportSummary] = useState(null);
+
+ // Reference Data
+ const [associations, setAssociations] = useState([]);
+ const [chartOfAccounts, setChartOfAccounts] = useState([]);
+ const [vendors, setVendors] = useState([]);
+ const [units, setUnits] = useState([]);
+
+ useEffect(() => {
+ if (!open) {
+ setStep(1);
+ setIsLoading(false);
+ setFileType(defaultFileType);
+ setFile(null);
+ setFileHeaders([]);
+ setMapping({});
+ setBankData([]);
+ setBankPreviewData([]);
+ setLockboxRows([]);
+ setImportSummary(null);
+ } else {
+ fetchReferenceData();
+ }
+ }, [open, defaultFileType]);
+
+ const fetchReferenceData = async () => {
+ setIsLoading(true);
+ const [assocRes, coaRes, vendorsRes, unitsRes] = await Promise.all([
+ supabase.from('associations').select('id, name').eq('status', 'active'),
+ supabase.from('chart_of_accounts').select('id, account_number, account_name, association_id'),
+ supabase.from('vendors').select('id, vendor_name, association_id'),
+ supabase.from('units').select('id, unit_number, account_number, association_id')
+ ]);
+
+ if (assocRes.data) setAssociations(assocRes.data);
+ if (coaRes.data) setChartOfAccounts(coaRes.data);
+ if (vendorsRes.data) setVendors(vendorsRes.data);
+ if (unitsRes.data) setUnits(unitsRes.data);
+ setIsLoading(false);
+ };
+
+ const handleFileChange = (e) => {
+ const selectedFile = e.target.files[0];
+ if (!selectedFile) return;
+
+ if (fileType === 'lockbox') {
+ const fileName = selectedFile.name.toLowerCase();
+ const isAsciiLockboxFile = fileName.endsWith('.txt') || fileName.endsWith('.asc') || fileName.endsWith('.dat') || selectedFile.type === 'text/plain' || selectedFile.type.startsWith('text/') || selectedFile.type === '' || selectedFile.type === 'application/octet-stream';
+ if (isAsciiLockboxFile) {
+ setFile(selectedFile);
+ } else {
+ toast({ variant: 'destructive', title: 'Invalid File Type', description: 'Please upload a .txt ASCII lockbox file.' });
+ }
+ } else {
+ if (selectedFile.type === 'text/csv' || selectedFile.name.endsWith('.csv') || selectedFile.name.endsWith('.xlsx') || selectedFile.name.endsWith('.xls')) {
+ setFile(selectedFile);
+ } else {
+ toast({ variant: 'destructive', title: 'Invalid File Type', description: 'Please upload a CSV or Excel file.' });
+ }
+ }
+ };
+
+ const handleParseFile = () => {
+ if (!file) {
+ toast({ variant: 'destructive', title: 'No file selected' });
+ return;
+ }
+ setIsLoading(true);
+
+ if (fileType === 'lockbox') {
+ parseLockboxFile();
+ } else {
+ parseBankFile();
+ }
+ };
+
+ const parseBankFile = () => {
+ const reader = new FileReader();
+ reader.onload = (e) => {
+ try {
+ const data = new Uint8Array(e.target.result);
+ const workbook = XLSX.read(data, { type: 'array', cellDates: true, dateNF: 'yyyy-mm-dd' });
+ const sheetName = workbook.SheetNames[0];
+ const worksheet = workbook.Sheets[sheetName];
+ const jsonData = XLSX.utils.sheet_to_json(worksheet, { header: 1 });
+
+ if (jsonData.length < 2) {
+ toast({ variant: 'destructive', title: 'Empty File', description: 'The file appears to be empty or has only headers.' });
+ setIsLoading(false);
+ return;
+ }
+
+ const headers = jsonData[0].map(h => String(h).trim());
+ const rows = jsonData.slice(1).map(row => {
+ let rowData = {};
+ headers.forEach((header, index) => {
+ rowData[header] = row[index];
+ });
+ return rowData;
+ });
+
+ setFileHeaders(headers);
+ setBankData(rows);
+
+ const initialMapping = {};
+ requiredFields.forEach(field => {
+ const matchedHeader = headers.find(h =>
+ h.toLowerCase().replace(/[\s_]/g, '') === field.label.toLowerCase().replace(/[\s_]/g, '') ||
+ h.toLowerCase().replace(/[\s_]/g, '') === field.key.toLowerCase().replace(/[\s_]/g, '')
+ );
+ if(matchedHeader) initialMapping[field.key] = matchedHeader;
+ });
+ setMapping(initialMapping);
+ setStep(2);
+ } catch (error) {
+ toast({ variant: 'destructive', title: 'File Parsing Error', description: 'Could not read the file.' });
+ } finally {
+ setIsLoading(false);
+ }
+ };
+ reader.readAsArrayBuffer(file);
+ };
+
+ const parseLockboxFile = () => {
+ const reader = new FileReader();
+ reader.onload = (e) => {
+ try {
+ const text = e.target.result;
+ const lines = text.split(/\r?\n/);
+ const parsedRows = [];
+
+ lines.forEach((line, index) => {
+ const cleanLine = line.trim();
+ if (!cleanLine) return;
+ const cols = cleanLine.replace(/^"|"$/g, '').split('","');
+ const [acctRaw, amtRaw, dateRaw, txnRaw, memoRaw] = cols;
+
+ const acct = acctRaw?.trim() || '';
+ const amt = parseFloat(amtRaw) || 0;
+
+ let isoDate = dateRaw || '';
+ if (dateRaw && dateRaw.length === 8) {
+ isoDate = `${dateRaw.substring(4,8)}-${dateRaw.substring(0,2)}-${dateRaw.substring(2,4)}`;
+ }
+
+ const normalizedAcct = acct.replace(/^0+/, '').trim();
+ const matchedUnit = units.find(u => {
+ const unitAccount = String(u.account_number || '').trim();
+ return unitAccount === acct || unitAccount.replace(/^0+/, '') === normalizedAcct;
+ });
+
+ parsedRows.push({
+ _id: index,
+ accountNumber: acct,
+ unitId: matchedUnit ? matchedUnit.id : '',
+ amount: amt,
+ date: isoDate,
+ transactionNumber: txnRaw || '',
+ memo: memoRaw || ''
+ });
+ });
+
+ setLockboxRows(parsedRows);
+ setStep(3);
+ } catch (error) {
+ toast({ variant: 'destructive', title: 'Parse Error', description: 'Error parsing Lockbox file.' });
+ } finally {
+ setIsLoading(false);
+ }
+ };
+ reader.readAsText(file);
+ };
+
+ const handleMappingChange = (fieldKey, header) => {
+ setMapping(prev => ({ ...prev, [fieldKey]: header }));
+ };
+
+ const handleGenerateBankPreview = () => {
+ const requiredUnmapped = requiredFields.filter(f => f.required && !mapping[f.key]);
+ if (requiredUnmapped.length > 0) {
+ toast({ variant: 'destructive', title: 'Mapping Incomplete', description: `Please map required fields: ${requiredUnmapped.map(f => f.label).join(', ')}` });
+ return;
+ }
+
+ const preview = bankData.map((row, index) => {
+ const getVal = (key) => row[mapping[key]];
+
+ const assocRaw = getVal('association');
+ const glRaw = getVal('gl_account');
+ const vendorRaw = getVal('vendor');
+ const unitRaw = getVal('unit');
+ const paymentRaw = getVal('payment');
+ const creditRaw = getVal('credit');
+ const amountRaw = getVal('amount');
+ const dateRaw = getVal('transaction_date');
+
+ const errors = [];
+ const warnings = [];
+
+ let parsedDate = new Date();
+ if (dateRaw) {
+ parsedDate = new Date(dateRaw);
+ if (isNaN(parsedDate)) {
+ parsedDate = new Date();
+ warnings.push('Invalid Date, using today');
+ }
+ } else {
+ warnings.push('Missing Date, using today');
+ }
+
+ let matchedAssoc = null;
+ if (assocRaw) {
+ matchedAssoc = associations.find(c => c.name.toLowerCase() === String(assocRaw).trim().toLowerCase());
+ if (!matchedAssoc) errors.push('Association not found');
+ } else {
+ errors.push('Association required');
+ }
+
+ let matchedCoa = null;
+ if (glRaw && matchedAssoc) {
+ matchedCoa = chartOfAccounts.find(c =>
+ c.association_id === matchedAssoc.id &&
+ (c.account_number === String(glRaw).trim() || c.account_name.toLowerCase() === String(glRaw).trim().toLowerCase())
+ );
+ if (!matchedCoa) errors.push('GL Account not found for this association');
+ } else if (!glRaw) {
+ errors.push('GL Account required');
+ }
+
+ let matchedVendor = null;
+ if (vendorRaw && matchedAssoc) {
+ matchedVendor = vendors.find(v => v.association_id === matchedAssoc.id && v.vendor_name.toLowerCase() === String(vendorRaw).trim().toLowerCase());
+ if (!matchedVendor) warnings.push('Vendor unmapped (will import as text)');
+ }
+
+ let matchedUnit = null;
+ if (unitRaw && matchedAssoc) {
+ matchedUnit = units.find(u =>
+ u.association_id === matchedAssoc.id &&
+ u.unit_number === String(unitRaw).trim()
+ );
+ if (!matchedUnit) warnings.push('Unit unmapped');
+ }
+
+ if (!paymentRaw) errors.push('Payment required');
+ if (!creditRaw) errors.push('Credit indicator required');
+
+ const parsedAmount = parseFloat(amountRaw);
+ if (isNaN(parsedAmount)) errors.push('Invalid Amount');
+
+ return {
+ _id: index,
+ raw: row,
+ association: assocRaw || '',
+ gl_account: glRaw || '',
+ vendor: vendorRaw || '',
+ unit: unitRaw || '',
+ payment: paymentRaw || '',
+ credit: creditRaw || '',
+ amount: isNaN(parsedAmount) ? amountRaw : parsedAmount,
+ transaction_date: parsedDate.toISOString().split('T')[0],
+ matchedAssoc,
+ matchedCoa,
+ matchedVendor,
+ matchedUnit,
+ errors,
+ warnings,
+ isValid: errors.length === 0
+ };
+ });
+
+ setBankPreviewData(preview);
+ setStep(3);
+ };
+
+ const handleImport = async () => {
+ setIsLoading(true);
+ if (fileType === 'lockbox') {
+ await importLockboxData();
+ } else {
+ await importBankData();
+ }
+ };
+
+ const importBankData = async () => {
+ const validRows = bankPreviewData.filter(r => r.isValid);
+
+ if (validRows.length === 0) {
+ toast({ variant: 'destructive', title: 'Import Failed', description: 'No valid rows to import.' });
+ setIsLoading(false);
+ return;
+ }
+
+ const processedBankData = [];
+
+ validRows.forEach(r => {
+ const isDebit = String(r.credit).toLowerCase().includes('debit');
+ const txType = isDebit ? 'debit' : 'credit';
+ const desc = r.matchedVendor ? `Vendor: ${r.matchedVendor.vendor_name}` : (r.vendor ? `Vendor: ${r.vendor}` : 'Imported Bank Transaction');
+
+ processedBankData.push({
+ association_id: r.matchedAssoc.id,
+ description: desc,
+ [isDebit ? 'debit' : 'credit']: r.amount,
+ date: r.transaction_date,
+ transaction_type: txType === 'debit' ? 'payment' : 'deposit',
+ ...additionalData
+ });
+ });
+
+ try {
+ const { error: bankErr } = await supabase.from('bank_transactions').insert(processedBankData);
+ if (bankErr) throw bankErr;
+
+ setImportSummary({
+ total: bankPreviewData.length,
+ success: validRows.length,
+ failed: bankPreviewData.length - validRows.length
+ });
+ setStep(4);
+ if(onSuccess) onSuccess();
+ } catch (error) {
+ toast({ variant: 'destructive', title: 'Import Failed', description: error.message });
+ }
+
+ setIsLoading(false);
+ };
+
+ const importLockboxData = async () => {
+ const validRows = lockboxRows.filter(r => r.unitId && !isNaN(r.amount) && r.amount > 0 && r.date);
+
+ if (validRows.length === 0) {
+ toast({ variant: 'destructive', title: 'Import Failed', description: 'No valid rows to import. Each row needs a matched unit, positive amount, and date.' });
+ setIsLoading(false);
+ return;
+ }
+
+ try {
+ // Resolve owner_id + association_id for each unit
+ const unitIds = [...new Set(validRows.map(r => r.unitId))];
+ const { data: ownerRows, error: ownerErr } = await supabase
+ .from('owners')
+ .select('id, unit_id, association_id, status')
+ .in('unit_id', unitIds)
+ .neq('status', 'archived');
+ if (ownerErr) throw ownerErr;
+
+ const ownerByUnit = {};
+ (ownerRows || []).forEach(o => {
+ const existing = ownerByUnit[o.unit_id];
+ // Prefer active owners
+ if (!existing || (existing.status !== 'active' && o.status === 'active')) {
+ ownerByUnit[o.unit_id] = o;
+ }
+ });
+
+ const entries = [];
+ const skipped = [];
+ validRows.forEach(r => {
+ const owner = ownerByUnit[r.unitId];
+ if (!owner) { skipped.push(r); return; }
+ entries.push({
+ association_id: owner.association_id,
+ owner_id: owner.id,
+ unit_id: r.unitId,
+ date: r.date,
+ transaction_type: 'payment',
+ description: `Lockbox payment${r.transactionNumber ? ` #${r.transactionNumber}` : ''}${r.memo ? ` - ${r.memo}` : ''}`,
+ debit: 0,
+ credit: r.amount,
+ reference_id: r.transactionNumber || `lockbox-${r.date}-${r.accountNumber}-${r.amount}`,
+ reference_type: 'lockbox',
+ });
+ });
+
+ if (entries.length === 0) {
+ toast({ variant: 'destructive', title: 'Import Failed', description: 'No matching owners found for the imported units.' });
+ setIsLoading(false);
+ return;
+ }
+
+ // Batch insert (500 at a time)
+ const BATCH = 500;
+ let inserted = 0;
+ for (let i = 0; i < entries.length; i += BATCH) {
+ const batch = entries.slice(i, i + BATCH);
+ const { error } = await supabase.from('owner_ledger_entries').insert(batch);
+ if (error) throw error;
+ inserted += batch.length;
+ }
+
+ toast({ title: 'Import Complete', description: `${inserted} payment(s) posted to the ledger.` });
+ setImportSummary({
+ total: lockboxRows.length,
+ success: inserted,
+ failed: lockboxRows.length - inserted,
+ });
+ setStep(4);
+ if (onSuccess) onSuccess();
+ } catch (error) {
+ console.error('Lockbox import error:', error);
+ toast({ variant: 'destructive', title: 'Import Failed', description: error.message });
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ const handleLockboxRowChange = (index, field, value) => {
+ const newRows = [...lockboxRows];
+ newRows[index][field] = value;
+ if (field === 'accountNumber') {
+ const normalizedAcct = String(value || '').replace(/^0+/, '').trim();
+ const matchedUnit = units.find(u => {
+ const unitAccount = String(u.account_number || '').trim();
+ return unitAccount === String(value || '').trim() || unitAccount.replace(/^0+/, '') === normalizedAcct;
+ });
+ newRows[index].unitId = matchedUnit ? matchedUnit.id : '';
+ }
+ setLockboxRows(newRows);
+ };
+
+ return (
+ { if (!isLoading) onOpenChange(val); }}>
+
+
+
+ {step === 1 && "Import Transactions"}
+ {step === 2 && "Map Fields"}
+ {step === 3 && "Preview Data"}
+ {step === 4 && "Import Complete"}
+
+
+ {step === 1 && 'Select the file type and upload your document to begin.'}
+ {step === 2 && 'Map your file columns to the database fields.'}
+ {step === 3 && 'Review parsed data and correct any errors before importing.'}
+ {step === 4 && 'Review the results of your import operation.'}
+
+
+
+
+ {step === 1 && (
+
+
+ File Type
+
+
+
+
+
+ Bank Transactions (CSV/Excel)
+ Lockbox Payments (.txt)
+
+
+
+
+
+
+
+ {fileType === 'lockbox' ? 'Lockbox Format Requirements' : 'Bank Transaction Format'}
+
+
+ {fileType === 'lockbox'
+ ? 'Upload a .txt ASCII lockbox file with quoted, comma-separated values.'
+ : 'Upload a CSV or Excel file containing the required fields. You will map the columns in the next step.'}
+
+
+
+
+ Select File
+
+
+
+ )}
+
+ {step === 2 && fileType === 'bank' && (
+
+
Map columns from your file to the required transaction fields.
+
+ {requiredFields.map(field => (
+
+
+
+ {field.label}
+ {field.required && * }
+
+
+
+ {field.description}
+
+
+
+
+
handleMappingChange(field.key, val)}>
+
+
+
+
+ {fileHeaders.map(header => (
+ {header}
+ ))}
+
+
+
+ ))}
+
+
+ )}
+
+ {step === 3 && fileType === 'bank' && (
+
+
!r.isValid) ? "destructive" : "default"} className="mb-4">
+ {bankPreviewData.some(r => !r.isValid) ? : }
+ {bankPreviewData.some(r => !r.isValid) ? 'Errors Found' : 'Ready to Import'}
+
+ {bankPreviewData.some(r => !r.isValid)
+ ? `${bankPreviewData.filter(r => !r.isValid).length} row(s) have errors and will be skipped.`
+ : `All ${bankPreviewData.length} rows are valid and ready to be imported.`}
+
+
+
+
+
+
+
+ Date
+ Association
+ GL Account
+ Vendor
+ Unit
+ Payment
+ Credit
+ Amount
+ Status
+
+
+
+ {bankPreviewData.map((row) => (
+ 0 ? "bg-amber-50/30" : "")}>
+ {row.transaction_date}
+ {row.matchedAssoc ? row.matchedAssoc.name : {row.association || 'Missing'} }
+ {row.matchedCoa ? row.matchedCoa.account_number : {row.gl_account || 'Missing'} }
+ {row.matchedVendor ? row.matchedVendor.vendor_name : {row.vendor || '-'} }
+ {row.matchedUnit ? row.matchedUnit.unit_number : {row.unit || '-'} }
+ {row.payment || Missing }
+ {row.credit || Missing }
+ ${!isNaN(row.amount) ? Number(row.amount).toFixed(2) : Invalid }
+
+ {row.isValid ? (
+ row.warnings.length > 0 ? (
+
+
+
+ {row.warnings.join(', ')}
+
+
+ ) :
+ ) : (
+
+
+
+ {row.errors.join(', ')}
+
+
+ )}
+
+
+ ))}
+
+
+
+
+ )}
+
+ {step === 3 && fileType === 'lockbox' && (
+
+
+
+ Review Data
+
+ Verify the mapped units and amounts. Rows with missing units or invalid amounts will not be imported.
+
+
+
+
+
+
+
+ Acct #
+ Matched Unit
+ Amount ($)
+ Date
+ Memo
+
+
+
+ {lockboxRows.map((row, i) => {
+ const hasError = !row.unitId || isNaN(row.amount) || row.amount <= 0 || !row.date;
+ return (
+
+
+ handleLockboxRowChange(i, 'accountNumber', e.target.value)}
+ className="h-8 text-xs"
+ />
+
+
+ handleLockboxRowChange(i, 'unitId', val)}>
+
+
+
+
+ {units.map(u => (
+
+ {u.account_number ? `${u.account_number} - ${u.unit_number}` : u.unit_number}
+
+ ))}
+
+
+
+
+ handleLockboxRowChange(i, 'amount', parseFloat(e.target.value))}
+ className={`h-8 text-xs ${isNaN(row.amount) || row.amount <= 0 ? 'border-destructive' : ''}`}
+ />
+
+
+ handleLockboxRowChange(i, 'date', e.target.value)}
+ className={`h-8 text-xs ${!row.date ? 'border-destructive' : ''}`}
+ />
+
+
+ handleLockboxRowChange(i, 'memo', e.target.value)}
+ className="h-8 text-xs"
+ />
+
+
+ )
+ })}
+
+
+
+
+ )}
+
+ {step === 4 && importSummary && (
+
+
+
+
+
+
+
Import Complete
+
Your file has been processed successfully.
+
+
+
+
+
Total
+
{importSummary.total}
+
+
+
Imported
+
{importSummary.success}
+
+
+
Skipped
+
{importSummary.failed}
+
+
+
+ {importSummary.failed > 0 && (
+
+
+ Some rows skipped
+
+ Rows with missing required data or validation errors were not imported.
+
+
+ )}
+
+ )}
+
+
+
+ {step < 4 ? (
+ <>
+ onOpenChange(false)} disabled={isLoading}>Cancel
+ {step === 1 && (
+
+ {isLoading ? 'Parsing...' : 'Continue'}
+
+ )}
+ {step === 2 && fileType === 'bank' && (
+ <>
+ setStep(1)} disabled={isLoading}>Back
+
+ Generate Preview
+
+ >
+ )}
+ {step === 3 && (
+ <>
+ setStep(fileType === 'bank' ? 2 : 1)} disabled={isLoading}>Back
+ r.isValid).length === 0) || (fileType === 'lockbox' && lockboxRows.filter(r => r.unitId && r.amount > 0 && r.date).length === 0)}>
+ {isLoading ? 'Importing...' : 'Run Import'}
+
+ >
+ )}
+ >
+ ) : (
+
+
onOpenChange(false)}>Close
+
onOpenChange(false)}>
+ Done
+
+
+ )}
+
+
+
+ );
+};
+
+export default ImportDialog;
diff --git a/src/components/ImportSpreadsheetDialog.tsx b/src/components/ImportSpreadsheetDialog.tsx
new file mode 100644
index 0000000..3756ef3
--- /dev/null
+++ b/src/components/ImportSpreadsheetDialog.tsx
@@ -0,0 +1,356 @@
+import { useState, useRef } from "react";
+import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
+import { Button } from "@/components/ui/button";
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
+import { Upload, FileSpreadsheet, AlertCircle, CheckCircle2, ArrowRight } from "lucide-react";
+import * as XLSX from "xlsx";
+
+interface ExpectedColumn {
+ key: string;
+ label: string;
+ required?: boolean;
+ aliases?: string[];
+}
+
+interface ImportSpreadsheetDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ title: string;
+ description: string;
+ expectedColumns: ExpectedColumn[];
+ onImport: (rows: Record[]) => Promise;
+ templateFileName?: string;
+}
+
+const normalizeHeader = (value: unknown) =>
+ String(value ?? "")
+ .trim()
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, "_")
+ .replace(/^_+|_+$/g, "");
+
+export default function ImportSpreadsheetDialog({
+ open, onOpenChange, title, description, expectedColumns, onImport, templateFileName,
+}: ImportSpreadsheetDialogProps) {
+ const [rawRows, setRawRows] = useState([]);
+ const [rawHeaders, setRawHeaders] = useState([]);
+ const [columnMapping, setColumnMapping] = useState>({});
+ const [step, setStep] = useState<"upload" | "map" | "preview">("upload");
+ const [parsedRows, setParsedRows] = useState[]>([]);
+ const [importing, setImporting] = useState(false);
+ const [error, setError] = useState("");
+ const [fileName, setFileName] = useState("");
+ const fileInputRef = useRef(null);
+
+ const reset = () => {
+ setRawRows([]);
+ setRawHeaders([]);
+ setColumnMapping({});
+ setStep("upload");
+ setParsedRows([]);
+ setError("");
+ setFileName("");
+ if (fileInputRef.current) fileInputRef.current.value = "";
+ };
+
+ const handleFile = async (e: React.ChangeEvent) => {
+ const file = e.target.files?.[0];
+ if (!file) return;
+
+ setError("");
+ setFileName(file.name);
+
+ try {
+ const data = await file.arrayBuffer();
+ const workbook = XLSX.read(data, { type: "array", cellDates: true });
+ const sheet = workbook.Sheets[workbook.SheetNames[0]];
+ const json: unknown[][] = XLSX.utils.sheet_to_json(sheet, { header: 1, defval: "", rawNumbers: false });
+
+ if (json.length < 2) {
+ setError("File must have a header row and at least one data row.");
+ return;
+ }
+
+ const headers = json[0].map((h) => String(h ?? "").trim()).filter(Boolean);
+ setRawHeaders(headers);
+ setRawRows(json.slice(1).filter((row) => (row as unknown[]).some((cell: unknown) => String(cell ?? "").trim())));
+
+ // Auto-suggest mapping with aliases and partial matching
+ const autoMap: Record = {};
+ for (const col of expectedColumns) {
+ const candidates = [col.key, col.label, ...(col.aliases || [])];
+ const normCandidates = candidates.map(normalizeHeader);
+ let matched = false;
+ for (const h of headers) {
+ const normH = normalizeHeader(h);
+ if (normCandidates.some(nc => nc === normH)) {
+ autoMap[col.key] = h;
+ matched = true;
+ break;
+ }
+ }
+ // Partial match fallback: check if header contains key or key contains header
+ if (!matched) {
+ for (const h of headers) {
+ const normH = normalizeHeader(h);
+ if (normCandidates.some(nc => (normH.length > 2 && nc.includes(normH)) || (nc.length > 2 && normH.includes(nc)))) {
+ autoMap[col.key] = h;
+ break;
+ }
+ }
+ }
+ }
+ setColumnMapping(autoMap);
+ setStep("map");
+ } catch {
+ setError("Could not read file. Please use a valid CSV or Excel (.xlsx/.xls) file.");
+ }
+ };
+
+ const proceedToPreview = () => {
+ const missingRequired = expectedColumns
+ .filter((c) => c.required)
+ .filter((c) => !columnMapping[c.key]);
+
+ if (missingRequired.length > 0) {
+ setError(`Please map required fields: ${missingRequired.map((c) => c.label).join(", ")}`);
+ return;
+ }
+ setError("");
+
+ const mappedHeaders = Object.keys(columnMapping).filter((k) => columnMapping[k]);
+
+ const rows = rawRows.map((row) => {
+ const obj: Record = {};
+ for (const [dbKey, csvHeader] of Object.entries(columnMapping)) {
+ if (!csvHeader) continue;
+ const colIndex = rawHeaders.indexOf(csvHeader);
+ if (colIndex === -1) continue;
+ const cell = (row as unknown[])[colIndex];
+ if (cell instanceof Date && !isNaN(cell.getTime())) {
+ const yyyy = cell.getFullYear();
+ const mm = String(cell.getMonth() + 1).padStart(2, "0");
+ const dd = String(cell.getDate()).padStart(2, "0");
+ obj[dbKey] = `${yyyy}-${mm}-${dd}`;
+ } else {
+ obj[dbKey] = String(cell ?? "").trim();
+ }
+ }
+ return obj;
+ }).filter((row) => Object.values(row).some((v) => v));
+
+ if (rows.length === 0) {
+ setError("No data rows found after mapping.");
+ return;
+ }
+
+ setParsedRows(rows);
+ setStep("preview");
+ };
+
+ const handleImport = async () => {
+ setImporting(true);
+ try {
+ await onImport(parsedRows);
+ reset();
+ onOpenChange(false);
+ } catch (err: any) {
+ setError(err.message || "Import failed");
+ } finally {
+ setImporting(false);
+ }
+ };
+
+ const downloadTemplate = () => {
+ const ws = XLSX.utils.aoa_to_sheet([expectedColumns.map((column) => column.label)]);
+ const wb = XLSX.utils.book_new();
+ XLSX.utils.book_append_sheet(wb, ws, "Template");
+ XLSX.writeFile(wb, templateFileName || "import_template.xlsx");
+ };
+
+ const updateMapping = (dbKey: string, csvHeader: string) => {
+ setColumnMapping((prev) => ({
+ ...prev,
+ [dbKey]: csvHeader === "__none__" ? "" : csvHeader,
+ }));
+ };
+
+ const mappedDbKeys = Object.keys(columnMapping).filter((k) => columnMapping[k]);
+ const previewHeaders = step === "preview" ? mappedDbKeys : [];
+
+ return (
+ {
+ if (!nextOpen) reset();
+ onOpenChange(nextOpen);
+ }}>
+
+
+
+ {title}
+
+ {description}
+
+
+ {/* Step 1: Upload */}
+ {step === "upload" && (
+
+
+
+
Drop a CSV or Excel file, or click to browse
+
+
fileInputRef.current?.click()}>Select File
+
+
+
+ Need a template?
+ Download Template (.xlsx)
+
+
+ {error && (
+
+ )}
+
+ )}
+
+ {/* Step 2: Map Fields */}
+ {step === "map" && (
+
+
+
+
+ {fileName} — {rawRows.length} rows, {rawHeaders.length} columns
+
+
Choose Different File
+
+
+
+
Map your file columns to fields
+
+ {expectedColumns.map((col) => (
+
+
+
+ {col.label}
+
+ {col.required && * }
+
+
+
updateMapping(col.key, v)}
+ >
+
+
+
+
+ — skip —
+ {rawHeaders.map((h) => (
+ {h}
+ ))}
+
+
+
+ ))}
+
+
+
+ {/* Sample data preview */}
+ {rawRows.length > 0 && (
+
+
+
+
+ {rawHeaders.map((h) => {h} )}
+
+
+
+ {rawRows.slice(0, 3).map((row, i) => (
+
+ {rawHeaders.map((h, ci) => (
+
+ {String((row as unknown[])[ci] ?? "").slice(0, 50) || "—"}
+
+ ))}
+
+ ))}
+
+
+
+ )}
+
+ {error && (
+
+ )}
+
+
+
{ reset(); onOpenChange(false); }}>Cancel
+
+ Continue
+
+
+
+ )}
+
+ {/* Step 3: Preview & Import */}
+ {step === "preview" && (
+
+
+
+
+ {parsedRows.length} rows ready to import
+
+
setStep("map")}>Back to Mapping
+
+
+
+
+
+
+ #
+ {previewHeaders.map((key) => {
+ const col = expectedColumns.find((c) => c.key === key);
+ return {col?.label || key} ;
+ })}
+
+
+
+ {parsedRows.slice(0, 10).map((row, index) => (
+
+ {index + 1}
+ {previewHeaders.map((key) => (
+ {row[key] || "—"}
+ ))}
+
+ ))}
+
+
+ {parsedRows.length > 10 && (
+
+ ...and {parsedRows.length - 10} more rows
+
+ )}
+
+
+ {error && (
+
+ )}
+
+
+ { reset(); onOpenChange(false); }}>Cancel
+
+ {importing ? "Importing..." : `Import ${parsedRows.length} rows`}
+
+
+
+ )}
+
+
+ );
+}
diff --git a/src/components/ImportZohoBankAccountsDialog.tsx b/src/components/ImportZohoBankAccountsDialog.tsx
new file mode 100644
index 0000000..b6dd3cb
--- /dev/null
+++ b/src/components/ImportZohoBankAccountsDialog.tsx
@@ -0,0 +1,346 @@
+import { useEffect, useState } from "react";
+import { supabase } from "@/integrations/supabase/client";
+import { useToast } from "@/hooks/use-toast";
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Label } from "@/components/ui/label";
+import { Checkbox } from "@/components/ui/checkbox";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { Loader2, Download, Landmark } from "lucide-react";
+
+interface Association {
+ id: string;
+ name: string;
+ zoho_organization_id: string | null;
+}
+
+interface ZohoBankAccount {
+ account_id: string;
+ account_name: string;
+ account_type?: string;
+ account_number?: string;
+ routing_number?: string | null;
+ bank_name?: string | null;
+ currency_code?: string;
+ balance?: number;
+ uncategorized_transactions?: number;
+ is_active?: boolean;
+}
+
+interface Props {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ associations: Association[];
+ defaultAssociationId?: string;
+ onImported: () => void;
+}
+
+/**
+ * Lets users browse the bank accounts available in an association's linked Zoho
+ * Books organization and import the selected ones into local `bank_accounts`.
+ */
+export default function ImportZohoBankAccountsDialog({
+ open,
+ onOpenChange,
+ associations,
+ defaultAssociationId,
+ onImported,
+}: Props) {
+ const { toast } = useToast();
+ const eligible = associations.filter((a) => !!a.zoho_organization_id);
+
+ const [associationId, setAssociationId] = useState("");
+ const [zohoAccounts, setZohoAccounts] = useState([]);
+ const [selected, setSelected] = useState>(new Set());
+ const [existing, setExisting] = useState>(new Set()); // account numbers already imported
+ const [loading, setLoading] = useState(false);
+ const [importing, setImporting] = useState(false);
+
+ // Reset on open
+ useEffect(() => {
+ if (!open) return;
+ setZohoAccounts([]);
+ setSelected(new Set());
+ setExisting(new Set());
+ const initial =
+ (defaultAssociationId && eligible.find((a) => a.id === defaultAssociationId)?.id) ||
+ eligible[0]?.id ||
+ "";
+ setAssociationId(initial);
+ }, [open, defaultAssociationId]); // eslint-disable-line react-hooks/exhaustive-deps
+
+ // Fetch when association changes
+ useEffect(() => {
+ if (!open || !associationId) return;
+ let cancelled = false;
+ (async () => {
+ setLoading(true);
+ try {
+ const [zohoRes, localRes] = await Promise.all([
+ supabase.functions.invoke("zoho-books", {
+ body: { action: "list_bank_accounts", params: { association_id: associationId } },
+ }),
+ supabase
+ .from("bank_accounts")
+ .select("account_number, account_name")
+ .eq("association_id", associationId),
+ ]);
+
+ if (cancelled) return;
+
+ if (zohoRes.error) throw zohoRes.error;
+ const list: ZohoBankAccount[] = Array.isArray(zohoRes.data?.data)
+ ? zohoRes.data.data
+ : zohoRes.data?.data?.bankaccounts || [];
+
+ setZohoAccounts(list);
+
+ const existingSet = new Set();
+ (localRes.data || []).forEach((a) => {
+ if (a.account_number) existingSet.add(a.account_number);
+ if (a.account_name) existingSet.add(`name:${a.account_name.toLowerCase()}`);
+ });
+ setExisting(existingSet);
+
+ // Pre-select accounts not already imported
+ setSelected(
+ new Set(
+ list
+ .filter(
+ (a) =>
+ !(a.account_number && existingSet.has(a.account_number)) &&
+ !existingSet.has(`name:${(a.account_name || "").toLowerCase()}`)
+ )
+ .map((a) => a.account_id)
+ )
+ );
+ } catch (e: any) {
+ toast({
+ variant: "destructive",
+ title: "Could not load Zoho bank accounts",
+ description: e?.message || "Check that this association is linked to a Zoho org.",
+ });
+ setZohoAccounts([]);
+ } finally {
+ if (!cancelled) setLoading(false);
+ }
+ })();
+ return () => {
+ cancelled = true;
+ };
+ }, [open, associationId, toast]);
+
+ const toggle = (id: string) => {
+ setSelected((prev) => {
+ const next = new Set(prev);
+ if (next.has(id)) next.delete(id);
+ else next.add(id);
+ return next;
+ });
+ };
+
+ const toggleAll = (checked: boolean) => {
+ if (!checked) {
+ setSelected(new Set());
+ return;
+ }
+ setSelected(
+ new Set(
+ zohoAccounts
+ .filter(
+ (a) =>
+ !(a.account_number && existing.has(a.account_number)) &&
+ !existing.has(`name:${(a.account_name || "").toLowerCase()}`)
+ )
+ .map((a) => a.account_id)
+ )
+ );
+ };
+
+ const mapZohoTypeToLocal = (t?: string): string => {
+ const v = (t || "").toLowerCase();
+ if (v.includes("credit")) return "credit_card";
+ if (v.includes("saving")) return "savings";
+ if (v.includes("paypal") || v.includes("other")) return "other";
+ return "checking";
+ };
+
+ const handleImport = async () => {
+ if (!associationId || selected.size === 0) return;
+ setImporting(true);
+ try {
+ const toImport = zohoAccounts.filter((a) => selected.has(a.account_id));
+ const payload = toImport.map((a) => ({
+ association_id: associationId,
+ account_name: a.account_name || "Zoho Bank Account",
+ account_number: a.account_number || null,
+ routing_number: a.routing_number || null,
+ bank_name: a.bank_name || null,
+ account_type: mapZohoTypeToLocal(a.account_type),
+ account_category: "operating",
+ current_balance: Number(a.balance || 0),
+ status: a.is_active === false ? "inactive" : "active",
+ }));
+
+ const { error } = await supabase.from("bank_accounts").insert(payload);
+ if (error) throw error;
+
+ toast({ title: `Imported ${payload.length} bank account${payload.length === 1 ? "" : "s"} from Zoho` });
+ onImported();
+ onOpenChange(false);
+ } catch (e: any) {
+ toast({
+ variant: "destructive",
+ title: "Import failed",
+ description: e?.message || "Could not import bank accounts.",
+ });
+ } finally {
+ setImporting(false);
+ }
+ };
+
+ const allSelectableSelected =
+ zohoAccounts.length > 0 &&
+ zohoAccounts
+ .filter(
+ (a) =>
+ !(a.account_number && existing.has(a.account_number)) &&
+ !existing.has(`name:${(a.account_name || "").toLowerCase()}`)
+ )
+ .every((a) => selected.has(a.account_id));
+
+ return (
+
+
+
+
+
+ Import Bank Accounts from Zoho Books
+
+
+ Select an association linked to Zoho Books, then choose which bank accounts to import.
+
+
+
+ {eligible.length === 0 ? (
+
+ No associations are linked to a Zoho Books organization yet. Open an association and set
+ its Zoho Organization ID first.
+
+ ) : (
+
+
+ Association (Zoho-linked)
+
+
+
+
+
+ {eligible.map((a) => (
+
+ {a.name}
+
+ ))}
+
+
+
+
+
+
+
+ toggleAll(!!v)}
+ disabled={loading || zohoAccounts.length === 0}
+ />
+
+ {loading
+ ? "Loading…"
+ : `${selected.size} selected of ${zohoAccounts.length}`}
+
+
+
+
+
+ {loading ? (
+
+ Fetching from Zoho…
+
+ ) : zohoAccounts.length === 0 ? (
+
+ No bank accounts returned by Zoho for this organization.
+
+ ) : (
+ zohoAccounts.map((a) => {
+ const dup =
+ (a.account_number && existing.has(a.account_number)) ||
+ existing.has(`name:${(a.account_name || "").toLowerCase()}`);
+ return (
+
+ toggle(a.account_id)}
+ disabled={dup}
+ />
+
+
+ {a.account_name}
+ {dup && (
+
+ (already imported)
+
+ )}
+
+
+ {[a.bank_name, a.account_type, a.account_number ? `•••• ${a.account_number.slice(-4)}` : null]
+ .filter(Boolean)
+ .join(" · ")}
+
+
+
+ ${Number(a.balance || 0).toLocaleString("en-US", { minimumFractionDigits: 2 })}
+
+
+ );
+ })
+ )}
+
+
+
+ )}
+
+
+ onOpenChange(false)} disabled={importing}>
+ Cancel
+
+
+ {importing ? : }
+ Import {selected.size > 0 ? `(${selected.size})` : ""}
+
+
+
+
+ );
+}
diff --git a/src/components/IndividualOwnerEditDialog.jsx b/src/components/IndividualOwnerEditDialog.jsx
new file mode 100644
index 0000000..51acf32
--- /dev/null
+++ b/src/components/IndividualOwnerEditDialog.jsx
@@ -0,0 +1,134 @@
+import React, { useState, useEffect } from 'react';
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogFooter,
+} from "@/components/ui/dialog";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { useToast } from "@/hooks/use-toast";
+import { supabase } from '@/integrations/supabase/client';
+import { Loader2 } from 'lucide-react';
+
+export default function IndividualOwnerEditDialog({ open, onOpenChange, owner, onSuccess }) {
+ const { toast } = useToast();
+ const [loading, setLoading] = useState(false);
+ const [formData, setFormData] = useState({
+ owner_name: '',
+ phone_number: '',
+ unit_id: ''
+ });
+
+ useEffect(() => {
+ if (owner) {
+ setFormData({
+ owner_name: owner.owner_name || '',
+ phone_number: owner.phone_number || '',
+ unit_id: owner.unit_id || ''
+ });
+ }
+ }, [owner]);
+
+ const handleChange = (e) => {
+ const { name, value } = e.target;
+ setFormData(prev => ({ ...prev, [name]: value }));
+ };
+
+ const handleSubmit = async (e) => {
+ e.preventDefault();
+
+ if (!formData.owner_name.trim()) {
+ toast({
+ variant: "destructive",
+ title: "Validation Error",
+ description: "Owner name is required."
+ });
+ return;
+ }
+
+ setLoading(true);
+ try {
+ const { error } = await supabase
+ .from('owners')
+ .update({
+ first_name: formData.owner_name.split(' ')[0] || '',
+ last_name: formData.owner_name.split(' ').slice(1).join(' ') || '',
+ phone: formData.phone_number,
+ unit_id: formData.unit_id || null,
+ updated_at: new Date().toISOString()
+ })
+ .eq('id', owner.id);
+
+ if (error) throw error;
+
+ toast({
+ title: "Success",
+ description: "Owner details updated successfully."
+ });
+ if (onSuccess) onSuccess();
+ onOpenChange(false);
+ } catch (error) {
+ toast({
+ variant: "destructive",
+ title: "Error",
+ description: "Failed to update owner details."
+ });
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+
+
+
+ Edit Individual Owner
+
+
+
+ Unit ID
+
+
+
+ Owner Name
+
+
+
+ Phone Number
+
+
+
+ onOpenChange(false)}>
+ Cancel
+
+
+ {loading && }
+ Save Changes
+
+
+
+
+
+ );
+}
diff --git a/src/components/IndividualOwnerUploadDialog.jsx b/src/components/IndividualOwnerUploadDialog.jsx
new file mode 100644
index 0000000..19ec1e6
--- /dev/null
+++ b/src/components/IndividualOwnerUploadDialog.jsx
@@ -0,0 +1,424 @@
+import React, { useState } from 'react';
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogDescription,
+ DialogFooter,
+} from "@/components/ui/dialog";
+import { Button } from "@/components/ui/button";
+import { useToast } from "@/hooks/use-toast";
+import { supabase } from '@/integrations/supabase/client';
+import { Upload, FileSpreadsheet, AlertCircle, Loader2, CheckCircle2 } from 'lucide-react';
+import Papa from 'papaparse';
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
+import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
+
+const normalizeText = (value) => String(value ?? '').trim().replace(/\s+/g, ' ').toLowerCase();
+
+const normalizePhone = (value) => String(value ?? '').replace(/\D/g, '');
+
+const splitOwnerName = (value) => {
+ const parts = String(value ?? '').trim().replace(/\s+/g, ' ').split(' ').filter(Boolean);
+
+ return {
+ firstName: parts[0] || 'Unknown',
+ lastName: parts.slice(1).join(' ') || '',
+ };
+};
+
+const getImportRowSignature = (row) => [
+ normalizeText(row.unit_id),
+ normalizeText(row.account_number),
+ normalizeText(row.owner_name),
+ normalizePhone(row.phone_number),
+].join('::');
+
+const getOwnerNameKey = (owner) => normalizeText(`${owner.first_name || ''} ${owner.last_name || ''}`);
+
+export default function IndividualOwnerUploadDialog({ open, onOpenChange, associationId, onSuccess }) {
+ const { toast } = useToast();
+ const [loading, setLoading] = useState(false);
+ const [parsedData, setParsedData] = useState([]);
+ const [previewData, setPreviewData] = useState([]);
+ const [error, setError] = useState(null);
+ const [step, setStep] = useState('upload');
+
+ const handleFileUpload = (e) => {
+ const file = e.target.files[0];
+ if (!file) return;
+
+ setError(null);
+ Papa.parse(file, {
+ header: true,
+ skipEmptyLines: true,
+ complete: (results) => {
+ if (results.errors.length > 0) {
+ setError(`CSV Parsing Error: ${results.errors[0].message}`);
+ return;
+ }
+
+ const data = results.data;
+ const requiredColumns = ['account_number', 'unit_id', 'owner_name', 'phone_number'];
+ const headers = results.meta.fields;
+
+ const missingColumns = requiredColumns.filter(col => !headers.includes(col));
+ if (missingColumns.length > 0) {
+ setError(`Missing required columns: ${missingColumns.join(', ')}`);
+ return;
+ }
+
+ const validRows = data
+ .filter(row => row.owner_name && (row.unit_id || row.account_number))
+ .map(row => ({
+ account_number: row.account_number?.trim(),
+ unit_id: row.unit_id?.trim(),
+ owner_name: row.owner_name?.trim(),
+ phone_number: row.phone_number?.trim()
+ }));
+
+ const dedupedRows = Array.from(
+ new Map(validRows.map(row => [getImportRowSignature(row), row])).values()
+ );
+
+ if (dedupedRows.length === 0) {
+ setError("No valid data found. Ensure rows have at least owner_name and either unit_id or account_number.");
+ return;
+ }
+
+ setParsedData(dedupedRows);
+ setPreviewData(dedupedRows.slice(0, 5));
+ setStep('preview');
+ },
+ error: (err) => {
+ setError(`File Read Error: ${err.message}`);
+ }
+ });
+ };
+
+ const handleSubmit = async () => {
+ setLoading(true);
+ setError(null);
+ try {
+ const timestamp = new Date().toISOString();
+ const archiveDate = timestamp.slice(0, 10);
+
+ const { data: units } = await supabase
+ .from('units')
+ .select('id, unit_number, account_number')
+ .eq('association_id', associationId);
+
+ const unitMap = new Map();
+ (units || []).forEach(u => {
+ const unitNumberKey = normalizeText(u.unit_number);
+ const unitAccountKey = normalizeText(u.account_number);
+
+ if (unitNumberKey) unitMap.set(unitNumberKey, u.id);
+ if (unitAccountKey) unitMap.set(unitAccountKey, u.id);
+ unitMap.set(u.id, u.id);
+ });
+
+ const { data: existingOwners } = await supabase
+ .from('owners')
+ .select('id, account_number, first_name, last_name, phone, unit_id')
+ .eq('association_id', associationId)
+ .eq('status', 'active');
+
+ const existingByUnit = new Map();
+ const existingByAccountNumber = new Map();
+
+ (existingOwners || []).forEach(owner => {
+ const unitKey = owner.unit_id || '__no_unit__';
+ if (!existingByUnit.has(unitKey)) {
+ existingByUnit.set(unitKey, []);
+ }
+ existingByUnit.get(unitKey).push(owner);
+
+ const accountKey = normalizeText(owner.account_number);
+ if (accountKey) {
+ if (!existingByAccountNumber.has(accountKey)) {
+ existingByAccountNumber.set(accountKey, []);
+ }
+ existingByAccountNumber.get(accountKey).push(owner);
+ }
+ });
+
+ const toInsert = [];
+ const toUpdate = [];
+ const toArchive = [];
+ const usedExistingOwnerIds = new Set();
+ const importedUnitIds = new Set();
+ const unresolvedRows = [];
+
+ parsedData.forEach(row => {
+ const rawUnit = row.unit_id ? String(row.unit_id).trim() : null;
+ const unitKey = rawUnit ? normalizeText(rawUnit) : null;
+ const accountKey = normalizeText(row.account_number);
+ const resolvedUnitId =
+ (unitKey && unitMap.get(unitKey)) ||
+ (accountKey && unitMap.get(accountKey)) ||
+ null;
+
+ if (rawUnit && !resolvedUnitId) {
+ unresolvedRows.push(rawUnit);
+ }
+
+ if (resolvedUnitId) {
+ importedUnitIds.add(resolvedUnitId);
+ }
+
+ const { firstName, lastName } = splitOwnerName(row.owner_name);
+
+ const ownerData = {
+ association_id: associationId,
+ account_number: row.account_number || null,
+ first_name: firstName,
+ last_name: lastName,
+ phone: row.phone_number || null,
+ unit_id: resolvedUnitId,
+ move_out_date: null,
+ status: 'active',
+ };
+
+ const existingCandidates = resolvedUnitId ? (existingByUnit.get(resolvedUnitId) || []) : [];
+ const normalizedName = normalizeText(row.owner_name);
+ const normalizedPhone = normalizePhone(row.phone_number);
+
+ let matchedOwner = existingCandidates.find(owner =>
+ !usedExistingOwnerIds.has(owner.id) &&
+ getOwnerNameKey(owner) === normalizedName
+ ) || null;
+
+ if (!matchedOwner && accountKey) {
+ const accountMatches = (existingByAccountNumber.get(accountKey) || []).filter(owner =>
+ !usedExistingOwnerIds.has(owner.id) &&
+ (!resolvedUnitId || owner.unit_id === resolvedUnitId)
+ );
+
+ if (accountMatches.length === 1) {
+ matchedOwner = accountMatches[0];
+ }
+ }
+
+ if (!matchedOwner && normalizedPhone) {
+ const phoneMatches = existingCandidates.filter(owner =>
+ !usedExistingOwnerIds.has(owner.id) &&
+ normalizePhone(owner.phone) === normalizedPhone
+ );
+
+ if (phoneMatches.length === 1) {
+ matchedOwner = phoneMatches[0];
+ }
+ }
+
+ if (matchedOwner) {
+ usedExistingOwnerIds.add(matchedOwner.id);
+ toUpdate.push({ id: matchedOwner.id, ...ownerData, updated_at: timestamp });
+ } else {
+ toInsert.push(ownerData);
+ }
+ });
+
+ importedUnitIds.forEach(unitId => {
+ const ownersForUnit = existingByUnit.get(unitId) || [];
+ ownersForUnit.forEach(owner => {
+ if (!usedExistingOwnerIds.has(owner.id)) {
+ toArchive.push(owner.id);
+ }
+ });
+ });
+
+ if (toInsert.length > 0) {
+ const chunkSize = 100;
+ for (let i = 0; i < toInsert.length; i += chunkSize) {
+ const chunk = toInsert.slice(i, i + chunkSize);
+ const { error: insertError } = await supabase.from('owners').insert(chunk);
+ if (insertError) throw insertError;
+ }
+ }
+
+ for (const owner of toUpdate) {
+ const { id, ...updates } = owner;
+ const { error: updateError } = await supabase
+ .from('owners')
+ .update(updates)
+ .eq('id', id);
+ if (updateError) throw updateError;
+ }
+
+ if (toArchive.length > 0) {
+ const chunkSize = 100;
+ for (let i = 0; i < toArchive.length; i += chunkSize) {
+ const chunk = toArchive.slice(i, i + chunkSize);
+ const { error: archiveError } = await supabase
+ .from('owners')
+ .update({
+ status: 'archived',
+ move_out_date: archiveDate,
+ updated_at: timestamp,
+ })
+ .in('id', chunk);
+
+ if (archiveError) throw archiveError;
+ }
+ }
+
+ unresolvedRows.forEach(unitReference => {
+ console.warn(`Could not resolve unit during import: "${unitReference}"`);
+ });
+
+ setStep('success');
+ const msg = [];
+ if (toInsert.length > 0) msg.push(`${toInsert.length} new`);
+ if (toUpdate.length > 0) msg.push(`${toUpdate.length} updated`);
+ if (toArchive.length > 0) msg.push(`${toArchive.length} archived`);
+ toast({
+ title: "Upload Successful",
+ description: `Successfully imported owners: ${msg.join(', ')}.`
+ });
+ if (onSuccess) onSuccess();
+ } catch (err) {
+ console.error("Upload error:", err);
+ if (err.code === '23505') {
+ setError("Duplicate records detected. Some account numbers or IDs already exist in the database.");
+ } else {
+ setError(`Upload Failed: ${err.message}`);
+ }
+ toast({
+ variant: "destructive",
+ title: "Error",
+ description: "Failed to upload owners."
+ });
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleClose = () => {
+ setParsedData([]);
+ setPreviewData([]);
+ setError(null);
+ setStep('upload');
+ onOpenChange(false);
+ };
+
+ return (
+
+
+
+ Upload Individual Owners
+
+ Import owner details via CSV. Required columns: account_number, unit_id, owner_name, phone_number.
+
+
+
+ {step === 'upload' && (
+
+
+
+
+
+
+
+ Select CSV File
+
+
+
+
+ Supported format: .csv
+
+
+
+ {error && (
+
+
+ Error
+ {error}
+
+ )}
+
+ )}
+
+ {step === 'preview' && (
+
+
+
Preview ({parsedData.length} records found)
+
+
+
+
+
+
+ Account
+ Unit
+ Name
+ Phone
+
+
+
+ {previewData.map((row, i) => (
+
+ {row.account_number}
+ {row.unit_id}
+ {row.owner_name}
+ {row.phone_number}
+
+ ))}
+ {parsedData.length > 5 && (
+
+
+ ...and {parsedData.length - 5} more rows
+
+
+ )}
+
+
+
+
+ {error && (
+
+
+ Error
+ {error}
+
+ )}
+
+
+ setStep('upload')}>Back
+
+ {loading && }
+ Import {parsedData.length} Owners
+
+
+
+ )}
+
+ {step === 'success' && (
+
+
+
Import Successful!
+
+ Successfully processed {parsedData.length} individual owner records.
+
+
Close
+
+ )}
+
+
+ );
+}
diff --git a/src/components/InvoiceBundleDialog.jsx b/src/components/InvoiceBundleDialog.jsx
new file mode 100644
index 0000000..96bbbc5
--- /dev/null
+++ b/src/components/InvoiceBundleDialog.jsx
@@ -0,0 +1,248 @@
+import React, { useState, useEffect } from 'react';
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
+import { Button } from '@/components/ui/button';
+import { Label } from '@/components/ui/label';
+import { Input } from '@/components/ui/input';
+import { Textarea } from '@/components/ui/textarea';
+import { supabase } from '@/integrations/supabase/client';
+import { useToast } from '@/hooks/use-toast';
+import { Image as ImageIcon, Loader2 } from 'lucide-react';
+
+function InvoiceBundleDialog({ open, onOpenChange, onSettingsUpdated }) {
+ const { toast } = useToast();
+ const [loading, setLoading] = useState(false);
+ const [uploading, setUploading] = useState(false);
+ const [formData, setFormData] = useState({
+ id: null,
+ business_name: '',
+ address: '',
+ phone: '',
+ email: '',
+ website: '',
+ logo_url: ''
+ });
+
+ useEffect(() => {
+ if (open) {
+ fetchSettings();
+ }
+ }, [open]);
+
+ const fetchSettings = async () => {
+ setLoading(true);
+ const { data, error } = await supabase
+ .from('invoice_settings')
+ .select('*')
+ .limit(1)
+ .maybeSingle();
+
+ if (data) {
+ setFormData({
+ ...data,
+ logo_url: data.logo_url || ''
+ });
+ } else if (error) {
+ toast({
+ variant: "destructive",
+ title: "Error fetching settings",
+ description: error.message
+ });
+ }
+ setLoading(false);
+ };
+
+ const handleFileChange = async (e) => {
+ if (!e.target.files || !e.target.files[0]) return;
+
+ const file = e.target.files[0];
+ const fileExt = file.name.split('.').pop();
+ const fileName = `logo-${Date.now()}.${fileExt}`;
+ const filePath = `${fileName}`;
+
+ setUploading(true);
+
+ try {
+ const { error: uploadError } = await supabase.storage
+ .from('company-assets')
+ .upload(filePath, file);
+
+ if (uploadError) throw uploadError;
+
+ const { data: urlData } = supabase.storage
+ .from('company-assets')
+ .getPublicUrl(filePath);
+
+ setFormData(prev => ({ ...prev, logo_url: urlData.publicUrl }));
+ toast({ title: "Logo uploaded successfully" });
+ } catch (error) {
+ toast({
+ variant: "destructive",
+ title: "Upload failed",
+ description: error.message
+ });
+ } finally {
+ setUploading(false);
+ }
+ };
+
+ const handleSubmit = async (e) => {
+ e.preventDefault();
+ setLoading(true);
+
+ const dataToSave = {
+ business_name: formData.business_name,
+ address: formData.address,
+ phone: formData.phone,
+ email: formData.email,
+ website: formData.website,
+ logo_url: formData.logo_url,
+ updated_at: new Date().toISOString()
+ };
+
+ let error;
+ if (formData.id) {
+ const { error: updateError } = await supabase
+ .from('invoice_settings')
+ .update(dataToSave)
+ .eq('id', formData.id);
+ error = updateError;
+ } else {
+ const { error: insertError } = await supabase
+ .from('invoice_settings')
+ .insert([dataToSave]);
+ error = insertError;
+ }
+
+ if (!error) {
+ toast({
+ title: "Settings Saved",
+ description: "Invoice settings have been updated.",
+ });
+ if (onSettingsUpdated) onSettingsUpdated();
+ onOpenChange(false);
+ } else {
+ toast({
+ variant: "destructive",
+ title: "Error",
+ description: error.message,
+ });
+ }
+
+ setLoading(false);
+ };
+
+ return (
+
+
+
+ Invoice Settings
+
+ Configure your business details and logo for generated invoices.
+
+
+
+
+
+
+ {uploading ? (
+
+ ) : formData.logo_url ? (
+
{
+ e.target.onerror = null;
+ e.target.src = "https://placehold.co/400x200?text=Logo+Error";
+ }}
+ />
+ ) : (
+
+ )}
+
+ {uploading ? '' : 'Upload New Logo'}
+
+
+
+
Business Logo (Upload or URL)
+
+
+
+ Logo URL (Optional)
+ setFormData({ ...formData, logo_url: e.target.value })}
+ placeholder="https://..."
+ className="text-xs text-muted-foreground"
+ />
+
+
+
+ Business Name
+ setFormData({ ...formData, business_name: e.target.value })}
+ placeholder="e.g. Your Business Name"
+ />
+
+
+
+
+
+ Website
+ setFormData({ ...formData, website: e.target.value })}
+ placeholder="e.g. example.com"
+ />
+
+
+
+ Address
+ setFormData({ ...formData, address: e.target.value })}
+ placeholder="Full business address..."
+ rows={3}
+ />
+
+
+
+ onOpenChange(false)}>
+ Cancel
+
+
+ {loading ? : null}
+ {loading ? 'Saving...' : 'Save Settings'}
+
+
+
+
+
+ );
+}
+
+export default InvoiceBundleDialog;
diff --git a/src/components/InvoiceMappingDialog.jsx b/src/components/InvoiceMappingDialog.jsx
new file mode 100644
index 0000000..f855b83
--- /dev/null
+++ b/src/components/InvoiceMappingDialog.jsx
@@ -0,0 +1,424 @@
+import React, { useState, useEffect } from 'react';
+import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import { useToast } from '@/hooks/use-toast';
+import { supabase } from '@/integrations/supabase/client';
+import { pushBillToZohoAfterCreate } from '@/lib/zohoBillSync';
+import { useAuth } from '@/contexts/AuthContext';
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
+import { Sparkles, Edit2, Loader2, AlertTriangle, ChevronDown, CheckCircle2, Building2 } from 'lucide-react';
+
+function AccountSelector({ accounts, value, onChange, label, error, requiredType }) {
+ const [isOpen, setIsOpen] = useState(false);
+ const [search, setSearch] = useState('');
+ const selected = accounts.find(a => a.id === value);
+
+ const filteredAccounts = accounts.filter(a => {
+ if (requiredType && !a.account_type?.toLowerCase().includes(requiredType)) return false;
+ if (!search) return true;
+ return `${a.account_number} ${a.account_name}`.toLowerCase().includes(search.toLowerCase());
+ });
+
+ return (
+
+
{label}
+
setIsOpen(!isOpen)}
+ >
+
+ {selected ? `${selected.account_number} - ${selected.account_name}` : 'Select Account...'}
+
+
+
+
+ {isOpen && (
+
+
+ setSearch(e.target.value)}
+ onClick={(e) => e.stopPropagation()}
+ />
+
+
+ {filteredAccounts.length === 0 ? (
+
No matching accounts
+ ) : (
+ filteredAccounts.map(acc => (
+
{ onChange(acc.id); setIsOpen(false); setSearch(''); }}
+ >
+
+ {acc.account_number}
+ {acc.account_name}
+
+
+ {acc.account_type}
+
+
+ ))
+ )}
+
+
+ )}
+ {error &&
{error} }
+
+ );
+}
+
+export default function InvoiceMappingDialog({ open, onOpenChange, invoice, onSuccess }) {
+ const { toast } = useToast();
+ const { user } = useAuth();
+
+ const [loadingInitial, setLoadingInitial] = useState(false);
+ const [loadingAccounts, setLoadingAccounts] = useState(false);
+ const [saving, setSaving] = useState(false);
+
+ const [associations, setAssociations] = useState([]);
+ const [selectedAssociationId, setSelectedAssociationId] = useState('');
+
+ const [accounts, setAccounts] = useState([]);
+ const [isAutoMapped, setIsAutoMapped] = useState(false);
+ const [isFallback, setIsFallback] = useState(false);
+ const [validationError, setValidationError] = useState('');
+
+ const [formData, setFormData] = useState({
+ vendor_name: '',
+ bill_date: '',
+ amount: '',
+ category: '',
+ description: '',
+ fund_type: 'operating',
+ debit_account_id: '',
+ credit_account_id: ''
+ });
+
+ useEffect(() => {
+ if (open) {
+ fetchAssociationsAndInit();
+ } else {
+ setSelectedAssociationId('');
+ setAccounts([]);
+ setValidationError('');
+ setAssociations([]);
+ setFormData({
+ vendor_name: '', bill_date: '', amount: '', category: '', description: '', fund_type: 'operating', debit_account_id: '', credit_account_id: ''
+ });
+ }
+ }, [open, invoice]);
+
+ useEffect(() => {
+ if (open && selectedAssociationId) {
+ fetchAccountsForAssociation(selectedAssociationId);
+ }
+ }, [selectedAssociationId]);
+
+ const fetchAssociationsAndInit = async () => {
+ setLoadingInitial(true);
+ try {
+ const { data, error } = await supabase
+ .from('associations')
+ .select('id, name')
+ .order('name');
+
+ if (error) throw error;
+ setAssociations(data || []);
+
+ if (invoice) {
+ setFormData({
+ vendor_name: invoice.vendor_name || '',
+ bill_date: invoice.issue_date || new Date().toISOString().split('T')[0],
+ amount: invoice.amount || 0,
+ category: invoice.category || '',
+ description: invoice.description || 'Parsed Invoice',
+ fund_type: 'operating',
+ debit_account_id: '',
+ credit_account_id: ''
+ });
+
+ if (invoice.association_id) {
+ setSelectedAssociationId(invoice.association_id);
+ }
+ }
+ } catch (err) {
+ console.error("Error fetching associations:", err);
+ toast({ title: 'Error', description: 'Failed to load associations.', variant: 'destructive' });
+ } finally {
+ setLoadingInitial(false);
+ }
+ };
+
+ const fetchAccountsForAssociation = async (associationId) => {
+ setLoadingAccounts(true);
+ setValidationError('');
+ try {
+ const { data: accData, error: accError } = await supabase
+ .from('chart_of_accounts')
+ .select('*')
+ .eq('is_active', true)
+ .eq('association_id', associationId)
+ .order('account_number');
+
+ if (accError) throw accError;
+ setAccounts(accData || []);
+ setIsAutoMapped(false);
+ setIsFallback(false);
+ } catch (err) {
+ console.error(err);
+ toast({ title: 'Error', description: 'Failed to load account data.', variant: 'destructive' });
+ } finally {
+ setLoadingAccounts(false);
+ }
+ };
+
+ const handleAssociationChange = (val) => {
+ if (selectedAssociationId && val !== selectedAssociationId) {
+ toast({ title: "Association Changed", description: "Account mappings have been reset." });
+ }
+
+ setSelectedAssociationId(val);
+ setFormData(prev => ({
+ ...prev,
+ debit_account_id: '',
+ credit_account_id: ''
+ }));
+ setIsAutoMapped(false);
+ setIsFallback(false);
+ };
+
+ const handleManualOverride = (field, value) => {
+ setFormData(prev => ({ ...prev, [field]: value }));
+ if (field === 'debit_account_id' || field === 'credit_account_id') {
+ setIsAutoMapped(false);
+ }
+ setValidationError('');
+ };
+
+ const validateSubmission = () => {
+ if (!selectedAssociationId) return "Please select an Association.";
+
+ const debitAcc = accounts.find(a => a.id === formData.debit_account_id);
+ const creditAcc = accounts.find(a => a.id === formData.credit_account_id);
+
+ if (!debitAcc) return "Please select a Debit Account.";
+ if (!creditAcc) return "Please select a Credit Account.";
+
+ return null;
+ };
+
+ const handleSubmit = async () => {
+ const vErr = validateSubmission();
+ if (vErr) {
+ setValidationError(vErr);
+ return;
+ }
+
+ setSaving(true);
+ try {
+ let finalVendorId = null;
+ if (formData.vendor_name) {
+ const { data: vData } = await supabase
+ .from('vendors')
+ .select('id')
+ .ilike('vendor_name', formData.vendor_name)
+ .eq('association_id', selectedAssociationId)
+ .maybeSingle();
+
+ if (vData) {
+ finalVendorId = vData.id;
+ } else {
+ const { data: newV } = await supabase
+ .from('vendors')
+ .insert({ vendor_name: formData.vendor_name, association_id: selectedAssociationId })
+ .select('id')
+ .single();
+ finalVendorId = newV?.id;
+ }
+ }
+
+ const { data: newBill, error: billError } = await supabase.from('bills').insert({
+ vendor_id: finalVendorId,
+ association_id: selectedAssociationId,
+ bill_date: formData.bill_date,
+ due_date: invoice?.due_date || formData.bill_date,
+ amount: formData.amount,
+ description: formData.description,
+ status: 'pending',
+ invoice_number: invoice?.invoice_number,
+ expense_account_id: formData.debit_account_id,
+ attachment_url: invoice?.raw_pdf_url || null,
+ created_by: user?.id,
+ }).select('id').single();
+
+ if (billError) throw billError;
+ if (newBill?.id) pushBillToZohoAfterCreate(newBill.id);
+
+ if (invoice?.id) {
+ await supabase.from('invoices')
+ .update({
+ status: 'converted_to_bill',
+ association_id: selectedAssociationId
+ })
+ .eq('id', invoice.id);
+ }
+
+ toast({ title: 'Bill Created', description: 'Invoice successfully mapped and routed to GL.' });
+ onSuccess?.();
+ onOpenChange(false);
+ } catch (err) {
+ console.error(err);
+ setValidationError(err.message || 'An error occurred while saving.');
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ const selectedAssocName = associations.find(c => c.id === selectedAssociationId)?.name;
+
+ return (
+
+
+
+
+ Review & Map Invoice Accounts
+
+
+
+ {loadingInitial ? (
+
+ ) : (
+
+
+ {/* Association Selection */}
+
+
+
+ Select Association
+
+
+
+
+
+
+ {associations.map(c => (
+ {c.name}
+ ))}
+
+
+ {selectedAssociationId && selectedAssocName && (
+
Mapping accounts for: {selectedAssocName}
+ )}
+
+
+ {!selectedAssociationId ? (
+
+
+
Association Selection Required
+
Please select an association above to load their chart of accounts.
+
+ ) : loadingAccounts ? (
+
Loading accounts...
+ ) : (
+
+ {/* Status Banner */}
+
+ {isAutoMapped && !isFallback ?
:
+ isAutoMapped && isFallback ?
:
+
}
+
+
+ {isAutoMapped && !isFallback ? 'Auto-Mapped Successfully' :
+ isAutoMapped && isFallback ? 'Fallback Rule Applied' :
+ 'Manual Mapping Required'}
+
+
+ {isAutoMapped ? 'Please verify the account mapping below.' :
+ 'Please manually select the Debit (Expense) and Credit (Liability) accounts.'}
+
+
+
+
+ {/* Core Invoice Details */}
+
+
+ {/* Accounting Routing */}
+
+
+ General Ledger Routing
+
+
+
+
handleManualOverride('debit_account_id', val)}
+ />
+
+ handleManualOverride('credit_account_id', val)}
+ />
+
+
+
+ Description / Memo
+ handleManualOverride('description', e.target.value)} />
+
+
+
+ )}
+
+ {validationError && (
+
+ )}
+
+
+ onOpenChange(false)}>Cancel
+
+ {saving ? : 'Confirm & Create Bill'}
+
+
+
+ )}
+
+
+ );
+}
diff --git a/src/components/InvoiceSettingsDialog.jsx b/src/components/InvoiceSettingsDialog.jsx
new file mode 100644
index 0000000..73ba775
--- /dev/null
+++ b/src/components/InvoiceSettingsDialog.jsx
@@ -0,0 +1,198 @@
+import React, { useState, useEffect } from 'react';
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
+import { Button } from '@/components/ui/button';
+import { Label } from '@/components/ui/label';
+import { Input } from '@/components/ui/input';
+import { Textarea } from '@/components/ui/textarea';
+import { supabase } from '@/integrations/supabase/client';
+import { useToast } from '@/hooks/use-toast';
+import { Image as ImageIcon, Loader2 } from 'lucide-react';
+
+function InvoiceSettingsDialog({ open, onOpenChange, onSettingsUpdated }) {
+ const { toast } = useToast();
+ const [loading, setLoading] = useState(false);
+ const [uploading, setUploading] = useState(false);
+
+ const [formData, setFormData] = useState({
+ id: null,
+ business_name: '',
+ address: '',
+ phone: '',
+ email: '',
+ website: '',
+ logo_url: ''
+ });
+
+ useEffect(() => {
+ if (open) {
+ fetchSettings();
+ }
+ }, [open]);
+
+ const fetchSettings = async () => {
+ setLoading(true);
+ const { data, error } = await supabase
+ .from('invoice_settings')
+ .select('*')
+ .limit(1)
+ .maybeSingle();
+
+ if (data) {
+ setFormData({
+ ...data,
+ logo_url: data.logo_url || '',
+ business_name: data.business_name || '',
+ address: data.address || '',
+ phone: data.phone || '',
+ email: data.email || '',
+ website: data.website || ''
+ });
+ } else if (error) {
+ toast({
+ variant: "destructive",
+ title: "Error fetching settings",
+ description: error.message
+ });
+ }
+ setLoading(false);
+ };
+
+ const handleFileChange = async (e) => {
+ if (!e.target.files || !e.target.files[0]) return;
+
+ const file = e.target.files[0];
+ const fileExt = file.name.split('.').pop();
+ const fileName = `logo-${Date.now()}.${fileExt}`;
+
+ setUploading(true);
+
+ try {
+ const { error: uploadError } = await supabase.storage
+ .from('company-assets')
+ .upload(fileName, file);
+
+ if (uploadError) throw uploadError;
+
+ const { data: urlData } = supabase.storage
+ .from('company-assets')
+ .getPublicUrl(fileName);
+
+ setFormData(prev => ({ ...prev, logo_url: urlData.publicUrl }));
+ toast({ title: "Logo uploaded successfully" });
+ } catch (error) {
+ toast({
+ variant: "destructive",
+ title: "Upload failed",
+ description: error.message
+ });
+ } finally {
+ setUploading(false);
+ }
+ };
+
+ const handleSubmit = async (e) => {
+ e.preventDefault();
+ setLoading(true);
+
+ const dataToSave = {
+ business_name: formData.business_name,
+ address: formData.address,
+ phone: formData.phone,
+ email: formData.email,
+ website: formData.website,
+ logo_url: formData.logo_url,
+ updated_at: new Date().toISOString()
+ };
+
+ let error;
+ if (formData.id) {
+ const { error: updateError } = await supabase
+ .from('invoice_settings')
+ .update(dataToSave)
+ .eq('id', formData.id);
+ error = updateError;
+ } else {
+ const { error: insertError } = await supabase
+ .from('invoice_settings')
+ .insert([dataToSave]);
+ error = insertError;
+ }
+
+ if (!error) {
+ toast({ title: "Settings Saved", description: "Invoice settings have been updated." });
+ if (onSettingsUpdated) onSettingsUpdated();
+ onOpenChange(false);
+ } else {
+ toast({ variant: "destructive", title: "Error", description: error.message });
+ }
+
+ setLoading(false);
+ };
+
+ return (
+
+
+
+ Invoice Settings
+ Configure your business details and logo for generated invoices.
+
+
+
+
+ {uploading ? (
+
+ ) : formData.logo_url ? (
+
{ e.target.onerror = null; e.target.src = "https://placehold.co/400x200?text=Logo+Error"; }} />
+ ) : (
+
+ )}
+
+ {uploading ? '' : 'Upload New Logo'}
+
+
+
+
Business Logo
+
+
+
+ Logo URL (Optional)
+ setFormData({ ...formData, logo_url: e.target.value })} placeholder="https://..." className="text-xs" />
+
+
+ Business Name
+ setFormData({ ...formData, business_name: e.target.value })} placeholder="Your Business Name" />
+
+
+
+ Website
+ setFormData({ ...formData, website: e.target.value })} placeholder="example.com" />
+
+
+ Address
+ setFormData({ ...formData, address: e.target.value })} placeholder="Full business address..." rows={3} />
+
+
+
+ onOpenChange(false)}>Cancel
+
+ {loading ? : null}
+ {loading ? 'Saving...' : 'Save Settings'}
+
+
+
+
+
+ );
+}
+
+export default InvoiceSettingsDialog;
diff --git a/src/components/LedgerCSVImportDialog.tsx b/src/components/LedgerCSVImportDialog.tsx
new file mode 100644
index 0000000..295f1c6
--- /dev/null
+++ b/src/components/LedgerCSVImportDialog.tsx
@@ -0,0 +1,185 @@
+import { useState, useCallback } from "react";
+import { supabase } from "@/integrations/supabase/client";
+import { useToast } from "@/hooks/use-toast";
+import ImportSpreadsheetDialog from "@/components/ImportSpreadsheetDialog";
+import { Upload } from "lucide-react";
+import { Button } from "@/components/ui/button";
+
+const EXPECTED_COLUMNS = [
+ { key: "unit", label: "Unit", required: true },
+ { key: "date", label: "Date", required: true },
+ { key: "type", label: "Type", required: true },
+ { key: "amount", label: "Amount", required: true },
+ { key: "description", label: "Description", required: false },
+ { key: "account", label: "Account", required: false },
+];
+
+interface LedgerCSVImportDialogProps {
+ onSuccess?: () => void;
+ associationId?: string;
+ /** If provided, restricts import to this unit only */
+ unitId?: string;
+}
+
+export default function LedgerCSVImportDialog({ onSuccess, associationId, unitId }: LedgerCSVImportDialogProps) {
+ const [open, setOpen] = useState(false);
+ const { toast } = useToast();
+
+ const handleImport = useCallback(async (rows: Record[]) => {
+ // 1. Gather unique unit identifiers from CSV
+ const unitIdentifiers = [...new Set(rows.map((r) => (r.unit || "").trim()).filter(Boolean))];
+
+ if (unitIdentifiers.length === 0) {
+ throw new Error("No valid unit identifiers found in the CSV.");
+ }
+
+ // 2. Look up units by unit_number or account_number
+ const { data: units, error: unitError } = await supabase
+ .from("units")
+ .select("id, unit_number, account_number, association_id")
+ .or(`unit_number.in.(${unitIdentifiers.map((u) => `"${u}"`).join(",")}),account_number.in.(${unitIdentifiers.map((u) => `"${u}"`).join(",")})`)
+
+ if (unitError) throw new Error(`Failed to look up units: ${unitError.message}`);
+
+ const unitMap = new Map();
+ (units || []).forEach((u) => {
+ if (u.unit_number) unitMap.set(u.unit_number.toLowerCase(), { id: u.id, association_id: u.association_id });
+ if (u.account_number) unitMap.set(u.account_number.toLowerCase(), { id: u.id, association_id: u.association_id });
+ });
+
+ // 3. Look up primary owners for matched units
+ const unitIds = [...new Set([...unitMap.values()].map((u) => u.id))];
+ const { data: owners } = await supabase
+ .from("owners")
+ .select("id, unit_id")
+ .in("unit_id", unitIds)
+ .eq("status", "active");
+
+ const ownerMap = new Map();
+ (owners || []).forEach((o) => {
+ if (o.unit_id && !ownerMap.has(o.unit_id)) {
+ ownerMap.set(o.unit_id, o.id);
+ }
+ });
+
+ // 4. Build insert records
+ const entries: any[] = [];
+ const errors: string[] = [];
+
+ rows.forEach((row, idx) => {
+ const unitKey = (row.unit || "").trim().toLowerCase();
+ const matched = unitMap.get(unitKey);
+
+ if (!matched) {
+ errors.push(`Row ${idx + 2}: Unit "${row.unit}" not found`);
+ return;
+ }
+
+ if (unitId && matched.id !== unitId) {
+ errors.push(`Row ${idx + 2}: Unit "${row.unit}" does not match current unit`);
+ return;
+ }
+
+ const ownerId = ownerMap.get(matched.id);
+ if (!ownerId) {
+ errors.push(`Row ${idx + 2}: No active owner found for unit "${row.unit}"`);
+ return;
+ }
+
+ const rawAmount = parseFloat((row.amount || "0").replace(/[$,]/g, ""));
+ if (isNaN(rawAmount) || rawAmount === 0) {
+ errors.push(`Row ${idx + 2}: Invalid amount "${row.amount}"`);
+ return;
+ }
+
+ const rawType = (row.type || "").toLowerCase().trim();
+ let transactionType = rawType;
+ let debit = 0;
+ let credit = 0;
+
+ // Normalize type and assign debit/credit
+ const debitTypes = ["assessment", "charge", "late_fee", "late fee", "interest", "fine", "debit", "fee", "violation", "admin_fee", "admin fee", "legal_fee", "legal fee", "bank_fee", "bank fee", "special_assessment", "special assessment"];
+ const creditTypes = ["payment", "credit", "refund", "adjustment", "discount", "waiver", "prepayment", "prepay", "overpayment"];
+
+ if (debitTypes.includes(rawType)) {
+ debit = Math.abs(rawAmount);
+ if (!["assessment", "charge", "late_fee", "interest", "fine", "fee", "violation", "admin_fee", "legal_fee", "bank_fee", "special_assessment"].includes(rawType.replace(/ /g, "_"))) {
+ transactionType = "charge";
+ } else {
+ transactionType = rawType.replace(/ /g, "_");
+ }
+ } else if (creditTypes.includes(rawType)) {
+ credit = Math.abs(rawAmount);
+ if (["prepayment", "prepay", "overpayment"].includes(rawType)) {
+ transactionType = "Prepayment";
+ } else if (!["payment", "credit", "refund", "adjustment"].includes(rawType)) {
+ transactionType = "payment";
+ }
+ } else {
+ // Default: positive = debit/charge, negative = credit/payment
+ if (rawAmount > 0) {
+ debit = rawAmount;
+ transactionType = rawType || "charge";
+ } else {
+ credit = Math.abs(rawAmount);
+ transactionType = rawType || "payment";
+ }
+ }
+
+ entries.push({
+ unit_id: matched.id,
+ owner_id: ownerId,
+ association_id: associationId || matched.association_id,
+ date: row.date || new Date().toISOString().split("T")[0],
+ transaction_type: transactionType,
+ description: row.description || row.account || transactionType,
+ debit,
+ credit,
+ });
+ });
+
+ if (entries.length === 0) {
+ throw new Error(`No valid entries to import.\n${errors.slice(0, 5).join("\n")}`);
+ }
+
+ // 5. Insert in batches of 100
+ const batchSize = 100;
+ let inserted = 0;
+ for (let i = 0; i < entries.length; i += batchSize) {
+ const batch = entries.slice(i, i + batchSize);
+ const { error: insertError } = await supabase.from("owner_ledger_entries").insert(batch);
+ if (insertError) throw new Error(`Insert failed at batch ${Math.floor(i / batchSize) + 1}: ${insertError.message}`);
+ inserted += batch.length;
+ }
+
+ const msg = `Successfully imported ${inserted} ledger entries.`;
+ if (errors.length > 0) {
+ toast({
+ title: `${inserted} imported, ${errors.length} skipped`,
+ description: errors.slice(0, 3).join("; "),
+ variant: "default",
+ });
+ } else {
+ toast({ title: "Import Complete", description: msg });
+ }
+
+ onSuccess?.();
+ }, [toast, onSuccess, associationId, unitId]);
+
+ return (
+ <>
+ setOpen(true)}>
+ Import Ledger CSV
+
+
+ >
+ );
+}
diff --git a/src/components/LegalMatterDetailsDialog.jsx b/src/components/LegalMatterDetailsDialog.jsx
new file mode 100644
index 0000000..ba423cb
--- /dev/null
+++ b/src/components/LegalMatterDetailsDialog.jsx
@@ -0,0 +1,112 @@
+import React, { useState } from 'react';
+import { format } from 'date-fns';
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog';
+import { Button } from '@/components/ui/button';
+import { Badge } from '@/components/ui/badge';
+import { ScrollArea } from '@/components/ui/scroll-area';
+import { MapPin, Calendar, Building, Gavel, FileText, User, DollarSign, ListChecks, Briefcase } from 'lucide-react';
+import { useAuth } from '@/contexts/AuthContext';
+
+// NOTE: These sub-components are referenced but may need to be created separately:
+// LegalMatterUpdateForm, LegalMatterUpdatesTimeline
+
+export default function LegalMatterDetailsDialog({ open, onOpenChange, matter }) {
+ const { user } = useAuth();
+ const [refreshTrigger, setRefreshTrigger] = useState(0);
+
+ if (!matter) return null;
+
+ const handleUpdateSuccess = () => {
+ setRefreshTrigger(prev => prev + 1);
+ };
+
+ return (
+
+
+
+
+
+
+
{matter.title}
+
+ {matter.associations && (
+
+
+ {matter.associations.name}
+
+ )}
+ {matter.status && (
+
+ {matter.status.replace(/_/g, ' ')}
+
+ )}
+
+
+ Created {format(new Date(matter.created_at), 'MMM d, yyyy')}
+
+
+
+
+
+
+
+
+
+
+ {matter.category && (
+
+
Case Type
+
+
+ {matter.category}
+
+
+ )}
+ {matter.case_number && (
+
+
Case No.
+
+
+ {matter.case_number}
+
+
+ )}
+ {matter.attorney && (
+
+
Attorney
+
+
+ {matter.attorney}
+
+
+ )}
+
+
+ {/* Matter Description */}
+ {matter.description && (
+
+
+ Description
+
+
+ {matter.description}
+
+
+ )}
+
+
+
+
+ onOpenChange(false)}>
+ Close
+
+
+
+
+ );
+}
diff --git a/src/components/LegalMatterDialog.jsx b/src/components/LegalMatterDialog.jsx
new file mode 100644
index 0000000..2f8efb6
--- /dev/null
+++ b/src/components/LegalMatterDialog.jsx
@@ -0,0 +1,264 @@
+import React, { useState, useEffect } from 'react';
+import { useForm } from 'react-hook-form';
+import { zodResolver } from '@hookform/resolvers/zod';
+import * as z from 'zod';
+import { Loader2, AlertCircle } from 'lucide-react';
+import { supabase } from '@/integrations/supabase/client';
+import { useToast } from '@/hooks/use-toast';
+import { useAuth } from '@/contexts/AuthContext';
+import {
+ Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle,
+} from '@/components/ui/dialog';
+import {
+ Form, FormControl, FormField, FormItem, FormLabel, FormMessage,
+} from '@/components/ui/form';
+import { Input } from '@/components/ui/input';
+import { Textarea } from '@/components/ui/textarea';
+import { Button } from '@/components/ui/button';
+import {
+ Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
+} from '@/components/ui/select';
+import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
+import { ScrollArea } from '@/components/ui/scroll-area';
+
+const formSchema = z.object({
+ title: z.string().min(1, 'Title is required'),
+ description: z.string().min(1, 'Description is required'),
+ association_id: z.string().min(1, 'Association is required'),
+ category: z.string().optional(),
+ case_number: z.string().optional(),
+ attorney: z.string().optional(),
+ status: z.string().min(1, 'Status is required'),
+});
+
+export default function LegalMatterDialog({ open, onOpenChange, onSuccess }) {
+ const { toast } = useToast();
+ const { user } = useAuth();
+ const [associations, setAssociations] = useState([]);
+ const [loadingAssociations, setLoadingAssociations] = useState(false);
+ const [error, setError] = useState(null);
+
+ const form = useForm({
+ resolver: zodResolver(formSchema),
+ defaultValues: {
+ title: '',
+ description: '',
+ association_id: '',
+ category: '',
+ case_number: '',
+ attorney: '',
+ status: 'open',
+ },
+ });
+
+ useEffect(() => {
+ if (open) {
+ fetchAssociations();
+ form.reset();
+ setError(null);
+ }
+ }, [open, form]);
+
+ const fetchAssociations = async () => {
+ setLoadingAssociations(true);
+ try {
+ const { data, error } = await supabase
+ .from('associations')
+ .select('id, name')
+ .order('name');
+
+ if (error) throw error;
+ setAssociations(data || []);
+ } catch (err) {
+ console.error('Error fetching associations:', err);
+ setError('Failed to load associations.');
+ } finally {
+ setLoadingAssociations(false);
+ }
+ };
+
+ const onSubmit = async (values) => {
+ try {
+ const { error: submitError } = await supabase.from('legal_matters').insert({
+ title: values.title,
+ description: values.description,
+ association_id: values.association_id,
+ category: values.category || null,
+ case_number: values.case_number || null,
+ attorney: values.attorney || null,
+ status: values.status,
+ created_by: user?.id,
+ });
+
+ if (submitError) throw submitError;
+
+ toast({ title: 'Success', description: 'Legal matter created successfully.' });
+ onSuccess();
+ onOpenChange(false);
+ } catch (err) {
+ console.error('Error creating legal matter:', err);
+ setError('Failed to create legal matter. Please try again.');
+ }
+ };
+
+ return (
+
+
+
+
+ Create Legal Case
+ Record a new legal matter case.
+
+
+ {error && (
+
+
+ Error
+ {error}
+
+ )}
+
+
+
+
+
+ (
+
+ Association *
+
+
+
+
+
+
+
+ {associations.map((assoc) => (
+ {assoc.name}
+ ))}
+
+
+
+
+ )}
+ />
+
+ (
+
+ Case Title *
+
+
+
+
+
+ )}
+ />
+
+
+ (
+
+ Category
+
+
+
+
+
+ Dispute
+ Compliance
+ Litigation
+ Collection
+ Other
+
+
+
+
+ )}
+ />
+ (
+
+ Status *
+
+
+
+
+
+ Open
+ In Progress
+ Resolved
+ Closed
+
+
+
+
+ )}
+ />
+
+
+
+ (
+
+ Case / Docket Number
+
+
+
+
+
+ )}
+ />
+ (
+
+ Attorney
+
+
+
+
+
+ )}
+ />
+
+
+ (
+
+ Matter Details *
+
+
+
+
+
+ )}
+ />
+
+
+
+
+
+ onOpenChange(false)}>Cancel
+
+ {form.formState.isSubmitting && }
+ Save Legal Case
+
+
+
+
+ );
+}
diff --git a/src/components/LoadReportDialog.jsx b/src/components/LoadReportDialog.jsx
new file mode 100644
index 0000000..037f9a0
--- /dev/null
+++ b/src/components/LoadReportDialog.jsx
@@ -0,0 +1,125 @@
+import React, { useEffect, useState } from 'react';
+import { format } from 'date-fns';
+import { Loader2, FileText, Trash2, Calendar, User } from 'lucide-react';
+import {
+ Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle,
+} from "@/components/ui/dialog";
+import { Button } from '@/components/ui/button';
+import { ScrollArea } from '@/components/ui/scroll-area';
+import DeleteReportDialog from './DeleteReportDialog';
+import { cn } from '@/lib/utils';
+import { supabase } from '@/integrations/supabase/client';
+import { useToast } from '@/hooks/use-toast';
+
+export default function LoadReportDialog({ open, onOpenChange, onLoad }) {
+ const { toast } = useToast();
+ const [reports, setReports] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [deleteReportId, setDeleteReportId] = useState(null);
+ const [reportToDelete, setReportToDelete] = useState(null);
+
+ const fetchReports = async () => {
+ setLoading(true);
+ // Placeholder: adjust table/columns as needed
+ const { data, error } = await supabase
+ .from('documents')
+ .select('*')
+ .order('created_at', { ascending: false });
+ if (data) setReports(data);
+ if (error) toast({ variant: 'destructive', title: 'Error', description: error.message });
+ setLoading(false);
+ };
+
+ useEffect(() => {
+ if (open) {
+ fetchReports();
+ }
+ }, [open]);
+
+ const handleDeleteClick = (e, report) => {
+ e.stopPropagation();
+ setReportToDelete(report);
+ setDeleteReportId(report.id);
+ };
+
+ const handleConfirmDelete = async (id) => {
+ const { error } = await supabase.from('documents').delete().eq('id', id);
+ if (error) toast({ variant: 'destructive', title: 'Error', description: error.message });
+ else {
+ toast({ title: 'Report deleted' });
+ fetchReports();
+ }
+ setReportToDelete(null);
+ setDeleteReportId(null);
+ };
+
+ return (
+ <>
+
+
+
+ Load Saved Report
+ Select a previously saved report configuration to load.
+
+
+
+ {loading && reports.length === 0 ? (
+
+
+
+ ) : reports.length === 0 ? (
+
+
+
No saved reports found.
+
+ ) : (
+
+
+ {reports.map((report) => (
+
onLoad(report)}
+ className="group flex flex-col sm:flex-row sm:items-center justify-between p-4 rounded-lg border bg-card hover:border-primary/30 hover:shadow-md hover:bg-accent/30 transition-all cursor-pointer"
+ >
+
+
+
{report.title}
+
+
+
+
+ {format(new Date(report.created_at), 'MMM d, yyyy')}
+
+
+
+
+
+ Load
+ handleDeleteClick(e, report)}
+ >
+
+
+
+
+ ))}
+
+
+ )}
+
+
+
+
+ !isOpen && setDeleteReportId(null)}
+ report={reportToDelete}
+ onConfirm={handleConfirmDelete}
+ isDeleting={loading}
+ />
+ >
+ );
+}
diff --git a/src/components/LoadTemplateDialog.jsx b/src/components/LoadTemplateDialog.jsx
new file mode 100644
index 0000000..82e8eea
--- /dev/null
+++ b/src/components/LoadTemplateDialog.jsx
@@ -0,0 +1,70 @@
+import { useState } from "react";
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { FolderOpen, Trash2 } from "lucide-react";
+import { format } from "date-fns";
+
+export default function LoadTemplateDialog({ templates, onLoad, onDelete, trigger }) {
+ const [open, setOpen] = useState(false);
+ const [search, setSearch] = useState("");
+
+ const filtered = templates.filter(t =>
+ t.name.toLowerCase().includes(search.toLowerCase()) ||
+ (t.associations?.name || "").toLowerCase().includes(search.toLowerCase())
+ );
+
+ return (
+
+
+ {trigger || (
+
+ Load
+
+ )}
+
+
+
+ Load Saved Template
+
+ setSearch(e.target.value)}
+ className="text-sm"
+ />
+
+ {filtered.length === 0 && (
+
No saved templates found.
+ )}
+ {filtered.map(t => (
+
+
{ onLoad(t.id); setOpen(false); }}
+ >
+ {t.name}
+ {t.associations?.name && (
+ {t.associations.name}
+ )}
+
+ Last updated: {format(new Date(t.updated_at), "MMM d, yyyy h:mm a")}
+
+
+ {onDelete && (
+
{ e.stopPropagation(); onDelete(t.id); }}
+ >
+
+
+ )}
+
+ ))}
+
+
+
+ );
+}
diff --git a/src/components/LogoUpload.tsx b/src/components/LogoUpload.tsx
new file mode 100644
index 0000000..c17b995
--- /dev/null
+++ b/src/components/LogoUpload.tsx
@@ -0,0 +1,91 @@
+import { useState, useRef } from "react";
+import { supabase } from "@/integrations/supabase/client";
+import { useToast } from "@/hooks/use-toast";
+import { Button } from "@/components/ui/button";
+import { Upload, Trash2, ImageIcon, Loader2 } from "lucide-react";
+
+interface LogoUploadProps {
+ currentUrl: string | null;
+ storagePath: string; // e.g. "associations/abc-123" or "company"
+ onUploaded: (url: string | null) => void;
+ label?: string;
+ className?: string;
+}
+
+export default function LogoUpload({ currentUrl, storagePath, onUploaded, label = "Logo", className = "" }: LogoUploadProps) {
+ const [uploading, setUploading] = useState(false);
+ const inputRef = useRef(null);
+ const { toast } = useToast();
+
+ const handleUpload = async (e: React.ChangeEvent) => {
+ const file = e.target.files?.[0];
+ if (!file) return;
+
+ if (!file.type.startsWith("image/")) {
+ toast({ title: "Please select an image file", variant: "destructive" });
+ return;
+ }
+ if (file.size > 5 * 1024 * 1024) {
+ toast({ title: "File must be under 5MB", variant: "destructive" });
+ return;
+ }
+
+ setUploading(true);
+ try {
+ const ext = file.name.split(".").pop() || "png";
+ const filePath = `${storagePath}/logo-${Date.now()}.${ext}`;
+
+ const { error: uploadError } = await supabase.storage.from("logos").upload(filePath, file, {
+ upsert: true,
+ contentType: file.type,
+ });
+ if (uploadError) throw uploadError;
+
+ const { data: urlData } = supabase.storage.from("logos").getPublicUrl(filePath);
+ onUploaded(urlData.publicUrl);
+ toast({ title: `${label} uploaded` });
+ } catch (err: any) {
+ toast({ title: "Upload failed", description: err.message, variant: "destructive" });
+ } finally {
+ setUploading(false);
+ if (inputRef.current) inputRef.current.value = "";
+ }
+ };
+
+ const handleRemove = () => {
+ onUploaded(null);
+ };
+
+ return (
+
+
{label}
+
+
+ {currentUrl ? (
+
+ ) : (
+
+ )}
+
+
+
+ inputRef.current?.click()}
+ disabled={uploading}
+ className="gap-2"
+ >
+ {uploading ? : }
+ {uploading ? "Uploading..." : currentUrl ? "Replace" : "Upload"}
+
+ {currentUrl && (
+
+ Remove
+
+ )}
+
+
+
+ );
+}
diff --git a/src/components/MarkAsPaidDialog.jsx b/src/components/MarkAsPaidDialog.jsx
new file mode 100644
index 0000000..eda3c8a
--- /dev/null
+++ b/src/components/MarkAsPaidDialog.jsx
@@ -0,0 +1,126 @@
+import React, { useState } from 'react';
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from '@/components/ui/dialog';
+import { Button } from '@/components/ui/button';
+import { Checkbox } from '@/components/ui/checkbox';
+import { Label } from '@/components/ui/label';
+import { Card, CardContent } from '@/components/ui/card';
+import { Loader2, CheckCircle } from 'lucide-react';
+import { useToast } from '@/hooks/use-toast';
+import { supabase } from '@/integrations/supabase/client';
+import { pushBillPaymentToZohoAfterPay } from '@/lib/zohoBillSync';
+import { format } from 'date-fns';
+import { useAuth } from '@/contexts/AuthContext';
+
+export default function MarkAsPaidDialog({ open, onOpenChange, bill, onSuccess }) {
+ const { toast } = useToast();
+ const { user } = useAuth();
+ const [isProcessing, setIsProcessing] = useState(false);
+ const [sendToPrintQueue, setSendToPrintQueue] = useState(false);
+
+ const handleSubmit = async () => {
+ if (!bill) return;
+
+ if (bill.status?.toLowerCase() === 'paid') {
+ toast({ title: "Already Paid", description: "This bill is already marked as paid.", variant: "destructive" });
+ onOpenChange(false);
+ return;
+ }
+
+ setIsProcessing(true);
+ try {
+ const { error: billError } = await supabase
+ .from('bills')
+ .update({
+ status: 'paid',
+ paid_date: new Date().toISOString()
+ })
+ .eq('id', bill.id);
+
+ if (billError) throw billError;
+
+ // Push payment + check info to Zoho Books (records the vendor payment in the banking journal)
+ pushBillPaymentToZohoAfterPay(bill.id).then((r) => {
+ if (!r.success) console.warn('Zoho payment sync failed for bill', bill.id, r.error);
+ });
+
+ toast({
+ title: "Success",
+ description: sendToPrintQueue
+ ? "Bill marked as paid and check added to print queue."
+ : "Bill marked as paid."
+ });
+
+ onSuccess?.();
+ onOpenChange(false);
+ } catch (err) {
+ console.error("Error marking bill as paid:", err);
+ toast({ title: "Error", description: err.message, variant: "destructive" });
+ } finally {
+ setIsProcessing(false);
+ }
+ };
+
+ if (!bill) return null;
+
+ return (
+
+
+
+
+
+ Confirm Payment
+
+
+ Are you sure you want to mark this bill as paid?
+
+
+
+
+
+
+
+ Bill / Invoice #
+ {bill.invoice_number || 'N/A'}
+
+
+ Amount
+
+ ${parseFloat(bill.amount || 0).toLocaleString('en-US', { minimumFractionDigits: 2 })}
+
+
+
+ Due Date
+
+ {bill.due_date ? format(new Date(bill.due_date), 'MMM d, yyyy') : 'N/A'}
+
+
+
+
+
+
+ setSendToPrintQueue(!!checked)}
+ />
+
+ Send Check to Print Queue
+
+
+
+
+
+ onOpenChange(false)} disabled={isProcessing}>Cancel
+
+ {isProcessing && }
+ Mark as Paid
+
+
+
+
+ );
+}
diff --git a/src/components/MessagesIconButton.tsx b/src/components/MessagesIconButton.tsx
new file mode 100644
index 0000000..6294a84
--- /dev/null
+++ b/src/components/MessagesIconButton.tsx
@@ -0,0 +1,46 @@
+import { MessageCircle } from "lucide-react";
+import { useNavigate } from "react-router-dom";
+import { Button } from "@/components/ui/button";
+import { Badge } from "@/components/ui/badge";
+import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
+import { useUnreadMessages } from "@/hooks/useUnreadMessages";
+import { cn } from "@/lib/utils";
+
+interface Props {
+ to: string;
+ size?: "sm" | "md";
+ className?: string;
+}
+
+/**
+ * Messages icon with realtime unread badge.
+ * Reused across Admin, Board, and Homeowner top nav bars.
+ */
+export function MessagesIconButton({ to, size = "sm", className }: Props) {
+ const navigate = useNavigate();
+ const { unreadCount } = useUnreadMessages();
+
+ const dim = size === "sm" ? "h-7 w-7" : "h-8 w-8";
+ const iconDim = size === "sm" ? "h-3.5 w-3.5" : "h-4 w-4";
+
+ return (
+
+
+ navigate(to)}
+ >
+
+ {unreadCount > 0 && (
+
+ {unreadCount > 9 ? "9+" : unreadCount}
+
+ )}
+
+
+ Messages{unreadCount > 0 ? ` (${unreadCount} unread)` : ""}
+
+ );
+}
diff --git a/src/components/MonthEndReportDialog.jsx b/src/components/MonthEndReportDialog.jsx
new file mode 100644
index 0000000..bd70c5f
--- /dev/null
+++ b/src/components/MonthEndReportDialog.jsx
@@ -0,0 +1,267 @@
+import React, { useState, useEffect } from 'react';
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
+import { Button } from '@/components/ui/button';
+import { Label } from '@/components/ui/label';
+import { Checkbox } from '@/components/ui/checkbox';
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
+import { Loader2, FileText, Calendar as CalendarIcon, Download, RefreshCw, AlertTriangle, X } from 'lucide-react';
+import { format, startOfMonth, endOfMonth, subMonths, isAfter, differenceInDays, isValid } from 'date-fns';
+import { useToast } from '@/hooks/use-toast';
+import { useAuth } from '@/contexts/AuthContext';
+import { supabase } from '@/integrations/supabase/client';
+import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
+import { Calendar } from '@/components/ui/calendar';
+import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
+import { cn } from '@/lib/utils';
+import { Alert, AlertDescription } from '@/components/ui/alert';
+
+export default function MonthEndReportDialog({ open, onOpenChange, onSuccess }) {
+ const { user } = useAuth();
+ const { toast } = useToast();
+
+ const now = new Date();
+ const [dateRange, setDateRange] = useState({
+ start: startOfMonth(now),
+ end: endOfMonth(now)
+ });
+ const [selectedMonth, setSelectedMonth] = useState('current');
+ const [isManualDate, setIsManualDate] = useState(false);
+ const [dateError, setDateError] = useState(null);
+
+ const [selections, setSelections] = useState({
+ status: true,
+ owner: true,
+ violations: true,
+ collections: true,
+ estoppels: true,
+ callLogs: true
+ });
+
+ const [selectedAssociationId, setSelectedAssociationId] = useState(null);
+ const [associations, setAssociations] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [activeTab, setActiveTab] = useState('config');
+
+ useEffect(() => {
+ if (open) {
+ setLoading(false);
+ fetchAssociations();
+ handleMonthChange('current');
+ setActiveTab('config');
+ }
+ }, [open]);
+
+ const validateDates = (start, end) => {
+ if (!isValid(start) || !isValid(end)) return "Invalid date selection.";
+ if (isAfter(start, end)) return "Start date cannot be after end date.";
+ if (differenceInDays(end, start) > 366) return "Date range cannot exceed one year.";
+ return null;
+ };
+
+ const handleManualDateChange = (type, date) => {
+ if (!date) return;
+ const newRange = { ...dateRange, [type]: date };
+ const error = validateDates(newRange.start, newRange.end);
+ setDateRange(newRange);
+ setIsManualDate(true);
+ setDateError(error);
+ if (selectedMonth !== 'custom') setSelectedMonth('custom');
+ };
+
+ const handleMonthChange = (val) => {
+ setSelectedMonth(val);
+ if (val === 'custom') { setIsManualDate(true); return; }
+ setIsManualDate(false);
+ setDateError(null);
+ const now = new Date();
+ if (val === 'current') {
+ setDateRange({ start: startOfMonth(now), end: endOfMonth(now) });
+ } else {
+ const last = subMonths(now, 1);
+ setDateRange({ start: startOfMonth(last), end: endOfMonth(last) });
+ }
+ };
+
+ const fetchAssociations = async () => {
+ const { data } = await supabase.from('associations').select('id, name').eq('status', 'active').order('name');
+ setAssociations(data || []);
+ if (data && data.length > 0 && !selectedAssociationId) {
+ setSelectedAssociationId(data[0].id);
+ }
+ };
+
+ const handleGenerate = async () => {
+ if (!selectedAssociationId) {
+ toast({ variant: "destructive", title: "Error", description: "Please select an association." });
+ return;
+ }
+ if (dateError) {
+ toast({ variant: "destructive", title: "Invalid Dates", description: dateError });
+ return;
+ }
+
+ setLoading(true);
+ try {
+ // Placeholder for actual report generation logic
+ toast({ title: "Report Generation", description: "Month-end report generation requires additional report generator components." });
+ if (onSuccess) onSuccess();
+ } catch (error) {
+ console.error("Generation error:", error);
+ toast({ variant: "destructive", title: "Generation Failed", description: error.message || "Failed to generate report." });
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleClose = () => {
+ if (loading) {
+ if (window.confirm("Report generation is in progress. Close anyway?")) {
+ setLoading(false);
+ onOpenChange(false);
+ }
+ } else {
+ onOpenChange(false);
+ }
+ };
+
+ const DatePicker = ({ date, onSelect, label }) => (
+
+
{label}
+
+
+
+
+ {date ? format(date, "MM/dd/yyyy") : Pick a date }
+
+
+
+ isAfter(d, new Date())} />
+
+
+
+ );
+
+ return (
+
+ { if (loading) e.preventDefault(); }}
+ >
+
+
+
+
+
+
+ Generate Month-End Report
+ Compile monthly activity into a professional PDF.
+
+
+
+
+
+
+
+
+
+
+
+ Report Settings
+
+
+
+
+
+
+ Association
+
+
+
+ {associations.map(c => {c.name} )}
+
+
+
+
+
+
+ Report Period
+ {isManualDate && (
+ Custom Range
+ )}
+
+
+
+
+
+
+
+ Current Month ({format(now, 'MMM')})
+ Last Month ({format(subMonths(now, 1), 'MMM')})
+ Custom Date Range
+
+
+
+
+ handleManualDateChange('start', d)} />
+ handleManualDateChange('end', d)} />
+
+
+ {dateError && (
+
+
+ {dateError}
+
+ )}
+
+ {isManualDate && !dateError && (
+
+ handleMonthChange('current')} className="text-xs h-7">
+ Reset to Month
+
+
+ )}
+
+
+
+
Included Standard Sections
+
+ {[
+ { id: 'status', label: 'Status Updates' },
+ { id: 'owner', label: 'Owner Reports' },
+ { id: 'violations', label: 'Violations Report' },
+ { id: 'collections', label: 'Collections' },
+ { id: 'estoppels', label: 'Estoppels Report' },
+ { id: 'callLogs', label: 'Call Logs' },
+ ].map((item) => (
+
+ setSelections(prev => ({ ...prev, [item.id]: !!c }))}
+ />
+ {item.label}
+
+ ))}
+
+
+
+
+
+
+
+
+
+ {loading && Generating... }
+
+
+ Cancel
+
+ {loading ? : }
+ {loading ? 'Generating...' : 'Generate Report'}
+
+
+
+
+
+ );
+}
diff --git a/src/components/NavLink.tsx b/src/components/NavLink.tsx
new file mode 100644
index 0000000..a561a95
--- /dev/null
+++ b/src/components/NavLink.tsx
@@ -0,0 +1,28 @@
+import { NavLink as RouterNavLink, NavLinkProps } from "react-router-dom";
+import { forwardRef } from "react";
+import { cn } from "@/lib/utils";
+
+interface NavLinkCompatProps extends Omit {
+ className?: string;
+ activeClassName?: string;
+ pendingClassName?: string;
+}
+
+const NavLink = forwardRef(
+ ({ className, activeClassName, pendingClassName, to, ...props }, ref) => {
+ return (
+
+ cn(className, isActive && activeClassName, isPending && pendingClassName)
+ }
+ {...props}
+ />
+ );
+ },
+);
+
+NavLink.displayName = "NavLink";
+
+export { NavLink };
diff --git a/src/components/NotificationBell.tsx b/src/components/NotificationBell.tsx
new file mode 100644
index 0000000..344f0db
--- /dev/null
+++ b/src/components/NotificationBell.tsx
@@ -0,0 +1,167 @@
+import { useState, useEffect, useRef, useCallback } from "react";
+import { supabase } from "@/integrations/supabase/client";
+import { Bell, Check, Trash2, X } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { Badge } from "@/components/ui/badge";
+import { ScrollArea } from "@/components/ui/scroll-area";
+import { formatDistanceToNow } from "date-fns";
+
+interface Notification {
+ id: string;
+ type: string;
+ title: string;
+ message: string | null;
+ is_read: boolean;
+ link: string | null;
+ related_item_type: string | null;
+ created_at: string;
+}
+
+export function NotificationBell({ userId }: { userId?: string }) {
+ const [open, setOpen] = useState(false);
+ const [notifications, setNotifications] = useState([]);
+ const [unreadCount, setUnreadCount] = useState(0);
+ const ref = useRef(null);
+
+ const fetchNotifications = useCallback(async () => {
+ if (!userId) return;
+ const { data } = await supabase
+ .from("in_app_notifications")
+ .select("*")
+ .eq("user_id", userId)
+ .order("created_at", { ascending: false })
+ .limit(20);
+ if (data) {
+ setNotifications(data as Notification[]);
+ setUnreadCount(data.filter((n: any) => !n.is_read).length);
+ }
+ }, [userId]);
+
+ useEffect(() => {
+ fetchNotifications();
+ }, [fetchNotifications]);
+
+ // Real-time subscription
+ useEffect(() => {
+ if (!userId) return;
+ const channel = supabase
+ .channel(`notifications:${userId}`)
+ .on("postgres_changes", {
+ event: "*",
+ schema: "public",
+ table: "in_app_notifications",
+ filter: `user_id=eq.${userId}`,
+ }, () => {
+ fetchNotifications();
+ })
+ .subscribe();
+
+ return () => { supabase.removeChannel(channel); };
+ }, [userId, fetchNotifications]);
+
+ // Close on outside click
+ useEffect(() => {
+ const handler = (e: MouseEvent) => {
+ if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
+ };
+ document.addEventListener("mousedown", handler);
+ return () => document.removeEventListener("mousedown", handler);
+ }, []);
+
+ const markAsRead = async (id: string) => {
+ await supabase.from("in_app_notifications").update({ is_read: true }).eq("id", id);
+ setNotifications(prev => prev.map(n => n.id === id ? { ...n, is_read: true } : n));
+ setUnreadCount(prev => Math.max(0, prev - 1));
+ };
+
+ const markAllRead = async () => {
+ if (!userId) return;
+ await supabase.from("in_app_notifications").update({ is_read: true }).eq("user_id", userId).eq("is_read", false);
+ setNotifications(prev => prev.map(n => ({ ...n, is_read: true })));
+ setUnreadCount(0);
+ };
+
+ const deleteNotification = async (id: string) => {
+ const was = notifications.find(n => n.id === id);
+ await supabase.from("in_app_notifications").delete().eq("id", id);
+ setNotifications(prev => prev.filter(n => n.id !== id));
+ if (was && !was.is_read) setUnreadCount(prev => Math.max(0, prev - 1));
+ };
+
+ const typeIcon: Record = {
+ task_assigned: "📋",
+ reminder_due: "⏰",
+ bill_approval: "💰",
+ info: "ℹ️",
+ };
+
+ return (
+
+
setOpen(o => !o)}
+ >
+
+ {unreadCount > 0 && (
+
+ {unreadCount > 9 ? "9+" : unreadCount}
+
+ )}
+
+
+ {open && (
+
+
+
Notifications
+
+ {unreadCount > 0 && (
+
+ Mark all read
+
+ )}
+ setOpen(false)}>
+
+
+
+
+
+
+ {notifications.length === 0 ? (
+
+ No notifications yet
+
+ ) : (
+ notifications.map((n) => (
+
+
{typeIcon[n.type] || "🔔"}
+
+
{n.title}
+ {n.message &&
{n.message}
}
+
+ {formatDistanceToNow(new Date(n.created_at), { addSuffix: true })}
+
+
+
+ {!n.is_read && (
+ markAsRead(n.id)} title="Mark as read">
+
+
+ )}
+ deleteNotification(n.id)} title="Delete">
+
+
+
+
+ ))
+ )}
+
+
+ )}
+
+ );
+}
diff --git a/src/components/NotifyBoardSendDialog.jsx b/src/components/NotifyBoardSendDialog.jsx
new file mode 100644
index 0000000..f15bf2d
--- /dev/null
+++ b/src/components/NotifyBoardSendDialog.jsx
@@ -0,0 +1,269 @@
+
+import React, { useState, useEffect, useMemo } from 'react';
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from '@/components/ui/dialog';
+import { Button } from '@/components/ui/button';
+import { Label } from '@/components/ui/label';
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
+import { Input } from '@/components/ui/input';
+import { Textarea } from '@/components/ui/textarea';
+import { useToast } from '@/hooks/use-toast';
+import { Loader2, Send, Eye, RefreshCw, Paperclip } from 'lucide-react';
+import { ScrollArea } from '@/components/ui/scroll-area';
+import { useAuth } from '@/contexts/AuthContext';
+
+export default function NotifyBoardSendDialog({
+ open,
+ onOpenChange,
+ clients = [],
+ additionalEmails = [],
+ loadingClients = false,
+ refreshClients = () => {}
+}) {
+ const { user } = useAuth();
+ const { toast } = useToast();
+
+ const [templates, setTemplates] = useState([]);
+ const [selectedTemplateId, setSelectedTemplateId] = useState('');
+ const [selectedSenderId, setSelectedSenderId] = useState('');
+ const [senders, setSenders] = useState([]);
+
+ const [subject, setSubject] = useState('');
+ const [body, setBody] = useState('');
+ const [variableValues, setVariableValues] = useState({});
+ const [selectedRecipients, setSelectedRecipients] = useState([]);
+
+ const [loadingTemplates, setLoadingTemplates] = useState(false);
+ const [sending, setSending] = useState(false);
+ const [sendResults, setSendResults] = useState(null);
+ const [oneOffAttachments, setOneOffAttachments] = useState([]);
+
+ useEffect(() => {
+ if (open) {
+ if (refreshClients) refreshClients();
+ } else {
+ resetState();
+ }
+ }, [open]);
+
+ useEffect(() => {
+ if (senders.length > 0 && !selectedSenderId) {
+ const def = senders.find(s => s.is_default);
+ if (def) setSelectedSenderId(def.id);
+ else setSelectedSenderId(senders[0].id);
+ }
+ }, [senders, selectedSenderId]);
+
+ const resetState = () => {
+ setSelectedTemplateId('');
+ setSubject('');
+ setBody('');
+ setVariableValues({});
+ setSelectedRecipients([]);
+ setSendResults(null);
+ setOneOffAttachments([]);
+ };
+
+ useEffect(() => {
+ if (selectedTemplateId === 'custom') {
+ setSubject('');
+ setBody('');
+ setVariableValues({});
+ } else if (selectedTemplateId) {
+ const tmpl = templates.find(t => t.id === selectedTemplateId);
+ if (tmpl) {
+ setSubject(tmpl.subject);
+ setBody(tmpl.body);
+ setVariableValues({});
+ }
+ }
+ }, [selectedTemplateId, templates]);
+
+ const selectedTemplateAttachments = useMemo(() => {
+ const tmpl = templates.find(t => t.id === selectedTemplateId);
+ return tmpl?.attachments || [];
+ }, [selectedTemplateId, templates]);
+
+ const allAttachments = useMemo(() => {
+ return [...selectedTemplateAttachments, ...oneOffAttachments];
+ }, [selectedTemplateAttachments, oneOffAttachments]);
+
+ const handleSend = async () => {
+ if (!subject || !body) {
+ toast({ variant: "destructive", title: "Validation Error", description: "Subject and Body are required." });
+ return;
+ }
+ if (selectedRecipients.length === 0) {
+ toast({ variant: "destructive", title: "Selection Error", description: "Please select at least one recipient." });
+ return;
+ }
+ if (!selectedSenderId) {
+ toast({ variant: "destructive", title: "Sender Error", description: "Please select a valid sender." });
+ return;
+ }
+
+ setSending(true);
+ setSendResults(null);
+
+ const results = [];
+
+ try {
+ for (const recipient of selectedRecipients) {
+ const { email, clientId } = recipient;
+ const clientInfo = clients.find(c => c.id === clientId) || { name: 'Homeowner Association' };
+
+ const context = { clientName: clientInfo.name, ...variableValues };
+ const finalSubject = subject; // Placeholder for template variable replacement
+
+ // TODO: Integrate email sending service
+ console.log(`Would send email to ${email}: ${finalSubject}`);
+ results.push({ email, status: 'sent', error: null });
+ }
+
+ setSendResults(results);
+
+ const successfulCount = results.filter(r => r.status === 'sent').length;
+ if (successfulCount > 0) {
+ toast({ title: 'Success', description: `Successfully delivered ${successfulCount} emails.` });
+ }
+
+ const failedCount = results.filter(r => r.status === 'failed').length;
+ if (failedCount > 0) {
+ toast({ variant: 'destructive', title: 'Partial Delivery', description: `${failedCount} emails failed to send.` });
+ }
+
+ } catch (err) {
+ console.error("Critical Send Error:", err);
+ toast({ variant: "destructive", title: "Sending Failed", description: err.message });
+ } finally {
+ setSending(false);
+ }
+ };
+
+ const isLoading = loadingClients || loadingTemplates;
+
+ return (
+
+
+
+
+
+ Send Board Notification
+ Select recipients and customize your message.
+
+ {loadingClients && (
+
+ Updating emails...
+
+ )}
+
+
+
+ {sendResults ? (
+
+
+
Delivery Results
+ {sendResults.map((r, i) => (
+
+ {r.email}
+ {r.status}
+
+ ))}
+
+
+ ) : (
+
+
+ {isLoading && !clients.length ? (
+
+ ) : (
+
+
+
+ Sender
+
+
+
+ {senders.map(s => (
+ {s.label} ({s.email_address})
+ ))}
+
+
+
+
+ Template
+
+
+
+ Use Custom Message
+ {templates.map(t => {t.name} )}
+
+
+
+
+
+ Subject
+ setSubject(e.target.value)} placeholder="Email Subject" />
+
+
+ Body
+ setBody(e.target.value)}
+ className="min-h-[150px]"
+ placeholder="Email body content..."
+ />
+
+
+ {selectedTemplateAttachments.length > 0 && (
+
+
Template Attachments
+ {selectedTemplateAttachments.map((file, idx) => (
+
+ ))}
+
+ )}
+
+
+ )}
+
+
+
+
+
+
+
{subject || 'No subject'}
+
{body || 'No body content'}
+
+
+
+
+
+ )}
+
+
+ {sendResults ? (
+
+ setSendResults(null)}> Start New
+ onOpenChange(false)}>Close
+
+ ) : (
+ <>
+ onOpenChange(false)}>Cancel
+
+ {sending ? : }
+ {sending ? 'Sending...' : `Send to ${selectedRecipients.length} Recipients`}
+
+ >
+ )}
+
+
+
+ );
+}
diff --git a/src/components/NotifyBoardTemplateDialog.jsx b/src/components/NotifyBoardTemplateDialog.jsx
new file mode 100644
index 0000000..1ac4d9f
--- /dev/null
+++ b/src/components/NotifyBoardTemplateDialog.jsx
@@ -0,0 +1,188 @@
+
+import React, { useState, useEffect } from 'react';
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from '@/components/ui/dialog';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import { Textarea } from '@/components/ui/textarea';
+import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
+import { Loader2, Plus } from 'lucide-react';
+
+const AVAILABLE_VARIABLES = [
+ { key: 'client_name', desc: 'Association Name' },
+ { key: 'recipient_name', desc: 'Board Member Name' },
+ { key: 'alert_type', desc: 'Type of Alert' },
+ { key: 'date', desc: 'Relevant Date' },
+ { key: 'amount', desc: 'Monetary Amount' },
+ { key: 'address', desc: 'Property Address' },
+ { key: 'status', desc: 'Current Status' }
+];
+
+const DUMMY_VARIABLES = {
+ client_name: 'Sample Association',
+ recipient_name: 'John Doe',
+ alert_type: 'Urgent Maintenance',
+ date: new Date().toLocaleDateString(),
+ amount: '$1,250.00',
+ address: '123 Maple Avenue',
+ status: 'Pending'
+};
+
+export default function NotifyBoardTemplateDialog({ open, onOpenChange, template, onSave }) {
+ const [activeTab, setActiveTab] = useState('edit');
+ const [formData, setFormData] = useState({
+ name: '',
+ subject: '',
+ body: '',
+ variables: [],
+ attachments: []
+ });
+ const [isSubmitting, setIsSubmitting] = useState(false);
+
+ useEffect(() => {
+ if (template) {
+ setFormData({
+ name: template.name || '',
+ subject: template.subject || '',
+ body: template.body || '',
+ variables: template.variables || [],
+ attachments: template.attachments || []
+ });
+ } else {
+ setFormData({
+ name: '',
+ subject: '',
+ body: 'Please log in to your account to review the requested vote for documentation.',
+ variables: [],
+ attachments: []
+ });
+ }
+ setActiveTab('edit');
+ }, [template, open]);
+
+ const handleSubmit = async (e) => {
+ e.preventDefault();
+ if (!formData.name || !formData.subject || !formData.body) return;
+
+ setIsSubmitting(true);
+ await onSave(formData);
+ setIsSubmitting(false);
+ onOpenChange(false);
+ };
+
+ const insertVariable = (variableKey) => {
+ const variableTag = `{{${variableKey}}}`;
+ setFormData(prev => ({
+ ...prev,
+ body: prev.body + ` ${variableTag} `
+ }));
+ };
+
+ const getPreviewHtml = () => {
+ let preview = formData.body || '';
+ Object.entries(DUMMY_VARIABLES).forEach(([key, val]) => {
+ preview = preview.replace(new RegExp(`\\{\\{${key}\\}\\}`, 'g'), val);
+ });
+ return preview;
+ };
+
+ return (
+
+
+
+ {template ? 'Edit Template' : 'Create New Template'}
+
+ Design email templates for board notifications. The header and footer will be automatically applied.
+
+
+
+
+
+
+
+ Editor
+ Preview
+
+
+
+
+
+
+
+ Template Name
+ setFormData({...formData, name: e.target.value})}
+ placeholder="e.g., Monthly Meeting Reminder"
+ className="bg-background"
+ />
+
+
+ Email Subject
+ setFormData({...formData, subject: e.target.value})}
+ placeholder="Subject line... (use variables like {{client_name}})"
+ className="bg-background"
+ />
+
+
+ Email Body
+ setFormData({...formData, body: e.target.value})}
+ className="min-h-[300px] bg-background font-mono text-sm"
+ placeholder="Email body content..."
+ />
+
+
+
+
+
Available Variables
+
Click to append to body.
+
+ {AVAILABLE_VARIABLES.map(v => (
+
insertVariable(v.key)}
+ >
+
+ {"{{" + v.key + "}}"}
+ {v.desc}
+
+
+
+ ))}
+
+
+
+
+
+
+
+
+ Previewing with sample data. Variables will be replaced with actual values when sent.
+
+
+
+
{formData.subject || 'No Subject'}
+
+
+
+
+
+
+
+
+
+ onOpenChange(false)}>Cancel
+
+ {isSubmitting && }
+ {template ? 'Update Template' : 'Create Template'}
+
+
+
+
+ );
+}
diff --git a/src/components/NotifyOwnersDialog.jsx b/src/components/NotifyOwnersDialog.jsx
new file mode 100644
index 0000000..099662f
--- /dev/null
+++ b/src/components/NotifyOwnersDialog.jsx
@@ -0,0 +1,474 @@
+
+import React, { useState, useEffect } from 'react';
+import {
+ Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter,
+} from '@/components/ui/dialog';
+import { Button } from '@/components/ui/button';
+import { Label } from '@/components/ui/label';
+import { Input } from '@/components/ui/input';
+import { Textarea } from '@/components/ui/textarea';
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
+import { useNotifyOwners } from '@/contexts/NotifyOwnersContext';
+import { supabase } from '@/integrations/supabase/client';
+import { useToast } from '@/hooks/use-toast';
+import { Loader2, CheckCircle2, Copy, ExternalLink, Eye, X, Paperclip, Upload, Users } from 'lucide-react';
+import { useAuth } from '@/contexts/AuthContext';
+import { Badge } from '@/components/ui/badge';
+import { useFileUpload } from '@/hooks/useFileUpload';
+
+// Success Dialog Component
+const SuccessDialog = ({ open, onOpenChange, result }) => {
+ const { toast } = useToast();
+ if (!result) return null;
+
+ const copyLink = () => {
+ if (result.proofUrl) {
+ navigator.clipboard.writeText(result.proofUrl);
+ toast({ title: 'Copied', description: 'Proof URL copied to clipboard.' });
+ }
+ };
+
+ return (
+
+
+
+ Notifications Sent
+ Your email blast has been delivered successfully.
+
+
+
+
+
+
Notifications Sent!
+
+ Successfully delivered to {result.count} owners.
+ {result.failed > 0 && ({result.failed} failed) }
+
+
+
+
+ Validation ID:
+ {result.validationId}
+
+
+ {result.proofUrl && (
+
+
Proof Document
+
+ window.open(result.proofUrl, '_blank')}>
+ View PDF
+
+
+ Copy Link
+
+
+
+ )}
+
+
+
+ onOpenChange(false)}>Close
+
+
+
+ );
+};
+
+// Reusable Email Chip Input
+const EmailChipInput = ({ emails, setEmails, placeholder, label, helpText }) => {
+ const [inputValue, setInputValue] = useState('');
+ const [error, setError] = useState('');
+
+ const validateEmail = (email) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
+
+ const processInput = (value) => {
+ const splitEmails = value.split(',').map((e) => e.trim()).filter(Boolean);
+ const newValidEmails = [];
+ let hasError = false;
+
+ splitEmails.forEach((email) => {
+ if (validateEmail(email)) {
+ if (!emails.includes(email)) newValidEmails.push(email);
+ } else {
+ hasError = true;
+ }
+ });
+
+ if (newValidEmails.length > 0) setEmails([...emails, ...newValidEmails]);
+ if (hasError) setError('One or more email addresses are invalid.');
+ else { setError(''); setInputValue(''); }
+ };
+
+ const handleKeyDown = (e) => {
+ if (e.key === 'Enter' || e.key === ',') {
+ e.preventDefault();
+ if (inputValue.trim()) processInput(inputValue);
+ }
+ };
+
+ const handleBlur = () => { if (inputValue.trim()) processInput(inputValue); };
+ const removeEmail = (emailToRemove) => { setEmails(emails.filter((email) => email !== emailToRemove)); setError(''); };
+
+ return (
+
+
+ {label}
+ {helpText && {helpText} }
+
+
+ {emails.map((email) => (
+
+ {email}
+ removeEmail(email)}>
+
+
+
+ ))}
+ setInputValue(e.target.value)}
+ onKeyDown={handleKeyDown} onBlur={handleBlur}
+ placeholder={emails.length === 0 ? placeholder : ''}
+ className="flex-1 outline-none bg-transparent min-w-[200px] text-sm text-foreground placeholder:text-muted-foreground"
+ />
+
+ {error &&
{error}
}
+
+ );
+};
+
+
+export default function NotifyOwnersDialog({ open, onOpenChange, initialTemplate, selectedOwners = [] }) {
+ const { user } = useAuth();
+ const { sendNotification, templates, clients, selectedClientId, setSelectedClientId, owners, getOwnersByConsent } = useNotifyOwners();
+ const { toast } = useToast();
+ const { uploadFiles, uploading } = useFileUpload();
+
+ const [senders, setSenders] = useState([]);
+ const [senderId, setSenderId] = useState('');
+ const [selectedTemplate, setSelectedTemplate] = useState('custom');
+ const [subject, setSubject] = useState('');
+ const [body, setBody] = useState('');
+ const [ccEmails, setCcEmails] = useState([]);
+ const [bccEmails, setBccEmails] = useState([]);
+ const [attachments, setAttachments] = useState([]);
+ const [sending, setSending] = useState(false);
+ const [targetGroup, setTargetGroup] = useState('all');
+ const [showPreview, setShowPreview] = useState(false);
+
+ const [successResult, setSuccessResult] = useState(null);
+ const [showSuccess, setShowSuccess] = useState(false);
+
+ // Compute owner stats based on context owners
+ const allOwnersWithEmail = getOwnersByConsent ? getOwnersByConsent(false) : [];
+ const consentedOwners = getOwnersByConsent ? getOwnersByConsent(true) : [];
+ const selectedOwnersWithEmail = selectedOwners.filter(owner => owner.email);
+ const recipientCount = targetGroup === 'selected' ? selectedOwnersWithEmail.length : targetGroup === 'consented' ? consentedOwners.length : allOwnersWithEmail.length;
+
+ useEffect(() => {
+ if (open && user) {
+ fetchSenders();
+ setCcEmails([]); setBccEmails([]);
+ setSelectedTemplate('custom'); setAttachments([]);
+ setSuccessResult(null); setShowPreview(false);
+ setTargetGroup(selectedOwnersWithEmail.length > 0 ? 'selected' : 'all');
+
+ // Pre-fill from template if provided
+ if (initialTemplate) {
+ setSubject(initialTemplate.subject || '');
+ setBody(initialTemplate.body_html || initialTemplate.body || '');
+ setSelectedTemplate(initialTemplate.id || 'custom');
+ } else {
+ setSubject('');
+ setBody('');
+ }
+ }
+ }, [open, user, initialTemplate]);
+
+ const fetchSenders = async () => {
+ const { data: result, error } = await supabase.functions.invoke('send-smtp-email', {
+ body: { action: 'list_senders' }
+ });
+
+ if (error || !result?.success) {
+ console.error('Failed to load email senders:', error || result?.error);
+ setSenders([]);
+ setSenderId('');
+ return;
+ }
+
+ const availableSenders = result.senders || [];
+ if (availableSenders.length > 0) {
+ setSenders(availableSenders);
+ setSenderId(availableSenders[0].id);
+ } else {
+ setSenders([]);
+ setSenderId('');
+ }
+ };
+
+ const handleTemplateChange = (val) => {
+ setSelectedTemplate(val);
+ if (val !== 'custom') {
+ const tmpl = templates.find(t => t.id === val);
+ if (tmpl) {
+ setSubject(tmpl.subject || '');
+ setBody(tmpl.body_html || tmpl.body || '');
+ }
+ } else { setSubject(''); setBody(''); }
+ };
+
+ const handleFileSelect = async (e) => {
+ const files = Array.from(e.target.files || []);
+ if (files.length === 0) return;
+
+ const uploaded = await uploadFiles(files, 'files', 'email-attachments');
+ if (uploaded.length > 0) {
+ setAttachments(prev => [...prev, ...uploaded]);
+ toast({ title: 'Files attached', description: `${uploaded.length} file(s) added.` });
+ }
+ e.target.value = '';
+ };
+
+ const removeAttachment = (index) => {
+ setAttachments(prev => prev.filter((_, i) => i !== index));
+ };
+
+ const getPreviewHtml = () => {
+ const clientInfo = clients.find(c => c.id === selectedClientId) || { name: 'Association' };
+ const formattedBody = body.replace(/\n/g, ' ');
+ return `To: ${clientInfo.name} Owners (${recipientCount} recipients)
${formattedBody}
`;
+ };
+
+ const handleSend = async () => {
+ if (!subject || subject.trim() === '' || !body || body.trim() === '' || !senderId) {
+ toast({ variant: 'destructive', title: 'Validation Error', description: 'Subject, body, and sender are required.' });
+ return;
+ }
+
+ if (!selectedClientId) {
+ toast({ variant: 'destructive', title: 'No Association', description: 'Please select an association first.' });
+ return;
+ }
+
+ if (senders.length === 0) {
+ toast({ variant: 'destructive', title: 'No Sender', description: 'Please configure an email sender in Email Settings first.' });
+ return;
+ }
+
+ if (recipientCount === 0) {
+ toast({ variant: 'destructive', title: 'No Recipients', description: `No owners with email found in the "${targetGroup}" group for this association.` });
+ return;
+ }
+
+ setSending(true);
+ const selectedSender = senders.find(s => s.id === senderId);
+
+ const result = await sendNotification({
+ subject, body, senderId,
+ senderName: selectedSender?.sender_name,
+ senderEmail: selectedSender?.email_address,
+ cc: ccEmails, bcc: bccEmails, attachments, targetGroup, customOwners: selectedOwnersWithEmail
+ });
+
+ setSending(false);
+
+ if (result.success) {
+ setSuccessResult(result);
+ onOpenChange(false);
+ setShowSuccess(true);
+ toast({ title: 'Success', description: 'Notifications successfully sent.' });
+ } else {
+ toast({ variant: 'destructive', title: 'Sending Failed', description: result.error || 'An unexpected error occurred.' });
+ }
+ };
+
+ const selectedAssocName = clients.find(c => c.id === selectedClientId)?.name || 'No association';
+
+ return (
+ <>
+
+
+
+ Notify Owners
+ Send an email blast to property owners. A cryptographic proof PDF will be generated.
+
+
+ {showPreview ? (
+
+
+
Email Preview
+ setShowPreview(false)}>Back to Editor
+
+
+
+
Association:
+
{selectedAssocName}
+
+
+ {(ccEmails.length > 0 || bccEmails.length > 0) && (
+
+ {ccEmails.length > 0 &&
CC: {ccEmails.join(', ')}
}
+ {bccEmails.length > 0 &&
BCC: {bccEmails.join(', ')}
}
+
+ )}
+
+ {attachments.length > 0 && (
+
+
Attachments:
+
+ {attachments.map((att, i) => (
+ {att.name}
+ ))}
+
+
+ )}
+
+
+ ) : (
+
+ {/* Association Selector + Owner Stats */}
+
+
+ Association
+
+
+
+ {clients.map(c => (
+ {c.name}
+ ))}
+
+
+
+
+
Recipients
+
+
+
+ {allOwnersWithEmail.length} w/ email
+ |
+ {consentedOwners.length} consented
+ {selectedOwnersWithEmail.length > 0 && <>| {selectedOwnersWithEmail.length} selected >}
+
+
+
+
+
+
+
+
From Sender
+
+
+
+ {senders.map(s => (
+ {s.sender_name} ({s.email_address})
+ ))}
+
+
+ {senders.length === 0 && (
+
No verified senders found. Configure one in Email Settings.
+ )}
+
+
+
+ Target Group
+
+
+
+ {selectedOwnersWithEmail.length > 0 && Selected Homeowners ({selectedOwnersWithEmail.length}) }
+ All Owners w/ Email ({allOwnersWithEmail.length})
+ Consented Only ({consentedOwners.length})
+
+
+
+
+
+
+
+
+
+ Apply Template (Optional)
+
+
+
+ -- No Template --
+ {templates.map(t => {t.name} )}
+
+
+
+
+
+ Subject
+ setSubject(e.target.value)} />
+
+
+
+
+ Message Body
+ Variables: {'{{ownerName}}, {{propertyAddress}}, {{clientName}}, {{date}}'}
+
+
setBody(e.target.value)} />
+
+
+ {/* Attachments */}
+
+
+
+ Attachments
+
+
+
+
+
+ {uploading ? : }
+ {uploading ? 'Uploading...' : 'Add Files'}
+
+
+
+
+ {attachments.length > 0 && (
+
+ {attachments.map((att, i) => (
+
+
+ {att.name}
+
+ ({(att.size / 1024).toFixed(0)}KB)
+
+ removeAttachment(i)}>
+
+
+
+ ))}
+
+ )}
+
Max 25MB per file. Supported: PDF, DOCX, images, etc.
+
+
+ )}
+
+
+ setShowPreview(!showPreview)} className="text-primary hover:text-primary/80">
+
+ {showPreview ? 'Edit Message' : 'Preview Output'}
+
+
+ onOpenChange(false)}>Cancel
+
+ {sending ? : null}
+ {sending ? 'Sending & Validating...' : `Send to ${recipientCount} Owner${recipientCount !== 1 ? 's' : ''}`}
+
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/src/components/OwnerDialog.jsx b/src/components/OwnerDialog.jsx
new file mode 100644
index 0000000..6ce94c2
--- /dev/null
+++ b/src/components/OwnerDialog.jsx
@@ -0,0 +1,199 @@
+
+import React, { useState, useEffect } from 'react';
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from '@/components/ui/dialog';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import { Checkbox } from '@/components/ui/checkbox';
+import { Textarea } from '@/components/ui/textarea';
+import { useToast } from '@/hooks/use-toast';
+import { supabase } from '@/integrations/supabase/client';
+import { Loader2, Save } from 'lucide-react';
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
+
+export default function OwnerDialog({ open, onOpenChange, owner, clientId, onSuccess }) {
+ const { toast } = useToast();
+ const [loading, setLoading] = useState(false);
+
+ const constructLegacyAddress = (street, city, state, zip) => {
+ if (!street && !city && !state && !zip) return '';
+ let address = street || '';
+ if (city || state || zip) {
+ address += '\n';
+ const cityLine = [city, state, zip].filter(Boolean).join(' ');
+ if (cityLine) address += cityLine;
+ }
+ return address.trim();
+ };
+
+ const [formData, setFormData] = useState({
+ account_number: '', unit_id: '', owner_name: '', property_address: '',
+ email: '', phone: '', balance: '', electronic_consent: false, show_proxy_text: true,
+ mailing_address: '', alt_address_1: '', alt_address_2: '',
+ });
+
+ useEffect(() => {
+ if (open) {
+ if (owner) {
+ const initialMailing = owner.mailing_address || constructLegacyAddress(owner.mailing_address_street, owner.mailing_address_city, owner.mailing_address_state, owner.mailing_address_zip);
+ const initialAlt1 = owner.alt_address_1 || constructLegacyAddress(owner.alt_address_1_street, owner.alt_address_1_city, owner.alt_address_1_state, owner.alt_address_1_zip);
+ const initialAlt2 = owner.alt_address_2 || constructLegacyAddress(owner.alt_address_2_street, owner.alt_address_2_city, owner.alt_address_2_state, owner.alt_address_2_zip);
+
+ setFormData({
+ account_number: owner.account_number || '', unit_id: owner.unit_id || '',
+ owner_name: owner.owner_name || '', property_address: owner.property_address || '',
+ email: owner.email || '', phone: owner.phone || '',
+ balance: owner.balance !== undefined && owner.balance !== null ? owner.balance : '',
+ electronic_consent: owner.electronic_consent || false,
+ show_proxy_text: owner.show_proxy_text !== undefined ? owner.show_proxy_text : true,
+ mailing_address: initialMailing, alt_address_1: initialAlt1, alt_address_2: initialAlt2,
+ });
+ } else {
+ setFormData({
+ account_number: '', unit_id: '', owner_name: '', property_address: '',
+ email: '', phone: '', balance: '', electronic_consent: false, show_proxy_text: true,
+ mailing_address: '', alt_address_1: '', alt_address_2: '',
+ });
+ }
+ }
+ }, [open, owner]);
+
+ const handleChange = (field, value) => setFormData(prev => ({ ...prev, [field]: value }));
+
+ const handleSubmit = async () => {
+ if (!clientId) return;
+ if (!formData.owner_name.trim()) { toast({ variant: "destructive", title: "Error", description: "Owner Name is required." }); return; }
+ if (!formData.property_address.trim()) { toast({ variant: "destructive", title: "Error", description: "Property Address is required." }); return; }
+
+ setLoading(true);
+ try {
+ const { data: existingDuplicate, error: checkError } = await supabase
+ .from('property_owners').select('id').eq('client_id', clientId)
+ .ilike('property_address', formData.property_address.trim()).maybeSingle();
+ if (checkError) throw checkError;
+ if (existingDuplicate && (!owner || existingDuplicate.id !== owner.id)) throw new Error("DUPLICATE_ADDRESS");
+
+ const payload = {
+ client_id: clientId, account_number: formData.account_number, unit_id: formData.unit_id,
+ owner_name: formData.owner_name, property_address: formData.property_address,
+ email: formData.email, phone: formData.phone,
+ balance: formData.balance === '' ? 0 : parseFloat(formData.balance),
+ electronic_consent: formData.electronic_consent, show_proxy_text: formData.show_proxy_text,
+ mailing_address: formData.mailing_address || null,
+ alt_address_1: formData.alt_address_1 || null, alt_address_2: formData.alt_address_2 || null,
+ };
+
+ let error;
+ if (owner?.id) {
+ const { error: updateError } = await supabase.from('property_owners').update(payload).eq('id', owner.id);
+ error = updateError;
+ } else {
+ const { error: insertError } = await supabase.from('property_owners').insert([payload]);
+ error = insertError;
+ }
+ if (error) throw error;
+
+ toast({ title: "Success", description: owner?.id ? "Owner updated successfully." : "Owner added successfully." });
+ onSuccess();
+ onOpenChange(false);
+ } catch (error) {
+ console.error("Error saving owner:", error);
+ if (error.message === "DUPLICATE_ADDRESS") {
+ toast({ variant: "destructive", title: "Duplicate Address", description: "A property with this address already exists." });
+ } else if (error.code === '23505') {
+ toast({ variant: "destructive", title: "Duplicate Record", description: "A record with this address or account number already exists." });
+ } else {
+ toast({ variant: "destructive", title: "Error", description: error.message });
+ }
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+
+
+
+ {owner ? "Edit Owner" : "Add New Owner"}
+ {owner ? "Modify the details for this property owner." : "Enter the details for the new property owner."}
+
+
+
+
+ Owner Details
+ Addresses
+
+
+
+
+
+ Owner Name *
+ handleChange('owner_name', e.target.value)} placeholder="John Doe" />
+
+
+ Property Address *
+ handleChange('property_address', e.target.value)} placeholder="123 Main St, City, State, Zip" />
+
+
+
+ Current Balance ($)
+ handleChange('balance', e.target.value)} placeholder="0.00" />
+
+
+
+ handleChange('electronic_consent', checked)} />
+ Electronic Consent Received
+
+
+ handleChange('show_proxy_text', checked)} />
+ Include Proxy Text on Sign-in Sheet
+
+
+
+
+
+
+ Mailing Address (Primary)
+ handleChange('mailing_address', e.target.value)} placeholder={"123 Main St\nCity, State Zip"} className="min-h-[100px]" />
+
+
+ Alternate Address 1
+ handleChange('alt_address_1', e.target.value)} placeholder={"123 Vacation Ln\nBeach City, FL 33139"} className="min-h-[100px]" />
+
+
+ Alternate Address 2
+ handleChange('alt_address_2', e.target.value)} placeholder="Another Address..." className="min-h-[100px]" />
+
+
+
+
+
+ onOpenChange(false)}>Cancel
+
+ {loading && }
+
+ {owner ? "Save Changes" : "Add Owner"}
+
+
+
+
+ );
+}
diff --git a/src/components/OwnerNotificationTemplateDialog.jsx b/src/components/OwnerNotificationTemplateDialog.jsx
new file mode 100644
index 0000000..71cc3f3
--- /dev/null
+++ b/src/components/OwnerNotificationTemplateDialog.jsx
@@ -0,0 +1,114 @@
+
+import React, { useState, useEffect } from 'react';
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from '@/components/ui/dialog';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import { Textarea } from '@/components/ui/textarea';
+import { supabase } from '@/integrations/supabase/client';
+import { useToast } from '@/hooks/use-toast';
+import { useAuth } from '@/contexts/AuthContext';
+import { Loader2 } from 'lucide-react';
+
+const AVAILABLE_VARIABLES = [
+ { key: '{ownerName}', description: "Owner's Full Name" },
+ { key: '{propertyAddress}', description: 'Property Address' },
+ { key: '{date}', description: 'Current Date' },
+ { key: '{subject}', description: 'Email Subject' },
+ { key: '{message}', description: 'Custom Message' }
+];
+
+export default function OwnerNotificationTemplateDialog({ open, onOpenChange, template, onSuccess }) {
+ const { toast } = useToast();
+ const { user } = useAuth();
+ const [loading, setLoading] = useState(false);
+ const [formData, setFormData] = useState({ name: '', subject: '', body: '' });
+
+ useEffect(() => {
+ if (open) {
+ if (template) {
+ setFormData({ name: template.name || '', subject: template.subject || '', body: template.body || '' });
+ } else {
+ setFormData({ name: '', subject: '', body: '' });
+ }
+ }
+ }, [open, template]);
+
+ const handleChange = (e) => setFormData(prev => ({ ...prev, [e.target.name]: e.target.value }));
+ const insertVariable = (variable) => setFormData(prev => ({ ...prev, body: prev.body + variable }));
+
+ const handleSubmit = async (e) => {
+ e.preventDefault();
+ if (!formData.name || !formData.subject || !formData.body) {
+ toast({ variant: 'destructive', title: 'Error', description: 'All fields are required.' });
+ return;
+ }
+
+ setLoading(true);
+ try {
+ if (template?.id) {
+ const { error } = await supabase
+ .from('owner_notification_templates')
+ .update({ name: formData.name, subject: formData.subject, body: formData.body, updated_at: new Date().toISOString() })
+ .eq('id', template.id);
+ if (error) throw error;
+ toast({ title: 'Success', description: 'Template updated.' });
+ } else {
+ const { error } = await supabase
+ .from('owner_notification_templates')
+ .insert([{ name: formData.name, subject: formData.subject, body: formData.body, created_by: user.id }]);
+ if (error) throw error;
+ toast({ title: 'Success', description: 'Template created.' });
+ }
+ onSuccess();
+ onOpenChange(false);
+ } catch (error) {
+ console.error(error);
+ toast({ variant: 'destructive', title: 'Error', description: error.message });
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+
+
+
+ {template ? 'Edit Template' : 'New Template'}
+ Create a reusable email template for owner notifications.
+
+
+
+
+ Template Name
+
+
+
+ Email Subject
+
+
+
+
Email Body
+
+ Available Variables: (Click to insert)
+ {AVAILABLE_VARIABLES.map(v => (
+ insertVariable(v.key)} title={v.description}>
+ {v.key}
+
+ ))}
+
+
+
+
+
+ onOpenChange(false)}>Cancel
+
+ {loading ? : null}
+ {template ? 'Update Template' : 'Create Template'}
+
+
+
+
+
+ );
+}
diff --git a/src/components/OwnerUnitSelector.jsx b/src/components/OwnerUnitSelector.jsx
new file mode 100644
index 0000000..d796c2c
--- /dev/null
+++ b/src/components/OwnerUnitSelector.jsx
@@ -0,0 +1,98 @@
+import React, { useState, useEffect } from 'react';
+import { supabase } from '@/integrations/supabase/client';
+import { Label } from '@/components/ui/label';
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
+import { Badge } from '@/components/ui/badge';
+import { Button } from '@/components/ui/button';
+import { Loader2, X } from 'lucide-react';
+
+export default function OwnerUnitSelector({ clientId, value = { type: 'all', ids: [] }, onChange }) {
+ const [owners, setOwners] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [selectorOpen, setSelectorOpen] = useState(false);
+
+ useEffect(() => {
+ if (clientId) fetchOwners();
+ }, [clientId]);
+
+ const fetchOwners = async () => {
+ setLoading(true);
+ try {
+ const { data, error } = await supabase
+ .from('property_owners')
+ .select('id, owner_name, property_address, unit_id')
+ .eq('client_id', clientId)
+ .order('owner_name');
+ if (!error) setOwners(data || []);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleTypeChange = (type) => {
+ onChange({ type, ids: type === 'all' ? [] : value.ids });
+ };
+
+ const handleAddOwner = (id) => {
+ if (!value.ids.includes(id)) {
+ onChange({ ...value, ids: [...value.ids, id] });
+ }
+ };
+
+ const handleRemoveOwner = (id) => {
+ onChange({ ...value, ids: value.ids.filter(i => i !== id) });
+ };
+
+ if (loading) return Loading owners...
;
+
+ return (
+
+
+ Assignment Scope
+
+
+
+
+
+
+
+ All Owners / Units
+ Specific Owners / Units
+
+
+
+ {value.type === 'specific' && (
+
+
+
+
+
+
+ {owners.filter(o => !value.ids.includes(o.id)).map(o => (
+
+ {o.owner_name} {o.unit_id ? `(Unit ${o.unit_id})` : ''} - {o.property_address}
+
+ ))}
+
+
+
+
+ {value.ids.length === 0 && No owners selected. }
+ {value.ids.map(id => {
+ const owner = owners.find(o => o.id === id);
+ if (!owner) return null;
+ return (
+
+ {owner.owner_name}
+ handleRemoveOwner(id)}>
+
+
+
+ );
+ })}
+
+
+ )}
+
+ );
+}
\ No newline at end of file
diff --git a/src/components/OwnerUpdateConfirmDialog.jsx b/src/components/OwnerUpdateConfirmDialog.jsx
new file mode 100644
index 0000000..ef0a304
--- /dev/null
+++ b/src/components/OwnerUpdateConfirmDialog.jsx
@@ -0,0 +1,55 @@
+
+import React from 'react';
+import {
+ AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
+ AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
+} from "@/components/ui/alert-dialog";
+import { ScrollArea } from "@/components/ui/scroll-area";
+
+export default function OwnerUpdateConfirmDialog({ open, onOpenChange, diffs, onConfirm }) {
+ const fields = Object.entries(diffs);
+
+ return (
+
+
+
+ Confirm Updates
+
+ You are about to update the following fields for this owner. This action cannot be undone.
+
+
+
+
+
+
Changes Summary:
+
+
+ {fields.length > 0 ? (
+ fields.map(([field, value]) => (
+
+
+ {field.replace(/_/g, ' ')}:
+
+
+ {value === true ? 'Yes' : value === false ? 'No' : value || '(empty)'}
+
+
+ ))
+ ) : (
+
No changes detected.
+ )}
+
+
+
+
+
+
+ Cancel
+
+ Confirm Update
+
+
+
+
+ );
+}
diff --git a/src/components/OwnerUpdateDialog.jsx b/src/components/OwnerUpdateDialog.jsx
new file mode 100644
index 0000000..cc014a9
--- /dev/null
+++ b/src/components/OwnerUpdateDialog.jsx
@@ -0,0 +1,235 @@
+
+import React, { useState, useEffect } from 'react';
+import { useForm, Controller } from 'react-hook-form';
+import { toEST } from '@/lib/timezoneUtils';
+import { supabase } from '@/integrations/supabase/client';
+import { useToast } from '@/hooks/use-toast';
+import { Button } from '@/components/ui/button';
+import { Label } from '@/components/ui/label';
+import { Input } from '@/components/ui/input';
+import { Textarea } from '@/components/ui/textarea';
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
+import { Loader2, AlertTriangle, DollarSign, Check, X } from 'lucide-react';
+import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
+import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem } from "@/components/ui/command";
+import { cn } from '@/lib/utils';
+import { Badge } from '@/components/ui/badge';
+
+export function OwnerUpdateDialog({ update, open, onOpenChange, onSuccess }) {
+ const { toast } = useToast();
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const [availableViolations, setAvailableViolations] = useState([]);
+ const [availableCollections, setAvailableCollections] = useState([]);
+ const [loadingRelations, setLoadingRelations] = useState(false);
+
+ const { control, handleSubmit, reset, setValue, watch } = useForm({
+ defaultValues: {
+ posted_at: toEST(new Date(), "yyyy-MM-dd'T'HH:mm"),
+ content: '',
+ collection_ids: [],
+ violation_ids: []
+ }
+ });
+
+ const currentCollectionIds = watch('collection_ids');
+ const currentViolationIds = watch('violation_ids');
+
+ useEffect(() => {
+ if (update && open) {
+ reset({
+ posted_at: toEST(update.posted_at, "yyyy-MM-dd'T'HH:mm"),
+ content: update.content || '',
+ collection_ids: update.collection_ids || [],
+ violation_ids: update.violation_ids || []
+ });
+ fetchRelatedItems(update.property_id);
+ }
+ }, [update, open, reset]);
+
+ const fetchRelatedItems = async (propertyId) => {
+ setLoadingRelations(true);
+ try {
+ const { data: property } = await supabase
+ .from('property_owners').select('property_address').eq('id', propertyId).single();
+
+ if (property) {
+ const address = property.property_address;
+ const { data: violations } = await supabase
+ .from('violations').select('id, violation_type, status, created_at, address')
+ .eq('address', address).order('created_at', { ascending: false });
+ setAvailableViolations(violations || []);
+
+ const { data: collections } = await supabase
+ .from('collections').select('id, amount_owed, status, created_at')
+ .order('created_at', { ascending: false });
+ setAvailableCollections(collections || []);
+ }
+ } catch (error) {
+ console.error("Error fetching related items:", error);
+ } finally {
+ setLoadingRelations(false);
+ }
+ };
+
+ const onSubmit = async (data) => {
+ if (!update) return;
+ setIsSubmitting(true);
+ try {
+ const { error } = await supabase
+ .from('owner_updates')
+ .update({
+ content: data.content,
+ posted_at: new Date(data.posted_at).toISOString(),
+ collection_ids: data.collection_ids,
+ violation_ids: data.violation_ids
+ })
+ .eq('id', update.id);
+ if (error) throw error;
+ toast({ title: "Updated", description: "Update modified successfully." });
+ onSuccess();
+ onOpenChange(false);
+ } catch (err) {
+ console.error(err);
+ toast({ variant: "destructive", title: "Error", description: "Failed to update record." });
+ } finally {
+ setIsSubmitting(false);
+ }
+ };
+
+ return (
+
+
+
+ Edit Update
+
+
+
+
+ Date & Time
+ }
+ />
+
+
+
Property
+
+ {update?.property?.property_address || 'Unknown'}
+
+
+
+
+
+
+
+ Linked Violations
+
+
+
+
+
+ {currentViolationIds.length > 0
+ ? currentViolationIds.map(id => {
+ const v = availableViolations.find(v => v.id === id);
+ return v ? {v.violation_type} : null;
+ })
+ : No violations linked }
+
+
+
+
+
+
+
+ No violations found.
+
+ {availableViolations.map((violation) => (
+ {
+ const current = currentViolationIds;
+ if (current.includes(violation.id)) setValue('violation_ids', current.filter(id => id !== violation.id));
+ else setValue('violation_ids', [...current, violation.id]);
+ }}
+ >
+
+
+ {violation.violation_type}
+ {violation.status}
+
+
+ ))}
+
+
+
+
+
+
+
+
+ Linked Collections
+
+
+
+
+
+ {currentCollectionIds.length > 0
+ ? currentCollectionIds.map(id => {
+ const c = availableCollections.find(c => c.id === id);
+ return c ? {c.status || 'Case'} : null;
+ })
+ : No collections linked }
+
+
+
+
+
+
+
+ No collections found.
+
+ {availableCollections.map((collection) => (
+ {
+ const current = currentCollectionIds;
+ if (current.includes(collection.id)) setValue('collection_ids', current.filter(id => id !== collection.id));
+ else setValue('collection_ids', [...current, collection.id]);
+ }}
+ >
+
+
+ {collection.status || 'Collection Case'}
+ ${Number(collection.amount_owed || 0).toFixed(2)}
+
+
+ ))}
+
+
+
+
+
+
+
+
+ Content
+ (
+
+ )}
+ />
+
+
+
+ onOpenChange(false)}>Cancel
+
+ {isSubmitting ? : null}
+ Save Changes
+
+
+
+
+
+ );
+}
diff --git a/src/components/ParentCategoryDialog.jsx b/src/components/ParentCategoryDialog.jsx
new file mode 100644
index 0000000..2a04c3d
--- /dev/null
+++ b/src/components/ParentCategoryDialog.jsx
@@ -0,0 +1,89 @@
+
+import React, { useEffect } from 'react';
+import { useForm } from 'react-hook-form';
+import { supabase } from '@/integrations/supabase/client';
+import { useToast } from '@/hooks/use-toast';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import { Textarea } from '@/components/ui/textarea';
+import {
+ Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
+} from '@/components/ui/dialog';
+import { Loader2 } from 'lucide-react';
+
+export default function ParentCategoryDialog({ open, onOpenChange, category, clientId, onSuccess }) {
+ const { toast } = useToast();
+ const { register, handleSubmit, reset, setValue, formState: { errors, isSubmitting } } = useForm({
+ defaultValues: { category_name: '', description: '' }
+ });
+
+ useEffect(() => {
+ if (open) {
+ if (category) {
+ setValue('category_name', category.category_name);
+ setValue('description', category.description || '');
+ } else {
+ reset({ category_name: '', description: '' });
+ }
+ }
+ }, [open, category, setValue, reset]);
+
+ const onSubmit = async (data) => {
+ if (!clientId) {
+ toast({ variant: "destructive", title: "Error", description: "No association selected." });
+ return;
+ }
+
+ try {
+ if (category) {
+ const { error } = await supabase
+ .from('parent_chart_of_accounts')
+ .update({ category_name: data.category_name, description: data.description, updated_at: new Date().toISOString() })
+ .eq('id', category.id);
+ if (error) throw error;
+ toast({ title: "Category Updated", description: "Parent category updated successfully." });
+ } else {
+ const { error } = await supabase
+ .from('parent_chart_of_accounts')
+ .insert({ client_id: clientId, category_name: data.category_name, description: data.description });
+ if (error) throw error;
+ toast({ title: "Category Created", description: "New parent category created successfully." });
+ }
+ onSuccess();
+ onOpenChange(false);
+ } catch (error) {
+ console.error('Error saving category:', error);
+ toast({ variant: "destructive", title: "Error", description: error.message || "Failed to save category." });
+ }
+ };
+
+ return (
+
+
+
+ {category ? 'Edit Parent Category' : 'New Parent Category'}
+ {category ? 'Update the category details below.' : 'Create a new parent category to group accounts.'}
+
+
+
+ Category Name
+
+ {errors.category_name && {errors.category_name.message} }
+
+
+ Description (Optional)
+
+
+
+ onOpenChange(false)}>Cancel
+
+ {isSubmitting && }
+ {isSubmitting ? 'Saving...' : (category ? 'Save Changes' : 'Create Category')}
+
+
+
+
+
+ );
+}
diff --git a/src/components/ParkingRecordEscalationDialog.jsx b/src/components/ParkingRecordEscalationDialog.jsx
new file mode 100644
index 0000000..17e5a91
--- /dev/null
+++ b/src/components/ParkingRecordEscalationDialog.jsx
@@ -0,0 +1,66 @@
+
+import React from 'react';
+import {
+ Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
+} from "@/components/ui/dialog";
+import { Button } from "@/components/ui/button";
+import { AlertTriangle, ArrowRight } from "lucide-react";
+
+export default function ParkingRecordEscalationDialog({
+ isOpen, onOpenChange, record, onConfirm, isEscalating
+}) {
+ if (!record) return null;
+
+ const getLevelLabel = (level) => {
+ switch (level) {
+ case 'first_warning': return 'First Warning';
+ case 'second_warning': return 'Second Warning';
+ case 'final_warning': return 'Final Warning';
+ default: return 'First Warning';
+ }
+ };
+
+ const safeLevel = record.warning_level || 'first_warning';
+ const currentLabel = getLevelLabel(safeLevel);
+ const nextLabel = safeLevel === 'first_warning' ? getLevelLabel('second_warning') : getLevelLabel('final_warning');
+
+ return (
+
+
+
+
+
+ Confirm Escalation
+
+
+ Are you sure you want to escalate this parking violation?
+
+
+
+
+
+ Current
+ {currentLabel}
+
+
+
+ Next Level
+ {nextLabel}
+
+
+
+
+
Vehicle: {record.vehicle_plate} ({record.vehicle_make} {record.vehicle_model})
+
Citation: {record.citation}
+
+
+
+ onOpenChange(false)} disabled={isEscalating}>Cancel
+ onConfirm(record.id, safeLevel)} disabled={isEscalating}>
+ {isEscalating ? 'Escalating...' : 'Confirm Escalation'}
+
+
+
+
+ );
+}
diff --git a/src/components/PayableEditDialog.jsx b/src/components/PayableEditDialog.jsx
new file mode 100644
index 0000000..b23bdd9
--- /dev/null
+++ b/src/components/PayableEditDialog.jsx
@@ -0,0 +1,126 @@
+
+import React, { useEffect } from 'react';
+import { useForm } from 'react-hook-form';
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
+import { useToast } from '@/hooks/use-toast';
+import { Loader2, AlertTriangle } from 'lucide-react';
+import { format, parseISO, isValid } from 'date-fns';
+import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
+
+export function PayableEditDialog({ open, onOpenChange, payable, onSave, clients }) {
+ const { register, handleSubmit, reset, setValue, formState: { errors, isSubmitting } } = useForm();
+ const { toast } = useToast();
+
+ const isReadOnly = payable?.removal_eligible || payable?.deleted_at;
+
+ useEffect(() => {
+ if (payable) {
+ setValue('bill_date', payable.bill_date);
+ setValue('due_date', payable.due_date);
+ setValue('payee', payable.payee);
+ setValue('amount', payable.amount);
+ setValue('expense_account', payable.expense_account || '');
+ setValue('client_id', payable.client_id || 'none');
+ } else {
+ reset({ bill_date: new Date().toISOString().split('T')[0], due_date: new Date().toISOString().split('T')[0], client_id: 'none', expense_account: '' });
+ }
+ }, [payable, setValue, reset, open]);
+
+ const onSubmit = async (data) => {
+ if (isReadOnly) return;
+ try {
+ const payload = { ...data, client_id: data.client_id === 'none' ? null : data.client_id, amount: parseFloat(data.amount) };
+ await onSave(payload);
+ onOpenChange(false);
+ } catch (error) {
+ console.error(error);
+ }
+ };
+
+ const formatDate = (dateString) => {
+ if (!dateString) return '-';
+ const date = parseISO(dateString);
+ return isValid(date) ? format(date, 'MMM dd, yyyy h:mm a') : '-';
+ };
+
+ return (
+
+
+
+ {payable ? 'Edit Payable' : 'Add Payable'}
+ {payable?.is_paid && (
+
+ Marked Paid: {formatDate(payable.marked_paid_date)}
+
+ )}
+
+
+ {isReadOnly && (
+
+
+ Read Only
+ This payable has been archived/removed and cannot be edited.
+
+ )}
+
+
+
+
+
+ Payee
+
+ {errors.payee && Required }
+
+
+
+ Expense Account
+
+
+
+
+ Association
+ setValue('client_id', val)} defaultValue={payable?.client_id || 'none'}>
+
+
+ None / General
+ {clients?.map(client => (
+ {client.name}
+ ))}
+
+
+
+
+
+ Amount ($)
+
+ {errors.amount && Required }
+
+
+
+ onOpenChange(false)}>Cancel
+ {!isReadOnly && (
+
+ {isSubmitting && }
+ {payable ? 'Update' : 'Create'}
+
+ )}
+
+
+
+
+ );
+}
diff --git a/src/components/PayablesImportDialog.jsx b/src/components/PayablesImportDialog.jsx
new file mode 100644
index 0000000..498f5fa
--- /dev/null
+++ b/src/components/PayablesImportDialog.jsx
@@ -0,0 +1,301 @@
+
+import React, { useState, useRef } from 'react';
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, DialogFooter } from '@/components/ui/dialog';
+import { Button } from '@/components/ui/button';
+import { Upload, FileUp, AlertCircle, CheckCircle2, Download, XCircle, RefreshCw } from 'lucide-react';
+import { useToast } from '@/hooks/use-toast';
+import { supabase } from '@/integrations/supabase/client';
+import { usePayables } from '@/hooks/usePayables';
+import * as XLSX from 'xlsx';
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
+import { ScrollArea } from '@/components/ui/scroll-area';
+import { Badge } from '@/components/ui/badge';
+import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
+
+export function PayablesImportDialog({ onImport }) {
+ const [open, setOpen] = useState(false);
+ const [step, setStep] = useState('upload');
+ const [file, setFile] = useState(null);
+ const [validRows, setValidRows] = useState([]);
+ const [validationErrors, setValidationErrors] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [clients, setClients] = useState([]);
+ const fileInputRef = useRef(null);
+ const { toast } = useToast();
+ const { bulkAddPayables } = usePayables();
+
+ const handleOpenChange = (newOpen) => {
+ setOpen(newOpen);
+ if (!newOpen) {
+ setTimeout(() => { setStep('upload'); setFile(null); setValidRows([]); setValidationErrors([]); }, 300);
+ } else {
+ fetchClients();
+ }
+ };
+
+ const fetchClients = async () => {
+ try {
+ const { data, error } = await supabase.from('associations').select('id, name').eq('status', 'active');
+ if (error) throw error;
+ if (data) setClients(data);
+ } catch (err) {
+ console.error("Failed to fetch associations for import matching", err);
+ toast({ variant: "destructive", title: "Warning", description: "Could not load association list." });
+ }
+ };
+
+ const handleFileChange = (e) => {
+ const selectedFile = e.target.files[0];
+ if (selectedFile) { setFile(selectedFile); parseFile(selectedFile); }
+ };
+
+ const KNOWN_HEADERS = {
+ bill_date: ['bill date', 'billdate', 'date', 'invoice date', 'inv date'],
+ due_date: ['due date', 'duedate', 'due'],
+ payee: ['payee', 'vendor', 'company', 'recipient', 'supplier'],
+ client_name: ['client', 'association', 'client association', 'property', 'client-association', 'community'],
+ amount: ['amount', 'total', 'cost', 'price', 'balance', 'total amount'],
+ expense_account: ['expense account', 'account', 'category', 'gl code', 'expense', 'account number']
+ };
+
+ const mapHeaders = (headerRow) => {
+ const map = {};
+ headerRow.forEach((h, index) => {
+ if (!h) return;
+ const text = String(h).toLowerCase().trim();
+ for (const [key, variations] of Object.entries(KNOWN_HEADERS)) {
+ if (variations.some(v => text === v || text.includes(v))) {
+ if (map[key] === undefined) map[key] = index;
+ }
+ }
+ });
+ return map;
+ };
+
+ const validateImportData = async (rawRows, clientsList) => {
+ const validRows = [];
+ const errors = [];
+
+ rawRows.forEach((row, index) => {
+ if (!row.payee) { errors.push({ row: index + 2, error: 'Missing payee' }); return; }
+ if (!row.amount || isNaN(parseFloat(row.amount))) { errors.push({ row: index + 2, error: 'Invalid amount' }); return; }
+
+ let clientId = null;
+ if (row.client_name) {
+ const match = clientsList.find(c => c.name.toLowerCase() === String(row.client_name).toLowerCase().trim());
+ if (match) clientId = match.id;
+ }
+
+ validRows.push({
+ bill_date: row.bill_date || null,
+ due_date: row.due_date || null,
+ payee: String(row.payee).trim(),
+ amount: parseFloat(row.amount),
+ expense_account: row.expense_account || null,
+ client_id: clientId
+ });
+ });
+
+ return { validRows, errors };
+ };
+
+ const parseFile = (file) => {
+ setLoading(true);
+ const reader = new FileReader();
+ reader.onload = async (e) => {
+ try {
+ const data = new Uint8Array(e.target.result);
+ const workbook = XLSX.read(data, { type: 'array' });
+ const sheetName = workbook.SheetNames[0];
+ const worksheet = workbook.Sheets[sheetName];
+ const jsonData = XLSX.utils.sheet_to_json(worksheet, { header: 1 });
+
+ if (!jsonData || jsonData.length < 2) {
+ toast({ variant: "destructive", title: "Empty File", description: "File contains no data rows." });
+ setLoading(false); setFile(null); return;
+ }
+
+ const headerRow = jsonData[0];
+ const colMap = mapHeaders(headerRow);
+
+ const missingCols = [];
+ if (colMap.payee === undefined) missingCols.push('Payee');
+ if (colMap.amount === undefined) missingCols.push('Amount');
+
+ if (missingCols.length > 0) {
+ toast({ variant: "destructive", title: "Invalid Format", description: `Could not find required columns: ${missingCols.join(', ')}.` });
+ setLoading(false); setFile(null); return;
+ }
+
+ const rawRows = jsonData.slice(1).map(row => {
+ const getValue = (idx) => idx !== undefined && row[idx] !== undefined ? row[idx] : null;
+ return {
+ bill_date: getValue(colMap.bill_date), due_date: getValue(colMap.due_date),
+ payee: getValue(colMap.payee), client_name: getValue(colMap.client_name),
+ amount: getValue(colMap.amount), expense_account: getValue(colMap.expense_account)
+ };
+ }).filter(r => r.payee || r.amount);
+
+ const { validRows: vRows, errors } = await validateImportData(rawRows, clients);
+ setValidRows(vRows);
+ setValidationErrors(errors);
+ setStep('preview');
+ } catch (err) {
+ console.error("Parse Error", err);
+ toast({ variant: "destructive", title: "Error", description: "Failed to parse file." });
+ setFile(null);
+ } finally {
+ setLoading(false);
+ }
+ };
+ reader.onerror = () => { toast({ variant: "destructive", title: "Error", description: "Failed to read file." }); setLoading(false); setFile(null); };
+ reader.readAsArrayBuffer(file);
+ };
+
+ const processImport = async () => {
+ if (validRows.length === 0) return;
+ setLoading(true);
+ try {
+ if (onImport) await onImport(validRows);
+ else await bulkAddPayables(validRows);
+ setStep('results');
+ toast({ title: "Success", description: "Import completed successfully." });
+ } catch (err) {
+ console.error(err);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const downloadTemplate = () => {
+ const headers = ['Bill Date', 'Due Date', 'Payee', 'Client Association', 'Expense Account', 'Amount'];
+ const sample = ['2024-05-15', '2024-06-15', 'Sample Vendor Inc.', 'Sunset Villas', '5010 - Utilities', '150.00'];
+ const ws = XLSX.utils.aoa_to_sheet([headers, sample]);
+ const wb = XLSX.utils.book_new();
+ XLSX.utils.book_append_sheet(wb, ws, "Payables Template");
+ XLSX.writeFile(wb, "Payables_Import_Template.xlsx");
+ };
+
+ const renderUploadStep = () => (
+
+
+
+
+
fileInputRef.current?.click()} disabled={loading}>
+ {loading ? : null}
+ {loading ? 'Processing...' : 'Select File to Import'}
+
+
Supports CSV, Excel (.xlsx, .xls)
+
+
+
+
+
+
+ Required: Payee, Amount
+ Optional: Bill Date, Due Date, Client Association, Expense Account
+ Date format: MM/DD/YYYY or YYYY-MM-DD
+
+
+
+ Download Standard Template
+
+
+
+
+ );
+
+ const renderPreviewStep = () => (
+
+
+
Import Summary
+
+ {validRows.length} Valid Rows
+ {validationErrors.length > 0 && {validationErrors.length} Errors }
+
+
+ {validationErrors.length > 0 && (
+
+
+ Attention Needed
+ {validationErrors.length} rows were skipped due to errors.
+
+ )}
+
+
+
+ Bill Date Payee Association Amount
+
+
+
+
+
+ {validationErrors.map((err, i) => (
+
+
+ Row {err.row}: {err.error}
+
+ ))}
+ {validRows.map((row, i) => {
+ const clientName = clients.find(c => c.id === row.client_id)?.name || (row.client_id ? 'Unknown ID' : 'Unassigned');
+ return (
+
+
+ {row.bill_date || '-'}
+ {row.payee}
+ {clientName}
+ ${row.amount}
+
+ );
+ })}
+
+
+
+
+
+ );
+
+ const renderResultsStep = () => (
+
+
+
+
+
+
Import Complete
+
Successfully imported {validRows.length} payables.
+
+
handleOpenChange(false)} className="min-w-[120px]">Close
+
+ );
+
+ return (
+
+
+ Import Payables
+
+
+
+
+ {step === 'upload' && 'Import Payables'}
+ {step === 'preview' && 'Review Data'}
+ {step === 'results' && 'Import Results'}
+
+
+ {step === 'upload' && renderUploadStep()}
+ {step === 'preview' && renderPreviewStep()}
+ {step === 'results' && renderResultsStep()}
+ {step === 'preview' && (
+
+ { setStep('upload'); setFile(null); }}>Back to Upload
+
+ handleOpenChange(false)}>Cancel
+
+ {loading ? 'Importing...' : `Import ${validRows.length} Payables`}
+
+
+
+ )}
+
+
+ );
+}
diff --git a/src/components/PaymentFormDialog.jsx b/src/components/PaymentFormDialog.jsx
new file mode 100644
index 0000000..b96d77c
--- /dev/null
+++ b/src/components/PaymentFormDialog.jsx
@@ -0,0 +1,289 @@
+
+import React, { useEffect, useState, useMemo } from 'react';
+import { useForm } from 'react-hook-form';
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from '@/components/ui/dialog';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import { Loader2, DollarSign, AlertCircle, Plus, Trash2 } from 'lucide-react';
+import { supabase } from '@/integrations/supabase/client';
+import { syncPaymentToZoho } from '@/lib/zohoFinancialSync';
+import { useToast } from '@/hooks/use-toast';
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
+import { Alert, AlertDescription } from '@/components/ui/alert';
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
+
+export default function PaymentFormDialog({ open, onOpenChange, vendorId, clientId, onSuccess }) {
+ const { register, handleSubmit, reset, watch, formState: { errors, isSubmitting } } = useForm({
+ defaultValues: { amount: '', payment_date: new Date().toISOString().split('T')[0], reference: '', notes: '' }
+ });
+ const paymentAmount = watch('amount');
+ const { toast } = useToast();
+
+ const [vendors, setVendors] = useState([]);
+ const [clients, setClients] = useState([]);
+ const [selectedVendorId, setSelectedVendorId] = useState(vendorId || '');
+ const [selectedClientId, setSelectedClientId] = useState(clientId || '');
+ const [allocations, setAllocations] = useState([]);
+ const [accounts, setAccounts] = useState([]);
+ const [accountsLoading, setAccountsLoading] = useState(false);
+
+ useEffect(() => {
+ if (open) {
+ reset({ amount: '', payment_date: new Date().toISOString().split('T')[0], reference: '', notes: '' });
+ setSelectedVendorId(vendorId || '');
+ setSelectedClientId(clientId || '');
+ setAllocations([]);
+ fetchInitialData();
+ }
+ }, [open, vendorId, clientId, reset]);
+
+ const fetchInitialData = async () => {
+ const [{ data: vData }, { data: cData }] = await Promise.all([
+ supabase.from('vendors').select('id, vendor_name').order('vendor_name'),
+ supabase.from('associations').select('id, name').eq('status', 'active').order('name')
+ ]);
+ setVendors(vData || []);
+ setClients(cData || []);
+ };
+
+ useEffect(() => {
+ if (selectedClientId) {
+ setAccountsLoading(true);
+ supabase.from('chart_of_accounts').select('*, association_ids').eq('is_active', true).order('account_number')
+ .then(({ data }) => {
+ const filtered = (data || []).filter(a => {
+ const ids = a.association_ids || [];
+ return ids.includes(selectedClientId) || a.association_id === selectedClientId;
+ });
+ setAccounts(filtered);
+ setAccountsLoading(false);
+ });
+ }
+ }, [selectedClientId]);
+
+ const handleAddRow = () => {
+ setAllocations([...allocations, { id: crypto.randomUUID(), accountId: '', balance: 0, amount: '' }]);
+ };
+
+ const handleRemoveRow = (id) => {
+ setAllocations(allocations.filter(a => a.id !== id));
+ };
+
+ const handleAllocationChange = (id, field, value) => {
+ setAllocations(allocations.map(a => {
+ if (a.id === id) {
+ const updated = { ...a, [field]: value };
+ if (field === 'accountId') {
+ const acc = accounts.find(ac => ac.id === value);
+ updated.balance = acc ? parseFloat(acc.current_balance || 0) : 0;
+ }
+ return updated;
+ }
+ return a;
+ }));
+ };
+
+ const totalAllocated = useMemo(() => allocations.reduce((sum, a) => sum + (parseFloat(a.amount) || 0), 0), [allocations]);
+ const paymentTotal = parseFloat(paymentAmount) || 0;
+ const allocationDifference = paymentTotal - totalAllocated;
+ const isAllocationValid = Math.abs(allocationDifference) < 0.01 &&
+ allocations.every(a => a.accountId && parseFloat(a.amount) > 0 && parseFloat(a.amount) <= a.balance) &&
+ allocations.length > 0;
+
+ const onSubmit = async (data) => {
+ if (!selectedVendorId) { toast({ variant: 'destructive', title: 'Error', description: 'Vendor is required.' }); return; }
+ if (!selectedClientId) { toast({ variant: 'destructive', title: 'Error', description: 'Association is required.' }); return; }
+ if (allocations.length > 0 && !isAllocationValid) {
+ toast({ variant: 'destructive', title: 'Error', description: 'Allocations must match total and not exceed balances.' }); return;
+ }
+
+ try {
+ const payload = {
+ vendor_id: selectedVendorId, association_id: selectedClientId,
+ amount: parseFloat(data.amount), payment_date: data.payment_date,
+ reference: data.reference, notes: data.notes
+ };
+
+ const { data: insertedPayment, error: paymentError } = await supabase.from('admin_payments').insert([{
+ association_id: selectedClientId, amount: parseFloat(data.amount),
+ payment_date: data.payment_date, reference_number: data.reference,
+ description: data.notes, status: 'completed'
+ }]).select('id').single();
+ if (paymentError) throw paymentError;
+ // Best-effort: push to Zoho (records into banking journal)
+ if (insertedPayment?.id) {
+ syncPaymentToZoho(insertedPayment.id).catch((e) => console.warn('Zoho payment sync failed:', e));
+ }
+
+ if (allocations.length > 0) {
+ const { data: userData } = await supabase.auth.getUser();
+ const userId = userData?.user?.id;
+
+ const journalEntries = allocations.map(alloc => ({
+ association_id: selectedClientId, date: data.payment_date,
+ description: `Payment Allocation: ${data.reference || 'Vendor Payment'}`,
+ amount: parseFloat(alloc.amount), type: 'debit',
+ chart_of_account_id: alloc.accountId, created_by: userId
+ }));
+
+ const { error: jeError } = await supabase.from('journal_entries').insert(journalEntries);
+ if (jeError) {
+ console.error("Journal Entry Error:", jeError);
+ toast({ variant: 'destructive', title: 'Warning', description: 'Payment saved, but allocations failed to post.' });
+ }
+ }
+
+ toast({ title: 'Success', description: 'Payment recorded successfully.' });
+ onSuccess?.();
+ onOpenChange(false);
+ } catch (err) {
+ console.error(err);
+ toast({ variant: 'destructive', title: 'Error', description: 'Failed to record payment.' });
+ }
+ };
+
+ const formatCurrency = (val) => new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(val || 0);
+
+ return (
+
+
+
+ Record Payment
+ Record an outgoing payment and apply it to outstanding balances.
+
+
+
+
+ Association *
+
+
+ {clients.map(c => {c.name} )}
+
+
+
+ Vendor *
+
+
+ {vendors.map(v => {v.vendor_name} )}
+
+
+
+
+
+
+
Total Amount *
+
+
+
+
+ {errors.amount &&
Required }
+
+
+ Payment Date *
+
+ {errors.payment_date && Required }
+
+
+
+
+
+
+
+
+
Apply payment to balances
+
Allocate the payment amount across outstanding accounts.
+
+
+ Add row
+
+
+
+ {!selectedClientId ? (
+
+
+ Please select an Association to view and apply to balances.
+
+ ) : (
+
+
+
+ {allocations.length > 0 && (
+
+
+
+
Payment total:
+
Total allocated:
+
0.01 ? 'text-destructive' : 'text-emerald-600'}`}>Difference:
+
+
+
{formatCurrency(paymentTotal)}
+
{formatCurrency(totalAllocated)}
+
0.01 ? 'text-destructive' : 'text-emerald-600'}>{formatCurrency(allocationDifference)}
+
+
+ {Math.abs(allocationDifference) > 0.01 && (
+
+ The allocated amount must equal the payment total.
+
+ )}
+
+ )}
+
+ )}
+
+
+
+ onOpenChange(false)}>Cancel
+ 0 && !isAllocationValid)}>
+ {isSubmitting && }
+ Save Payment
+
+
+
+
+
+ );
+}
diff --git a/src/components/PaymentItemDialog.jsx b/src/components/PaymentItemDialog.jsx
new file mode 100644
index 0000000..0069221
--- /dev/null
+++ b/src/components/PaymentItemDialog.jsx
@@ -0,0 +1,62 @@
+
+import React from 'react';
+import { useForm } from 'react-hook-form';
+import { Loader2, Plus } from 'lucide-react';
+import { Button } from '@/components/ui/button';
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import { Textarea } from '@/components/ui/textarea';
+import { usePaymentPlans } from '@/hooks/usePaymentPlans';
+
+export function PaymentItemDialog({ open, onOpenChange, planId, onSuccess }) {
+ const { register, handleSubmit, reset, formState: { errors } } = useForm();
+ const { addPaymentItem, loading } = usePaymentPlans();
+
+ const onSubmit = async (data) => {
+ if (!planId) return;
+ const result = await addPaymentItem({
+ payment_plan_id: planId, description: data.description,
+ amount: parseFloat(data.amount), due_date: data.due_date, notes: data.notes
+ });
+ if (result) { reset(); onOpenChange(false); if (onSuccess) onSuccess(); }
+ };
+
+ return (
+
+
+ Add Payment Item
+
+
+
Description *
+
+ {errors.description &&
{errors.description.message}
}
+
+
+
+
Amount ($) *
+
+ {errors.amount &&
{errors.amount.message}
}
+
+
+
Due Date (EST) *
+
+ {errors.due_date &&
{errors.due_date.message}
}
+
+
+
+ Notes
+
+
+
+ onOpenChange(false)}>Cancel
+
+ {loading ? : }
+ Add Item
+
+
+
+
+
+ );
+}
diff --git a/src/components/PaymentPlanDialog.jsx b/src/components/PaymentPlanDialog.jsx
new file mode 100644
index 0000000..c86a0c8
--- /dev/null
+++ b/src/components/PaymentPlanDialog.jsx
@@ -0,0 +1,80 @@
+
+import React, { useEffect, useState } from 'react';
+import { useForm } from 'react-hook-form';
+import { Loader2, Save } from 'lucide-react';
+import { Button } from '@/components/ui/button';
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import { Textarea } from '@/components/ui/textarea';
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
+import { usePaymentPlans } from '@/hooks/usePaymentPlans';
+import { useAuth } from '@/contexts/AuthContext';
+import { supabase } from '@/integrations/supabase/client';
+
+export function PaymentPlanDialog({ open, onOpenChange, onSuccess }) {
+ const { register, handleSubmit, reset, setValue, formState: { errors } } = useForm();
+ const { createPaymentPlan, loading } = usePaymentPlans();
+ const { user } = useAuth();
+ const [clients, setClients] = useState([]);
+ const [loadingClients, setLoadingClients] = useState(false);
+
+ useEffect(() => {
+ if (open) {
+ const fetchClients = async () => {
+ setLoadingClients(true);
+ const { data } = await supabase.from('associations').select('id, name').eq('status', 'active').order('name');
+ setClients(data || []);
+ setLoadingClients(false);
+ };
+ fetchClients();
+ }
+ }, [open]);
+
+ const onSubmit = async (data) => {
+ const associationId = data.association_id;
+ if (!associationId) return;
+
+ const result = await createPaymentPlan({
+ title: data.title, description: data.description,
+ association_id: associationId, created_by: user?.id, status: 'active'
+ });
+
+ if (result) { reset(); onOpenChange(false); if (onSuccess) onSuccess(); }
+ };
+
+ return (
+
+
+ Create New Payment Plan
+
+
+
Association *
+
setValue('association_id', val)}>
+
+ {clients.map(client => {client.name} )}
+
+
+ {errors.association_id &&
{errors.association_id.message}
}
+
+
+
Plan Title *
+
+ {errors.title &&
{errors.title.message}
}
+
+
+ Description
+
+
+
+ onOpenChange(false)}>Cancel
+
+ {loading ? : }
+ Create Plan
+
+
+
+
+
+ );
+}
diff --git a/src/components/PaymentPlanExportDialog.jsx b/src/components/PaymentPlanExportDialog.jsx
new file mode 100644
index 0000000..f5c837a
--- /dev/null
+++ b/src/components/PaymentPlanExportDialog.jsx
@@ -0,0 +1,120 @@
+
+import React, { useState } from 'react';
+import { Download, Loader2 } from 'lucide-react';
+import { format } from 'date-fns';
+import Papa from 'papaparse';
+import { Button } from '@/components/ui/button';
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
+import { Label } from '@/components/ui/label';
+import { Input } from '@/components/ui/input';
+import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
+import { toESTDateDisplay } from '@/lib/timezoneUtils';
+import { useToast } from '@/hooks/use-toast';
+
+export function PaymentPlanExportDialog({ open, onOpenChange, paymentPlans }) {
+ const [filterType, setFilterType] = useState('all');
+ const [startDate, setStartDate] = useState('');
+ const [endDate, setEndDate] = useState('');
+ const [isExporting, setIsExporting] = useState(false);
+ const { toast } = useToast();
+
+ const getFilteredItems = () => {
+ let items = [];
+ paymentPlans.forEach(plan => {
+ if (plan.items && Array.isArray(plan.items)) {
+ plan.items.forEach(item => {
+ items.push({ ...item, planTitle: plan.title, planStatus: plan.status, clientName: plan.client?.name || 'N/A' });
+ });
+ }
+ });
+ if (filterType === 'active') items = items.filter(i => i.planStatus === 'active');
+ else if (filterType === 'custom' && startDate && endDate) {
+ const start = new Date(startDate); const end = new Date(endDate);
+ items = items.filter(i => { const d = new Date(i.due_date); return d >= start && d <= end; });
+ }
+ return items.sort((a, b) => new Date(a.due_date) - new Date(b.due_date));
+ };
+
+ const filteredItems = getFilteredItems();
+ const totalAmount = filteredItems.reduce((sum, item) => sum + (parseFloat(item.amount) || 0), 0);
+
+ const handleExport = async () => {
+ try {
+ setIsExporting(true);
+ const csvData = filteredItems.map(item => ({
+ 'Payment Plan': item.planTitle, 'Association': item.clientName,
+ 'Due Date (EST)': toESTDateDisplay(item.due_date), 'Description': item.description,
+ 'Amount Due': parseFloat(item.amount).toFixed(2),
+ 'Status': item.received_date ? 'Paid' : 'Pending',
+ 'Received Date': item.received_date ? toESTDateDisplay(item.received_date) : '-'
+ }));
+ const csv = Papa.unparse(csvData);
+ const { saveCsv } = await import('@/lib/saveFile');
+ const saved = await saveCsv(csv, `payment_plans_export_${format(new Date(), 'yyyy-MM-dd')}.csv`);
+ if (saved) {
+ toast({ title: "Export Successful", description: `Exported ${filteredItems.length} records to CSV.` });
+ onOpenChange(false);
+ }
+ } catch (error) {
+ console.error("Export failed:", error);
+ toast({ variant: "destructive", title: "Export Failed", description: "Error generating CSV." });
+ } finally { setIsExporting(false); }
+ };
+
+ return (
+
+
+
+ Export Payment Plans
+ Download a CSV report of payment plan items including EST dates.
+
+
+
+
Export Filters
+
+ All Plans
+ Active Plans Only
+ Custom Date Range
+
+
+ {filterType === 'custom' && (
+
+ )}
+
+
+ Preview ({filteredItems.length} items)
+ Total: ${totalAmount.toFixed(2)}
+
+
+
+ Due Date (EST) Plan Amount
+
+ {filteredItems.length === 0 ? (
+ No items found
+ ) : filteredItems.map((item, idx) => (
+
+ {toESTDateDisplay(item.due_date)}
+ {item.planTitle} - {item.description}
+ ${parseFloat(item.amount).toFixed(2)}
+
+ ))}
+
+
+
+
+
+
+ onOpenChange(false)}>Cancel
+
+ {isExporting ? : }
+ Export CSV
+
+
+
+
+ );
+}
diff --git a/src/components/PaymentPlanItemDeleteDialog.jsx b/src/components/PaymentPlanItemDeleteDialog.jsx
new file mode 100644
index 0000000..85c2cb8
--- /dev/null
+++ b/src/components/PaymentPlanItemDeleteDialog.jsx
@@ -0,0 +1,56 @@
+
+import React, { useState } from 'react';
+import { Loader2, Trash2, AlertTriangle } from 'lucide-react';
+import {
+ AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
+ AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
+} from '@/components/ui/alert-dialog';
+import { Button } from '@/components/ui/button';
+import { usePaymentPlans } from '@/hooks/usePaymentPlans';
+import { toESTDateDisplay } from '@/lib/timezoneUtils';
+
+export function PaymentPlanItemDeleteDialog({ open, onOpenChange, item, onSuccess }) {
+ const { deletePaymentPlanItem } = usePaymentPlans();
+ const [isDeleting, setIsDeleting] = useState(false);
+
+ const handleDelete = async () => {
+ if (!item) return;
+ setIsDeleting(true);
+ try {
+ const success = await deletePaymentPlanItem(item.id);
+ if (success) { onSuccess?.(); onOpenChange(false); }
+ } finally { setIsDeleting(false); }
+ };
+
+ if (!item) return null;
+
+ return (
+
+
+
+
+ Delete Payment Item?
+
+
+ Are you sure you want to delete this payment item? This action cannot be undone.
+
+
Description: {item.description}
+
Due Date: {toESTDateDisplay(item.due_date)}
+
Amount: ${parseFloat(item.amount).toFixed(2)}
+
+ {item.received_date && (
+ Warning: This item is marked as PAID. Deleting it will affect financial records.
+ )}
+
+
+
+ Cancel
+
+ {isDeleting ? : }
+ Delete Item
+
+
+
+
+ );
+}
diff --git a/src/components/PdfPreviewDialog.jsx b/src/components/PdfPreviewDialog.jsx
new file mode 100644
index 0000000..32fea31
--- /dev/null
+++ b/src/components/PdfPreviewDialog.jsx
@@ -0,0 +1,29 @@
+
+import React from 'react';
+import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
+
+const PdfPreviewDialog = ({ open, onOpenChange, title, fileUrl }) => {
+ return (
+
+
+
+
+ {title}
+
+
+
+
+
+ );
+};
+
+export default PdfPreviewDialog;
diff --git a/src/components/ProcessRecurringTransactionsDialog.jsx b/src/components/ProcessRecurringTransactionsDialog.jsx
new file mode 100644
index 0000000..14fea9f
--- /dev/null
+++ b/src/components/ProcessRecurringTransactionsDialog.jsx
@@ -0,0 +1,261 @@
+
+import React, { useState, useEffect } from 'react';
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
+import { Button } from '@/components/ui/button';
+import { Checkbox } from '@/components/ui/checkbox';
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
+import { Progress } from '@/components/ui/progress';
+import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
+import { ScrollArea } from '@/components/ui/scroll-area';
+import { useToast } from '@/hooks/use-toast';
+import { Loader2, AlertCircle, CheckCircle2, PlayCircle, RefreshCw } from 'lucide-react';
+import { supabase } from '@/integrations/supabase/client';
+import { format, addDays, addWeeks, addMonths, addQuarters, addYears } from 'date-fns';
+
+export default function ProcessRecurringTransactionsDialog({ open, onOpenChange, clientId, onProcessed }) {
+ const { toast } = useToast();
+ const [loading, setLoading] = useState(false);
+ const [dueTransactions, setDueTransactions] = useState([]);
+ const [selectedIds, setSelectedIds] = useState(new Set());
+ const [processingState, setProcessingState] = useState('idle');
+ const [progress, setProgress] = useState(0);
+ const [results, setResults] = useState({ success: [], failed: [] });
+
+ useEffect(() => {
+ if (open && clientId) {
+ fetchDueTransactions();
+ setProcessingState('idle'); setProgress(0);
+ setResults({ success: [], failed: [] }); setSelectedIds(new Set());
+ }
+ }, [open, clientId]);
+
+ const fetchDueTransactions = async () => {
+ try {
+ setLoading(true);
+ const today = new Date().toISOString().split('T')[0];
+ const { data, error } = await supabase
+ .from('recurring_transactions').select('*')
+ .eq('association_id', clientId).eq('status', 'active')
+ .lte('next_run_date', today).order('next_run_date', { ascending: true });
+ if (error) throw error;
+ setDueTransactions(data || []);
+ if (data) setSelectedIds(new Set(data.map(t => t.id)));
+ } catch (error) {
+ console.error('Error fetching due transactions:', error);
+ toast({ variant: 'destructive', title: 'Error', description: 'Failed to load due recurring transactions.' });
+ } finally { setLoading(false); }
+ };
+
+ const handleToggleSelectAll = (checked) => {
+ if (checked) setSelectedIds(new Set(dueTransactions.map(t => t.id)));
+ else setSelectedIds(new Set());
+ };
+
+ const handleToggleSelect = (id, checked) => {
+ const newSelected = new Set(selectedIds);
+ if (checked) newSelected.add(id); else newSelected.delete(id);
+ setSelectedIds(newSelected);
+ };
+
+ const calculateNextRunDate = (currentDateStr, frequency) => {
+ const date = new Date(currentDateStr);
+ switch (frequency?.toLowerCase()) {
+ case 'daily': return addDays(date, 1);
+ case 'weekly': return addWeeks(date, 1);
+ case 'monthly': return addMonths(date, 1);
+ case 'quarterly': return addQuarters(date, 1);
+ case 'yearly': return addYears(date, 1);
+ default: return addMonths(date, 1);
+ }
+ };
+
+ const processSelected = async () => {
+ if (selectedIds.size === 0) return;
+ setProcessingState('processing'); setProgress(0);
+ const successList = []; const failedList = [];
+ const toProcess = dueTransactions.filter(t => selectedIds.has(t.id));
+ const total = toProcess.length;
+ const { data: userData } = await supabase.auth.getUser();
+ const userId = userData?.user?.id;
+
+ for (let i = 0; i < total; i++) {
+ const rt = toProcess[i];
+ try {
+ let unitIds = [];
+ if (rt.target_type === 'all') {
+ const { data: owners, error: ownerErr } = await supabase.from('owners').select('id').eq('association_id', clientId).eq('status', 'active');
+ if (ownerErr) throw new Error('Failed to fetch owners');
+ unitIds = owners.map(o => o.id);
+ } else {
+ unitIds = rt.target_ids || [];
+ }
+ if (unitIds.length === 0) throw new Error('No target properties found.');
+
+ const todayStr = new Date().toISOString().split('T')[0];
+ const txTypeStr = (rt.transaction_type || 'charge').toLowerCase();
+ const debitCredit = txTypeStr.includes('charge') ? 'debit' : 'credit';
+
+ const transactionsToInsert = unitIds.map(uid => ({
+ unit_id: uid, transaction_date: todayStr,
+ description: `Auto: ${rt.name}`, amount: rt.amount,
+ debit_credit: debitCredit, transaction_type: txTypeStr.charAt(0).toUpperCase() + txTypeStr.slice(1),
+ category: 'Recurring', status: 'active', created_by: userId
+ }));
+
+ const chunkSize = 100;
+ for (let j = 0; j < transactionsToInsert.length; j += chunkSize) {
+ const chunk = transactionsToInsert.slice(j, j + chunkSize);
+ const { error: txErr } = await supabase.from('owner_ledger_entries').insert(chunk.map(t => ({
+ association_id: clientId, owner_id: t.unit_id, date: t.transaction_date,
+ description: t.description, debit: t.debit_credit === 'debit' ? t.amount : 0,
+ credit: t.debit_credit === 'credit' ? t.amount : 0,
+ transaction_type: t.transaction_type, created_by: t.created_by
+ })));
+ if (txErr) throw new Error(`Failed to insert ledger entries: ${txErr.message}`);
+ }
+
+ const nextDate = calculateNextRunDate(rt.next_run_date || todayStr, rt.frequency);
+ const { error: updateErr } = await supabase.from('recurring_transactions')
+ .update({ last_applied_date: todayStr, next_run_date: nextDate.toISOString().split('T')[0] })
+ .eq('id', rt.id);
+ if (updateErr) throw new Error(`Failed to update next run date: ${updateErr.message}`);
+ successList.push(rt);
+ } catch (err) {
+ console.error(`Error processing ${rt.name}:`, err);
+ failedList.push({ item: rt, error: err.message });
+ }
+ setProgress(((i + 1) / total) * 100);
+ }
+
+ setResults({ success: successList, failed: failedList });
+ setProcessingState('complete');
+ if (failedList.length === 0) toast({ title: 'Success', description: `Processed ${successList.length} recurring transactions.` });
+ else toast({ variant: 'destructive', title: 'Completed with Errors', description: `${successList.length} succeeded, ${failedList.length} failed.` });
+ if (onProcessed) onProcessed();
+ };
+
+ const handleClose = () => { if (processingState === 'processing') return; onOpenChange(false); };
+ const formatCurrency = (val) => new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(val);
+
+ return (
+
+
+
+
+ Process Due Recurring Transactions
+
+ Review and generate ledger entries for transactions scheduled on or before today.
+
+
+ {processingState === 'idle' && (
+ <>
+ {loading ? (
+
+
+
Checking for due transactions...
+
+ ) : dueTransactions.length === 0 ? (
+
+
+
All Caught Up!
+
No recurring transactions currently due.
+
+ ) : (
+
+
+
+
+
+
+
+ 0} onCheckedChange={handleToggleSelectAll} />
+
+ Name / Description
+ Amount
+ Due Date
+ Target
+
+
+
+ {dueTransactions.map((tx) => (
+
+ handleToggleSelect(tx.id, c)} />
+ {tx.name}
+ {formatCurrency(tx.amount)}
+ {format(new Date(tx.next_run_date), 'MMM d, yyyy')}
+ {tx.target_type === 'all' ? 'All Units' : `${tx.target_ids?.length || 0} Units`}
+
+ ))}
+
+
+
+
+
+
+ Ready to Process
+ {selectedIds.size} transaction(s) selected. This will generate ledger charges and update next run dates.
+
+
+ )}
+ >
+ )}
+
+ {processingState === 'processing' && (
+
+
+
+
Processing Transactions...
+
Please do not close this window.
+
+
{Math.round(progress)}%
+
+
+ )}
+
+ {processingState === 'complete' && (
+
+
+
+ {results.success.length}
+ Success
+
+
+ {results.failed.length}
+ Failed
+
+
+ {results.failed.length > 0 && (
+
+
+ Errors Occurred
+
+
+ {results.failed.map((f, i) => {f.item.name}: {f.error} )}
+
+
+
+ )}
+ {results.success.length > 0 && results.failed.length === 0 && (
+
+
+
All Done!
+
All selected transactions were successfully applied.
+
+ )}
+
+ )}
+
+
+ {processingState === 'idle' ? (
+
+ Cancel
+ Process Selected
+
+ ) : processingState === 'complete' ? (
+ Close & Return
+ ) : null}
+
+
+
+ );
+}
diff --git a/src/components/ProjectDialog.jsx b/src/components/ProjectDialog.jsx
new file mode 100644
index 0000000..aee2ca1
--- /dev/null
+++ b/src/components/ProjectDialog.jsx
@@ -0,0 +1,187 @@
+
+import React, { useState, useEffect } from 'react';
+import { supabase } from '@/integrations/supabase/client';
+import { useAuth } from '@/contexts/AuthContext';
+import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
+import { Input } from '@/components/ui/input';
+import { Textarea } from '@/components/ui/textarea';
+import { Label } from '@/components/ui/label';
+import { Button } from '@/components/ui/button';
+import { Loader2 } from 'lucide-react';
+import { useToast } from '@/hooks/use-toast';
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
+import { Checkbox } from "@/components/ui/checkbox";
+
+export default function ProjectDialog({ open, onOpenChange, project, onSaved }) {
+ const { user } = useAuth();
+ const { toast } = useToast();
+ const isEditing = !!project;
+
+ const [formData, setFormData] = useState({
+ title: '', description: '', status: 'active', budget: '', timeline: '',
+ client_id: 'none', assigned_client_ids: [], assigned_board_member_ids: [], assigned_property_ids: []
+ });
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const [clients, setClients] = useState([]);
+ const [boardMembers, setBoardMembers] = useState([]);
+
+ useEffect(() => {
+ if (open) {
+ if (project) {
+ setFormData({
+ title: project.title || '', description: project.description || '', status: project.status || 'active',
+ budget: project.budget || '', timeline: project.timeline || '', client_id: project.client_id || 'none',
+ assigned_client_ids: project.assigned_client_ids || [],
+ assigned_board_member_ids: project.assigned_board_member_ids || [],
+ assigned_property_ids: project.assigned_property_ids || []
+ });
+ } else {
+ setFormData({ title: '', description: '', status: 'active', budget: '', timeline: '', client_id: 'none', assigned_client_ids: [], assigned_board_member_ids: [], assigned_property_ids: [] });
+ }
+ fetchOptions();
+ }
+ }, [open, project]);
+
+ const fetchOptions = async () => {
+ try {
+ const [clientsRes, membersRes] = await Promise.all([
+ supabase.from('associations').select('id, name').eq('status', 'active').order('name'),
+ supabase.from('board_members').select('id, member_name')
+ ]);
+ if (clientsRes.data) setClients(clientsRes.data);
+ if (membersRes.data) setBoardMembers(membersRes.data);
+ } catch (error) { console.error("Error fetching options:", error); }
+ };
+
+ const handleChange = (field, value) => setFormData(prev => ({ ...prev, [field]: value }));
+ const toggleArrayItem = (field, id) => {
+ setFormData(prev => {
+ const arr = prev[field] || [];
+ return { ...prev, [field]: arr.includes(id) ? arr.filter(i => i !== id) : [...arr, id] };
+ });
+ };
+
+ const handleSubmit = async (e) => {
+ e.preventDefault();
+ if (!formData.title.trim()) { toast({ title: "Validation Error", description: "Project name is required.", variant: "destructive" }); return; }
+ if (!formData.description.trim()) { toast({ title: "Validation Error", description: "Description is required.", variant: "destructive" }); return; }
+
+ setIsSubmitting(true);
+ try {
+ const payload = {
+ title: formData.title, description: formData.description, status: formData.status,
+ budget: formData.budget ? parseFloat(formData.budget) : null,
+ timeline: formData.timeline || null,
+ client_id: formData.client_id === 'none' ? null : formData.client_id,
+ assigned_client_ids: formData.assigned_client_ids,
+ assigned_board_member_ids: formData.assigned_board_member_ids,
+ assigned_property_ids: formData.assigned_property_ids
+ };
+ if (!isEditing) payload.created_by = user?.id;
+
+ let error;
+ if (isEditing) {
+ const res = await supabase.from('projects').update(payload).eq('id', project.id);
+ error = res.error;
+ } else {
+ const res = await supabase.from('projects').insert([payload]);
+ error = res.error;
+ }
+ if (error) throw error;
+ toast({ title: "Success", description: `Project ${isEditing ? 'updated' : 'created'} successfully.` });
+ onSaved(); onOpenChange(false);
+ } catch (error) {
+ console.error("Error saving project:", error);
+ toast({ title: "Error", description: error.message, variant: "destructive" });
+ } finally { setIsSubmitting(false); }
+ };
+
+ return (
+
+
+
+ {isEditing ? 'Edit Project' : 'Create New Project'}
+ {isEditing ? 'Update project details and assignments.' : 'Add a new project to track progress.'}
+
+
+
+
+ Project Name *
+ handleChange('title', e.target.value)} required />
+
+
+ Description *
+ handleChange('description', e.target.value)} className="min-h-[100px]" required />
+
+
+
+ Status
+ handleChange('status', v)}>
+
+
+ Active On Hold
+ Completed Cancelled
+
+
+
+
+ Budget ($) (Optional)
+ handleChange('budget', e.target.value)} />
+
+
+
+ Timeline / Schedule (Optional)
+ handleChange('timeline', e.target.value)} />
+
+
+
+
+
Assignments
+
+ Primary Association
+ handleChange('client_id', v)}>
+
+
+ None
+ {clients.map(c => {c.name} )}
+
+
+
+
+
+
Assigned Associations
+
+ {clients.map(c => (
+
+ toggleArrayItem('assigned_client_ids', c.id)} />
+ {c.name}
+
+ ))}
+
+
+
+
Assigned Board Members
+
+ {boardMembers.map(b => (
+
+ toggleArrayItem('assigned_board_member_ids', b.id)} />
+ {b.member_name || 'Unnamed'}
+
+ ))}
+
+
+
+
+
+
+ onOpenChange(false)}>Cancel
+
+ {isSubmitting ? : null}
+ {isEditing ? 'Save Changes' : 'Create Project'}
+
+
+
+
+
+ );
+}
diff --git a/src/components/PropertyImage.tsx b/src/components/PropertyImage.tsx
new file mode 100644
index 0000000..2cc246e
--- /dev/null
+++ b/src/components/PropertyImage.tsx
@@ -0,0 +1,93 @@
+import { useState, useEffect } from "react";
+import { MapPin } from "lucide-react";
+import { cn } from "@/lib/utils";
+import { geocodeAddress, staticSatelliteUrl } from "@/lib/leafletMaps";
+
+interface PropertyImageProps {
+ address: string;
+ imageUrl?: string | null;
+ width?: number;
+ height?: number;
+ className?: string;
+}
+
+export default function PropertyImage({ address, imageUrl, width = 320, height = 240, className }: PropertyImageProps) {
+ const [imageSrc, setImageSrc] = useState(null);
+ const [imageType, setImageType] = useState<"custom" | "satellite" | "none">("none");
+ const [loading, setLoading] = useState(true);
+
+ useEffect(() => {
+ if (imageUrl) {
+ setImageSrc(imageUrl);
+ setImageType("custom");
+ setLoading(false);
+ return;
+ }
+
+ if (!address) {
+ setImageSrc(null);
+ setImageType("none");
+ setLoading(false);
+ return;
+ }
+
+ let cancelled = false;
+ setLoading(true);
+ setImageSrc(null);
+
+ (async () => {
+ const geo = await geocodeAddress(address);
+ if (cancelled) return;
+ if (!geo) {
+ setImageSrc(null);
+ setImageType("none");
+ setLoading(false);
+ return;
+ }
+ setImageSrc(staticSatelliteUrl(geo.lat, geo.lng, width, height, 19));
+ setImageType("satellite");
+ setLoading(false);
+ })();
+
+ return () => { cancelled = true; };
+ }, [address, imageUrl, width, height]);
+
+ if (!address && !imageUrl) return null;
+
+ return (
+
+ {loading && (
+
+ )}
+
+ {!loading && imageSrc && (
+ <>
+
{ setImageSrc(null); setImageType("none"); }}
+ />
+ {imageType === "satellite" && (
+
+ Satellite View
+
+ )}
+ >
+ )}
+
+ {!loading && !imageSrc && (
+
+
+ No image available
+
+ )}
+
+ );
+}
diff --git a/src/components/PropertySearchDialog.jsx b/src/components/PropertySearchDialog.jsx
new file mode 100644
index 0000000..95a61f7
--- /dev/null
+++ b/src/components/PropertySearchDialog.jsx
@@ -0,0 +1,91 @@
+
+import React, { useState, useEffect } from 'react';
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
+import { Input } from '@/components/ui/input';
+import { Search, Loader2, Home, User, AlertCircle } from 'lucide-react';
+import { supabase } from '@/integrations/supabase/client';
+import { useToast } from '@/hooks/use-toast';
+import { ScrollArea } from '@/components/ui/scroll-area';
+
+export default function PropertySearchDialog({ open, onOpenChange, clientId, onSelect }) {
+ const [searchTerm, setSearchTerm] = useState('');
+ const [results, setResults] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [debouncedTerm, setDebouncedTerm] = useState('');
+ const { toast } = useToast();
+
+ useEffect(() => {
+ const timer = setTimeout(() => setDebouncedTerm(searchTerm), 500);
+ return () => clearTimeout(timer);
+ }, [searchTerm]);
+
+ useEffect(() => {
+ const searchProperties = async () => {
+ if (!clientId || debouncedTerm.length < 2) { setResults([]); return; }
+ setLoading(true);
+ try {
+ const { data, error } = await supabase
+ .from('owners').select('id, property_address, owner_name, unit_id, units(account_number)')
+ .eq('association_id', clientId)
+ .eq('status', 'active')
+ .or(`property_address.ilike.%${debouncedTerm}%,owner_name.ilike.%${debouncedTerm}%`)
+ .limit(20);
+ if (error) throw error;
+ setResults(data || []);
+ } catch (error) {
+ console.error('Error searching properties:', error);
+ toast({ variant: 'destructive', title: 'Search Failed', description: 'Could not fetch properties.' });
+ } finally { setLoading(false); }
+ };
+ if (open) searchProperties();
+ }, [debouncedTerm, clientId, open, toast]);
+
+ const handleSelect = (property) => { onSelect(property); onOpenChange(false); setSearchTerm(''); };
+
+ return (
+
+
+
+ Search Properties
+ Find a property by address or owner name.
+
+
+ setSearchTerm(e.target.value)} placeholder="Start typing address or owner..." className="pl-9" autoFocus />
+
+
+
+
+ {loading ? (
+
+ Searching...
+
+ ) : results.length > 0 ? (
+ results.map((property) => (
+
handleSelect(property)}
+ className="flex flex-col items-start p-4 hover:bg-muted/50 transition-colors border-b last:border-0 w-full text-left group">
+
+
+ {property.property_address}
+
+
+ {property.owner_name}
+ {property.units?.account_number && #{property.units.account_number} }
+
+
+ ))
+ ) : debouncedTerm.length > 1 ? (
+
+
No properties found matching "{debouncedTerm}"
+
+ ) : (
+
+ Type to search properties
+
+ )}
+
+
+ Showing {results.length} result(s)
+
+
+ );
+}
diff --git a/src/components/PropertySelect.jsx b/src/components/PropertySelect.jsx
new file mode 100644
index 0000000..6a99664
--- /dev/null
+++ b/src/components/PropertySelect.jsx
@@ -0,0 +1,70 @@
+import { useState, useEffect } from 'react';
+import { supabase } from '@/integrations/supabase/client';
+import { Combobox } from '@/components/Combobox';
+
+export function PropertySelect({ associationId, value, onChange, onSelectProperty, className }) {
+ const [options, setOptions] = useState([]);
+
+ useEffect(() => {
+ if (!associationId) {
+ setOptions([]);
+ return;
+ }
+
+ const fetchOwners = async () => {
+ const { data, error } = await supabase
+ .from('owners')
+ .select('id, first_name, last_name, property_address, mailing_address, unit_id, status, units(unit_number, account_number)')
+ .eq('association_id', associationId)
+ .order('last_name');
+
+ if (!error && data) {
+ const sorted = [...data].sort((a, b) => {
+ const aArch = a.status === 'archived' ? 1 : 0;
+ const bArch = b.status === 'archived' ? 1 : 0;
+ if (aArch !== bArch) return aArch - bArch;
+ return (a.last_name || '').localeCompare(b.last_name || '');
+ });
+ setOptions(sorted.map(o => {
+ const unitLabel = o.units?.account_number || o.units?.unit_number;
+ const addr = o.property_address ? ` - ${o.property_address}` : '';
+ const unit = unitLabel ? ` [${unitLabel}]` : '';
+ const archived = o.status === 'archived' ? ' (archived)' : '';
+ return {
+ value: o.id,
+ label: `${o.first_name} ${o.last_name}${unit}${addr}${archived}`,
+ raw: o,
+ };
+ }));
+ }
+ };
+ fetchOwners();
+ }, [associationId]);
+
+ const handleChange = (val) => {
+ if (onChange) onChange(val);
+ if (onSelectProperty) {
+ const selected = options.find(o => o.value === val);
+ if (selected) {
+ onSelectProperty({
+ id: selected.raw.id,
+ property_address: selected.raw.property_address || '',
+ owner_name: `${selected.raw.first_name} ${selected.raw.last_name}`,
+ mailing_address: selected.raw.mailing_address
+ });
+ }
+ }
+ };
+
+ return (
+
+ );
+}
+
+export default PropertySelect;
diff --git a/src/components/PublicFormReportGenerator.jsx b/src/components/PublicFormReportGenerator.jsx
new file mode 100644
index 0000000..2a806df
--- /dev/null
+++ b/src/components/PublicFormReportGenerator.jsx
@@ -0,0 +1,98 @@
+import { format } from 'date-fns';
+import { Check, X } from 'lucide-react';
+import { cn } from '@/lib/utils';
+
+export function PublicFormReportGenerator({ report }) {
+ if (!report) return null;
+
+ const { report_data, public_form_templates } = report;
+ const fields = report_data?.fields_snapshot || [];
+ const values = report_data?.submission_values || {};
+ const metadata = report_data?.metadata || {};
+ const settings = public_form_templates?.report_styling || {};
+
+ return (
+
+ {/* Header */}
+
+
+
+
{public_form_templates?.title}
+
Submission Report
+
+
+
Date: {metadata.submitted_at ? format(new Date(metadata.submitted_at), 'PPP') : 'N/A'}
+
Time: {metadata.submitted_at ? format(new Date(metadata.submitted_at), 'p') : 'N/A'}
+
ID: {metadata.submission_id?.slice(0, 8)}
+
+
+
+
+ {/* Body */}
+
+ {fields.map((field, idx) => {
+ if (field.type === 'header') {
+ return (
+
+
+ {field.label || field.content}
+
+
+ );
+ }
+
+ if (field.type === 'paragraph') {
+ return (
+
+ {field.label || field.content}
+
+ );
+ }
+
+ if (['spacer', 'horizontal_line'].includes(field.type)) return null;
+
+ const value = values[field.id];
+
+ return (
+
+
+ {field.label}
+
+
+ {field.type === 'checkbox' ? (
+
+ {value ? (
+ Yes
+ ) : (
+ No
+ )}
+
+ ) : field.type === 'signature' ? (
+ value ? (
+
+
+
+ ) :
Not signed
+ ) : field.type === 'time' && typeof value === 'object' ? (
+
{value.time} ({value.timezone})
+ ) : (
+
{value || - }
+ )}
+
+
+ );
+ })}
+
+
+ {/* Footer */}
+
+ Generated • {format(new Date(), 'PP pp')}
+
+
+ );
+}
+
+export default PublicFormReportGenerator;
diff --git a/src/components/PublicFormReportPdfExport.jsx b/src/components/PublicFormReportPdfExport.jsx
new file mode 100644
index 0000000..f87c126
--- /dev/null
+++ b/src/components/PublicFormReportPdfExport.jsx
@@ -0,0 +1,136 @@
+import { useState } from 'react';
+import { Button } from '@/components/ui/button';
+import { Download, Loader2 } from 'lucide-react';
+import { jsPDF } from 'jspdf';
+import { format } from 'date-fns';
+
+export function PublicFormReportPdfExport({ reportData, templateData, fileName = 'Submission_Report' }) {
+ const [generating, setGenerating] = useState(false);
+
+ const generatePDF = () => {
+ setGenerating(true);
+ try {
+ const doc = new jsPDF();
+ const pageWidth = doc.internal.pageSize.getWidth();
+ const margin = 20;
+ let yPos = 20;
+
+ // Header
+ doc.setFontSize(18);
+ doc.setFont('helvetica', 'bold');
+ doc.text(templateData?.title || 'Form Submission Report', margin, yPos);
+ yPos += 10;
+
+ doc.setFontSize(10);
+ doc.setFont('helvetica', 'normal');
+ doc.setTextColor(100);
+ const subDate = reportData?.metadata?.submitted_at
+ ? format(new Date(reportData.metadata.submitted_at), 'PPP pp')
+ : 'N/A';
+
+ doc.text(`Submitted: ${subDate}`, margin, yPos);
+ doc.text(`ID: ${reportData?.metadata?.submission_id || 'N/A'}`, pageWidth - margin, yPos, { align: 'right' });
+ yPos += 20;
+
+ doc.setDrawColor(200);
+ doc.line(margin, yPos - 10, pageWidth - margin, yPos - 10);
+
+ // Content
+ doc.setTextColor(0);
+
+ const fields = reportData?.fields_snapshot || [];
+ const values = reportData?.submission_values || {};
+
+ fields.forEach((field) => {
+ if (yPos > 270) {
+ doc.addPage();
+ yPos = 20;
+ }
+
+ if (field.type === 'spacer' || field.type === 'horizontal_line') return;
+
+ if (field.type === 'header') {
+ yPos += 5;
+ doc.setFontSize(14);
+ doc.setFont('helvetica', 'bold');
+ doc.setFillColor(245, 247, 250);
+ doc.rect(margin, yPos - 12, pageWidth - (margin * 2), 16, 'F');
+ doc.text(field.label || field.content || '', margin + 5, yPos);
+ yPos += 15;
+ return;
+ }
+
+ if (field.type === 'paragraph') {
+ doc.setFontSize(10);
+ doc.setFont('helvetica', 'italic');
+ doc.setTextColor(80);
+ const text = doc.splitTextToSize(field.label || field.content || '', pageWidth - (margin * 2));
+ doc.text(text, margin, yPos);
+ yPos += (text.length * 5) + 10;
+ doc.setTextColor(0);
+ return;
+ }
+
+ // Standard Fields
+ doc.setFontSize(9);
+ doc.setFont('helvetica', 'bold');
+ doc.setTextColor(100);
+ doc.text((field.label || 'Field').toUpperCase(), margin, yPos);
+ yPos += 5;
+
+ doc.setFontSize(11);
+ doc.setFont('helvetica', 'normal');
+ doc.setTextColor(0);
+
+ let value = values[field.id];
+
+ if (field.type === 'signature' && value) {
+ try {
+ doc.addImage(value, 'PNG', margin, yPos, 100, 40);
+ yPos += 45;
+ } catch (e) {
+ doc.text("[Signature Image]", margin, yPos);
+ yPos += 10;
+ }
+ } else if (field.type === 'checkbox') {
+ doc.text(value ? 'Yes' : 'No', margin, yPos);
+ yPos += 10;
+ } else if (field.type === 'time' && typeof value === 'object') {
+ doc.text(value.time || '', margin, yPos);
+ yPos += 10;
+ } else {
+ const strValue = value !== undefined && value !== null ? String(value) : '';
+ const text = doc.splitTextToSize(strValue, pageWidth - (margin * 2));
+ doc.text(text, margin, yPos);
+ yPos += (text.length * 6) + 10;
+ }
+
+ yPos += 5;
+ });
+
+ // Footer
+ const pageCount = doc.internal.getNumberOfPages();
+ for (let i = 1; i <= pageCount; i++) {
+ doc.setPage(i);
+ doc.setFontSize(8);
+ doc.setTextColor(150);
+ doc.text(`Page ${i} of ${pageCount}`, pageWidth / 2, 290, { align: 'center' });
+ }
+
+ doc.save(`${fileName}_${format(new Date(), 'yyyyMMdd')}.pdf`);
+ } catch (err) {
+ console.error("PDF Generation failed:", err);
+ } finally {
+ setGenerating(false);
+ }
+ };
+
+ return (
+
+ {generating ? : }
+ Download PDF
+
+ );
+}
+
+export default PublicFormReportPdfExport;
diff --git a/src/components/PublicFormReportTemplate.jsx b/src/components/PublicFormReportTemplate.jsx
new file mode 100644
index 0000000..262c02c
--- /dev/null
+++ b/src/components/PublicFormReportTemplate.jsx
@@ -0,0 +1,110 @@
+import { useState } from 'react';
+import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
+import { Label } from '@/components/ui/label';
+import { Switch } from '@/components/ui/switch';
+import { Input } from '@/components/ui/input';
+import { Button } from '@/components/ui/button';
+import { supabase } from '@/integrations/supabase/client';
+import { useToast } from '@/hooks/use-toast';
+import { Loader2, Save, LayoutTemplate } from 'lucide-react';
+
+export function PublicFormReportTemplate({ formId, initialSettings = {}, onSave }) {
+ const { toast } = useToast();
+ const [loading, setLoading] = useState(false);
+ const [settings, setSettings] = useState({
+ headerColor: initialSettings.headerColor || '#1e293b',
+ showMetadata: initialSettings.showMetadata !== false,
+ customFooterText: initialSettings.customFooterText || '',
+ compactMode: initialSettings.compactMode || false
+ });
+
+ const handleSave = async () => {
+ setLoading(true);
+ try {
+ const { error } = await supabase
+ .from('public_form_templates')
+ .update({ report_styling: settings })
+ .eq('id', formId);
+
+ if (error) throw error;
+
+ toast({ title: "Settings Saved", description: "Report template updated." });
+ if (onSave) onSave(settings);
+ } catch (err) {
+ console.error(err);
+ toast({ variant: "destructive", title: "Error", description: "Failed to save settings." });
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+
+
+
+
+ Report Configuration
+
+ Customize how the submission report looks for this form.
+
+
+
+
+
+
+
+ Custom Footer Text
+ setSettings({ ...settings, customFooterText: e.target.value })}
+ />
+
+
+
+
+
+ Include Submission Metadata
+ setSettings({ ...settings, showMetadata: c })}
+ />
+
+
+
+ Compact Mode
+ setSettings({ ...settings, compactMode: c })}
+ />
+
+
+
+
+
+
+ {loading ? : }
+ Save Configuration
+
+
+
+
+ );
+}
+
+export default PublicFormReportTemplate;
diff --git a/src/components/PublicFormReportViewer.jsx b/src/components/PublicFormReportViewer.jsx
new file mode 100644
index 0000000..3bfb162
--- /dev/null
+++ b/src/components/PublicFormReportViewer.jsx
@@ -0,0 +1,54 @@
+import { usePublicFormReport } from '@/hooks/usePublicFormReport';
+import { PublicFormReportGenerator } from '@/components/PublicFormReportGenerator';
+import { PublicFormReportPdfExport } from '@/components/PublicFormReportPdfExport';
+import { Loader2, AlertCircle } from 'lucide-react';
+import { Button } from '@/components/ui/button';
+import { Dialog, DialogContent, DialogTrigger } from '@/components/ui/dialog';
+
+export function PublicFormReportViewer({ submissionId, triggerLabel = "View Report" }) {
+ const { report, loading, error } = usePublicFormReport(submissionId);
+ const hasReportData = report && report.report_data;
+
+ return (
+
+
+ {triggerLabel}
+
+
+ {loading ? (
+
+
+
+ ) : error ? (
+
+
+
Unable to load report. {error}
+
+ ) : !hasReportData ? (
+
+
+
Report data is unavailable or generating.
+
+ ) : (
+ <>
+
+
Submission Report
+
+
window.print()}>Print
+
+
+
+
+ >
+ )}
+
+
+ );
+}
+
+export default PublicFormReportViewer;
diff --git a/src/components/PublicFormSubmissionTimeDisplay.jsx b/src/components/PublicFormSubmissionTimeDisplay.jsx
new file mode 100644
index 0000000..e1424f9
--- /dev/null
+++ b/src/components/PublicFormSubmissionTimeDisplay.jsx
@@ -0,0 +1,40 @@
+import { Badge } from '@/components/ui/badge';
+import { Clock } from 'lucide-react';
+import { toEST } from '@/lib/timezoneUtils';
+
+export function PublicFormSubmissionTimeDisplay({ timeData }) {
+ if (!timeData) return - ;
+
+ if (typeof timeData === 'string') {
+ return (
+
+
+ {timeData}
+ EST
+
+ );
+ }
+
+ const { time, timezone, utc_time } = timeData;
+
+ if (!time) return - ;
+
+ return (
+
+
+
+ {time}
+
+ {timezone || 'EST'}
+
+
+ {utc_time && (
+
+ UTC: {toEST(utc_time, 'HH:mm')}
+
+ )}
+
+ );
+}
+
+export default PublicFormSubmissionTimeDisplay;
diff --git a/src/components/RecordImportButton.tsx b/src/components/RecordImportButton.tsx
new file mode 100644
index 0000000..8cdb6d4
--- /dev/null
+++ b/src/components/RecordImportButton.tsx
@@ -0,0 +1,43 @@
+import { useState } from "react";
+import { Upload } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import ImportSpreadsheetDialog from "@/components/ImportSpreadsheetDialog";
+
+interface RecordImportButtonProps {
+ title: string;
+ description: string;
+ expectedColumns: { key: string; label: string; required?: boolean; aliases?: string[] }[];
+ onImport: (rows: Record[]) => Promise;
+ templateFileName?: string;
+ variant?: "default" | "outline" | "ghost";
+ label?: string;
+}
+
+export default function RecordImportButton({
+ title,
+ description,
+ expectedColumns,
+ onImport,
+ templateFileName,
+ variant = "outline",
+ label = "Import",
+}: RecordImportButtonProps) {
+ const [open, setOpen] = useState(false);
+
+ return (
+ <>
+ setOpen(true)}>
+ {label}
+
+
+ >
+ );
+}
diff --git a/src/components/ReminderDialog.jsx b/src/components/ReminderDialog.jsx
new file mode 100644
index 0000000..476beb4
--- /dev/null
+++ b/src/components/ReminderDialog.jsx
@@ -0,0 +1,189 @@
+
+import React, { useState, useEffect } from 'react';
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from '@/components/ui/dialog';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import { Textarea } from '@/components/ui/textarea';
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
+import { useToast } from '@/hooks/use-toast';
+import { supabase } from '@/integrations/supabase/client';
+import { useAuth } from '@/contexts/AuthContext';
+import { Loader2, Save, Trash2, CheckCircle2 } from 'lucide-react';
+import { format } from 'date-fns';
+
+export default function ReminderDialog({ open, onOpenChange, reminder, onSuccess }) {
+ const { toast } = useToast();
+ const { user } = useAuth();
+ const [loading, setLoading] = useState(false);
+ const [users, setUsers] = useState([]);
+
+ const [formData, setFormData] = useState({
+ title: '', description: '', due_date: '', assigned_to: '', priority: 'medium', status: 'pending'
+ });
+
+ useEffect(() => { fetchUsers(); }, []);
+
+ useEffect(() => {
+ if (open) {
+ if (reminder) {
+ setFormData({
+ title: reminder.title || '', description: reminder.description || '',
+ due_date: reminder.due_date ? format(new Date(reminder.due_date), 'yyyy-MM-dd HH:mm') : '',
+ assigned_to: reminder.assigned_to || '', priority: reminder.priority || 'medium', status: reminder.status || 'pending'
+ });
+ } else {
+ const nextHour = new Date();
+ nextHour.setHours(nextHour.getHours() + 1);
+ nextHour.setMinutes(0);
+ setFormData({
+ title: '', description: '', due_date: format(nextHour, 'yyyy-MM-dd HH:mm'),
+ assigned_to: user?.id || '', priority: 'medium', status: 'pending'
+ });
+ }
+ }
+ }, [open, reminder, user]);
+
+ const fetchUsers = async () => {
+ const { data } = await supabase.from('profiles').select('id, email, full_name').order('email');
+ if (data) setUsers(data);
+ };
+
+ const handleChange = (field, value) => setFormData(prev => ({ ...prev, [field]: value }));
+
+ const handleSubmit = async () => {
+ if (!formData.title.trim()) { toast({ variant: "destructive", title: "Required", description: "Title is required." }); return; }
+ if (!formData.due_date) { toast({ variant: "destructive", title: "Required", description: "Due date is required." }); return; }
+
+ setLoading(true);
+ try {
+ const payload = {
+ title: formData.title, description: formData.description,
+ due_date: new Date(formData.due_date).toISOString(),
+ assigned_to: formData.assigned_to, priority: formData.priority,
+ status: formData.status, updated_at: new Date().toISOString()
+ };
+ if (!reminder) { payload.created_by = user.id; payload.created_at = new Date().toISOString(); }
+
+ let error;
+ if (reminder?.id) {
+ const { error: updateError } = await supabase.from('reminders').update(payload).eq('id', reminder.id);
+ error = updateError;
+ } else {
+ const { error: insertError } = await supabase.from('reminders').insert([payload]);
+ error = insertError;
+ }
+ if (error) throw error;
+ toast({ title: "Success", description: reminder ? "Reminder updated." : "Reminder created." });
+ if (onSuccess) onSuccess();
+ onOpenChange(false);
+ } catch (error) {
+ console.error(error);
+ toast({ variant: "destructive", title: "Error", description: error.message });
+ } finally { setLoading(false); }
+ };
+
+ const handleDelete = async () => {
+ if (!confirm("Are you sure you want to delete this reminder?")) return;
+ setLoading(true);
+ try {
+ const { error } = await supabase.from('reminders').delete().eq('id', reminder.id);
+ if (error) throw error;
+ toast({ title: "Deleted", description: "Reminder removed." });
+ if (onSuccess) onSuccess();
+ onOpenChange(false);
+ } catch (e) {
+ toast({ variant: "destructive", title: "Error", description: e.message });
+ } finally { setLoading(false); }
+ };
+
+ const handleMarkComplete = async () => {
+ setLoading(true);
+ try {
+ const { error } = await supabase.from('reminders')
+ .update({ status: 'completed', updated_at: new Date().toISOString() })
+ .eq('id', reminder.id);
+ if (error) throw error;
+ toast({ title: "Completed", description: "Reminder marked as complete." });
+ if (onSuccess) onSuccess();
+ onOpenChange(false);
+ } catch (e) {
+ toast({ variant: "destructive", title: "Error", description: e.message });
+ } finally { setLoading(false); }
+ };
+
+ return (
+
+
+
+ {reminder ? "Edit Reminder" : "Create Reminder"}
+ Set a reminder details, priority and assignment.
+
+
+
+
+ Title *
+ handleChange('title', e.target.value)} placeholder="e.g. Follow up on violation" />
+
+
+ Description
+ handleChange('description', e.target.value)} placeholder="Add details..." rows={3} />
+
+
+
+ Due Date *
+ handleChange('due_date', e.target.value)} />
+
+
+ Priority
+ handleChange('priority', val)}>
+
+
+ Low
+ Medium
+ High
+
+
+
+
+
+ Assign To
+ handleChange('assigned_to', val)}>
+
+
+ {users.map(u => ({u.full_name || u.email} ))}
+
+
+
+ {reminder && (
+
+ Created: {format(new Date(reminder.created_at), 'PPP p')}
+
+ )}
+
+
+
+
+ {reminder && (
+ <>
+
+ Delete
+
+
+ Complete
+
+ >
+ )}
+
+
+ onOpenChange(false)} type="button">Cancel
+
+ {loading && }
+ Save
+
+
+
+
+
+ );
+}
diff --git a/src/components/RemindersSection.tsx b/src/components/RemindersSection.tsx
new file mode 100644
index 0000000..1b5c170
--- /dev/null
+++ b/src/components/RemindersSection.tsx
@@ -0,0 +1,398 @@
+import { useState, useEffect, useCallback } from "react";
+import { supabase } from "@/integrations/supabase/client";
+import { Button } from "@/components/ui/button";
+import { Badge } from "@/components/ui/badge";
+import { format } from "date-fns";
+import { Bell, Plus, RefreshCw, Send, Pencil, Trash2 } from "lucide-react";
+import {
+ Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
+} from "@/components/ui/dialog";
+import {
+ Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
+} from "@/components/ui/table";
+import { Input } from "@/components/ui/input";
+import { Textarea } from "@/components/ui/textarea";
+import { Label } from "@/components/ui/label";
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
+import { useAuth } from "@/contexts/AuthContext";
+import { useToast } from "@/components/ui/use-toast";
+
+interface Reminder {
+ id: string;
+ title: string;
+ description: string | null;
+ due_date: string;
+ status: string;
+ created_by: string;
+ assigned_to: string | null;
+ last_notified_at: string | null;
+ created_at: string;
+}
+
+interface Profile {
+ user_id: string;
+ email: string | null;
+ full_name: string | null;
+}
+
+export default function RemindersSection() {
+ const { user } = useAuth();
+ const [reminders, setReminders] = useState([]);
+ const [profiles, setProfiles] = useState>({});
+ const [assignableUsers, setAssignableUsers] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [modalOpen, setModalOpen] = useState(false);
+ const [editingId, setEditingId] = useState(null);
+ const [tab, setTab] = useState<"all" | "mine">("all");
+ const [formData, setFormData] = useState({ title: "", description: "", due_date: "", assigned_to: "" });
+ const [running, setRunning] = useState(false);
+ const { toast } = useToast();
+
+ const handleRunCheck = async () => {
+ try {
+ setRunning(true);
+ const { data, error } = await supabase.functions.invoke("check-reminder-emails", { body: {} });
+ if (error) throw error;
+ const processed = (data as any)?.processed ?? 0;
+ toast({ title: "Reminder check complete", description: `${processed} reminder${processed === 1 ? "" : "s"} processed.` });
+ fetchReminders();
+ } catch (err: any) {
+ toast({ variant: "destructive", title: "Run Check failed", description: err?.message || "Could not run reminder check." });
+ } finally {
+ setRunning(false);
+ }
+ };
+
+ const fetchReminders = useCallback(async () => {
+ if (!user) return;
+ try {
+ setLoading(true);
+ let query = supabase
+ .from("reminders")
+ .select("*")
+ .or(`created_by.eq.${user.id},assigned_to.eq.${user.id}`)
+ .order("due_date", { ascending: true });
+
+ const { data, error } = await query;
+ if (error) throw error;
+ setReminders((data as Reminder[]) ?? []);
+
+ const userIds = [...new Set((data || []).map(r => r.assigned_to).filter(Boolean))] as string[];
+ if (userIds.length > 0) {
+ const { data: profileData } = await supabase
+ .from("profiles")
+ .select("user_id, email, full_name")
+ .in("user_id", userIds);
+ const map: Record = {};
+ (profileData || []).forEach(p => { map[p.user_id] = p; });
+ setProfiles(map);
+ }
+ } catch (err) {
+ console.error("Error fetching reminders:", err);
+ } finally {
+ setLoading(false);
+ }
+ }, [user]);
+
+ const fetchAssignableUsers = useCallback(async () => {
+ const { data: roleRows, error: roleError } = await supabase
+ .from("user_roles")
+ .select("user_id")
+ .in("role", ["admin", "staff"]);
+
+ if (roleError) {
+ console.error("Error fetching reminder assignees:", roleError);
+ return;
+ }
+
+ const userIds = [...new Set((roleRows || []).map((row) => row.user_id).filter(Boolean))];
+ if (userIds.length === 0) {
+ setAssignableUsers([]);
+ return;
+ }
+
+ const { data: profileData, error: profileError } = await supabase
+ .from("profiles")
+ .select("user_id, email, full_name")
+ .in("user_id", userIds)
+ .order("full_name");
+
+ if (profileError) {
+ console.error("Error fetching assignee profiles:", profileError);
+ return;
+ }
+
+ const staffProfiles = (profileData as Profile[] | null) ?? [];
+ setAssignableUsers(staffProfiles);
+ setProfiles((prev) => {
+ const next = { ...prev };
+ staffProfiles.forEach((profile) => { next[profile.user_id] = profile; });
+ return next;
+ });
+ }, []);
+
+ useEffect(() => {
+ if (user) {
+ fetchReminders();
+ fetchAssignableUsers();
+ }
+ }, [user, fetchReminders, fetchAssignableUsers]);
+
+ const filtered = tab === "mine"
+ ? reminders.filter(r => r.assigned_to === user?.id)
+ : reminders;
+
+ const handleSave = async () => {
+ if (!formData.title || !formData.due_date) {
+ toast({ variant: "destructive", title: "Title and Due Date required" });
+ return;
+ }
+ try {
+ const payload: any = {
+ title: formData.title,
+ description: formData.description || null,
+ due_date: formData.due_date,
+ assigned_to: formData.assigned_to || null,
+ };
+ if (editingId) {
+ const { error } = await supabase.from("reminders").update(payload).eq("id", editingId);
+ if (error) throw error;
+ } else {
+ const { error } = await supabase.from("reminders").insert([{ ...payload, status: "pending", created_by: user!.id }]);
+ if (error) throw error;
+ }
+ toast({ title: editingId ? "Reminder updated" : "Reminder created" });
+ setModalOpen(false);
+ fetchReminders();
+ } catch (err: any) {
+ toast({ variant: "destructive", title: "Error saving reminder", description: err.message });
+ }
+ };
+
+ const handleDelete = async (id: string) => {
+ try {
+ const { error } = await supabase.from("reminders").delete().eq("id", id);
+ if (error) throw error;
+ fetchReminders();
+ toast({ title: "Reminder deleted" });
+ } catch {
+ toast({ variant: "destructive", title: "Error deleting reminder" });
+ }
+ };
+
+ const openModal = (reminder?: Reminder | null) => {
+ if (reminder) {
+ setEditingId(reminder.id);
+ setFormData({
+ title: reminder.title,
+ description: reminder.description || "",
+ due_date: new Date(reminder.due_date).toISOString().split("T")[0],
+ assigned_to: reminder.assigned_to || "",
+ });
+ } else {
+ setEditingId(null);
+ setFormData({ title: "", description: "", due_date: "", assigned_to: "" });
+ }
+ setModalOpen(true);
+ };
+
+ const statusBadge = (status: string) => {
+ switch (status) {
+ case "completed":
+ return Completed ;
+ case "pending":
+ return Pending ;
+ default:
+ return {status} ;
+ }
+ };
+
+ return (
+
+ {/* Header */}
+
+
+
+
+
Reminders
+
Manage tasks and trigger automated email notifications.
+
+
+
+
+ {running ? "Running..." : "Run Check"}
+
+
+
+
+
openModal()}>
+ New Reminder
+
+
+
+
+ {/* Table Card */}
+
+
+
Reminder List
+
+ Emails are automatically sent for items due within 24 hours, provided they haven't been notified recently.
+
+
+
+ {/* Tabs */}
+
+
+ setTab("all")}
+ className={`px-4 py-1.5 text-sm font-medium rounded-md transition-colors ${
+ tab === "all" ? "bg-background shadow-sm text-foreground" : "text-muted-foreground hover:text-foreground"
+ }`}
+ >
+ All Reminders
+
+ setTab("mine")}
+ className={`px-4 py-1.5 text-sm font-medium rounded-md transition-colors ${
+ tab === "mine" ? "bg-background shadow-sm text-foreground" : "text-muted-foreground hover:text-foreground"
+ }`}
+ >
+ My Reminders
+
+
+
+
+ {/* Table */}
+
+ {loading ? (
+
Loading reminders...
+ ) : filtered.length === 0 ? (
+
No reminders found.
+ ) : (
+
+
+
+ Status
+ Title
+ Due Date
+ Assigned To
+ Last Notified
+ Actions
+
+
+
+ {filtered.map((rem) => {
+ const profile = rem.assigned_to ? profiles[rem.assigned_to] : null;
+ return (
+
+ {statusBadge(rem.status)}
+
+ {rem.title}
+ {rem.description && (
+ {rem.description}
+ )}
+
+
+ {format(new Date(rem.due_date), "MMM d, yyyy")}
+
+
+ {profile?.email ? (
+ {profile.email}
+ ) : (
+ —
+ )}
+
+
+ {rem.last_notified_at
+ ? format(new Date(rem.last_notified_at), "MMM d, h:mm a")
+ : "Never"}
+
+
+
+
openModal(rem)}
+ >
+
+
+
handleDelete(rem.id)}
+ >
+
+
+
+
+
+ );
+ })}
+
+
+ )}
+
+
+
+ {/* Modal */}
+
+
+
+ {editingId ? "Edit Reminder" : "New Reminder"}
+
+
+
+ Title
+ setFormData({ ...formData, title: e.target.value })}
+ placeholder="What needs to be done?"
+ />
+
+
+ Description (Optional)
+ setFormData({ ...formData, description: e.target.value })}
+ placeholder="Additional details..."
+ rows={3}
+ />
+
+
+ Due Date
+ setFormData({ ...formData, due_date: e.target.value })}
+ />
+
+
+ Assigned To
+ setFormData({ ...formData, assigned_to: value })}
+ >
+
+
+
+
+ {assignableUsers.map((profile) => (
+
+ {profile.full_name || profile.email || profile.user_id}
+
+ ))}
+
+
+
+
+
+ setModalOpen(false)}>Cancel
+ Save Reminder
+
+
+
+
+ );
+}
diff --git a/src/components/RemoveProxyTextDialog.jsx b/src/components/RemoveProxyTextDialog.jsx
new file mode 100644
index 0000000..393f9ea
--- /dev/null
+++ b/src/components/RemoveProxyTextDialog.jsx
@@ -0,0 +1,57 @@
+
+import React from 'react';
+import {
+ AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
+ AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
+} from "@/components/ui/alert-dialog";
+import { Loader2, AlertTriangle } from 'lucide-react';
+import { useRemoveProxyText } from '@/hooks/useRemoveProxyText';
+import { useToast } from '@/hooks/use-toast';
+
+export default function RemoveProxyTextDialog({ open, onOpenChange, clientId, onSuccess }) {
+ const { removeProxyText, isRemoving } = useRemoveProxyText();
+ const { toast } = useToast();
+
+ const handleConfirm = async () => {
+ try {
+ const count = await removeProxyText(clientId);
+ if (count > 0) {
+ toast({ title: "Success", description: `Removed proxy information from ${count} owner records.` });
+ onSuccess?.();
+ } else {
+ toast({ title: "No Changes", description: "No proxy information found to remove." });
+ }
+ onOpenChange(false);
+ } catch (error) {
+ // Error handling is done in the hook
+ }
+ };
+
+ return (
+
+
+
+
+
+ Remove All Proxy Info?
+
+
+ This action will search all owners in this association and remove any text related to proxies (e.g., "(Proxy)", "Proxy Received").
+ This will also clear the "Proxy Received" indicator from the sign-in sheet.
+ This action cannot be undone.
+
+
+
+ Cancel
+ { e.preventDefault(); handleConfirm(); }}
+ disabled={isRemoving}
+ className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
+ >
+ {isRemoving ? (<> Processing...>) : "Yes, Remove Proxy Info"}
+
+
+
+
+ );
+}
diff --git a/src/components/ReportCoverPage.tsx b/src/components/ReportCoverPage.tsx
new file mode 100644
index 0000000..af0ded7
--- /dev/null
+++ b/src/components/ReportCoverPage.tsx
@@ -0,0 +1,46 @@
+import { FileText } from "lucide-react";
+import { format } from "date-fns";
+
+interface ReportCoverPageProps {
+ clientName: string;
+ startDate?: Date | null;
+ endDate?: Date | null;
+ logoUrl?: string;
+}
+
+export default function ReportCoverPage({ clientName, startDate, endDate, logoUrl }: ReportCoverPageProps) {
+ return (
+
+ {logoUrl ? (
+
+ ) : (
+
+
+
+ )}
+
+
+
+ {clientName || "Association Name"}
+
+
+ Month-End Report
+
+
+
+
+
Reporting Period
+
+ {startDate ? format(startDate, "MMMM d, yyyy") : "Start"} —{" "}
+ {endDate ? format(endDate, "MMMM d, yyyy") : "End"}
+
+
+
+
+
+ Generated on {format(new Date(), "MMMM d, yyyy")}
+
+
+
+ );
+}
diff --git a/src/components/ReportExportDialog.tsx b/src/components/ReportExportDialog.tsx
new file mode 100644
index 0000000..a7dc40a
--- /dev/null
+++ b/src/components/ReportExportDialog.tsx
@@ -0,0 +1,92 @@
+import { useState } from "react";
+import {
+ Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
+} from "@/components/ui/dialog";
+import { Button } from "@/components/ui/button";
+import { Label } from "@/components/ui/label";
+import { Input } from "@/components/ui/input";
+import { FileDown, Loader2 } from "lucide-react";
+
+interface DateRange {
+ start: Date | null;
+ end: Date | null;
+}
+
+interface ReportExportDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ title?: string;
+ onExport: (range: DateRange) => void;
+ isLoading?: boolean;
+}
+
+export function ReportExportDialog({
+ open,
+ onOpenChange,
+ title,
+ onExport,
+ isLoading = false,
+}: ReportExportDialogProps) {
+ const [startDate, setStartDate] = useState("");
+ const [endDate, setEndDate] = useState("");
+
+ const handleExport = () => {
+ onExport({
+ start: startDate ? new Date(startDate) : null,
+ end: endDate ? new Date(endDate) : null,
+ });
+ };
+
+ return (
+
+
+
+ {title || "Export Report"}
+
+ Select a date range to filter the exported data. Leave blank to export all available records.
+
+
+
+
+ onOpenChange(false)} disabled={isLoading}>
+ Cancel
+
+
+ {isLoading ? (
+ <>
+
+ Generating...
+ >
+ ) : (
+ <>
+
+ Export PDF
+ >
+ )}
+
+
+
+
+ );
+}
diff --git a/src/components/ReportPreviewPanel.tsx b/src/components/ReportPreviewPanel.tsx
new file mode 100644
index 0000000..b903957
--- /dev/null
+++ b/src/components/ReportPreviewPanel.tsx
@@ -0,0 +1,60 @@
+import { AlertCircle, Loader2 } from "lucide-react";
+import ARCApplicationsSection from "./report-sections/ARCApplicationsSection";
+import BidsQuotesSection from "./report-sections/BidsQuotesSection";
+import BoardVotesSection from "./report-sections/BoardVotesSection";
+import CallLogsSection from "./report-sections/CallLogsSection";
+import CollectionsSection from "./report-sections/CollectionsSection";
+import EstoppelsSection from "./report-sections/EstoppelsSection";
+import HomeownerRequestsSection from "./report-sections/HomeownerRequestsSection";
+import LegalMattersSection from "./report-sections/LegalMattersSection";
+import OwnerUpdatesSection from "./report-sections/OwnerUpdatesSection";
+import StatusUpdatesSection from "./report-sections/StatusUpdatesSection";
+import ViolationsSection from "./report-sections/ViolationsSection";
+
+interface ReportPreviewPanelProps {
+ data: Record;
+ loading: boolean;
+}
+
+export default function ReportPreviewPanel({ data, loading }: ReportPreviewPanelProps) {
+ if (loading) {
+ return (
+
+
+
+
Generating preview...
+
+
+ );
+ }
+
+ const hasData = Object.values(data).some((arr) => arr && arr.length > 0);
+
+ if (!hasData) {
+ return (
+
+
+
No Activity Found
+
There is no data recorded for the selected period.
+
+ );
+ }
+
+ return (
+
+
+ {data.statusUpdates?.length > 0 &&
}
+ {data.ownerUpdates?.length > 0 &&
}
+ {data.arcApplications?.length > 0 &&
}
+ {data.violations?.length > 0 &&
}
+ {data.collections?.length > 0 &&
}
+ {data.legalMatters?.length > 0 &&
}
+ {data.estoppels?.length > 0 &&
}
+ {data.homeownerRequests?.length > 0 &&
}
+ {data.bidsQuotes?.length > 0 &&
}
+ {data.boardVotes?.length > 0 &&
}
+ {data.callLogs?.length > 0 &&
}
+
+
+ );
+}
diff --git a/src/components/RestoreUsersDialog.jsx b/src/components/RestoreUsersDialog.jsx
new file mode 100644
index 0000000..c47a326
--- /dev/null
+++ b/src/components/RestoreUsersDialog.jsx
@@ -0,0 +1,135 @@
+
+import React, { useState } from 'react';
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
+import { Button } from '@/components/ui/button';
+import { Loader2, RefreshCw, CheckCircle2, UserCheck, AlertCircle } from 'lucide-react';
+import { useRestoreUsers } from '@/hooks/useRestoreUsers';
+import { ScrollArea } from '@/components/ui/scroll-area';
+import { Badge } from '@/components/ui/badge';
+import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
+import { useToast } from '@/hooks/use-toast';
+
+export default function RestoreUsersDialog({ open, onOpenChange, onSuccess }) {
+ const { toast } = useToast();
+ const { syncUsers, loading: syncLoading } = useRestoreUsers();
+ const [loading, setLoading] = useState(false);
+ const [results, setResults] = useState(null);
+ const [hasSynced, setHasSynced] = useState(false);
+
+ const handleSync = async () => {
+ setLoading(true);
+ setResults(null);
+ try {
+ const result = await syncUsers();
+ if (result.success) {
+ setResults(result);
+ setHasSynced(true);
+ toast({ title: "Sync Successful", description: `Restored ${result.restored_count} users.` });
+ if (onSuccess) onSuccess(result.restored_count);
+ } else {
+ throw new Error(result.error || "Unknown error occurred during sync.");
+ }
+ } catch (err) {
+ toast({ title: "Sync Failed", description: err.message, variant: "destructive" });
+ } finally { setLoading(false); }
+ };
+
+ const handleClose = () => {
+ if (!loading) { setResults(null); setHasSynced(false); onOpenChange(false); }
+ };
+
+ return (
+
+
+
+
+
+ Sync Authentication Users
+
+
+ This utility scans the authentication database for users that are missing from your main users table and restores them automatically.
+
+
+
+
+ {!hasSynced && !loading && (
+
+
+
+
+
Ready to Sync
+
Click the button below to start the synchronization process.
+
Start Synchronization
+
+ )}
+
+ {loading && (
+
+
+
Syncing user database... Please do not close.
+
+ )}
+
+ {results && !loading && (
+
+
+
+
{results.total_auth_users || 0}
+
Total Auth
+
+
+
+{results.restored_count || 0}
+
Restored
+
+
+
{results.updated_count || 0}
+
Updated
+
+
+
+ {results.restored_count > 0 ? (
+
+
+ Success
+
+ Successfully restored {results.restored_count} user profiles.
+
+
+ ) : (
+
+
+ Up to Date
+ All authentication accounts already have matching user profiles.
+
+ )}
+
+ {results.restored_users_list?.length > 0 && (
+
+
+ Restored Users ({results.restored_users_list.length})
+
+
+
+ {results.restored_users_list.map((u) => (
+
+ {u.email}
+ Restored
+
+ ))}
+
+
+
+ )}
+
+ )}
+
+
+
+
+ {hasSynced ? "Done" : "Cancel"}
+
+
+
+
+ );
+}
diff --git a/src/components/RosterImportDialog.jsx b/src/components/RosterImportDialog.jsx
new file mode 100644
index 0000000..d0c51c0
--- /dev/null
+++ b/src/components/RosterImportDialog.jsx
@@ -0,0 +1,246 @@
+
+import React, { useState, useEffect } from 'react';
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
+import { Button } from '@/components/ui/button';
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
+import { Label } from '@/components/ui/label';
+import { useToast } from '@/hooks/use-toast';
+import { supabase } from '@/integrations/supabase/client';
+import * as XLSX from 'xlsx';
+import { Upload, AlertCircle, FileSpreadsheet, Loader2, Check } from 'lucide-react';
+import { cn } from '@/lib/utils';
+
+const REQUIRED_FIELDS = [
+ { key: 'account_number', label: 'Account Number', required: true },
+ { key: 'owner_name', label: 'Owners (Name)', required: true },
+ { key: 'property_address', label: 'Property Address', required: true },
+];
+
+const OPTIONAL_FIELDS = [
+ { key: 'unit_id', label: 'Unit ID', required: false },
+ { key: 'client_name', label: 'Client (Association)', required: false },
+ { key: 'mailing_address', label: 'Mailing Address', required: false },
+ { key: 'alternate_address_1', label: 'Alternate Address 1', required: false },
+ { key: 'alternate_address_2', label: 'Alternate Address 2', required: false },
+ { key: 'mail_format_1', label: 'Mail Format 1', required: false },
+ { key: 'mail_format_2', label: 'Mail Format 2', required: false },
+ { key: 'mail_format_3', label: 'Mail Format 3', required: false },
+ { key: 'mail_format_4', label: 'Mail Format 4', required: false },
+ { key: 'email', label: 'Email', required: false },
+ { key: 'phone', label: 'Phone', required: false },
+ { key: 'balance', label: 'Balance', required: false },
+];
+
+const ALL_FIELDS = [...REQUIRED_FIELDS, ...OPTIONAL_FIELDS];
+
+export default function RosterImportDialog({ open, onOpenChange, clientId, onSuccess }) {
+ const { toast } = useToast();
+ const [step, setStep] = useState(1);
+ const [file, setFile] = useState(null);
+ const [headers, setHeaders] = useState([]);
+ const [parsedData, setParsedData] = useState([]);
+ const [mapping, setMapping] = useState({});
+ const [loading, setLoading] = useState(false);
+ const [importStats, setImportStats] = useState(null);
+
+ useEffect(() => {
+ if (open) { setStep(1); setFile(null); setHeaders([]); setParsedData([]); setMapping({}); setImportStats(null); }
+ }, [open]);
+
+ const handleFileChange = (e) => {
+ const selectedFile = e.target.files[0];
+ if (selectedFile) { setFile(selectedFile); parseFile(selectedFile); }
+ };
+
+ const parseFile = (file) => {
+ setLoading(true);
+ const reader = new FileReader();
+ reader.onload = (e) => {
+ try {
+ const data = new Uint8Array(e.target.result);
+ const workbook = XLSX.read(data, { type: 'array' });
+ const sheetName = workbook.SheetNames[0];
+ const worksheet = workbook.Sheets[sheetName];
+ const jsonData = XLSX.utils.sheet_to_json(worksheet, { header: 1 });
+ if (jsonData.length < 2) throw new Error("File appears empty or missing headers");
+
+ const fileHeaders = jsonData[0].map(h => String(h).trim());
+ const rows = jsonData.slice(1);
+ setHeaders(fileHeaders);
+ setParsedData(rows);
+
+ const newMapping = {};
+ ALL_FIELDS.forEach(field => {
+ let match = fileHeaders.find(h => h.toLowerCase() === field.label.toLowerCase());
+ if (!match) match = fileHeaders.find(h => h.toLowerCase() === field.key.toLowerCase().replace(/_/g, ' '));
+ if (!match) {
+ if (field.key === 'owner_name' && fileHeaders.includes('Owner')) match = 'Owner';
+ if (field.key === 'owner_name' && (fileHeaders.includes('Name') || fileHeaders.includes('Owner Name'))) match = fileHeaders.includes('Owner Name') ? 'Owner Name' : 'Name';
+ if (field.key === 'account_number' && (fileHeaders.includes('Account') || fileHeaders.includes('Account Number'))) match = fileHeaders.includes('Account Number') ? 'Account Number' : 'Account';
+ if (field.key === 'property_address' && fileHeaders.includes('Address')) match = 'Address';
+ if (field.key === 'client_name' && fileHeaders.includes('Client')) match = 'Client';
+ if (field.key === 'unit_id' && (fileHeaders.includes('Unit') || fileHeaders.includes('Unit ID') || fileHeaders.includes('Unit Number'))) match = fileHeaders.find(h => ['Unit ID', 'Unit Number', 'Unit'].includes(h));
+ if (field.key === 'mailing_address' && fileHeaders.includes('Mailing Address')) match = 'Mailing Address';
+ if (field.key === 'alternate_address_1' && fileHeaders.includes('Alternate Address 1')) match = 'Alternate Address 1';
+ if (field.key === 'alternate_address_2' && fileHeaders.includes('Alternate Address 2')) match = 'Alternate Address 2';
+ }
+ if (match) newMapping[field.key] = match;
+ });
+ setMapping(newMapping);
+ setStep(2);
+ } catch (error) {
+ console.error(error);
+ toast({ variant: "destructive", title: "Error parsing file", description: error.message });
+ } finally { setLoading(false); }
+ };
+ reader.readAsArrayBuffer(file);
+ };
+
+ const handleMappingChange = (fieldKey, header) => setMapping(prev => ({ ...prev, [fieldKey]: header }));
+
+ const processImport = async () => {
+ if (!clientId) { toast({ variant: "destructive", title: "Error", description: "No association selected." }); return; }
+ const missingRequired = REQUIRED_FIELDS.filter(f => f.required && !mapping[f.key]);
+ if (missingRequired.length > 0) {
+ toast({ variant: "destructive", title: "Missing Mappings", description: `Please map: ${missingRequired.map(f => f.label).join(', ')}` });
+ return;
+ }
+
+ setLoading(true);
+ try {
+ const ownersToUpsert = [];
+ let skippedCount = 0;
+ const headerMap = headers.reduce((acc, h, i) => { acc[h] = i; return acc; }, {});
+
+ for (const row of parsedData) {
+ const getValue = (key) => {
+ const header = mapping[key];
+ if (!header) return null;
+ const index = headerMap[header];
+ return row[index];
+ };
+ const accountNo = getValue('account_number');
+ const ownerName = getValue('owner_name');
+ const propAddress = getValue('property_address');
+ if (!accountNo && !ownerName && !propAddress) { skippedCount++; continue; }
+ if (!propAddress) { skippedCount++; continue; }
+
+ // Direct mailing address takes priority; fallback to mail_format fields
+ const directMailing = getValue('mailing_address');
+ const mailFormats = [getValue('mail_format_1'), getValue('mail_format_2'), getValue('mail_format_3'), getValue('mail_format_4')]
+ .filter(val => val && String(val).trim().length > 0);
+ const mailingAddress = directMailing ? String(directMailing) : (mailFormats.length > 0 ? mailFormats.join('\n') : '');
+
+ // Split owner name into first/last
+ const fullName = String(ownerName || 'Unknown').trim();
+ const nameParts = fullName.split(/\s+/);
+ const firstName = nameParts[0] || 'Unknown';
+ const lastName = nameParts.length > 1 ? nameParts.slice(1).join(' ') : '';
+
+ ownersToUpsert.push({
+ association_id: clientId, account_number: String(accountNo || ''),
+ first_name: firstName, last_name: lastName, property_address: String(propAddress),
+ mailing_address: mailingAddress,
+ alternate_address_1: getValue('alternate_address_1') ? String(getValue('alternate_address_1')) : '',
+ alternate_address_2: getValue('alternate_address_2') ? String(getValue('alternate_address_2')) : '',
+ email: getValue('email') || '',
+ phone: getValue('phone') || '', balance: parseFloat(getValue('balance') || 0)
+ });
+ }
+
+ if (ownersToUpsert.length === 0) throw new Error("No valid records found to import.");
+
+ const { error } = await supabase.from('owners').upsert(ownersToUpsert, { onConflict: 'association_id, first_name, last_name, property_address' });
+ if (error) throw error;
+
+ setImportStats({ total: parsedData.length, imported: ownersToUpsert.length, skipped: skippedCount });
+ setStep(3);
+ if (onSuccess) onSuccess();
+ } catch (error) {
+ console.error("Import failed:", error);
+ if (error.code === '23505') {
+ toast({ variant: "destructive", title: "Duplicate Constraint Violation", description: "Some records duplicate existing entries." });
+ } else {
+ toast({ variant: "destructive", title: "Import Failed", description: error.message });
+ }
+ } finally { setLoading(false); }
+ };
+
+ return (
+
+
+
+ Import Owner Roster
+ Upload a CSV or Excel file to import property owners.
+
+
+ {step === 1 && (
+
+
+
+ {loading ? : }
+ Select File
+
+
+
Supports .csv, .xlsx
+
+ )}
+
+ {step === 2 && (
+
+
+
+ Map CSV Columns
+
+
+ {ALL_FIELDS.map((field) => (
+
+
+ {field.label} {field.required && '*'}
+
+ handleMappingChange(field.key, val === "ignore" ? null : val)}>
+
+
+ -- Ignore --
+ {headers.map(h => ({h} ))}
+
+
+
+ ))}
+
+
+
+
Update mode: Existing owners matched by property address will be updated , not duplicated.
+
Tip: Use "Mailing Address" for a single address field, or "Mail Format 1-4" to combine multiple lines.
+
+
+ )}
+
+ {step === 3 && importStats && (
+
+
+
+
+
Import Complete!
+
+
{importStats.total}
Total Rows
+
{importStats.imported}
Imported
+
{importStats.skipped}
Skipped
+
+
+ )}
+
+
+ {step === 2 && ( setStep(1)} disabled={loading}>Back )}
+ {step < 3 && (
+
+ {loading && }
+ {step === 1 ? 'Next' : 'Run Import'}
+
+ )}
+ {step === 3 && ( onOpenChange(false)}>Close )}
+
+
+
+ );
+}
diff --git a/src/components/RuleBuilderDialog.jsx b/src/components/RuleBuilderDialog.jsx
new file mode 100644
index 0000000..9d339b6
--- /dev/null
+++ b/src/components/RuleBuilderDialog.jsx
@@ -0,0 +1,269 @@
+
+import React, { useState, useEffect } from 'react';
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from '@/components/ui/dialog';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, SelectGroup, SelectLabel } from '@/components/ui/select';
+import { Switch } from '@/components/ui/switch';
+import { Textarea } from '@/components/ui/textarea';
+import { Badge } from '@/components/ui/badge';
+import { Plus, Trash2, ArrowRight, Save, LayoutTemplate } from 'lucide-react';
+import { supabase } from '@/integrations/supabase/client';
+import { useAuth } from '@/contexts/AuthContext';
+import { useToast } from '@/hooks/use-toast';
+import { RULE_TYPES, OPERATORS, ACTION_TYPES, getAvailableFields } from '@/lib/ruleEngineFields';
+import RuleTestDialog from './RuleTestDialog';
+
+export default function RuleBuilderDialog({ open, onOpenChange, existingRule, onSuccess }) {
+ const { user } = useAuth();
+ const { toast } = useToast();
+ const [step, setStep] = useState(1);
+ const [isSaving, setIsSaving] = useState(false);
+ const [testerOpen, setTesterOpen] = useState(false);
+ const [customParams, setCustomParams] = useState([]);
+
+ const [ruleData, setRuleData] = useState({ name: '', description: '', rule_type: 'journal_entry', priority: 10, status: 'active' });
+ const [conditions, setConditions] = useState([{ id: Date.now(), field: 'amount', operator: 'greater_than', value: '', logical_operator: 'AND' }]);
+ const [actions, setActions] = useState([{ id: Date.now(), action_type: 'set_field', field: 'category', value: '' }]);
+
+ useEffect(() => {
+ if (open) {
+ setStep(1);
+ fetchCustomParameters();
+ if (existingRule) {
+ setRuleData({ name: existingRule.name || '', description: existingRule.description || '', rule_type: existingRule.rule_type || 'journal_entry', priority: existingRule.priority || 10, status: existingRule.status || 'active' });
+ setConditions(existingRule.rule_conditions?.length ? existingRule.rule_conditions.map(c => ({...c, id: c.id || Date.now() + Math.random()})) : [{ id: Date.now(), field: 'amount', operator: 'greater_than', value: '', logical_operator: 'AND' }]);
+ setActions(existingRule.rule_actions?.length ? existingRule.rule_actions.map(a => ({...a, id: a.id || Date.now() + Math.random()})) : [{ id: Date.now(), action_type: 'set_field', field: 'category', value: '' }]);
+ } else {
+ setRuleData({ name: '', description: '', rule_type: 'journal_entry', priority: 10, status: 'active' });
+ setConditions([{ id: Date.now(), field: 'amount', operator: 'greater_than', value: '', logical_operator: 'AND' }]);
+ setActions([{ id: Date.now(), action_type: 'set_field', field: 'category', value: '' }]);
+ }
+ }
+ }, [open, existingRule, user]);
+
+ const fetchCustomParameters = async () => {
+ if (!user) return;
+ try {
+ const { data, error } = await supabase.from('custom_parameters').select('*');
+ if (error) throw error;
+ setCustomParams(data || []);
+ } catch (err) { console.error('Error fetching custom parameters:', err); }
+ };
+
+ const predefinedFields = getAvailableFields(ruleData.rule_type);
+ const allFields = [...predefinedFields, ...customParams.map(cp => ({ value: cp.name, label: `${cp.name} (Custom)`, type: cp.type, isCustom: true }))];
+
+ const renderFieldOptions = () => (
+ <>
+ Standard Fields {predefinedFields.map(f => {f.label} )}
+ {customParams.length > 0 && (Global Parameters {customParams.map(cp => {cp.name} )} )}
+ >
+ );
+
+ const getFieldLabel = (fieldValue) => { const field = allFields.find(f => f.value === fieldValue); return field ? field.label : fieldValue; };
+
+ const handleSave = async () => {
+ if (!ruleData.name.trim()) return toast({ variant: 'destructive', description: 'Rule name is required.' });
+ if (conditions.length === 0) return toast({ variant: 'destructive', description: 'At least one condition is required.' });
+ if (actions.length === 0) return toast({ variant: 'destructive', description: 'At least one action is required.' });
+
+ setIsSaving(true);
+ try {
+ let savedRuleId = existingRule?.id;
+ const rulePayload = { name: ruleData.name, description: ruleData.description, rule_type: ruleData.rule_type, priority: parseInt(ruleData.priority) || 10, status: ruleData.status, updated_at: new Date().toISOString() };
+
+ if (savedRuleId) {
+ await supabase.from('accounting_rules').update(rulePayload).eq('id', savedRuleId);
+ } else {
+ const { data, error } = await supabase.from('accounting_rules').insert([rulePayload]).select().single();
+ if (error) throw error;
+ savedRuleId = data.id;
+ }
+
+ await supabase.from('rule_conditions').delete().eq('rule_id', savedRuleId);
+ const condPayload = conditions.map((c, i) => ({ rule_id: savedRuleId, field: c.field, operator: c.operator, value: c.value, logical_operator: i === 0 ? 'AND' : c.logical_operator, order_index: i }));
+ await supabase.from('rule_conditions').insert(condPayload);
+
+ await supabase.from('rule_actions').delete().eq('rule_id', savedRuleId);
+ const actPayload = actions.map((a, i) => ({ rule_id: savedRuleId, action_type: a.action_type, field: a.field, value: a.value, order_index: i }));
+ await supabase.from('rule_actions').insert(actPayload);
+
+ toast({ title: 'Success', description: `Global rule ${existingRule ? 'updated' : 'created'} successfully.` });
+ onSuccess();
+ onOpenChange(false);
+ } catch (err) {
+ console.error(err);
+ toast({ variant: 'destructive', title: 'Error', description: err.message });
+ } finally { setIsSaving(false); }
+ };
+
+ return (
+ <>
+
+
+
+ {existingRule ? 'Edit Global Rule' : 'Build Global Accounting Rule'}
+ Automate categorization, flagging, and blocking of entries globally across all associations.
+
+
+
+ {[1, 2, 3, 4].map(s => (
+
s ? 'border-emerald-500 text-emerald-600' : 'border-transparent text-muted-foreground'}`}>
+ {s === 1 ? '1. Basics' : s === 2 ? '2. Conditions' : s === 3 ? '3. Actions' : '4. Review'}
+
+ ))}
+
+
+
+ {step === 1 && (
+
+
+
Global Rule Name * setRuleData({...ruleData, name: e.target.value})} placeholder="e.g., Auto-categorize Home Depot to Maintenance" />
+
+
Target Entity Type
+
setRuleData({...ruleData, rule_type: v})}>
+
+ {RULE_TYPES.map(t => {t.label} )}
+
+
Determines which entry stream this global rule applies to.
+
+
+
Priority (Execution Order)
+
setRuleData({...ruleData, priority: e.target.value})} />
+
Higher numbers run first.
+
+
Description setRuleData({...ruleData, description: e.target.value})} className="h-20" />
+
+ setRuleData({...ruleData, status: v ? 'active' : 'inactive'})} />
+ Rule Active Globally
+
+
+
+ )}
+
+ {step === 2 && (
+
+
+
IF these conditions are met... Define the triggers for this rule.
+
setConditions([...conditions, { id: Date.now(), field: predefinedFields[0]?.value, operator: 'equals', value: '', logical_operator: 'AND' }])}> Add Condition
+
+ {conditions.map((cond, index) => (
+
+ {index > 0 ? (
+
{ const n = [...conditions]; n[index].logical_operator = v; setConditions(n); }}>
+
+ AND OR
+
+ ) :
WHERE
}
+
{ const n = [...conditions]; n[index].field = v; setConditions(n); }}>
+
+ {renderFieldOptions()}
+
+
{ const n = [...conditions]; n[index].operator = v; setConditions(n); }}>
+
+ {OPERATORS.map(o => {o.label} )}
+
+
{ const n = [...conditions]; n[index].value = e.target.value; setConditions(n); }} className="flex-1" />
+
setConditions(conditions.filter(c => c.id !== cond.id))} disabled={conditions.length === 1}>
+
+ ))}
+
+ )}
+
+ {step === 3 && (
+
+
+
THEN perform these actions... What happens when the conditions match.
+
setActions([...actions, { id: Date.now(), action_type: 'set_field', field: predefinedFields[0]?.value, value: '' }])}> Add Action
+
+ {actions.map((act, index) => (
+
+ { const n = [...actions]; n[index].action_type = v; setActions(n); }}>
+
+ {ACTION_TYPES.map(a => {a.label} )}
+
+ {act.action_type === 'set_field' && (
+ { const n = [...actions]; n[index].field = v; setActions(n); }}>
+
+ {renderFieldOptions()}
+
+ )}
+ {act.action_type !== 'flag_review' && (
+ { const n = [...actions]; n[index].value = e.target.value; setActions(n); }} className="flex-1" />
+ )}
+ setActions(actions.filter(a => a.id !== act.id))} disabled={actions.length === 1}>
+
+ ))}
+
+ )}
+
+ {step === 4 && (
+
+
+
{ruleData.name || 'Unnamed Rule'}
+
+ {RULE_TYPES.find(t=>t.value===ruleData.rule_type)?.label}
+ Priority: {ruleData.priority}
+ {ruleData.status.toUpperCase()}
+
+
+
+
If
+
+ {conditions.map((c, i) => (
+
+ {i > 0 && {c.logical_operator} }
+ {getFieldLabel(c.field)}
+ {OPERATORS.find(o=>o.value===c.operator)?.label.toLowerCase()}
+ "{c.value}"
+
+ ))}
+
+
+
+
Then
+
+ {actions.map((a) => (
+
+
+ {a.action_type === 'set_field' && Set {getFieldLabel(a.field)} to "{a.value}" }
+ {a.action_type === 'append_description' && Append "{a.value}" to description }
+ {a.action_type === 'prevent_save' && Block entry saving. Reason: "{a.value}" }
+ {a.action_type === 'flag_review' && Flag entry for review }
+
+ ))}
+
+
+
+
+
+ setTesterOpen(true)} className="bg-primary/5 text-primary border-primary/20 hover:bg-primary/10">
+ Test Rule Logic
+
+
+
+ )}
+
+
+
+ onOpenChange(false)}>Cancel
+
+ {step > 1 &&
setStep(s => s - 1)}>Back }
+ {step < 4 ? (
+
setStep(s => s + 1)}>Next Step
+ ) : (
+
+ {isSaving ? 'Saving...' : <> Save Global Rule>}
+
+ )}
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/src/components/RuleEditorDialog.jsx b/src/components/RuleEditorDialog.jsx
new file mode 100644
index 0000000..c20a748
--- /dev/null
+++ b/src/components/RuleEditorDialog.jsx
@@ -0,0 +1,142 @@
+
+import React, { useState, useEffect } from 'react';
+import { supabase } from '@/integrations/supabase/client';
+import { useToast } from '@/hooks/use-toast';
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
+import { Switch } from '@/components/ui/switch';
+import { Loader2 } from 'lucide-react';
+import { useAuth } from '@/contexts/AuthContext';
+
+export default function RuleEditorDialog({ open, onOpenChange, rule, onSuccess }) {
+ const { toast } = useToast();
+ const { user } = useAuth();
+ const [loading, setLoading] = useState(false);
+ const [accounts, setAccounts] = useState([]);
+
+ const [formData, setFormData] = useState({
+ transaction_type: 'vendor_bill', category_key: '', fund_type: 'operating',
+ debit_account_id: '', credit_account_id: '', priority: 10, is_active: true, description: ''
+ });
+
+ useEffect(() => { fetchAccounts(); }, []);
+
+ useEffect(() => {
+ if (open && rule) {
+ setFormData({
+ transaction_type: rule.transaction_type || 'vendor_bill', category_key: rule.category_key || '',
+ fund_type: rule.fund_type || 'operating', debit_account_id: rule.debit_account_id || '',
+ credit_account_id: rule.credit_account_id || '', priority: rule.priority || 10,
+ is_active: rule.is_active !== false, description: rule.description || ''
+ });
+ } else if (open) {
+ setFormData({ transaction_type: 'vendor_bill', category_key: '', fund_type: 'operating', debit_account_id: '', credit_account_id: '', priority: 10, is_active: true, description: '' });
+ }
+ }, [open, rule]);
+
+ const fetchAccounts = async () => {
+ try {
+ const { data } = await supabase.from('chart_of_accounts').select('id, account_name, account_number, account_type').order('account_number');
+ if (data) setAccounts(data);
+ } catch (err) { console.error(err); }
+ };
+
+ const handleChange = (e) => { const { name, value } = e.target; setFormData(prev => ({ ...prev, [name]: value })); };
+
+ const handleSubmit = async (e) => {
+ e.preventDefault();
+ if (formData.debit_account_id === formData.credit_account_id) {
+ toast({ variant: 'destructive', title: 'Error', description: 'Debit and Credit accounts must be different.' }); return;
+ }
+ if (formData.priority <= 0) {
+ toast({ variant: 'destructive', title: 'Error', description: 'Priority must be > 0.' }); return;
+ }
+
+ setLoading(true);
+ try {
+ const payload = { ...formData };
+
+ if (rule?.id) {
+ const { error } = await supabase.from('coa_mapping_rules').update(payload).eq('id', rule.id);
+ if (error) throw error;
+ toast({ title: 'Success', description: 'Rule updated successfully.' });
+ } else {
+ const { error } = await supabase.from('coa_mapping_rules').insert([payload]);
+ if (error) throw error;
+ toast({ title: 'Success', description: 'Rule created successfully.' });
+ }
+ onSuccess?.();
+ onOpenChange(false);
+ } catch (err) {
+ console.error(err);
+ toast({ variant: 'destructive', title: 'Error saving rule', description: err.message });
+ } finally { setLoading(false); }
+ };
+
+ return (
+
+
+ {rule ? 'Edit Mapping Rule' : 'Create Mapping Rule'}
+
+
+
+ Transaction Type
+ setFormData(p => ({...p, transaction_type: val}))}>
+
+
+ Vendor Bill
+ Owner Charge
+ Owner Payment
+ Bank Deposit
+
+
+
+
+ Fund Type
+ setFormData(p => ({...p, fund_type: val}))}>
+
+
+ Operating
+ Reserve
+ Special
+
+
+
+
+ Category Key (Optional)
+
+ Debit Account
+ setFormData(p => ({...p, debit_account_id: val}))}>
+
+ {accounts.map(acc => {acc.account_number} - {acc.account_name} )}
+
+
+
+ Credit Account
+ setFormData(p => ({...p, credit_account_id: val}))}>
+
+ {accounts.map(acc => {acc.account_number} - {acc.account_name} )}
+
+
+
+
Priority
+
+
+ setFormData(p => ({...p, is_active: c}))} id="active-mode" />
+ Is Active
+
+
+
+ Description
+
+ onOpenChange(false)}>Cancel
+ {loading && } Save Rule
+
+
+
+
+ );
+}
diff --git a/src/components/RuleTemplateDialog.jsx b/src/components/RuleTemplateDialog.jsx
new file mode 100644
index 0000000..e838cfa
--- /dev/null
+++ b/src/components/RuleTemplateDialog.jsx
@@ -0,0 +1,73 @@
+
+import React from 'react';
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
+import { Button } from '@/components/ui/button';
+import { Copy, Tag, Ban, AlertTriangle, ListChecks } from 'lucide-react';
+
+const TEMPLATES = [
+ {
+ id: 1, name: 'Auto-Categorize Home Depot',
+ description: 'Automatically assigns transactions containing "Home Depot" to the Maintenance Expense category.',
+ rule_type: 'transaction', icon: ,
+ mockRule: { name: 'Auto-Categorize Home Depot', description: 'Automatically assigns transactions containing "Home Depot" to the Maintenance Expense category.', rule_type: 'transaction', priority: 10, status: 'active', rule_conditions: [{ field: 'description', operator: 'contains', value: 'Home Depot', logical_operator: 'AND' }], rule_actions: [{ action_type: 'set_field', field: 'category', value: 'Maintenance' }] }
+ },
+ {
+ id: 2, name: 'Block Large Unexpected Charges',
+ description: 'Prevents saving any manual charge over $5000 to catch typos.',
+ rule_type: 'charge', icon: ,
+ mockRule: { name: 'Block Large Unexpected Charges', description: 'Prevents saving any manual charge over $5000 to catch typos.', rule_type: 'charge', priority: 100, status: 'active', rule_conditions: [{ field: 'amount', operator: 'greater_than', value: '5000', logical_operator: 'AND' }], rule_actions: [{ action_type: 'prevent_save', field: '', value: 'Amount exceeds $5000 threshold limit.' }] }
+ },
+ {
+ id: 3, name: 'Flag Voided Checks',
+ description: 'Flags any journal entry with "VOID" in description for manual review.',
+ rule_type: 'journal_entry', icon: ,
+ mockRule: { name: 'Flag Voided Checks', description: 'Flags any journal entry with "VOID" in description for manual review.', rule_type: 'journal_entry', priority: 50, status: 'active', rule_conditions: [{ field: 'description', operator: 'contains', value: 'VOID', logical_operator: 'AND' }], rule_actions: [{ action_type: 'flag_review', field: '', value: '' }] }
+ },
+ {
+ id: 4, name: 'Standardize Late Fee Descriptions',
+ description: 'Appends "[SYSTEM GENERATED]" to descriptions of automatically created late fees.',
+ rule_type: 'charge', icon: ,
+ mockRule: { name: 'Standardize Late Fee Descriptions', description: 'Appends "[SYSTEM GENERATED]" to descriptions of automatically created late fees.', rule_type: 'charge', priority: 20, status: 'active', rule_conditions: [{ field: 'description', operator: 'contains', value: 'Late Fee', logical_operator: 'AND' }], rule_actions: [{ action_type: 'append_description', field: '', value: '[SYSTEM GENERATED]' }] }
+ },
+ {
+ id: 5, name: 'Default Cash Account for Checks',
+ description: 'Sets the Account ID to your primary checking when payment method is check.',
+ rule_type: 'payment', icon: ,
+ mockRule: { name: 'Default Cash Account for Checks', description: 'Sets the Account ID to your primary checking when payment method is check.', rule_type: 'payment', priority: 10, status: 'active', rule_conditions: [{ field: 'payment_method', operator: 'equals', value: 'Check', logical_operator: 'AND' }], rule_actions: [{ action_type: 'set_field', field: 'account_id', value: 'YOUR_CASH_ACCOUNT_ID_HERE' }] }
+ }
+];
+
+export default function RuleTemplateDialog({ open, onOpenChange, onSelectTemplate }) {
+ return (
+
+
+
+ Accounting Rule Templates
+ Clone a pre-built rule template to quickly set up automation.
+
+
+ {TEMPLATES.map(t => (
+
+
+
+
+
+ {t.description}
+
+ {t.rule_type.replace('_', ' ')}
+ onSelectTemplate(t.mockRule)} className="bg-primary/5 text-primary hover:bg-primary/10 border-primary/20">
+ Use Template
+
+
+
+
+ ))}
+
+
+
+ );
+}
diff --git a/src/components/RuleTestDialog.jsx b/src/components/RuleTestDialog.jsx
new file mode 100644
index 0000000..5e2c901
--- /dev/null
+++ b/src/components/RuleTestDialog.jsx
@@ -0,0 +1,88 @@
+
+import React, { useState } from 'react';
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from '@/components/ui/dialog';
+import { Button } from '@/components/ui/button';
+import { Textarea } from '@/components/ui/textarea';
+import { processEntryWithRules } from '@/lib/ruleEngine';
+import { useToast } from '@/hooks/use-toast';
+import { CheckCircle2, XCircle, AlertTriangle } from 'lucide-react';
+import { Badge } from '@/components/ui/badge';
+
+export default function RuleTestDialog({ open, onOpenChange, ruleType }) {
+ const { toast } = useToast();
+ const [testPayload, setTestPayload] = useState('{\n "amount": 100,\n "description": "Home Depot",\n "category": "Uncategorized"\n}');
+ const [testResult, setTestResult] = useState(null);
+ const [isTesting, setIsTesting] = useState(false);
+
+ const handleTest = async () => {
+ setIsTesting(true);
+ setTestResult(null);
+ try {
+ const parsed = JSON.parse(testPayload);
+ const result = await processEntryWithRules(parsed, ruleType);
+ setTestResult(result);
+ toast({ title: 'Test Complete', description: 'Rule engine processed the entry.' });
+ } catch (err) {
+ toast({ variant: 'destructive', title: 'Invalid JSON', description: 'Please check your payload format.' });
+ } finally { setIsTesting(false); }
+ };
+
+ return (
+
+
+
+ Test Global Rules Engine
+ Simulate an incoming {ruleType ? ruleType.replace('_', ' ') : 'entry'} to see how global active rules will process it.
+
+
+
+
+
Test Payload (JSON)
+
setTestPayload(e.target.value)} className="font-mono text-sm h-[300px] bg-muted/50" />
+ {isTesting ? 'Testing...' : 'Run Test'}
+
+
+
+
Evaluation Results
+
+ {!testResult ? (
+
Run a test to see results
+ ) : (
+
+
+ {testResult.blocked ? (
+ Blocked
+ ) : (
+ Allowed
+ )}
+
+ {testResult.blockReason && (
+
+
{testResult.blockReason}
+
+ )}
+
+
Execution Logs
+ {testResult.logs.length === 0 ? (
+
No rules matched.
+ ) : (
+
+ {testResult.logs.map((log, i) => (
+ {log}
+ ))}
+
+ )}
+
+
+
Final Output Object
+
{JSON.stringify(testResult.entry, null, 2)}
+
+
+ )}
+
+
+
+
+
+ );
+}
diff --git a/src/components/SaveReportDialog.jsx b/src/components/SaveReportDialog.jsx
new file mode 100644
index 0000000..eea06eb
--- /dev/null
+++ b/src/components/SaveReportDialog.jsx
@@ -0,0 +1,41 @@
+
+import React, { useState } from 'react';
+import { Loader2, Save } from 'lucide-react';
+import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+
+export default function SaveReportDialog({ open, onOpenChange, onSave, isSaving }) {
+ const [name, setName] = useState('');
+
+ const handleSubmit = (e) => {
+ e.preventDefault();
+ if (!name.trim()) return;
+ onSave(name);
+ setName('');
+ };
+
+ return (
+
+
+
+ Save Report Configuration
+ Save the current report settings, including client selection, date range, and executive summary.
+
+
+
+ Report Name
+ setName(e.target.value)} autoFocus />
+
+
+ onOpenChange(false)}>Cancel
+
+ {isSaving ? (<> Saving...>) : (<> Save Report>)}
+
+
+
+
+
+ );
+}
diff --git a/src/components/SaveToDocumentsDialog.jsx b/src/components/SaveToDocumentsDialog.jsx
new file mode 100644
index 0000000..cd0c373
--- /dev/null
+++ b/src/components/SaveToDocumentsDialog.jsx
@@ -0,0 +1,272 @@
+
+import React, { useState, useEffect } from 'react';
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from '@/components/ui/dialog';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import { Textarea } from '@/components/ui/textarea';
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
+import { supabase } from '@/integrations/supabase/client';
+import { HardDrive, Loader2 } from 'lucide-react';
+import { toast as sonnerToast } from 'sonner';
+import { useGoogleDrive } from '@/hooks/useGoogleDrive';
+import GoogleDriveFolderPickerDialog from '@/components/documents/GoogleDriveFolderPickerDialog';
+
+const DEFAULT_FOLDERS = [
+ 'Bank Statements',
+ 'Collections',
+ 'Financials',
+ 'Insurance',
+ 'Litigation',
+ 'Management Reports',
+ 'Meetings',
+ 'New Owner Files',
+ 'Owner Correspondence',
+ 'Reports',
+];
+
+export function SaveToDocumentsDialog({
+ open,
+ onOpenChange,
+ onSave,
+ defaultName = '',
+ defaultClientId = '',
+ residentFileMode = false,
+ unitId = '',
+ unitNumber = '',
+ unitAddress = '',
+}) {
+ const [loading, setLoading] = useState(false);
+ const [clients, setClients] = useState([]);
+ const [folders, setFolders] = useState(DEFAULT_FOLDERS);
+ const drive = useGoogleDrive();
+ const [saveToDrive, setSaveToDrive] = useState(false);
+ const [driveFolder, setDriveFolder] = useState(null);
+ const [drivePickerOpen, setDrivePickerOpen] = useState(false);
+ const [resolvedAddress, setResolvedAddress] = useState('');
+
+ // Auto-recognize the unit's street address so the Resident Files folder
+ // is labeled by address instead of an opaque UUID.
+ useEffect(() => {
+ if (!residentFileMode) { setResolvedAddress(''); return; }
+ if (unitAddress) { setResolvedAddress(unitAddress); return; }
+ if (!unitId) { setResolvedAddress(''); return; }
+ let cancelled = false;
+ supabase.from('units').select('address, unit_number').eq('id', unitId).maybeSingle()
+ .then(({ data }) => { if (!cancelled) setResolvedAddress(data?.address || data?.unit_number || ''); });
+ return () => { cancelled = true; };
+ }, [residentFileMode, unitId, unitAddress]);
+
+ const residentFolderName = resolvedAddress || unitAddress || unitNumber || unitId;
+ const residentFolder = residentFolderName ? `Resident Files/${residentFolderName}` : 'Resident Files';
+
+ const [formData, setFormData] = useState({
+ name: defaultName,
+ description: '',
+ clientId: defaultClientId,
+ category: residentFileMode ? residentFolder : 'general'
+ });
+
+ useEffect(() => {
+ if (open) {
+ setFormData({
+ name: defaultName || 'Untitled Document',
+ description: '',
+ clientId: defaultClientId,
+ category: residentFileMode ? residentFolder : 'general'
+ });
+ setSaveToDrive(false);
+ setDriveFolder(null);
+
+ try {
+ const stored = localStorage.getItem('doc_custom_folders');
+ const customFolders = stored ? JSON.parse(stored) : [];
+ setFolders([...new Set([...DEFAULT_FOLDERS, ...customFolders])].sort());
+ } catch {
+ setFolders(DEFAULT_FOLDERS);
+ }
+
+ fetchOptions();
+ }
+ }, [open, defaultName, defaultClientId, residentFileMode, residentFolder]);
+
+ const fetchOptions = async () => {
+ try {
+ const { data, error } = await supabase
+ .from('associations')
+ .select('id, name')
+ .eq('status', 'active')
+ .order('name');
+
+ if (error) {
+ console.error('Error fetching associations for dialog:', error);
+ return;
+ }
+
+ if (data) setClients(data);
+ } catch (err) {
+ console.error('Error fetching options for dialog:', err);
+ }
+ };
+
+ const handleSubmit = async (e) => {
+ e.preventDefault();
+ if (!formData.name || !formData.clientId) return;
+
+ setLoading(true);
+ try {
+ const result = await onSave({
+ name: formData.name,
+ description: formData.description,
+ clientId: formData.clientId,
+ category: formData.category
+ });
+
+ if (saveToDrive) {
+ if (!drive.isConnected) throw new Error('Connect Google Drive before saving there.');
+ const pdfBlob = result instanceof Blob ? result : result?.blob;
+ if (!pdfBlob) throw new Error('This file was saved locally, but could not be sent to Google Drive.');
+ const fileName = `${formData.name.replace(/[^a-zA-Z0-9._ -]+/g, '_').trim() || 'Document'}.pdf`;
+ const driveFile = new File([pdfBlob], fileName, { type: 'application/pdf' });
+ const uploadResult = await drive.uploadFile(driveFile, driveFolder?.id || undefined);
+ if (!uploadResult.success) throw new Error('Document saved locally, but Google Drive upload failed.');
+ }
+ onOpenChange(false);
+ } catch (err) {
+ console.error('Save to documents failed:', err);
+ sonnerToast.error('Save to Documents failed', {
+ description: err?.message || 'Please try again.',
+ });
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+
+
+
+ {residentFileMode ? 'Save to Resident File' : 'Save to Documents'}
+
+ {residentFileMode
+ ? `This document will be saved under "Resident Files / ${residentFolderName || '(no unit)'}" inside the selected association.`
+ : 'Save this PDF into the document library and optionally place it in a folder.'}
+
+
+
+
+
+ Association *
+ setFormData(prev => ({ ...prev, clientId: val }))}
+ required
+ >
+
+
+
+
+ {clients.map(c => (
+ {c.name}
+ ))}
+
+
+
+
+
+ Folder {residentFileMode ? '' : '(Optional)'}
+ {residentFileMode ? (
+
+ ) : (
+ setFormData(prev => ({ ...prev, category: val }))}
+ >
+
+
+
+
+ No Folder (Root Level)
+ {folders.map(folder => (
+ {folder}
+ ))}
+
+
+ )}
+
+
+
+ Document Name *
+ setFormData(prev => ({ ...prev, name: e.target.value }))}
+ placeholder="e.g. Official Notice"
+ required
+ />
+
+
+
+ Description (Optional)
+ setFormData(prev => ({ ...prev, description: e.target.value }))}
+ placeholder="Brief description of this document..."
+ rows={3}
+ />
+
+
+
+
+
+
+ Save to Google Drive
+
+
+ {driveFolder?.name || 'Choose a Drive folder for this PDF.'}
+
+
+
{ setSaveToDrive(true); setDrivePickerOpen(true); }} disabled={!drive.isConnected || loading}>
+ Choose
+
+
+ {!drive.isConnected &&
Connect Google Drive from Documents before saving files there.
}
+ {driveFolder && (
+
{ setSaveToDrive(false); setDriveFolder(null); }}>
+ Remove Google Drive save
+
+ )}
+
+
+
+ onOpenChange(false)} disabled={loading}>
+ Cancel
+
+
+ {loading && }
+ Save Document
+
+
+
+
+
+ { setDriveFolder(selected); setSaveToDrive(true); }}
+ />
+
+ );
+}
+
+export default SaveToDocumentsDialog;
diff --git a/src/components/SecureExpenseImportDialog.jsx b/src/components/SecureExpenseImportDialog.jsx
new file mode 100644
index 0000000..36f9ce5
--- /dev/null
+++ b/src/components/SecureExpenseImportDialog.jsx
@@ -0,0 +1,275 @@
+
+import React, { useState, useRef } from 'react';
+import * as XLSX from 'xlsx';
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
+import { Progress } from '@/components/ui/progress';
+import { ScrollArea } from '@/components/ui/scroll-area';
+import { Upload, FileText, AlertTriangle, CheckCircle, Shield, X, Download } from 'lucide-react';
+import { useAuth } from '@/contexts/AuthContext';
+import { useToast } from '@/hooks/use-toast';
+import { validateExpenseImport } from '@/lib/expenseImportValidator';
+import { logExpenseAudit } from '@/lib/expenseAuditLogger';
+import { supabase } from '@/integrations/supabase/client';
+
+export function SecureExpenseImportDialog({ open, onOpenChange, onSuccess, validCategories, clientId }) {
+ const { user } = useAuth();
+ const { toast } = useToast();
+ const fileInputRef = useRef(null);
+
+ const [file, setFile] = useState(null);
+ const [analyzing, setAnalyzing] = useState(false);
+ const [importing, setImporting] = useState(false);
+ const [validationResult, setValidationResult] = useState(null);
+ const [progress, setProgress] = useState(0);
+
+ const handleFileChange = (e) => {
+ const selectedFile = e.target.files[0];
+ if (selectedFile) {
+ const type = selectedFile.name.split('.').pop().toLowerCase();
+ if (!['csv', 'xlsx', 'xls'].includes(type)) {
+ toast({ variant: "destructive", title: "Invalid File Type", description: "Please upload CSV or Excel files only." });
+ return;
+ }
+ if (selectedFile.size > 10 * 1024 * 1024) {
+ toast({ variant: "destructive", title: "File Too Large", description: "Max file size is 10MB." });
+ return;
+ }
+ setFile(selectedFile);
+ setValidationResult(null);
+ }
+ };
+
+ const analyzeFile = async () => {
+ if (!file) return;
+ setAnalyzing(true);
+
+ const reader = new FileReader();
+ reader.onload = async (e) => {
+ try {
+ const data = e.target.result;
+ let jsonData = [];
+
+ if (file.name.endsWith('.csv')) {
+ const workbook = XLSX.read(data, { type: 'binary' });
+ jsonData = XLSX.utils.sheet_to_json(workbook.Sheets[workbook.SheetNames[0]]);
+ } else {
+ const workbook = XLSX.read(data, { type: 'binary' });
+ jsonData = XLSX.utils.sheet_to_json(workbook.Sheets[workbook.SheetNames[0]]);
+ }
+
+ const normalizedData = jsonData.map(row => {
+ const newRow = {};
+ Object.keys(row).forEach(key => {
+ newRow[key.toLowerCase().trim().replace(/\s+/g, '_')] = row[key];
+ });
+ return newRow;
+ });
+
+ const categorySet = new Set(validCategories.map(c => c.category_name));
+ const result = validateExpenseImport(normalizedData, categorySet, clientId);
+
+ setValidationResult(result);
+
+ if (!result.valid) {
+ await logExpenseAudit({
+ userId: user.id,
+ action: 'IMPORT_VALIDATION_FAILURE',
+ status: 'FAILURE',
+ details: {
+ fileName: file.name,
+ errorCount: result.errors.length,
+ firstError: result.errors[0]
+ }
+ });
+ }
+
+ } catch (err) {
+ console.error(err);
+ toast({ variant: "destructive", title: "Analysis Error", description: "Failed to parse file." });
+ } finally {
+ setAnalyzing(false);
+ }
+ };
+ reader.readAsBinaryString(file);
+ };
+
+ const executeImport = async () => {
+ if (!validationResult || !validationResult.valid) return;
+
+ setImporting(true);
+ setProgress(10);
+
+ try {
+ const records = validationResult.sanitizedRecords;
+ const batchSize = 50;
+ const total = records.length;
+
+ for (let i = 0; i < total; i += batchSize) {
+ const batch = records.slice(i, i + batchSize).map(r => ({
+ ...r,
+ created_by: user.id,
+ status: 'pending',
+ created_at: new Date().toISOString()
+ }));
+
+ const { error } = await supabase.from('billable_expenses').insert(batch);
+ if (error) throw error;
+
+ setProgress(10 + Math.round(((i + batchSize) / total) * 90));
+ }
+
+ await logExpenseAudit({
+ userId: user.id,
+ action: 'IMPORT_SUCCESS',
+ status: 'SUCCESS',
+ details: { fileName: file.name, recordCount: total }
+ });
+
+ toast({ title: "Import Successful", description: `Imported ${total} expenses.` });
+ if (onSuccess) onSuccess();
+ onOpenChange(false);
+
+ } catch (err) {
+ console.error(err);
+ toast({ variant: "destructive", title: "Import Failed", description: err.message });
+
+ await logExpenseAudit({
+ userId: user.id,
+ action: 'IMPORT_FAILURE',
+ status: 'FAILURE',
+ details: { fileName: file.name, error: err.message }
+ });
+ } finally {
+ setImporting(false);
+ setProgress(0);
+ }
+ };
+
+ const downloadTemplate = () => {
+ const headers = ['amount', 'date', 'description', 'category', 'client_id', 'billable_type', 'unit_price', 'quantity', 'address'];
+ const ws = XLSX.utils.aoa_to_sheet([headers]);
+ const wb = XLSX.utils.book_new();
+ XLSX.utils.book_append_sheet(wb, ws, "Template");
+ XLSX.writeFile(wb, "expense_import_template.xlsx");
+ };
+
+ return (
+ {
+ if (!importing) {
+ setFile(null);
+ setValidationResult(null);
+ onOpenChange(val);
+ }
+ }}>
+
+
+
+
+ Secure Expense Import
+
+
+ Import expenses from CSV/Excel. Data is validated for security and integrity.
+
+
+
+
+ {!file ? (
+
+
+
Upload Expense File
+
CSV or Excel (max 10MB)
+
+ fileInputRef.current?.click()}>
+ Select File
+
+
+
+
+
+
+
+ ) : (
+
+
+
+
+ {file.name}
+
+
{ setFile(null); setValidationResult(null); }} disabled={importing || analyzing}>
+
+
+
+
+ {!validationResult && (
+
+ {analyzing ? "Analyzing..." : "Validate & Preview"}
+
+ )}
+
+ {validationResult && (
+
+ {validationResult.valid ? (
+
+
+ Validation Passed
+
+ Ready to import {validationResult.sanitizedRecords.length} records.
+
+
+ ) : (
+
+
+ Validation Failed
+
+ Found {validationResult.errors.length} issues. Import blocked.
+
+
+ )}
+
+ {!validationResult.valid && (
+
+ {validationResult.errors.map((err, i) => (
+
+ Row {err.row}: {err.messages.join(', ')}
+
+ ))}
+
+ )}
+
+ )}
+
+ {importing && (
+
+
+ Importing...
+ {progress}%
+
+
+
+ )}
+
+ )}
+
+
+
+ onOpenChange(false)} disabled={importing}>Close
+ {validationResult?.valid && (
+
+ {importing ? "Importing..." : "Confirm Import"}
+
+ )}
+
+
+
+ );
+}
diff --git a/src/components/SettingsSidebar.tsx b/src/components/SettingsSidebar.tsx
new file mode 100644
index 0000000..6b21f7c
--- /dev/null
+++ b/src/components/SettingsSidebar.tsx
@@ -0,0 +1,118 @@
+import { NavLink, useLocation } from "react-router-dom";
+import {
+ Settings, Users, Mail, ShieldCheck, Bell, UserCircle,
+ Landmark, ScrollText, LayoutDashboard, Network, BookOpen, Paintbrush, CreditCard, BellRing, Building2, CalendarClock, Eye, PenLine, Wallet,
+} from "lucide-react";
+import { cn } from "@/lib/utils";
+
+export const SETTINGS_PAGES = [
+ {
+ category: "My Account",
+ items: [
+ { path: "/dashboard/settings/profile", title: "My Profile", icon: UserCircle },
+ ],
+ },
+ {
+ category: "General Settings",
+ items: [
+ { path: "/dashboard/settings/general", title: "General Config", icon: Settings },
+ { path: "/dashboard/settings/branding", title: "Branding & Logos", icon: Paintbrush },
+ { path: "/dashboard/settings/role-permissions", title: "Role Permissions", icon: ShieldCheck },
+ { path: "/dashboard/settings/portal-visibility", title: "Portal Visibility", icon: Eye },
+ ],
+ },
+ {
+ category: "User Management",
+ items: [
+ { path: "/dashboard/user-management", title: "User Management", icon: Users },
+ ],
+ },
+ {
+ category: "Accounting Setup",
+ items: [
+ { path: "/dashboard/chart-of-accounts", title: "Chart of Accounts", icon: Network },
+ { path: "/dashboard/settings/recurring-rules", title: "Recurring Rules", icon: CalendarClock },
+ { path: "/dashboard/company-bank-accounts", title: "Company Banking", icon: Landmark },
+ { path: "/dashboard/company-bank-register", title: "Company Bank Register", icon: Wallet },
+ { path: "/dashboard/company-checks", title: "Company Checks", icon: PenLine },
+ ],
+ },
+ {
+ category: "Email & Notifications",
+ items: [
+ { path: "/dashboard/email-senders", title: "Email Senders & SMTP", icon: Mail },
+ { path: "/dashboard/email-templates", title: "Template Library", icon: ScrollText },
+ { path: "/dashboard/notify-owners", title: "Blast Notifications", icon: BellRing },
+ ],
+ },
+ {
+ category: "Integrations",
+ items: [
+ { path: "/dashboard/settings/zoho-books", title: "Zoho Books", icon: BookOpen },
+ { path: "/dashboard/settings/buildium", title: "Buildium", icon: Building2 },
+ { path: "/dashboard/settings/stripe-accounts", title: "Payment Gateways", icon: CreditCard },
+ { path: "/dashboard/mailchimp", title: "Mailchimp", icon: Mail },
+ ],
+ },
+];
+
+interface SettingsSidebarProps {
+ onItemClick?: () => void;
+}
+
+export default function SettingsSidebar({ onItemClick }: SettingsSidebarProps) {
+ const location = useLocation();
+
+ return (
+
+
+
+
+ Settings
+
+
System Configuration
+
+
+
+ {SETTINGS_PAGES.map((group, index) => (
+
+
+ {group.category}
+
+
+ {group.items.map((item) => {
+ const Icon = item.icon;
+ const isActive = location.pathname.startsWith(item.path);
+
+ return (
+
+ {isActive && (
+
+ )}
+
+ {item.title}
+
+ );
+ })}
+
+
+ ))}
+
+
+ );
+}
diff --git a/src/components/ShareDialog.tsx b/src/components/ShareDialog.tsx
new file mode 100644
index 0000000..a066a79
--- /dev/null
+++ b/src/components/ShareDialog.tsx
@@ -0,0 +1,182 @@
+import { useState, useEffect } from "react";
+import { supabase } from "@/integrations/supabase/client";
+import { useToast } from "@/hooks/use-toast";
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Switch } from "@/components/ui/switch";
+import { Globe, Copy, RefreshCw, Lock, Shield } from "lucide-react";
+
+function generateCode(length = 8): string {
+ const chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
+ let code = "";
+ for (let i = 0; i < length; i++) code += chars[Math.floor(Math.random() * chars.length)];
+ return code;
+}
+
+interface ShareDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ shareType: "folder" | "document";
+ folderName?: string;
+ documentId?: string;
+ itemName: string;
+}
+
+export default function ShareDialog({ open, onOpenChange, shareType, folderName, documentId, itemName }: ShareDialogProps) {
+ const { toast } = useToast();
+ const [link, setLink] = useState(null);
+ const [loading, setLoading] = useState(false);
+ const [saving, setSaving] = useState(false);
+
+ useEffect(() => {
+ if (open) fetchExisting();
+ }, [open, shareType, folderName, documentId]);
+
+ const fetchExisting = async () => {
+ setLoading(true);
+ let query = supabase.from("shared_links").select("*");
+ if (shareType === "folder") query = query.eq("share_type", "folder").eq("folder_name", folderName || "");
+ else query = query.eq("share_type", "document").eq("document_id", documentId || "");
+ const { data } = await query.limit(1).single();
+ setLink(data || null);
+ setLoading(false);
+ };
+
+ const baseUrl = window.location.origin;
+
+ const handleTogglePublic = async (enabled: boolean) => {
+ setSaving(true);
+ if (!link) {
+ const { data: { user } } = await supabase.auth.getUser();
+ const newCode = generateCode();
+ const payload: any = {
+ share_type: shareType,
+ is_public: enabled,
+ access_code: newCode,
+ created_by: user?.id,
+ };
+ if (shareType === "folder") payload.folder_name = folderName;
+ else payload.document_id = documentId;
+ const { data, error } = await supabase.from("shared_links").insert(payload).select().single();
+ if (error) toast({ title: "Error", description: error.message, variant: "destructive" });
+ else setLink(data);
+ } else {
+ const { data, error } = await supabase.from("shared_links").update({ is_public: enabled }).eq("id", link.id).select().single();
+ if (error) toast({ title: "Error", description: error.message, variant: "destructive" });
+ else setLink(data);
+ }
+ setSaving(false);
+ };
+
+ const regenerateLink = async () => {
+ if (!link) return;
+ const newToken = Array.from(crypto.getRandomValues(new Uint8Array(16))).map(b => b.toString(16).padStart(2, "0")).join("");
+ const { data, error } = await supabase.from("shared_links").update({ share_token: newToken }).eq("id", link.id).select().single();
+ if (error) toast({ title: "Error", description: error.message, variant: "destructive" });
+ else { setLink(data); toast({ title: "Link regenerated" }); }
+ };
+
+ const regenerateCode = async () => {
+ if (!link) return;
+ const newCode = generateCode();
+ const { data, error } = await supabase.from("shared_links").update({ access_code: newCode }).eq("id", link.id).select().single();
+ if (error) toast({ title: "Error", description: error.message, variant: "destructive" });
+ else { setLink(data); toast({ title: "Access code regenerated" }); }
+ };
+
+ const copyToClipboard = (text: string, label: string) => {
+ navigator.clipboard.writeText(text);
+ toast({ title: "Copied", description: `${label} copied to clipboard.` });
+ };
+
+ const shareUrl = link ? `${baseUrl}/shared/${link.share_token}` : "";
+
+ return (
+
+
+
+
+
+ Share {shareType === "folder" ? "Folder" : "Document"}: {itemName}
+
+ Generate a secure link for external users.
+
+
+ {loading ? (
+ Loading...
+ ) : (
+
+ {/* Public Sharing Toggle */}
+
+
+
Public Sharing
+
+ {link?.is_public ? "Enabled - Folder is accessible with link & code" : "Disabled - Not shared"}
+
+
+
+
+
+ {link?.is_public && (
+ <>
+ {/* Share Link */}
+
+
1. Share Link
+
+
+ copyToClipboard(shareUrl, "Share link")}>
+
+
+
+
+ Regenerate Link
+
+
+
+ {/* Access Code */}
+
+
+ 2. Access Code
+
+ New Code
+
+
+
+
+
+ Authentication Required
+
+
+
+ copyToClipboard(link?.access_code || "", "Access code")}>
+
+
+
+
+ External users must enter this code to view files.
+
+
+
+ >
+ )}
+
+ )}
+
+
+ onOpenChange(false)}>Cancel
+ onOpenChange(false)}>Save Settings
+
+
+
+ );
+}
diff --git a/src/components/SignatureFieldPlacer.tsx b/src/components/SignatureFieldPlacer.tsx
new file mode 100644
index 0000000..7545437
--- /dev/null
+++ b/src/components/SignatureFieldPlacer.tsx
@@ -0,0 +1,195 @@
+import { useEffect, useRef, useState } from "react";
+import { Document, Page, pdfjs } from "react-pdf";
+import { Button } from "@/components/ui/button";
+import { Badge } from "@/components/ui/badge";
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
+import { ChevronLeft, ChevronRight, X, Pen, Calendar as CalIcon, User as UserIcon } from "lucide-react";
+import { cn } from "@/lib/utils";
+
+// Use the bundled worker
+pdfjs.GlobalWorkerOptions.workerSrc = new URL(
+ "pdfjs-dist/build/pdf.worker.min.mjs",
+ import.meta.url
+).toString();
+
+export interface PlacedField {
+ id: string;
+ recipientIndex: number;
+ field_type: "signature" | "date" | "name";
+ page_number: number;
+ x_ratio: number;
+ y_ratio: number;
+ width_ratio: number;
+ height_ratio: number;
+}
+
+interface Props {
+ fileUrl: string;
+ recipients: { name: string; email: string }[];
+ fields: PlacedField[];
+ onChange: (fields: PlacedField[]) => void;
+}
+
+const FIELD_COLORS = [
+ "border-primary bg-primary/15 text-primary",
+ "border-emerald-500 bg-emerald-500/15 text-emerald-700",
+ "border-amber-500 bg-amber-500/15 text-amber-700",
+ "border-rose-500 bg-rose-500/15 text-rose-700",
+];
+
+const FIELD_DEFAULTS = {
+ signature: { width_ratio: 0.22, height_ratio: 0.05 },
+ date: { width_ratio: 0.14, height_ratio: 0.035 },
+ name: { width_ratio: 0.22, height_ratio: 0.035 },
+};
+
+export default function SignatureFieldPlacer({ fileUrl, recipients, fields, onChange }: Props) {
+ const [numPages, setNumPages] = useState(0);
+ const [page, setPage] = useState(1);
+ const [pageWidth, setPageWidth] = useState(700);
+ const [activeRecipient, setActiveRecipient] = useState(0);
+ const [activeType, setActiveType] = useState<"signature" | "date" | "name">("signature");
+ const containerRef = useRef(null);
+
+ useEffect(() => {
+ const update = () => {
+ if (containerRef.current) {
+ setPageWidth(Math.min(containerRef.current.clientWidth - 24, 900));
+ }
+ };
+ update();
+ window.addEventListener("resize", update);
+ return () => window.removeEventListener("resize", update);
+ }, []);
+
+ const handlePageClick = (e: React.MouseEvent) => {
+ const target = e.currentTarget;
+ const rect = target.getBoundingClientRect();
+ const x_ratio = (e.clientX - rect.left) / rect.width;
+ const y_ratio = (e.clientY - rect.top) / rect.height;
+ const def = FIELD_DEFAULTS[activeType];
+ const newField: PlacedField = {
+ id: crypto.randomUUID(),
+ recipientIndex: activeRecipient,
+ field_type: activeType,
+ page_number: page,
+ x_ratio: Math.max(0, x_ratio - def.width_ratio / 2),
+ y_ratio: Math.max(0, y_ratio - def.height_ratio / 2),
+ width_ratio: def.width_ratio,
+ height_ratio: def.height_ratio,
+ };
+ onChange([...fields, newField]);
+ };
+
+ const removeField = (id: string) => onChange(fields.filter(f => f.id !== id));
+
+ const pageFields = fields.filter(f => f.page_number === page);
+
+ return (
+
+ {/* Toolbar */}
+
+
+ setPage(p => p - 1)}>
+
+
+ Page {page} / {numPages || "…"}
+ = numPages} onClick={() => setPage(p => p + 1)}>
+
+
+
+
+
+
+
setActiveRecipient(Number(v))}>
+
+
+ {recipients.map((r, i) => (
+
+
+ {r.name || r.email || `Signer ${i + 1}`}
+
+ ))}
+
+
+
+
+ {[
+ { v: "signature" as const, label: "Signature", Icon: Pen },
+ { v: "date" as const, label: "Date", Icon: CalIcon },
+ { v: "name" as const, label: "Name", Icon: UserIcon },
+ ].map(({ v, label, Icon }) => (
+ setActiveType(v)}
+ >
+ {label}
+
+ ))}
+
+
+
+
+ Click on the document to place a {activeType} field for the selected signer.
+
+
+ {/* PDF + overlay */}
+
+
setNumPages(numPages)}
+ loading={Loading document…
}
+ error={Could not preview document. Fields can still be placed by page.
}
+ >
+
+
+ {pageFields.map(f => (
+
e.stopPropagation()}
+ className={cn(
+ "absolute border-2 border-dashed rounded flex items-center justify-center text-[10px] font-semibold uppercase tracking-wide cursor-default group",
+ FIELD_COLORS[f.recipientIndex % 4]
+ )}
+ style={{
+ left: `${f.x_ratio * 100}%`,
+ top: `${f.y_ratio * 100}%`,
+ width: `${f.width_ratio * 100}%`,
+ height: `${f.height_ratio * 100}%`,
+ }}
+ >
+ {f.field_type}
+ removeField(f.id)}
+ className="absolute -top-2 -right-2 h-4 w-4 rounded-full bg-destructive text-destructive-foreground text-[9px] flex items-center justify-center opacity-0 group-hover:opacity-100"
+ >
+
+
+
+ ))}
+
+
+
+
+
+
+ {recipients.map((r, i) => {
+ const count = fields.filter(f => f.recipientIndex === i).length;
+ return (
+
+ {r.name || `Signer ${i + 1}`}: {count}
+
+ );
+ })}
+
+ {fields.length > 0 && (
+
onChange([])}>
+ Clear all
+
+ )}
+
+
+ );
+}
diff --git a/src/components/SignaturePad.tsx b/src/components/SignaturePad.tsx
new file mode 100644
index 0000000..d9de27a
--- /dev/null
+++ b/src/components/SignaturePad.tsx
@@ -0,0 +1,146 @@
+import { useRef, useState, useEffect } from "react";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import { Pencil, Type, Upload, RotateCcw } from "lucide-react";
+
+interface SignaturePadProps {
+ onChange: (dataUrl: string | null, method: "draw" | "type" | "upload") => void;
+ signerName?: string;
+}
+
+const SCRIPT_FONTS = [
+ { label: "Allura", style: "'Allura', cursive" },
+ { label: "Dancing Script", style: "'Dancing Script', cursive" },
+ { label: "Great Vibes", style: "'Great Vibes', cursive" },
+];
+
+export default function SignaturePad({ onChange, signerName = "" }: SignaturePadProps) {
+ const canvasRef = useRef(null);
+ const [drawing, setDrawing] = useState(false);
+ const [hasDrawn, setHasDrawn] = useState(false);
+ const [typedName, setTypedName] = useState(signerName);
+ const [fontIdx, setFontIdx] = useState(0);
+ const [tab, setTab] = useState<"draw" | "type" | "upload">("draw");
+
+ // Inject Google Fonts once
+ useEffect(() => {
+ const id = "avria-sig-fonts";
+ if (document.getElementById(id)) return;
+ const link = document.createElement("link");
+ link.id = id;
+ link.rel = "stylesheet";
+ link.href = "https://fonts.googleapis.com/css2?family=Allura&family=Dancing+Script:wght@600&family=Great+Vibes&display=swap";
+ document.head.appendChild(link);
+ }, []);
+
+ useEffect(() => { setTypedName(signerName); }, [signerName]);
+
+ const getCtx = () => canvasRef.current?.getContext("2d");
+
+ const start = (e: React.MouseEvent | React.TouchEvent) => {
+ const ctx = getCtx(); if (!ctx) return;
+ const { x, y } = getPos(e);
+ ctx.beginPath(); ctx.moveTo(x, y); setDrawing(true);
+ };
+ const move = (e: React.MouseEvent | React.TouchEvent) => {
+ if (!drawing) return;
+ const ctx = getCtx(); if (!ctx) return;
+ const { x, y } = getPos(e);
+ ctx.lineTo(x, y); ctx.strokeStyle = "#0a2540"; ctx.lineWidth = 2; ctx.lineCap = "round"; ctx.stroke();
+ setHasDrawn(true);
+ };
+ const end = () => {
+ setDrawing(false);
+ if (canvasRef.current && hasDrawn) onChange(canvasRef.current.toDataURL("image/png"), "draw");
+ };
+
+ const getPos = (e: React.MouseEvent | React.TouchEvent) => {
+ const c = canvasRef.current!; const r = c.getBoundingClientRect();
+ if ("touches" in e) {
+ return { x: (e.touches[0].clientX - r.left) * (c.width / r.width), y: (e.touches[0].clientY - r.top) * (c.height / r.height) };
+ }
+ return { x: (e.clientX - r.left) * (c.width / r.width), y: (e.clientY - r.top) * (c.height / r.height) };
+ };
+
+ const clearCanvas = () => {
+ const ctx = getCtx(); if (!ctx || !canvasRef.current) return;
+ ctx.clearRect(0, 0, canvasRef.current.width, canvasRef.current.height);
+ setHasDrawn(false); onChange(null, "draw");
+ };
+
+ const renderTyped = (name: string, fontStyle: string): string => {
+ const c = document.createElement("canvas");
+ c.width = 600; c.height = 160;
+ const ctx = c.getContext("2d")!;
+ ctx.fillStyle = "#0a2540";
+ ctx.font = `64px ${fontStyle}`;
+ ctx.textBaseline = "middle";
+ ctx.fillText(name || " ", 20, 80);
+ return c.toDataURL("image/png");
+ };
+
+ const onTypedChange = (name: string, idx: number) => {
+ setTypedName(name); setFontIdx(idx);
+ if (name.trim()) onChange(renderTyped(name, SCRIPT_FONTS[idx].style), "type");
+ else onChange(null, "type");
+ };
+
+ const onUpload = (e: React.ChangeEvent) => {
+ const f = e.target.files?.[0]; if (!f) return;
+ const reader = new FileReader();
+ reader.onload = () => onChange(reader.result as string, "upload");
+ reader.readAsDataURL(f);
+ };
+
+ return (
+
+
{ setTab(v as any); onChange(null, v as any); clearCanvas(); }}>
+
+ Draw
+ Type
+ Upload
+
+
+
+
+
+
+
+ Clear
+
+
+
+
+
+ Type your full name
+ onTypedChange(e.target.value, fontIdx)} placeholder="John Doe" />
+
+
+
Style
+
+ {SCRIPT_FONTS.map((f, i) => (
+ onTypedChange(typedName, i)}
+ className={`border rounded p-3 text-2xl truncate ${fontIdx === i ? "border-primary bg-primary/5" : "border-border"}`}
+ style={{ fontFamily: f.style }}>
+ {typedName || "Signature"}
+
+ ))}
+
+
+
+
+
+
+ Upload a transparent PNG of your signature for best results.
+
+
+
+ );
+}
diff --git a/src/components/StaffEventDialog.jsx b/src/components/StaffEventDialog.jsx
new file mode 100644
index 0000000..a982a58
--- /dev/null
+++ b/src/components/StaffEventDialog.jsx
@@ -0,0 +1,205 @@
+
+import React, { useEffect } from 'react';
+import { useForm } from 'react-hook-form';
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import { Textarea } from '@/components/ui/textarea';
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
+import { Loader2, Trash2, Lock, MapPin } from 'lucide-react';
+import { Alert, AlertDescription } from '@/components/ui/alert';
+import { useStaffCalendarCategories } from '@/hooks/useStaffCalendarCategories';
+
+export function StaffEventDialog({ open, onOpenChange, event, onSave, onDelete }) {
+ const { categories, loading: categoriesLoading } = useStaffCalendarCategories();
+ const { register, handleSubmit, reset, setValue, watch, formState: { errors, isSubmitting } } = useForm({
+ defaultValues: {
+ title: '',
+ description: '',
+ location: '',
+ start_date: '',
+ start_time: '',
+ end_date: '',
+ end_time: '',
+ category: 'meeting',
+ }
+ });
+
+ const selectedCategory = watch('category');
+
+ useEffect(() => {
+ if (open && event) {
+ const start = new Date(event.start_time);
+ const end = new Date(event.end_time);
+
+ reset({
+ title: event.title,
+ description: event.description || '',
+ location: event.location || '',
+ start_date: start.toISOString().split('T')[0],
+ start_time: start.toTimeString().slice(0, 5),
+ end_date: end.toISOString().split('T')[0],
+ end_time: end.toTimeString().slice(0, 5),
+ category: event.category || 'meeting',
+ });
+ } else if (open) {
+ const now = new Date();
+ const oneHourLater = new Date(now.getTime() + 60 * 60 * 1000);
+
+ reset({
+ title: '',
+ description: '',
+ location: '',
+ start_date: now.toISOString().split('T')[0],
+ start_time: now.toTimeString().slice(0, 5),
+ end_date: oneHourLater.toISOString().split('T')[0],
+ end_time: oneHourLater.toTimeString().slice(0, 5),
+ category: 'meeting',
+ });
+ }
+ }, [open, event, reset]);
+
+ const onSubmit = async (data) => {
+ if (data.category === 'client') return;
+
+ const startDateTime = new Date(`${data.start_date}T${data.start_time}`);
+ const endDateTime = new Date(`${data.end_date}T${data.end_time}`);
+
+ if (endDateTime <= startDateTime) {
+ alert("End time must be after start time");
+ return;
+ }
+
+ const payload = {
+ title: data.title,
+ description: data.description,
+ location: data.location,
+ start_time: startDateTime.toISOString(),
+ end_time: endDateTime.toISOString(),
+ category: data.category,
+ };
+
+ if (event?.id) {
+ await onSave(event.id, payload);
+ } else {
+ await onSave(null, payload);
+ }
+ onOpenChange(false);
+ };
+
+ const activeCategory = categories.find(c => c.category_name === selectedCategory);
+ const activeColor = activeCategory?.color || 'hsl(var(--primary))';
+
+ return (
+
+
+
+
+ {event ? 'Edit Staff Event' : 'Create Staff Event'}
+ {activeColor && (
+
+ )}
+
+
+
+
+
+ Event Title
+
+ {errors.title && {errors.title.message} }
+
+
+
+
+
+
+
+
+
Category
+
setValue('category', val)} value={selectedCategory}>
+
+
+
+
+ {categoriesLoading ? (
+ Loading...
+ ) : (
+ categories.map((cat) => (
+
+
+
+ {cat.label || cat.category_name}
+
+
+ ))
+ )}
+
+
+
+
+
+
+
+ Description
+
+
+
+ {event?.category === 'client' && (
+
+
+
+ This appears to be a client event. You cannot edit it here.
+
+
+ )}
+
+
+
+ {event && onDelete && event.category !== 'client' && (
+ onDelete(event.id)}>
+ Delete
+
+ )}
+
+
+ onOpenChange(false)}>Cancel
+
+ {isSubmitting && }
+ Save Event
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/SubcategoryManagementDialog.jsx b/src/components/SubcategoryManagementDialog.jsx
new file mode 100644
index 0000000..842abf4
--- /dev/null
+++ b/src/components/SubcategoryManagementDialog.jsx
@@ -0,0 +1,116 @@
+
+import React, { useEffect } from 'react';
+import {
+ Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle,
+} from "@/components/ui/dialog";
+import {
+ Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
+} from "@/components/ui/table";
+import { Button } from "@/components/ui/button";
+import { Badge } from "@/components/ui/badge";
+import { Trash2, Loader2, Plus } from "lucide-react";
+import { AddSubcategoryDialog } from './AddSubcategoryDialog';
+import { useSubcategoryManager } from '@/hooks/useSubcategoryManager';
+
+export function SubcategoryManagementDialog({ open, onOpenChange }) {
+ const {
+ subcategories,
+ loading,
+ fetchSubcategories,
+ deleteSubcategory,
+ addSubcategory
+ } = useSubcategoryManager();
+
+ const [addDialogOpen, setAddDialogOpen] = React.useState(false);
+
+ useEffect(() => {
+ if (open) {
+ fetchSubcategories();
+ }
+ }, [open, fetchSubcategories]);
+
+ const handleDelete = async (id) => {
+ if (window.confirm("Are you sure you want to delete this subcategory?")) {
+ await deleteSubcategory(id);
+ }
+ };
+
+ return (
+ <>
+
+
+
+ Manage Subcategories
+
+ View and manage fee schedule subcategories.
+
+
+
+
+
setAddDialogOpen(true)}>
+ Add Subcategory
+
+
+
+
+
+
+
+ Name
+ Type
+ Actions
+
+
+
+ {loading ? (
+
+
+
+
+
+ ) : subcategories.length === 0 ? (
+
+
+ No subcategories found.
+
+
+ ) : (
+ subcategories.map((sub) => (
+
+ {sub.name}
+
+ {sub.is_custom ? (
+ Custom
+ ) : (
+ System
+ )}
+
+
+ {sub.is_custom && (
+ handleDelete(sub.id)}
+ >
+
+
+ )}
+
+
+ ))
+ )}
+
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/src/components/TableElementDialog.jsx b/src/components/TableElementDialog.jsx
new file mode 100644
index 0000000..3b85528
--- /dev/null
+++ b/src/components/TableElementDialog.jsx
@@ -0,0 +1,170 @@
+
+import React, { useState, useEffect } from 'react';
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from '@/components/ui/dialog';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
+import { Plus, Trash2 } from 'lucide-react';
+import TableStylePanel from './TableStylePanel';
+import TableElement from './TableElement';
+import { createEmptyTable, addTableRow, removeTableRow, addTableColumn, removeTableColumn, updateTableCell } from '@/lib/tableUtils';
+import { ScrollArea } from '@/components/ui/scroll-area';
+
+const TableElementDialog = ({
+ open,
+ onOpenChange,
+ initialData,
+ initialStyles,
+ onSave
+}) => {
+ const [tableData, setTableData] = useState([]);
+ const [styles, setStyles] = useState({});
+ const [activeTab, setActiveTab] = useState('structure');
+
+ useEffect(() => {
+ if (open) {
+ setTableData(initialData || createEmptyTable(3, 3));
+ setStyles(initialStyles || {
+ borderColor: '#e2e8f0',
+ cellBgColor: '#ffffff',
+ headerBgColor: '#f8fafc',
+ borderWidth: 1,
+ borderStyle: 'solid',
+ fontSize: '11',
+ textAlign: 'left',
+ padding: 8,
+ firstRowIsHeader: true
+ });
+ }
+ }, [open, initialData, initialStyles]);
+
+ const handleSave = () => {
+ onSave({ content: { data: tableData }, styles });
+ onOpenChange(false);
+ };
+
+ const handleCellChange = (rowIndex, colIndex, value) => {
+ setTableData(prev => updateTableCell(prev, rowIndex, colIndex, value));
+ };
+
+ return (
+
+
+
+ Configure Table
+ Structure your table data and customize styling.
+
+
+
+
+
+
+ Dimensions: {tableData.length} Rows × {tableData[0]?.length || 0} Cols
+
+
+
setTableData(prev => addTableRow(prev))}>
+ Add Row
+
+
setTableData(prev => addTableColumn(prev))}>
+ Add Col
+
+
+
+
+
+
+
'minmax(120px, 1fr)').join(' ')}`
+ }}>
+
+ {tableData[0]?.map((_, colIndex) => (
+
+ Col {colIndex + 1}
+ setTableData(prev => removeTableColumn(prev, colIndex))}
+ title="Delete Column"
+ >
+
+
+
+ ))}
+
+ {tableData.map((row, rowIndex) => (
+
+
+ setTableData(prev => removeTableRow(prev, rowIndex))}
+ title="Delete Row"
+ >
+
+
+ Row {rowIndex + 1}
+
+
+ {row.map((cell, colIndex) => (
+
+ handleCellChange(rowIndex, colIndex, e.target.value)}
+ placeholder="..."
+ />
+
+ ))}
+
+ ))}
+
+
+
+
+
+
+
+
+
+ Preview
+ Styling
+
+
+
+
+
+
+
+
+ This is how the table will look in the document.
+
+
+
+
+
+
+
+
+
+
+
+
+
+ onOpenChange(false)}>Cancel
+ Save Configuration
+
+
+
+ );
+};
+
+export { TableElementDialog };
+export default TableElementDialog;
diff --git a/src/components/TableOfContentsDialog.jsx b/src/components/TableOfContentsDialog.jsx
new file mode 100644
index 0000000..93f5485
--- /dev/null
+++ b/src/components/TableOfContentsDialog.jsx
@@ -0,0 +1,48 @@
+
+import React, { useState, useEffect } from 'react';
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
+import { Button } from '@/components/ui/button';
+import { ListOrdered } from 'lucide-react';
+import TableOfContentsManager from './TableOfContentsManager';
+
+export default function TableOfContentsDialog({ open, onOpenChange, initialData, onSave }) {
+ const [currentData, setCurrentData] = useState([]);
+
+ useEffect(() => {
+ if (open) {
+ setCurrentData(initialData || []);
+ }
+ }, [open, initialData]);
+
+ const handleSave = () => {
+ onSave(currentData);
+ onOpenChange(false);
+ };
+
+ return (
+
+
+
+
+ Table of Contents Management
+
+
+ Manually define the table of contents structure for your report. Drag to reorder.
+
+
+
+
+
+
+ onOpenChange(false)}>Cancel
+ Save Changes
+
+
+
+ );
+}
diff --git a/src/components/TransactionFormDialog.tsx b/src/components/TransactionFormDialog.tsx
new file mode 100644
index 0000000..abba93a
--- /dev/null
+++ b/src/components/TransactionFormDialog.tsx
@@ -0,0 +1,115 @@
+import { useState } from "react";
+import {
+ Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
+} from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
+import { Button } from "@/components/ui/button";
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
+import { Label } from "@/components/ui/label";
+import { Loader2 } from "lucide-react";
+import AccountDropdown from "@/components/AccountDropdown";
+
+interface TransactionFormData {
+ transaction_date: string;
+ description: string;
+ amount: string;
+ type: string;
+ chart_of_account_id: string | null;
+}
+
+interface TransactionFormDialogProps {
+ isOpen: boolean;
+ onClose: (open: boolean) => void;
+ onSubmit: (data: TransactionFormData) => void;
+ loading?: boolean;
+ title?: string;
+ showChartOfAccount?: boolean;
+}
+
+export default function TransactionFormDialog({
+ isOpen,
+ onClose,
+ onSubmit,
+ loading = false,
+ title = "Add Transaction",
+ showChartOfAccount = false,
+}: TransactionFormDialogProps) {
+ const [formData, setFormData] = useState({
+ transaction_date: new Date().toISOString().split("T")[0],
+ description: "",
+ amount: "",
+ type: "debit",
+ chart_of_account_id: "",
+ });
+
+ const handleChange = (e: React.ChangeEvent) => {
+ const { name, value } = e.target;
+ setFormData((prev) => ({ ...prev, [name]: value }));
+ };
+
+ const handleSubmit = (e: React.FormEvent) => {
+ e.preventDefault();
+ onSubmit({
+ ...formData,
+ chart_of_account_id: formData.chart_of_account_id || null,
+ });
+ };
+
+ return (
+
+
+
+ {title}
+ Enter the details for this manual transaction.
+
+
+
+ Date
+
+
+
+
+ Description
+
+
+
+
+
+ Amount
+
+
+
+ Type
+ setFormData((prev) => ({ ...prev, type: val }))}>
+
+
+ Debit
+ Credit
+
+
+
+
+
+ {showChartOfAccount && (
+
+
Chart of Account
+
setFormData((prev) => ({ ...prev, chart_of_account_id: val }))}
+ placeholder="Optional – select account"
+ />
+
+ )}
+
+
+ onClose(false)}>Cancel
+
+ {loading && }
+ Save Transaction
+
+
+
+
+
+ );
+}
diff --git a/src/components/UnitOwnerSelect.tsx b/src/components/UnitOwnerSelect.tsx
new file mode 100644
index 0000000..cb81bc3
--- /dev/null
+++ b/src/components/UnitOwnerSelect.tsx
@@ -0,0 +1,133 @@
+import { useMemo } from "react";
+import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectTrigger, SelectValue } from "@/components/ui/select";
+
+interface Owner {
+ id: string;
+ first_name: string;
+ last_name: string;
+ unit_id?: string | null;
+ association_id?: string;
+ balance?: number;
+ units?: { unit_number?: string } | null;
+}
+
+interface UnitGroup {
+ unitId: string;
+ unitNumber: string;
+ owners: Owner[];
+ label: string;
+}
+
+interface UnitOwnerSelectProps {
+ owners: Owner[];
+ value: string;
+ onValueChange: (ownerId: string) => void;
+ placeholder?: string;
+ associationId?: string;
+ showBalance?: boolean;
+ /** When true, shows individual owners instead of grouped units */
+ showIndividual?: boolean;
+}
+
+export default function UnitOwnerSelect({
+ owners,
+ value,
+ onValueChange,
+ placeholder = "Select unit / owner",
+ associationId,
+ showBalance = false,
+ showIndividual = false,
+}: UnitOwnerSelectProps) {
+ const filtered = useMemo(() => {
+ if (!associationId) return owners;
+ return owners.filter((o) => o.association_id === associationId);
+ }, [owners, associationId]);
+
+ const { groups, unassigned } = useMemo(() => {
+ const unitMap = new Map();
+ const noUnit: Owner[] = [];
+
+ for (const o of filtered) {
+ if (!o.unit_id) {
+ noUnit.push(o);
+ continue;
+ }
+ const existing = unitMap.get(o.unit_id);
+ if (existing) {
+ existing.owners.push(o);
+ existing.label = existing.owners
+ .map((ow) => `${ow.first_name} ${ow.last_name}`)
+ .join(", ");
+ } else {
+ unitMap.set(o.unit_id, {
+ unitId: o.unit_id,
+ unitNumber: o.units?.unit_number || "—",
+ owners: [o],
+ label: `${o.first_name} ${o.last_name}`,
+ });
+ }
+ }
+
+ // Sort by unit number
+ const sorted = Array.from(unitMap.values()).sort((a, b) =>
+ a.unitNumber.localeCompare(b.unitNumber, undefined, { numeric: true })
+ );
+
+ return { groups: sorted, unassigned: noUnit };
+ }, [filtered]);
+
+ if (showIndividual) {
+ return (
+
+
+
+
+
+ {filtered.map((o) => (
+
+ {o.last_name}, {o.first_name}
+ {o.units?.unit_number ? ` (Unit ${o.units.unit_number})` : ""}
+ {showBalance ? ` — $${Number(o.balance || 0).toFixed(2)}` : ""}
+
+ ))}
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+ {groups.map((g) => (
+
+
+ Unit {g.unitNumber}
+
+ {g.owners.map((o) => (
+
+ {o.first_name} {o.last_name} — Unit {g.unitNumber}
+ {showBalance ? ` ($${Number(o.balance || 0).toFixed(2)})` : ""}
+
+ ))}
+
+ ))}
+ {unassigned.length > 0 && (
+
+
+ No Unit Assigned
+
+ {unassigned.map((o) => (
+
+ {o.first_name} {o.last_name}
+ {showBalance ? ` ($${Number(o.balance || 0).toFixed(2)})` : ""}
+
+ ))}
+
+ )}
+
+
+ );
+}
diff --git a/src/components/ValidationProofPage.tsx b/src/components/ValidationProofPage.tsx
new file mode 100644
index 0000000..f0abb02
--- /dev/null
+++ b/src/components/ValidationProofPage.tsx
@@ -0,0 +1,86 @@
+import { ShieldCheck, FileCheck } from "lucide-react";
+import { formatDateTimeEST } from "@/lib/timezoneUtils";
+
+interface ValidationProofPageProps {
+ documentName?: string;
+ issuerName?: string;
+ timestamp?: string;
+ validationId?: string;
+}
+
+export function ValidationProofPage({
+ documentName = "Official Document",
+ issuerName = "ACM Community Management",
+ timestamp = new Date().toISOString(),
+ validationId = "XXXX-XXXX-XXXX-XXXX",
+}: ValidationProofPageProps) {
+ const formattedDate = formatDateTimeEST(timestamp);
+
+ return (
+
+
+
+
+
+
+
+
+ CERTIFICATE OF VALIDATION
+
+
+
+ This page serves as cryptographic proof of document generation and integrity.
+
+
+
+
+ Document Name:
+ {documentName}
+
+
+ Generated On:
+ {formattedDate}
+
+
+ Issued By:
+ {issuerName}
+
+
+ Validation ID:
+ {validationId}
+
+
+
+
+ To verify the authenticity of this document, please scan the QR code below or reference
+ the Validation ID with the issuing authority.
+
+
+
+
+
+
+
+
+
Authorized Digital System Signature
+
{issuerName}
+
+
+
+
+ OFFICIAL
+ SEAL
+
+
+
+
+
+
+ );
+}
+
+export default ValidationProofPage;
diff --git a/src/components/ViewAsBanner.tsx b/src/components/ViewAsBanner.tsx
new file mode 100644
index 0000000..f4e273b
--- /dev/null
+++ b/src/components/ViewAsBanner.tsx
@@ -0,0 +1,56 @@
+import { useNavigate } from "react-router-dom";
+import { Eye, ArrowLeft } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { useAuth } from "@/contexts/AuthContext";
+import type { Enums } from "@/integrations/supabase/types";
+
+const ROLE_LABELS: Partial, string>> = {
+ homeowner: "Homeowner",
+ board_member: "Board Member",
+ master_board_member: "Master Board Member",
+ legal: "Legal",
+ rv_boat_lot: "RV / Boat Lot Renter",
+ rv_renter: "RV Renter",
+ rv_owner: "RV Owner",
+ association_management: "Association Management",
+ manager: "Manager",
+ employee: "Employee",
+ staff: "Staff",
+ arc_member: "ARC Member",
+ fining_member: "Fining Member",
+};
+
+export function ViewAsBanner() {
+ const { isViewingAs, viewAsRole, clearViewAsRole } = useAuth();
+ const navigate = useNavigate();
+
+ if (!isViewingAs || !viewAsRole) return null;
+
+ const handleReturn = () => {
+ clearViewAsRole();
+ navigate("/dashboard");
+ };
+
+ const label = ROLE_LABELS[viewAsRole] ?? viewAsRole;
+
+ return (
+
+
+
+
+
+ Admin Preview · Viewing as {label} (read-only simulation)
+
+
+
+ Return to Admin View
+
+
+
+ );
+}
diff --git a/src/components/ViolationBulkActions.tsx b/src/components/ViolationBulkActions.tsx
new file mode 100644
index 0000000..1c00bdc
--- /dev/null
+++ b/src/components/ViolationBulkActions.tsx
@@ -0,0 +1,280 @@
+import { useState, useEffect } from "react";
+import { format } from "date-fns";
+import { supabase } from "@/integrations/supabase/client";
+import { Button } from "@/components/ui/button";
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
+import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
+import { Calendar } from "@/components/ui/calendar";
+import { Input } from "@/components/ui/input";
+import { Textarea } from "@/components/ui/textarea";
+import { useToast } from "@/hooks/use-toast";
+import { Loader2, X, CheckCheck, CalendarIcon, ChevronDown, ChevronUp } from "lucide-react";
+import { cn } from "@/lib/utils";
+
+interface ViolationBulkActionsProps {
+ selectedIds: string[];
+ onClear: () => void;
+ onComplete: () => void;
+ /** Association ID scoping the available violation types. If undefined, type selectors are hidden. */
+ associationId?: string;
+}
+
+const STATUSES = [
+ { value: "open", label: "Open" },
+ { value: "pending", label: "Notice Sent" },
+ { value: "escalated", label: "Second Notice" },
+ { value: "recommended_for_fining", label: "Recommended for Fining" },
+ { value: "fined", label: "Fining" },
+ { value: "resolved", label: "Resolved" },
+ { value: "closed", label: "Closed" },
+];
+
+const STAGES = [
+ { value: "first_notice", label: "1st Notice" },
+ { value: "second_notice", label: "2nd Notice" },
+ { value: "third_final", label: "Final Notice" },
+ { value: "hearing", label: "Hearing" },
+ { value: "fining", label: "Fining" },
+];
+
+const PRIORITIES = [
+ { value: "low", label: "Low" },
+ { value: "medium", label: "Medium" },
+ { value: "high", label: "High" },
+ { value: "critical", label: "Critical" },
+];
+
+export default function ViolationBulkActions({ selectedIds, onClear, onComplete, associationId }: ViolationBulkActionsProps) {
+ const { toast } = useToast();
+ const [loading, setLoading] = useState(false);
+ const [bulkStatus, setBulkStatus] = useState("");
+ const [bulkStage, setBulkStage] = useState("");
+ const [bulkPriority, setBulkPriority] = useState("");
+ const [bulkViolationDate, setBulkViolationDate] = useState();
+ const [bulkDueDate, setBulkDueDate] = useState();
+ const [bulkViolationType, setBulkViolationType] = useState("");
+ const [bulkRequestedAction, setBulkRequestedAction] = useState("");
+ const [bulkArticleSection, setBulkArticleSection] = useState("");
+ const [bulkCitation, setBulkCitation] = useState("");
+ const [expanded, setExpanded] = useState(false);
+
+ // Violation types are scoped to the selected association only
+ const [definedTypes, setDefinedTypes] = useState([]);
+
+ useEffect(() => {
+ const fetchTypes = async () => {
+ if (!associationId) {
+ setDefinedTypes([]);
+ return;
+ }
+ const { data } = await supabase
+ .from("violation_types")
+ .select("id, category, article_section, citation, requested_action")
+ .eq("association_id", associationId)
+ .order("category");
+ setDefinedTypes(data || []);
+ };
+ fetchTypes();
+ // Reset any previously chosen type when the association scope changes
+ setBulkViolationType("");
+ }, [associationId]);
+
+ const handleDefinedTypeChange = (typeId: string) => {
+ const dt = definedTypes.find((t: any) => t.id === typeId);
+ if (dt) {
+ setBulkViolationType(dt.category || "");
+ if (dt.requested_action) setBulkRequestedAction(dt.requested_action);
+ setBulkArticleSection(dt.article_section || "");
+ setBulkCitation(dt.citation || "");
+ }
+ };
+
+ const handleApply = async () => {
+ const updates: Record = {};
+ if (bulkStatus) updates.status = bulkStatus;
+ if (bulkStage) updates.stage = bulkStage;
+ if (bulkPriority) updates.priority = bulkPriority;
+ if (bulkViolationDate) updates.violation_date = format(bulkViolationDate, "yyyy-MM-dd");
+ if (bulkDueDate) updates.due_date = format(bulkDueDate, "yyyy-MM-dd");
+ if (bulkViolationType) {
+ updates.violation_type = bulkViolationType;
+ updates.title = bulkViolationType;
+ }
+ if (bulkRequestedAction) updates.description = bulkRequestedAction;
+
+ if (Object.keys(updates).length === 0) {
+ toast({ variant: "destructive", title: "No changes selected", description: "Pick at least one field to update." });
+ return;
+ }
+
+ setLoading(true);
+ try {
+ const { error } = await supabase
+ .from("violations")
+ .update(updates)
+ .in("id", selectedIds);
+ if (error) throw error;
+
+ // Persist article/citation/requested_action via notice_history entries
+ // (violations table has no article_section/citation columns).
+ if (bulkViolationType || bulkArticleSection || bulkCitation || bulkRequestedAction) {
+ const { data: existing } = await supabase
+ .from("violations")
+ .select("id, notice_history, stage, status")
+ .in("id", selectedIds);
+ const timestamp = new Date().toISOString();
+ await Promise.all((existing || []).map((v: any) => {
+ const history = Array.isArray(v.notice_history) ? v.notice_history : [];
+ const entry = {
+ date: timestamp,
+ status: v.status,
+ notice_level: v.stage,
+ violation_type: bulkViolationType || undefined,
+ article_section: bulkArticleSection || undefined,
+ citation: bulkCitation || undefined,
+ requested_action: bulkRequestedAction || undefined,
+ action: "Bulk Update",
+ };
+ return supabase
+ .from("violations")
+ .update({ notice_history: [...history, entry] })
+ .eq("id", v.id);
+ }));
+ }
+ toast({ title: `Updated ${selectedIds.length} violation(s)` });
+ setBulkStatus("");
+ setBulkStage("");
+ setBulkPriority("");
+ setBulkViolationDate(undefined);
+ setBulkDueDate(undefined);
+ setBulkViolationType("");
+ setBulkRequestedAction("");
+ setBulkArticleSection("");
+ setBulkCitation("");
+ onComplete();
+ } catch (err: any) {
+ toast({ variant: "destructive", title: "Bulk update failed", description: err.message });
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+
+
+
+
+ {selectedIds.length} selected
+
+
+
+
+
+
+
+ {STATUSES.map(s => {s.label} )}
+
+
+
+
+
+
+
+
+ {STAGES.map(s => {s.label} )}
+
+
+
+
+
+
+
+
+ {PRIORITIES.map(s => {s.label} )}
+
+
+
+
setExpanded(!expanded)}>
+ {expanded ? : }
+ {expanded ? "Less" : "More"}
+
+
+
+ {loading ? : null}
+ Apply
+
+
+
+
+
+
+
+ {expanded && (
+
+
+
+
+
+ {bulkViolationDate ? format(bulkViolationDate, "MM/dd/yyyy") : "Violation Date"}
+
+
+
+
+
+
+
+
+
+
+
+ {bulkDueDate ? format(bulkDueDate, "MM/dd/yyyy") : "Due Date"}
+
+
+
+
+
+
+
+ {associationId ? (
+
+ Violation Type
+ d.category === bulkViolationType)?.id || "") : ""}
+ onValueChange={handleDefinedTypeChange}
+ >
+
+
+
+
+ {definedTypes.map((dt: any) => (
+
+ {dt.category}{dt.article_section ? ` (${dt.article_section})` : ""}
+
+ ))}
+
+
+
+ ) : (
+
+
Violation Type
+
+ Filter by an association to choose a type
+
+
+ )}
+
+
+ Requested Action
+ setBulkRequestedAction(e.target.value)}
+ placeholder="Set requested action..."
+ className="text-[12px] min-h-[32px] h-[32px] resize-none"
+ rows={1}
+ />
+
+
+ )}
+
+ );
+}
diff --git a/src/components/ViolationCard.jsx b/src/components/ViolationCard.jsx
new file mode 100644
index 0000000..14463a7
--- /dev/null
+++ b/src/components/ViolationCard.jsx
@@ -0,0 +1,296 @@
+import React, { useState } from 'react';
+import { logStatusChange } from '@/lib/violationTimelineLogger';
+import { motion } from 'framer-motion';
+import { Calendar, MapPin, Camera, User, MoreHorizontal, CheckCircle, Ban, AlertCircle, MessageSquare, Clock, Trash2, ImageOff, TrendingUp, BookOpen, AlertTriangle } from 'lucide-react';
+import { useNavigate } from 'react-router-dom';
+import { Button } from '@/components/ui/button';
+import { Badge } from '@/components/ui/badge';
+import { Checkbox } from '@/components/ui/checkbox';
+import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, DropdownMenuSeparator } from '@/components/ui/dropdown-menu';
+import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from '@/components/ui/alert-dialog';
+import { Dialog, DialogContent } from '@/components/ui/dialog';
+import { supabase } from '@/integrations/supabase/client';
+import { useToast } from '@/components/ui/use-toast';
+import { cn } from '@/lib/utils';
+import { useCommentCount } from '@/hooks/useCommentCount';
+import { getNoticeColor, isValidStatus, VALID_STATUSES } from '@/lib/ViolationNoticeProgressionHelper';
+import { formatPhotoTimestamp } from '@/lib/exifUtils';
+
+const parseDateSafe = (dateString) => {
+ if (!dateString) return null;
+ if (typeof dateString === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(dateString)) {
+ return new Date(`${dateString}T12:00:00`);
+ }
+ return new Date(dateString);
+};
+
+const formatViolationDate = (dateString) => {
+ if (!dateString) return 'N/A';
+ return parseDateSafe(dateString).toLocaleDateString('en-US', {
+ month: 'short', day: 'numeric', year: 'numeric'
+ });
+};
+
+const getOwnerName = (violation) => {
+ if (violation.allOwnerNames) return violation.allOwnerNames;
+ if (violation.owners) {
+ return `${violation.owners.first_name || ''} ${violation.owners.last_name || ''}`.trim() || 'Unknown Owner';
+ }
+ return 'Unknown Owner';
+};
+
+const ViolationCard = ({ violation, onStatusChange, isSelected, onSelect, showSelection, canEdit = true }) => {
+ const navigate = useNavigate();
+ const { toast } = useToast();
+ const [isUpdateDialogOpen, setIsUpdateDialogOpen] = useState(false);
+ const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
+ const [pendingStatus, setPendingStatus] = useState(null);
+ const [isLightboxOpen, setIsLightboxOpen] = useState(false);
+ const [lightboxIndex, setLightboxIndex] = useState(0);
+
+ const { count: commentCount, loading: countLoading } = useCommentCount(violation?.id, 'violation');
+
+ const statusConfig = {
+ open: { color: 'bg-red-100 text-red-700 border-red-200', label: 'Open', icon: AlertCircle },
+ resolved: { color: 'bg-green-100 text-green-700 border-green-200', label: 'Resolved', icon: CheckCircle },
+ fined: { color: 'bg-blue-100 text-blue-700 border-blue-200', label: 'Fined', icon: Ban },
+ escalated: { color: 'bg-purple-100 text-purple-700 border-purple-200', label: 'Escalated', icon: TrendingUp },
+ closed: { color: 'bg-gray-100 text-gray-700 border-gray-200', label: 'Closed', icon: CheckCircle },
+ pending: { color: 'bg-yellow-100 text-yellow-700 border-yellow-200', label: 'Pending', icon: Clock }
+ };
+
+ const currentStatus = violation.status?.toLowerCase() || 'open';
+ const statusStyle = statusConfig[currentStatus] || statusConfig.open;
+ const stage = violation.stage || violation.notice_level || 'First Notice';
+
+ const history = violation.notice_history || [];
+ const latestHistory = history.length > 0 ? history[history.length - 1] : {};
+
+ const articleSection = latestHistory.article_section || violation.article || null;
+ const requestedAction = violation.description || latestHistory.requested_action || null;
+
+ const daysUntilDue = violation.due_date
+ ? Math.ceil((parseDateSafe(violation.due_date) - new Date()) / (1000 * 60 * 60 * 24))
+ : null;
+ const dueStatusColor = daysUntilDue < 0 ? 'text-red-600' : daysUntilDue <= 3 ? 'text-orange-600' : 'text-slate-500';
+
+ const initiateStatusUpdate = (newStatus, e) => {
+ e.stopPropagation();
+ setPendingStatus(newStatus);
+ setIsUpdateDialogOpen(true);
+ };
+
+ const confirmStatusUpdate = async () => {
+ if (!pendingStatus) return;
+ if (!isValidStatus(pendingStatus)) {
+ toast({ variant: "destructive", title: "Invalid Status", description: `Status '${pendingStatus}' is not valid.` });
+ setIsUpdateDialogOpen(false);
+ setPendingStatus(null);
+ return;
+ }
+ try {
+ const previousStatus = violation.status;
+ const { error } = await supabase.from('violations').update({ status: pendingStatus }).eq('id', violation.id);
+ if (error) throw error;
+ await logStatusChange(violation.id, previousStatus, pendingStatus);
+ toast({ title: "Status Updated", description: `Violation marked as ${pendingStatus}.` });
+ if (onStatusChange) onStatusChange();
+ } catch (error) {
+ toast({ variant: "destructive", title: "Error", description: "Failed to update status." });
+ } finally {
+ setIsUpdateDialogOpen(false);
+ setPendingStatus(null);
+ }
+ };
+
+ const handleDelete = async () => {
+ try {
+ const { error } = await supabase.from('violations').delete().eq('id', violation.id);
+ if (error) throw error;
+ toast({ title: "Violation Deleted", description: "The violation has been successfully removed." });
+ if (onStatusChange) onStatusChange();
+ } catch (error) {
+ toast({ variant: "destructive", title: "Error", description: "Failed to delete violation." });
+ } finally {
+ setIsDeleteDialogOpen(false);
+ }
+ };
+
+ const handleCheckboxChange = (checked) => {
+ if (onSelect) onSelect(violation.id, checked);
+ };
+
+ const normalizePhoto = (p) => typeof p === 'string' ? { url: p, timestamp: null } : p;
+ const splitSemicolonUrls = (s) => s.split(/;\s*/).map(u => u.trim()).filter(Boolean);
+
+ let allPhotos = [];
+ if (violation.photo_urls && Array.isArray(violation.photo_urls) && violation.photo_urls.length > 0) {
+ allPhotos = violation.photo_urls.flatMap(p => {
+ const norm = normalizePhoto(p);
+ return norm.url.includes(';') ? splitSemicolonUrls(norm.url).map(u => ({ url: u, timestamp: norm.timestamp || null })) : [norm];
+ });
+ } else if (violation.photo_url) {
+ allPhotos = splitSemicolonUrls(violation.photo_url).map(u => ({ url: u, timestamp: null }));
+ }
+
+ const openLightbox = (e, index) => {
+ e.stopPropagation();
+ setLightboxIndex(index);
+ setIsLightboxOpen(true);
+ };
+
+ return (
+ <>
+
+ {canEdit && showSelection && (
+ e.stopPropagation()}>
+
+
+ )}
+
+ {canEdit && (
+
+
+
+
+
+
+
+
+ {['open', 'resolved', 'recommended_for_fining', 'fined', 'escalated'].map(s => (
+ initiateStatusUpdate(s, e)} disabled={currentStatus === s} className="capitalize">
+ Mark as {s.replace(/_/g, ' ')}
+
+ ))}
+
+ { e.stopPropagation(); setIsDeleteDialogOpen(true); }}
+ >
+ Delete Violation
+
+
+
+
+ )}
+
+
+
+
+
+ {statusStyle.label}
+
+
+ {stage}
+
+
+
+
{violation.violation_type || violation.title}
+
+
+
+
+ {violation.owners?.property_address || violation.address || 'No address'}
+
+
+
+ {getOwnerName(violation)}
+
+
+
+
+
+ {articleSection && (
+
+
+
Article: {articleSection}
+
+ )}
+ {requestedAction && (
+
+
+
Action: {requestedAction}
+
+ )}
+
+
+ {allPhotos.length > 0 && (
+
+
+ {allPhotos.slice(0, 4).map((photo, idx) => (
+
openLightbox(e, idx)}>
+
+
+
+
+ {idx === 3 && allPhotos.length > 4 && (
+
+{allPhotos.length - 4}
+ )}
+
+ ))}
+
+
+ )}
+
+
+
+
+
+
+ Violation: {formatViolationDate(violation.violation_date || violation.created_at)}
+
+
+
+ Original: {formatViolationDate(violation.created_at)}
+
+
+
+ Due: {formatViolationDate(violation.due_date)}
+
+
+ {!countLoading && commentCount > 0 && (
+
+ {commentCount}
+
+ )}
+
+
+
+
+
+ {allPhotos[lightboxIndex] && (
+ <>
+
+ {allPhotos[lightboxIndex].timestamp && (
+ {formatPhotoTimestamp(allPhotos[lightboxIndex].timestamp)}
+ )}
+ >
+ )}
+
+
+
+
+
+ Update Status Change status to {pendingStatus} ?
+ Cancel Confirm
+
+
+
+
+
+ Delete Violation Are you sure? This action cannot be undone.
+ Cancel Delete
+
+
+ >
+ );
+};
+
+export default ViolationCard;
diff --git a/src/components/ViolationDetailsDialog.jsx b/src/components/ViolationDetailsDialog.jsx
new file mode 100644
index 0000000..6ea780e
--- /dev/null
+++ b/src/components/ViolationDetailsDialog.jsx
@@ -0,0 +1,413 @@
+import React, { useState, useRef, useEffect } from 'react';
+import { v4 as uuidv4 } from 'uuid';
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog";
+import { Button } from "@/components/ui/button";
+import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
+import { AlertTriangle, Calendar, MapPin, ExternalLink, ArrowRight, Pencil, History, Info, Upload, User, FileText, Camera, ImagePlus, Loader2, MessageSquare, Mail, Trash2, GitMerge } from "lucide-react";
+import { cn } from '@/lib/utils';
+import { useAuth } from '@/contexts/AuthContext';
+import { useViolationTimeline } from '@/hooks/useViolationTimeline';
+import { useToast } from '@/components/ui/use-toast';
+import { supabase } from '@/integrations/supabase/client';
+import ViolationTimelineManager from '@/components/ViolationTimelineManager';
+import ViolationTimelineEntryDialog from '@/components/ViolationTimelineEntryDialog';
+import ViolationTimelineImportDialog from '@/components/ViolationTimelineImportDialog';
+import ViolationTimelineExportButton from '@/components/ViolationTimelineExportButton';
+import ViolationMergeDialog from '@/components/ViolationMergeDialog';
+import { formatPhotoTimestamp, getPhotoDate } from '@/lib/exifUtils';
+import { Badge } from '@/components/ui/badge';
+import { formatDateTimeShortEST, formatShortDateEST } from '@/lib/timezoneUtils';
+import { logPhotoChange } from '@/lib/violationTimelineLogger';
+
+export default function ViolationDetailsDialog({ open, onOpenChange, violation, onEdit, onUpdate, onStatusChange, onRegenerateNotice, onEmailNotice }) {
+ const { userRole } = useAuth();
+ const { toast } = useToast();
+ const [activeTab, setActiveTab] = useState('details');
+ const [isEntryDialogOpen, setIsEntryDialogOpen] = useState(false);
+ const [isImportDialogOpen, setIsImportDialogOpen] = useState(false);
+ const [isMergeDialogOpen, setIsMergeDialogOpen] = useState(false);
+ const [editingEntry, setEditingEntry] = useState(null);
+ const [isUpdatingStatus, setIsUpdatingStatus] = useState(false);
+ const [isUploadingPhotos, setIsUploadingPhotos] = useState(false);
+ const [dragActive, setDragActive] = useState(false);
+ const [isDeletingPhoto, setIsDeletingPhoto] = useState(null);
+ const fileInputRef = useRef(null);
+
+ // Responses state
+ const [responses, setResponses] = useState([]);
+ const [loadingResponses, setLoadingResponses] = useState(false);
+
+ const canEdit = ['admin', 'manager', 'employee'].includes(userRole?.role);
+ const { addEntry, updateEntry, deleteEntry, loading: timelineLoading } = useViolationTimeline(violation?.id, onUpdate);
+
+ // Fetch responses when the tab is opened or violation changes
+ useEffect(() => {
+ if (!violation?.id || !open) return;
+ fetchResponses();
+ }, [violation?.id, open]);
+
+ const fetchResponses = async () => {
+ if (!violation?.id) return;
+ setLoadingResponses(true);
+ const { data, error } = await supabase
+ .from("violation_responses")
+ .select("*")
+ .eq("violation_id", violation.id)
+ .order("created_at", { ascending: false });
+ if (!error) setResponses(data || []);
+ setLoadingResponses(false);
+ };
+
+ const handleDeletePhoto = async (photoUrl, idx) => {
+ if (!canEdit || !violation?.id) return;
+ setIsDeletingPhoto(idx);
+ try {
+ const currentPhotos = Array.isArray(violation.photo_urls) ? [...violation.photo_urls] : violation.photo_url ? [{ url: violation.photo_url }] : [];
+ const updatedPhotos = currentPhotos.filter((p) => {
+ const url = typeof p === 'string' ? p : p?.url;
+ return url !== photoUrl;
+ });
+ const { error } = await supabase.from('violations').update({
+ photo_urls: updatedPhotos.length > 0 ? updatedPhotos : [],
+ photo_url: updatedPhotos.length > 0 ? (typeof updatedPhotos[0] === 'string' ? updatedPhotos[0] : updatedPhotos[0]?.url) : null,
+ }).eq('id', violation.id);
+ if (error) throw error;
+ toast({ title: 'Photo Deleted', description: 'The photo has been removed.' });
+ await logPhotoChange(violation.id, 'removed', 1);
+ if (onUpdate) onUpdate();
+ } catch (err) {
+ toast({ title: 'Delete Failed', description: err.message, variant: 'destructive' });
+ } finally {
+ setIsDeletingPhoto(null);
+ }
+ };
+
+ if (!violation) return null;
+
+ const formatDate = (dateString) => {
+ if (!dateString) return '-';
+ return new Date(dateString).toLocaleDateString();
+ };
+
+ const handleStatusUpdate = async (newStatus) => {
+ if (!onStatusChange) return;
+ setIsUpdatingStatus(true);
+ try {
+ await onStatusChange(violation.id, newStatus);
+ toast({ title: 'Status Updated', description: `Violation marked as ${newStatus}.` });
+ if (onUpdate) onUpdate();
+ } catch (err) {
+ toast({ title: 'Update Failed', description: err.message, variant: 'destructive' });
+ } finally {
+ setIsUpdatingStatus(false);
+ }
+ };
+
+ const handleSaveTimelineEntry = async (data) => {
+ let success = false;
+ if (editingEntry) { success = await updateEntry(editingEntry.id, data); }
+ else { success = await addEntry(data); }
+ if (success) { setIsEntryDialogOpen(false); setEditingEntry(null); }
+ };
+
+ const handleDrag = (e) => { e.preventDefault(); e.stopPropagation(); setDragActive(e.type === "dragenter" || e.type === "dragover"); };
+
+ const handleDrop = async (e) => {
+ e.preventDefault(); e.stopPropagation(); setDragActive(false);
+ if (e.dataTransfer.files?.length > 0) await processPhotoUploads(Array.from(e.dataTransfer.files));
+ };
+
+ const handleFileSelect = async (e) => {
+ if (e.target.files?.length > 0) await processPhotoUploads(Array.from(e.target.files));
+ };
+
+ const processPhotoUploads = async (files) => {
+ const validFiles = files.filter(f => f.type.startsWith('image/'));
+ if (validFiles.length === 0) { toast({ variant: 'destructive', title: 'Invalid Files' }); return; }
+ setIsUploadingPhotos(true);
+ try {
+ const newUrls = [];
+ for (const file of validFiles) {
+ const captureTimestamp = await getPhotoDate(file);
+ const fileName = `${uuidv4()}.${file.name.split('.').pop()}`;
+ const { data: uploadData, error: uploadError } = await supabase.storage.from('violation-photos').upload(fileName, file);
+ if (uploadError) throw uploadError;
+ const { data: urlData } = supabase.storage.from('violation-photos').getPublicUrl(uploadData.path);
+ newUrls.push({ url: urlData.publicUrl, timestamp: captureTimestamp || null });
+ }
+ let currentPhotos = Array.isArray(violation.photo_urls) ? [...violation.photo_urls] : violation.photo_url ? [{ url: violation.photo_url }] : [];
+ const updatedPhotos = [...currentPhotos, ...newUrls];
+ const { error } = await supabase.from('violations').update({ photo_urls: updatedPhotos, photo_url: updatedPhotos[0]?.url || null }).eq('id', violation.id);
+ if (error) throw error;
+ toast({ title: 'Upload Successful', description: `Added ${validFiles.length} photo(s).` });
+ await logPhotoChange(violation.id, 'added', validFiles.length);
+ if (onUpdate) onUpdate();
+ } catch (err) {
+ toast({ variant: 'destructive', title: 'Upload Failed', description: err.message });
+ } finally {
+ setIsUploadingPhotos(false);
+ if (fileInputRef.current) fileInputRef.current.value = '';
+ }
+ };
+
+ const statusColors = {
+ open: 'bg-red-100 text-red-800', resolved: 'bg-green-100 text-green-800',
+ };
+
+ const stageColors = {
+ 'First Notice': 'bg-blue-100 text-blue-700',
+ 'Second Notice': 'bg-orange-100 text-orange-700',
+ 'Third & Final Notice': 'bg-red-100 text-red-700',
+ };
+
+ const timelineEntries = Array.isArray(violation.timeline_entries) ? violation.timeline_entries : [];
+
+ const normalizePhoto = (p) => typeof p === 'string' ? { url: p, timestamp: null } : p;
+ const splitSemicolonUrls = (s) => s.split(/;\s*/).map(u => u.trim()).filter(Boolean);
+
+ let displayPhotos = [];
+ if (violation.photo_urls && Array.isArray(violation.photo_urls)) {
+ displayPhotos = violation.photo_urls.flatMap(p => {
+ const norm = normalizePhoto(p);
+ return norm.url.includes(';') ? splitSemicolonUrls(norm.url).map(u => ({ url: u, timestamp: norm.timestamp || null })) : [norm];
+ });
+ } else if (violation.photo_url) {
+ displayPhotos = splitSemicolonUrls(violation.photo_url).map(u => ({ url: u, timestamp: null }));
+ }
+
+ const getOwnerName = () => {
+ if (violation.owners) return `${violation.owners.first_name || ''} ${violation.owners.last_name || ''}`.trim();
+ return 'Unknown';
+ };
+
+ return (
+
+
+
+
+
+
+ Violation Details
+ Review full details and history.
+
+
+
+
+
+
+
+ Details
+
+ Responses
+ {responses.length > 0 && {responses.length} }
+
+ Timeline
+
+
+
+
+
+
Status
+ {canEdit && onStatusChange ? (
+
+
+
+ Open
+ Resolved
+
+
+ ) : (
+
{violation.status || 'Open'}
+ )}
+
+
+
Stage
+
+ {violation.stage || 'First Notice'}
+
+ {canEdit && onStatusChange && violation.status !== 'resolved' && violation.stage !== 'Third & Final Notice' && (
+
handleStatusUpdate('escalated')} disabled={isUpdatingStatus}>
+ Escalate
+
+ )}
+
+
+
Violation Type
+
{violation.violation_type || violation.title || 'General'}
+
+
+
+
+
+
+
+
+
Property
+
{violation.owners?.property_address || violation.address || 'N/A'}
+ {violation.associations?.name &&
{violation.associations.name}
}
+
+
+
+
+
+
+
+
Date Reported
{formatDate(violation.violation_date || violation.created_at)}
+
+ {violation.due_date && (
+
+
+
Resolution Due
{formatDate(violation.due_date)}
+
+ )}
+ {violation.certified_mail && (
+
+
+
Certified Mail #
{violation.certified_mail}
+
+ )}
+
+
+
+ {(violation.description || violation.notes) && (
+
+
+
+ {violation.description &&
{violation.description}
}
+ {violation.notes &&
{violation.notes}
}
+
+
+ )}
+
+
+
Evidence Photos
+ {displayPhotos.length > 0 && (
+
+ {displayPhotos.map((photo, idx) => (
+
+
+
+
+ {canEdit && (
+
{ e.stopPropagation(); handleDeletePhoto(photo.url, idx); }}
+ disabled={isDeletingPhoto === idx}
+ className="absolute top-2 left-2 bg-destructive/90 hover:bg-destructive text-white p-1.5 rounded-full shadow-sm opacity-0 group-hover:opacity-100 transition-opacity disabled:opacity-50"
+ title="Delete photo"
+ >
+ {isDeletingPhoto === idx ? : }
+
+ )}
+
+ {photo.timestamp &&
{formatPhotoTimestamp(photo.timestamp)}
}
+
+ ))}
+
+ )}
+ {canEdit && (
+
!isUploadingPhotos && fileInputRef.current?.click()}>
+
+ {isUploadingPhotos ?
:
}
+
{isUploadingPhotos ? 'Uploading photos...' : 'Click to upload or drag & drop'}
+
+ )}
+
+
+
+
+
+
Homeowner Responses
+
+ {loadingResponses ? : 'Refresh'}
+
+
+ {loadingResponses ? (
+
+ ) : responses.length === 0 ? (
+
+
+
No responses submitted yet.
+
Homeowners can respond via the QR code on the violation notice.
+
+ ) : (
+
+ {responses.map((r) => (
+
+
+
+
+ {r.respondent_name || 'Anonymous'}
+
+
{formatDateTimeShortEST(r.created_at)}
+
+
+ {r.respondent_email && (
+
Email: {r.respondent_email}
+ )}
+ {r.respondent_phone && (
+
Phone: {r.respondent_phone}
+ )}
+ {r.date_corrected && (
+
Date Corrected: {formatShortDateEST(r.date_corrected)}
+ )}
+
+ {r.response_text && (
+
{r.response_text}
+ )}
+
{r.status || 'pending'}
+
+ ))}
+
+ )}
+
+
+
+
+
Activity History
+
+
+ {canEdit && (
+ <>
+
setIsMergeDialogOpen(true)}> Merge
+
setIsImportDialogOpen(true)}> Import
+
{ setEditingEntry(null); setIsEntryDialogOpen(true); }}> Add Entry
+ >
+ )}
+
+
+ { setEditingEntry(entry); setIsEntryDialogOpen(true); }} onDelete={deleteEntry} />
+
+
+
+
+
+
+
onOpenChange(false)}>Close
+ {onEdit && canEdit && (
Edit Record)}
+
+
+ {onEmailNotice && canEdit && violation.owners?.electronic_consent && violation.owners?.email && (
+
{ onOpenChange(false); onEmailNotice(violation); }}>
+ Email Notice
+
+ )}
+ {onRegenerateNotice && canEdit && (
+
{ onOpenChange(false); onRegenerateNotice(violation); }}>
+ Regenerate Notice
+
+ )}
+ {activeTab === 'details' && (
setActiveTab('timeline')} className="w-full sm:w-auto">View Timeline )}
+
+
+
+
+ { if (onUpdate) onUpdate(); }} />
+ { if (onUpdate) onUpdate(); }} />
+
+
+ );
+}
diff --git a/src/components/ViolationDialog.jsx b/src/components/ViolationDialog.jsx
new file mode 100644
index 0000000..2a068d3
--- /dev/null
+++ b/src/components/ViolationDialog.jsx
@@ -0,0 +1,567 @@
+import React, { useState, useEffect } from 'react';
+import { logViolationCreated, logViolationUpdated, logStatusChange, logStageChange } from '@/lib/violationTimelineLogger';
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
+import { Button } from '@/components/ui/button';
+import { Label } from '@/components/ui/label';
+import { Input } from '@/components/ui/input';
+import { Textarea } from '@/components/ui/textarea';
+import { supabase } from '@/integrations/supabase/client';
+import { toast } from 'sonner';
+import { useAuth } from '@/contexts/AuthContext';
+import { Combobox } from '@/components/Combobox';
+import { PropertySelect } from '@/components/PropertySelect';
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
+import { calculateNextNoticeLevel, getNoticeColor, isValidStatus, VALID_STATUSES } from '@/lib/ViolationNoticeProgressionHelper';
+import { Badge } from '@/components/ui/badge';
+import { Loader2, Crop, BookOpen, X, Tag } from 'lucide-react';
+
+function ViolationDialog({ open, onOpenChange, violation, onSuccess, associationId }) {
+ const { user } = useAuth();
+ const [associations, setAssociations] = useState([]);
+ const [assocOptions, setAssocOptions] = useState([]);
+ const [calculatingLevel, setCalculatingLevel] = useState(false);
+ const [processingImage, setProcessingImage] = useState(false);
+
+ const [definedTypes, setDefinedTypes] = useState([]);
+ const [selectedDefinedType, setSelectedDefinedType] = useState("");
+
+ const [formData, setFormData] = useState({
+ association_id: '',
+ property_id: '',
+ unit_id: '',
+ address: '',
+ violation_type: '',
+ article_section: '',
+ citation: '',
+ requested_action: '',
+ notes: '',
+ violation_date: '',
+ due_date: '',
+ stage: 'First Notice',
+ priority: 'medium',
+ status: 'open'
+ });
+ const [photoFile, setPhotoFile] = useState(null);
+ const [loading, setLoading] = useState(false);
+ const [selectedTags, setSelectedTags] = useState([]);
+
+ const defaultViolationTypeOptions = [
+ 'Parking Violation', 'Noise Complaint', 'Trash Disposal Issue',
+ 'Unkempt Property', 'Landscaping Non-compliance',
+ 'Structural Alteration Without Approval', 'Other'
+ ];
+
+ const STAGE_OPTIONS = ["First Notice", "Second Notice", "Third & Final Notice"];
+
+ const formatDateForInput = (date) => {
+ if (!date) return '';
+ if (typeof date === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(date)) return date;
+ const d = new Date(date);
+ const y = d.getFullYear();
+ const m = String(d.getMonth() + 1).padStart(2, '0');
+ const day = String(d.getDate()).padStart(2, '0');
+ return `${y}-${m}-${day}`;
+ };
+
+ useEffect(() => {
+ const fetchAssociations = async () => {
+ const { data, error } = await supabase.from('associations').select('id, name').eq('status', 'active').order('name');
+ if (!error) {
+ setAssociations(data);
+ setAssocOptions(data.map(c => ({ value: c.id, label: c.name })));
+ }
+ };
+ if (open) fetchAssociations();
+ }, [open]);
+
+ useEffect(() => {
+ const fetchDefinedTypes = async () => {
+ if (!formData.association_id) { setDefinedTypes([]); return; }
+ const { data, error } = await supabase.from('violation_types').select('*').eq('association_id', formData.association_id).order('category');
+ if (!error) setDefinedTypes(data || []);
+ };
+ fetchDefinedTypes();
+ }, [formData.association_id]);
+
+ useEffect(() => {
+ if (violation) {
+ let latestHistory = {};
+ if (violation.notice_history && Array.isArray(violation.notice_history) && violation.notice_history.length > 0) {
+ latestHistory = violation.notice_history[violation.notice_history.length - 1] || {};
+ }
+ setFormData({
+ association_id: violation.association_id || '',
+ property_id: violation.owner_id || '',
+ unit_id: violation.unit_id || '',
+ address: violation.address || '',
+ violation_type: violation.violation_type || '',
+ article_section: latestHistory.article_section || '',
+ citation: latestHistory.citation || '',
+ requested_action: violation.description || latestHistory.requested_action || '',
+ notes: violation.notes || '',
+ violation_date: formatDateForInput(violation.violation_date),
+ due_date: formatDateForInput(violation.due_date),
+ stage: violation.stage || violation.notice_level || 'First Notice',
+ priority: violation.priority || 'medium',
+ status: violation.status || 'open',
+ certified_mail: violation.certified_mail || '',
+ });
+ setSelectedTags(violation.tags || []);
+ setPhotoFile(null);
+ } else {
+ setFormData({
+ association_id: associationId || '',
+ property_id: '', unit_id: '', address: '', violation_type: '',
+ article_section: '', citation: '', requested_action: '',
+ notes: '', violation_date: formatDateForInput(new Date()),
+ due_date: '', stage: 'First Notice', priority: 'medium', status: 'open',
+ certified_mail: '',
+ });
+ setSelectedTags([]);
+ setPhotoFile(null);
+ }
+ if (!violation && open) {
+ const params = new URLSearchParams(window.location.search);
+ const associationIdParam = params.get('associationId');
+ const ownerIdParam = params.get('ownerId');
+ if (associationIdParam || ownerIdParam) {
+ setFormData(prev => ({
+ ...prev,
+ association_id: associationIdParam || prev.association_id,
+ property_id: ownerIdParam || prev.property_id,
+ }));
+ }
+ }
+ setSelectedDefinedType("");
+ }, [violation, open, associationId]);
+
+ useEffect(() => {
+ const updateLevel = async () => {
+ if (!violation && formData.property_id) {
+ setCalculatingLevel(true);
+ const level = await calculateNextNoticeLevel(formData.property_id);
+ setFormData(prev => ({ ...prev, stage: level }));
+ setCalculatingLevel(false);
+ }
+ };
+ updateLevel();
+ }, [formData.property_id, violation]);
+
+ const handleDefinedTypeChange = (typeId) => {
+ const selected = definedTypes.find(t => t.id === typeId);
+ if (selected) {
+ setFormData(prev => ({
+ ...prev,
+ violation_type: selected.category || prev.violation_type,
+ article_section: selected.article_section || prev.article_section,
+ citation: selected.citation || prev.citation,
+ requested_action: selected.requested_action || prev.requested_action
+ }));
+ setSelectedDefinedType(typeId);
+ }
+ };
+
+ const processImage = (file) => {
+ return new Promise((resolve) => {
+ if (!file.type.startsWith('image/')) { resolve(file); return; }
+ const img = new Image();
+ const url = URL.createObjectURL(file);
+ img.src = url;
+ img.onload = () => {
+ URL.revokeObjectURL(url);
+ const { width, height } = img;
+ if (height > width) {
+ try {
+ const canvas = document.createElement('canvas');
+ const size = width;
+ canvas.width = size;
+ canvas.height = size;
+ const ctx = canvas.getContext('2d');
+ const yOffset = (height - width) / 2;
+ ctx.drawImage(img, 0, yOffset, width, width, 0, 0, size, size);
+ canvas.toBlob((blob) => {
+ if (blob) {
+ resolve(new File([blob], file.name, { type: file.type, lastModified: Date.now() }));
+ } else { resolve(file); }
+ }, file.type, 0.9);
+ } catch (e) {
+ console.error("Image processing failed", e);
+ resolve(file);
+ }
+ } else { resolve(file); }
+ };
+ img.onerror = () => { URL.revokeObjectURL(url); resolve(file); };
+ });
+ };
+
+ const handleFileChange = async (e) => {
+ const file = e.target.files[0];
+ if (!file) return;
+ const allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
+ if (!allowedTypes.includes(file.type)) {
+ toast.error('Please upload a valid image file (JPEG, PNG, GIF, or WebP).');
+ return;
+ }
+ setProcessingImage(true);
+ try {
+ const processedFile = await processImage(file);
+ setPhotoFile(processedFile);
+ if (processedFile !== file) {
+ toast.success('Portrait image was automatically cropped to square format.');
+ }
+ } catch (err) {
+ console.error("Error processing image:", err);
+ setPhotoFile(file);
+ } finally {
+ setProcessingImage(false);
+ }
+ };
+
+ const handleSubmit = async (e) => {
+ e.preventDefault();
+ if (!formData.association_id) {
+ toast.error('Please select an Association.');
+ return;
+ }
+ if (!formData.property_id && !formData.address) {
+ toast.error('Please select a Property or enter an address.');
+ return;
+ }
+ if (!isValidStatus(formData.status)) {
+ toast.error(`Status '${formData.status}' is not valid. Allowed: ${VALID_STATUSES.join(', ')}`);
+ return;
+ }
+
+ setLoading(true);
+ try {
+ let photo_url = violation?.photo_url || null;
+ if (photoFile) {
+ const fileExtension = photoFile.name.split('.').pop();
+ const fileName = `${crypto.randomUUID()}.${fileExtension}`;
+ const { data: uploadData, error: uploadError } = await supabase.storage.from('violation-photos').upload(fileName, photoFile);
+ if (uploadError) throw uploadError;
+ const { data: urlData } = supabase.storage.from('violation-photos').getPublicUrl(uploadData.path);
+ photo_url = urlData.publicUrl;
+ }
+
+ const timestamp = new Date().toISOString();
+ const newHistoryEntry = {
+ date: timestamp, status: formData.status, notice_level: formData.stage,
+ violation_type: formData.violation_type, article_section: formData.article_section,
+ citation: formData.citation, requested_action: formData.requested_action,
+ notes: formData.notes, action: violation ? 'Violation Updated' : 'Violation Created'
+ };
+
+ let updatedHistory = [];
+ if (violation && violation.notice_history && Array.isArray(violation.notice_history)) {
+ updatedHistory = [...violation.notice_history, newHistoryEntry];
+ } else {
+ updatedHistory = [newHistoryEntry];
+ }
+
+ const dataToSubmit = {
+ association_id: formData.association_id,
+ owner_id: formData.property_id && formData.property_id.length > 0 ? formData.property_id : null,
+ unit_id: formData.unit_id && formData.unit_id.length > 0 ? formData.unit_id : null,
+ address: formData.address,
+ title: formData.violation_type || 'Violation',
+ violation_type: formData.violation_type,
+ status: formData.status,
+ priority: formData.priority,
+ stage: formData.stage,
+ notice_level: formData.stage,
+ violation_date: formData.violation_date || null,
+ due_date: formData.due_date || null,
+ notes: formData.notes,
+ description: formData.requested_action,
+ photo_url,
+ notice_history: updatedHistory,
+ tags: selectedTags
+ };
+
+ if (formData.certified_mail !== undefined) {
+ dataToSubmit.certified_mail = formData.certified_mail?.trim() || null;
+ }
+
+ if (!violation) dataToSubmit.created_by = user?.id;
+
+ const { data: insertResult, error } = violation
+ ? await supabase.from('violations').update(dataToSubmit).eq('id', violation.id).select('id').maybeSingle()
+ : await supabase.from('violations').insert([dataToSubmit]).select('id').single();
+
+ if (error) throw error;
+
+ // ── Auto-log timeline entries ──
+ const targetId = violation?.id || insertResult?.id;
+ if (targetId) {
+ if (!violation) {
+ // New violation created
+ await logViolationCreated(targetId, {
+ address: formData.address,
+ violationType: formData.violation_type,
+ noticeLevel: formData.stage,
+ });
+ } else {
+ // Existing violation updated — log specific changes
+ const changes = {};
+ if (violation.status !== formData.status) {
+ changes.status = { from: violation.status, to: formData.status };
+ await logStatusChange(targetId, violation.status, formData.status);
+ }
+ if ((violation.stage || violation.notice_level) !== formData.stage) {
+ const prevStage = violation.stage || violation.notice_level;
+ changes.stage = { from: prevStage, to: formData.stage };
+ await logStageChange(targetId, prevStage, formData.stage);
+ }
+ if (violation.priority !== formData.priority) {
+ changes.priority = { from: violation.priority, to: formData.priority };
+ }
+ if (violation.violation_type !== formData.violation_type) {
+ changes.violation_type = { from: violation.violation_type, to: formData.violation_type };
+ }
+ if ((violation.address || '') !== (formData.address || '')) {
+ changes.address = { from: violation.address, to: formData.address };
+ }
+ if ((violation.notes || '') !== (formData.notes || '')) {
+ changes.notes = { from: violation.notes, to: formData.notes };
+ }
+ if ((violation.violation_date || '') !== (formData.violation_date || '')) {
+ changes.violation_date = { from: violation.violation_date, to: formData.violation_date };
+ }
+ if ((violation.due_date || '') !== (formData.due_date || '')) {
+ changes.due_date = { from: violation.due_date, to: formData.due_date };
+ }
+ if ((violation.description || '') !== (formData.requested_action || '')) {
+ changes.requested_action = { from: violation.description, to: formData.requested_action };
+ }
+ if ((violation.article_section || '') !== (formData.article_section || '')) {
+ changes.article_section = { from: violation.article_section, to: formData.article_section };
+ }
+ if ((violation.citation || '') !== (formData.citation || '')) {
+ changes.citation = { from: violation.citation, to: formData.citation };
+ }
+ if (Object.keys(changes).length > 0) {
+ await logViolationUpdated(targetId, changes);
+ }
+ }
+ }
+
+ // Send in-app notifications
+ try {
+ const isEscalated = formData.status === 'escalated' || (violation && violation.status !== 'escalated' && formData.status === 'escalated');
+ const notifTitle = isEscalated
+ ? `⚠️ Violation Escalated: ${formData.violation_type || 'Violation'}`
+ : violation
+ ? `Violation Updated: ${formData.violation_type || 'Violation'}`
+ : `New Violation: ${formData.violation_type || 'Violation'}`;
+ const notifMessage = `${formData.address ? formData.address + ' — ' : ''}Status: ${formData.status}, Stage: ${formData.stage}`;
+
+ const { data: staffRoles } = await supabase
+ .from('user_roles')
+ .select('user_id')
+ .in('role', ['admin', 'manager']);
+
+ if (staffRoles && staffRoles.length > 0) {
+ const notifications = staffRoles
+ .filter(r => r.user_id !== user?.id)
+ .map(r => ({
+ user_id: r.user_id,
+ type: isEscalated ? 'warning' : 'info',
+ title: notifTitle,
+ message: notifMessage,
+ related_item_type: 'violation',
+ }));
+ if (notifications.length > 0) {
+ await supabase.from('in_app_notifications').insert(notifications);
+ }
+ }
+ } catch (notifErr) {
+ console.error('Failed to send violation notification:', notifErr);
+ }
+
+ toast.success(`Violation ${violation ? 'updated' : 'created'} successfully.`);
+ onSuccess();
+ onOpenChange(false);
+ } catch (error) {
+ console.error(error);
+ toast.error(error.message);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ // Only allow selection from the association's defined violation types
+ const associationTypeOptions = definedTypes.map(dt => dt.category).filter(Boolean);
+ const combinedTypeOptions = Array.from(new Set([...associationTypeOptions, formData.violation_type])).filter(Boolean);
+
+ return (
+
+
+
+ {violation ? 'Edit Violation' : 'Log New Violation'}
+ {violation ? 'Update the details for this violation.' : 'Create a new violation record.'}
+
+
+
+
+
+
+
Association *
+
+ setFormData({ ...formData, association_id: value, property_id: '', unit_id: '', address: '' })} placeholder="Select association..." className="bg-card" />
+
+
+
+ Status
+ setFormData({ ...formData, status: val })}>
+
+
+ Open
+ Resolved
+ Recommended for Fining
+ Fined
+ Escalated
+ Closed
+ Pending
+
+
+
+
+
+
+
Property / Owner *
+
+
setFormData(prev => ({ ...prev, property_id: val }))} onSelectProperty={(prop) => setFormData(prev => ({ ...prev, property_id: prop?.id, unit_id: prop?.unit_id || '', address: prop?.property_address || '' }))} className="bg-card" />
+
+ {formData.address &&
{formData.address}
}
+
+
+ {definedTypes.length > 0 && (
+
+ Load Standard Violation
+
+
+
+ {definedTypes.map(dt => ({dt.category} {dt.article_section ? `(${dt.article_section})` : ''} ))}
+
+
+
+ )}
+
+
+
+
Violation Stage
+ {calculatingLevel &&
Calculating...
}
+ {!calculatingLevel && !violation && formData.property_id && (
Auto-detected )}
+
+
setFormData({ ...formData, stage: val })}>
+
+
+ None
+ {STAGE_OPTIONS.map(stage => ({stage} ))}
+
+
+
+
+
+
+ Priority
+ setFormData({ ...formData, priority: val })}>
+
+
+ Low
+ Medium
+ High
+
+
+
+
+ Violation Date
+ setFormData({ ...formData, violation_date: e.target.value })} className="mt-1 bg-card" />
+
+
+
+
+
+
+ Certified Mail # (optional)
+ setFormData({ ...formData, certified_mail: e.target.value })}
+ placeholder="e.g. 9407 1234 5678 9012 3456 78"
+ className="mt-1 bg-card"
+ />
+
+
+
+
+ Requested Action setFormData({ ...formData, requested_action: e.target.value })} placeholder="What needs to be done to fix this?" className="mt-1 bg-card" rows={3} />
+
+ {/* Tags */}
+
+
Tags
+
+ {selectedTags.map((tag, idx) => (
+
+ {tag}
+ setSelectedTags(prev => prev.filter((_, i) => i !== idx))} className="ml-0.5 hover:text-destructive">
+
+ ))}
+
+ {definedTypes.length > 0 && (
+
+ {definedTypes.filter(dt => !selectedTags.includes(dt.category)).map(dt => (
+ setSelectedTags(prev => [...prev, dt.category])} className="inline-flex items-center rounded-full border border-dashed border-muted-foreground/30 px-2.5 py-0.5 text-xs text-muted-foreground hover:bg-muted hover:text-foreground transition-colors">
+ + {dt.category}
+
+ ))}
+
+ )}
+ {definedTypes.length === 0 && formData.association_id && (
+
No violation types defined for this association. Manage types from the Violations page.
+ )}
+
+
+
+
+ Photo
+ {processingImage && Cropping... }
+
+
+ {photoFile && !processingImage &&
Image ready for upload.
}
+
+
+ Internal Notes setFormData({ ...formData, notes: e.target.value })} rows={2} className="mt-1 bg-card" />
+
+
+
+
+ onOpenChange(false)}>Cancel
+
+ {loading ? 'Saving...' : violation ? 'Update' : 'Create'}
+
+
+
+
+ );
+}
+
+export default ViolationDialog;
diff --git a/src/components/ViolationExportDialog.jsx b/src/components/ViolationExportDialog.jsx
new file mode 100644
index 0000000..1ab4f5e
--- /dev/null
+++ b/src/components/ViolationExportDialog.jsx
@@ -0,0 +1,163 @@
+
+import React, { useState } from 'react';
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
+import { Button } from '@/components/ui/button';
+import { Label } from '@/components/ui/label';
+import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
+import { Switch } from '@/components/ui/switch';
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
+import { Progress } from '@/components/ui/progress';
+import { FileDown, Loader2, Eye, LayoutGrid, List, History, XCircle } from 'lucide-react';
+import { generateViolationsPDF, generateViolationTimelinePDF } from '@/lib/violationPdfGenerator';
+import { useToast } from '@/components/ui/use-toast';
+import { useViolationReportOptimization } from '@/hooks/useViolationReportOptimization';
+
+export default function ViolationExportDialog({ open, onOpenChange, violations = [] }) {
+ const { toast } = useToast();
+ const { isGenerating, progress, startGeneration, cancelGeneration } = useViolationReportOptimization();
+
+ const [settings, setSettings] = useState({
+ layout: 'grid',
+ pageSize: 'letter',
+ includeImages: true,
+ includeTimeline: true,
+ colorScheme: 'default'
+ });
+
+ const handleExport = async (action = 'download') => {
+ if (isGenerating) return;
+
+ if (!violations || violations.length === 0) {
+ toast({ variant: "destructive", title: "Nothing to export", description: "No violations selected or available." });
+ return;
+ }
+
+ try {
+ const clientName = (violations[0]?.clients?.name) || 'Association Report';
+
+ const generatorFn = settings.layout === 'timeline' ? generateViolationTimelinePDF : generateViolationsPDF;
+
+ const doc = await startGeneration(violations, {
+ ...settings,
+ clientName: clientName
+ }, generatorFn);
+
+ if (!doc) return;
+
+ const dateStr = new Date().toISOString().split('T')[0];
+
+ if (action === 'preview') {
+ window.open(URL.createObjectURL(doc.output('blob')), '_blank');
+ } else {
+ const prefix = settings.layout === 'timeline' ? 'Timeline_Report' : 'Violations_Report';
+ doc.save(`${prefix}_${dateStr}.pdf`);
+ toast({ title: "Export Successful", description: "Your PDF report has been downloaded." });
+ onOpenChange(false);
+ }
+ } catch (error) {
+ console.error("Export dialog error:", error);
+ if (error?.message !== 'Report generation cancelled') {
+ toast({
+ variant: "destructive",
+ title: "Export Failed",
+ description: error?.message || "An unexpected error occurred during PDF generation."
+ });
+ }
+ }
+ };
+
+ return (
+ { if(!isGenerating) onOpenChange(val); }}>
+
+
+ Export Violations PDF
+
+ {isGenerating
+ ? `Generating report for ${violations?.length || 0} records...`
+ : `Configure options for the violation report. Exporting ${violations?.length || 0} record(s).`
+ }
+
+
+
+ {isGenerating ? (
+
+
+
{progress}% Complete
+
+
+ Cancel Operation
+
+
+
+ ) : (
+
+
+
Report Type
+
setSettings(s => ({ ...s, layout: val }))}
+ className="grid grid-cols-3 gap-4"
+ >
+
+
+
+ Grid
+
+
+
+
+
+
List
+
+
+
+
+
+ Timeline
+
+
+
+
+
+
+
+ Page Size
+ setSettings(s => ({ ...s, pageSize: val }))}>
+
+
+ Letter (8.5 x 11)
+ A4
+
+
+
+
+
+ {settings.layout !== 'timeline' && (
+
+
+
Include Evidence Images
+
setSettings(s => ({ ...s, includeImages: val }))} />
+
+
+
Include Timeline History
+
setSettings(s => ({ ...s, includeTimeline: val }))} />
+
+
+ )}
+
+ )}
+
+ {!isGenerating && (
+
+ handleExport('preview')} disabled={isGenerating}>
+ Preview
+
+ handleExport('download')} disabled={isGenerating}>
+ Download
+
+
+ )}
+
+
+ );
+}
diff --git a/src/components/ViolationExportPDF.jsx b/src/components/ViolationExportPDF.jsx
new file mode 100644
index 0000000..01aa78a
--- /dev/null
+++ b/src/components/ViolationExportPDF.jsx
@@ -0,0 +1,14 @@
+/**
+ * Note: This file acts as a re-export wrapper to satisfy the specific file creation requirement.
+ * The core logic is implemented in src/lib/violationPdfGenerator.js for better separation of concerns
+ * (JS logic vs React Components) and to allow reuse across the application.
+ *
+ * In a future refactor, this component could be expanded to render a live HTML preview
+ * using the same styles as the PDF generator.
+ */
+
+import { generateViolationsPDF } from '@/lib/violationPdfGenerator';
+import ViolationExportDialog from '@/components/ViolationExportDialog';
+
+export { generateViolationsPDF };
+export default ViolationExportDialog;
\ No newline at end of file
diff --git a/src/components/ViolationMergeDialog.jsx b/src/components/ViolationMergeDialog.jsx
new file mode 100644
index 0000000..06b27ce
--- /dev/null
+++ b/src/components/ViolationMergeDialog.jsx
@@ -0,0 +1,279 @@
+import { useState, useEffect, useMemo } from 'react';
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
+import { Button } from '@/components/ui/button';
+import { Checkbox } from '@/components/ui/checkbox';
+import { Label } from '@/components/ui/label';
+import { Badge } from '@/components/ui/badge';
+import { Input } from '@/components/ui/input';
+import { ScrollArea } from '@/components/ui/scroll-area';
+import { Loader2, GitMerge, AlertTriangle, Search } from 'lucide-react';
+import { supabase } from '@/integrations/supabase/client';
+import { useToast } from '@/components/ui/use-toast';
+import { formatShortDateEST } from '@/lib/timezoneUtils';
+
+/**
+ * Merge prior violations into the currently-open (newest) violation.
+ * - Combines timeline entries (deduped by id/date+description)
+ * - Merges photo_urls
+ * - Appends prior descriptions/notes into notes
+ * - Optionally archives merged source violations
+ */
+export default function ViolationMergeDialog({ open, onOpenChange, violation, onSuccess }) {
+ const { toast } = useToast();
+ const [candidates, setCandidates] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [merging, setMerging] = useState(false);
+ const [selected, setSelected] = useState({});
+ const [archiveSources, setArchiveSources] = useState(true);
+ const [search, setSearch] = useState('');
+
+ useEffect(() => {
+ if (!open || !violation?.id) return;
+ setSelected({});
+ setSearch('');
+ fetchCandidates();
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [open, violation?.id]);
+
+ const fetchCandidates = async () => {
+ setLoading(true);
+ try {
+ let query = supabase
+ .from('violations')
+ .select('id, title, violation_type, address, status, stage, created_at, violation_date, owner_id, unit_id, description, notes, timeline_entries, photo_urls, photo_url')
+ .eq('association_id', violation.association_id)
+ .neq('id', violation.id)
+ .order('created_at', { ascending: false })
+ .limit(200);
+
+ // Prefer matching by unit, then owner, then address
+ if (violation.unit_id) query = query.eq('unit_id', violation.unit_id);
+ else if (violation.owner_id) query = query.eq('owner_id', violation.owner_id);
+ else if (violation.address) query = query.eq('address', violation.address);
+
+ const { data, error } = await query;
+ if (error) throw error;
+ setCandidates(data || []);
+ } catch (err) {
+ toast({ variant: 'destructive', title: 'Failed to load violations', description: err.message });
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const filtered = useMemo(() => {
+ if (!search.trim()) return candidates;
+ const q = search.toLowerCase();
+ return candidates.filter(c =>
+ (c.title || '').toLowerCase().includes(q) ||
+ (c.violation_type || '').toLowerCase().includes(q) ||
+ (c.address || '').toLowerCase().includes(q) ||
+ (c.description || '').toLowerCase().includes(q)
+ );
+ }, [candidates, search]);
+
+ const selectedIds = Object.keys(selected).filter(id => selected[id]);
+
+ const toggleAll = (val) => {
+ const next = {};
+ if (val) filtered.forEach(c => { next[c.id] = true; });
+ setSelected(next);
+ };
+
+ const handleMerge = async () => {
+ if (selectedIds.length === 0) {
+ toast({ variant: 'destructive', title: 'Select at least one violation to merge' });
+ return;
+ }
+ setMerging(true);
+ try {
+ const sources = candidates.filter(c => selectedIds.includes(c.id));
+
+ // Combine timeline entries
+ const existingTimeline = Array.isArray(violation.timeline_entries) ? [...violation.timeline_entries] : [];
+ const seenKeys = new Set(
+ existingTimeline.map(e => `${e.id || ''}|${e.date || ''}|${(e.description || '').slice(0, 60)}`)
+ );
+ const mergedTimeline = [...existingTimeline];
+
+ // Combine photos (normalize to {url, timestamp})
+ const normalizePhotos = (v) => {
+ if (Array.isArray(v.photo_urls) && v.photo_urls.length) {
+ return v.photo_urls.map(p => typeof p === 'string' ? { url: p } : p);
+ }
+ if (v.photo_url) return [{ url: v.photo_url }];
+ return [];
+ };
+ const existingPhotos = normalizePhotos(violation);
+ const seenPhotoUrls = new Set(existingPhotos.map(p => p.url));
+ const mergedPhotos = [...existingPhotos];
+
+ const noteAdditions = [];
+
+ for (const src of sources) {
+ const srcEntries = Array.isArray(src.timeline_entries) ? src.timeline_entries : [];
+ for (const entry of srcEntries) {
+ const key = `${entry.id || ''}|${entry.date || ''}|${(entry.description || '').slice(0, 60)}`;
+ if (!seenKeys.has(key)) {
+ mergedTimeline.push({
+ ...entry,
+ merged_from_violation_id: src.id,
+ });
+ seenKeys.add(key);
+ }
+ }
+
+ for (const photo of normalizePhotos(src)) {
+ if (photo.url && !seenPhotoUrls.has(photo.url)) {
+ mergedPhotos.push(photo);
+ seenPhotoUrls.add(photo.url);
+ }
+ }
+
+ const dateLabel = src.violation_date || src.created_at;
+ const header = `--- Merged from violation ${formatShortDateEST(dateLabel)} (${src.violation_type || src.title || 'Violation'}) ---`;
+ const body = [src.description, src.notes].filter(Boolean).join('\n');
+ if (body) noteAdditions.push(`${header}\n${body}`);
+ }
+
+ // Sort timeline ascending by date
+ mergedTimeline.sort((a, b) => new Date(a.date || 0) - new Date(b.date || 0));
+
+ const combinedNotes = [violation.notes || '', ...noteAdditions].filter(Boolean).join('\n\n');
+
+ const updatePayload = {
+ timeline_entries: mergedTimeline,
+ photo_urls: mergedPhotos,
+ photo_url: mergedPhotos[0]?.url || violation.photo_url || null,
+ notes: combinedNotes || null,
+ updated_at: new Date().toISOString(),
+ };
+
+ const { error: updateErr } = await supabase
+ .from('violations')
+ .update(updatePayload)
+ .eq('id', violation.id);
+ if (updateErr) throw updateErr;
+
+ if (archiveSources) {
+ const { error: archiveErr } = await supabase
+ .from('violations')
+ .update({ status: 'resolved', stage: 'Merged', updated_at: new Date().toISOString() })
+ .in('id', selectedIds);
+ if (archiveErr) throw archiveErr;
+ }
+
+ toast({
+ title: 'Merge Complete',
+ description: `Merged ${selectedIds.length} violation(s) into the current record.`,
+ });
+ onOpenChange(false);
+ if (onSuccess) onSuccess();
+ } catch (err) {
+ toast({ variant: 'destructive', title: 'Merge Failed', description: err.message });
+ } finally {
+ setMerging(false);
+ }
+ };
+
+ const allSelected = filtered.length > 0 && filtered.every(c => selected[c.id]);
+
+ return (
+
+
+
+
+ Merge Violations
+
+
+ Select prior violations for this {violation?.unit_id ? 'unit' : violation?.owner_id ? 'owner' : 'address'} to merge their timeline history, photos, and notes into the current violation.
+
+
+
+
+
+
+
Target (current) violation
+
+
+ {violation?.violation_type || violation?.title || 'Violation'} · {violation?.address || '—'} · {formatShortDateEST(violation?.violation_date || violation?.created_at)}
+
+
+
+
+
+ setSearch(e.target.value)}
+ />
+
+
+ {loading ? (
+
+ ) : filtered.length === 0 ? (
+
+ No prior violations found for this {violation?.unit_id ? 'unit' : violation?.owner_id ? 'owner' : 'address'}.
+
+ ) : (
+ <>
+
+
+ toggleAll(!!v)} />
+ Select all ({filtered.length})
+
+ {selectedIds.length} selected
+
+
+
+ {filtered.map((c) => {
+ const entryCount = Array.isArray(c.timeline_entries) ? c.timeline_entries.length : 0;
+ const photoCount = Array.isArray(c.photo_urls) ? c.photo_urls.length : (c.photo_url ? 1 : 0);
+ return (
+
+ setSelected(prev => ({ ...prev, [c.id]: !!v }))}
+ className="mt-0.5"
+ />
+
+
+ {c.violation_type || c.title || 'Violation'}
+ {c.status || 'open'}
+ {c.stage && {c.stage} }
+
+
+ {formatShortDateEST(c.violation_date || c.created_at)}
+ {c.address && ` · ${c.address}`}
+
+
+ {entryCount} timeline {entryCount === 1 ? 'entry' : 'entries'}
+ {photoCount} photo{photoCount === 1 ? '' : 's'}
+
+
+
+ );
+ })}
+
+
+ >
+ )}
+
+
+ setArchiveSources(!!v)} />
+ Mark merged violations as resolved (stage: Merged)
+
+
+
+
+ onOpenChange(false)} disabled={merging}>Cancel
+
+ {merging ? : }
+ Merge {selectedIds.length > 0 ? `${selectedIds.length} ` : ''}Violation{selectedIds.length === 1 ? '' : 's'}
+
+
+
+
+ );
+}
diff --git a/src/components/ViolationNoticeProgressionReport.jsx b/src/components/ViolationNoticeProgressionReport.jsx
new file mode 100644
index 0000000..f401dfa
--- /dev/null
+++ b/src/components/ViolationNoticeProgressionReport.jsx
@@ -0,0 +1,115 @@
+
+import React from 'react';
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
+import { Badge } from '@/components/ui/badge';
+import { Button } from '@/components/ui/button';
+import { getNoticeColor } from '@/lib/ViolationNoticeProgressionHelper';
+import { Calendar, MapPin, AlertCircle, FileText, Download } from 'lucide-react';
+import jsPDF from 'jspdf';
+import 'jspdf-autotable';
+import { getHighQualityPdfSettings } from '@/lib/pdfQualityUtils';
+
+export function ViolationNoticeProgressionReport({ violations, propertyAddress, ownerName, onExport }) {
+
+ const handleExportPdf = () => {
+ if (onExport) {
+ onExport();
+ return;
+ }
+
+ const doc = new jsPDF(getHighQualityPdfSettings());
+ const pageWidth = doc.internal.pageSize.getWidth();
+
+ doc.setFontSize(20);
+ doc.setTextColor(33, 33, 33);
+ doc.text('Violation Notice Report', 14, 20);
+
+ doc.setFontSize(10);
+ doc.setTextColor(100, 100, 100);
+ doc.text(`Generated on ${new Date().toLocaleDateString()}`, 14, 26);
+
+ doc.setFillColor(245, 247, 250);
+ doc.rect(14, 32, pageWidth - 28, 24, 'F');
+ doc.setFontSize(12);
+ doc.setTextColor(0, 0, 0);
+ doc.text(`Property: ${propertyAddress || 'N/A'}`, 20, 42);
+ doc.text(`Owner: ${ownerName || 'N/A'}`, 20, 50);
+
+ const tableData = violations.map(v => [
+ new Date(v.created_at).toLocaleDateString(),
+ v.notice_level || 'First Notice',
+ v.violation_type,
+ v.status,
+ v.notes || '-'
+ ]);
+
+ doc.autoTable({
+ startY: 65,
+ head: [['Date', 'Notice Level', 'Violation Type', 'Status', 'Notes']],
+ body: tableData,
+ theme: 'grid',
+ headStyles: { fillColor: [41, 128, 185], textColor: 255 },
+ styles: { fontSize: 9, cellPadding: 3 },
+ columnStyles: {
+ 0: { cellWidth: 25 },
+ 1: { cellWidth: 30 },
+ 4: { cellWidth: 'auto' }
+ }
+ });
+
+ doc.save(`Violation_Report_${propertyAddress?.replace(/\s+/g, '_') || 'Export'}.pdf`);
+ };
+
+ return (
+
+
+
+
Notice Progression Report
+
{violations?.length || 0} violations recorded for this property
+
+
+ Export PDF
+
+
+
+
+ {violations?.map((violation, index) => (
+
+
+
+
+
+
+
+
+ {violation.violation_type}
+
+
+
+ {new Date(violation.created_at).toLocaleDateString()}
+
+
+
+ {violation.notice_level || 'First Notice'}
+
+
+
+
+
+
+
{violation.notes || 'No specific notes recorded.'}
+
+
+ {violation.status}
+ {violation.priority && {violation.priority} Priority }
+
+
+
+
+ ))}
+
+
+ );
+}
diff --git a/src/components/ViolationReportGenerator.jsx b/src/components/ViolationReportGenerator.jsx
new file mode 100644
index 0000000..5d3a4fe
--- /dev/null
+++ b/src/components/ViolationReportGenerator.jsx
@@ -0,0 +1,188 @@
+import React, { useState } from 'react';
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
+import { Button } from '@/components/ui/button';
+import { Label } from '@/components/ui/label';
+import { Input } from '@/components/ui/input';
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
+import { jsPDF } from 'jspdf';
+import { FileText, Loader2 } from 'lucide-react';
+import { supabase } from '@/lib/customSupabaseClient';
+import { useToast } from '@/components/ui/use-toast';
+
+export function ViolationReportGenerator({ onExport, defaultClientId }) {
+ const { toast } = useToast();
+ const [isOpen, setIsOpen] = useState(false);
+ const [loading, setLoading] = useState(false);
+ const [filters, setFilters] = useState({
+ startDate: '',
+ endDate: '',
+ status: 'all'
+ });
+
+ const loadImage = (url) => {
+ return new Promise((resolve) => {
+ const img = new Image();
+ img.crossOrigin = 'Anonymous';
+ img.onload = () => {
+ const canvas = document.createElement('canvas');
+ canvas.width = img.width;
+ canvas.height = img.height;
+ const ctx = canvas.getContext('2d');
+ ctx.drawImage(img, 0, 0);
+ resolve({ dataURL: canvas.toDataURL('image/jpeg', 0.7), width: img.width, height: img.height });
+ };
+ img.onerror = () => resolve(null);
+ img.src = url;
+ });
+ };
+
+ const generateReport = async () => {
+ setLoading(true);
+ try {
+ let query = supabase.from('violations')
+ .select(`*, associations(name), owners(first_name, last_name, property_address, status)`)
+ .neq('owners.status', 'archived')
+ .order('violation_date', { ascending: false });
+
+ if (defaultClientId) query = query.eq('association_id', defaultClientId);
+ if (filters.status !== 'all') query = query.eq('status', filters.status);
+ if (filters.startDate) query = query.gte('violation_date', filters.startDate);
+ if (filters.endDate) query = query.lte('violation_date', filters.endDate);
+
+ const { data: violations, error } = await query;
+ if (error) throw error;
+
+ if (!violations.length) {
+ toast({ title: "No Violations Found", description: "Try adjusting your filters." });
+ setLoading(false);
+ return;
+ }
+
+ const doc = new jsPDF();
+ const pageWidth = doc.internal.pageSize.getWidth();
+ const pageHeight = doc.internal.pageSize.getHeight();
+ const margin = 14;
+ const contentWidth = pageWidth - (margin * 2);
+ let y = 20;
+
+ // Header
+ doc.setFillColor(51, 65, 85);
+ doc.rect(0, 0, pageWidth, 25, 'F');
+ doc.setTextColor(255, 255, 255);
+ doc.setFontSize(18);
+ doc.setFont("helvetica", "bold");
+ doc.text("Violation Summary Report", margin, 17);
+
+ y = 40;
+
+ for (let i = 0; i < violations.length; i++) {
+ const v = violations[i];
+
+ // Page break check
+ if (y + 80 > pageHeight - margin) {
+ doc.addPage();
+ y = 20;
+ }
+
+ // Card-like Container
+ doc.setDrawColor(226, 232, 240);
+ doc.setFillColor(248, 250, 252);
+ doc.roundedRect(margin, y, contentWidth, 70, 2, 2, 'FD');
+
+ // Left Side: Text Details
+ doc.setTextColor(51, 65, 85);
+ doc.setFontSize(12);
+ doc.setFont("helvetica", "bold");
+ doc.text(v.violation_type || 'Violation', margin + 5, y + 10);
+
+ doc.setFontSize(10);
+ doc.setFont("helvetica", "normal");
+ doc.setTextColor(100);
+ const ownerName = v.allOwnerNames || `${v.owners?.first_name || ''} ${v.owners?.last_name || ''}`.trim() || 'Unknown Owner';
+ const address = v.owners?.property_address || v.address || 'Unknown Address';
+ doc.text(`${ownerName} — ${address}`, margin + 5, y + 18);
+
+ // Status Badge simulation
+ const statusColor = v.status === 'resolved' ? [22, 163, 74] : [220, 38, 38];
+ doc.setTextColor(...statusColor);
+ doc.setFont("helvetica", "bold");
+ doc.text(v.status.toUpperCase(), margin + 5, y + 26);
+
+ // Details grid
+ doc.setTextColor(51, 65, 85);
+ doc.setFont("helvetica", "normal");
+ doc.text(`Stage: ${v.stage?.replace('_', ' ') || '-'}`, margin + 5, y + 36);
+ const parseSafe = (ds) => ds && /^\d{4}-\d{2}-\d{2}$/.test(ds) ? new Date(`${ds}T12:00:00`) : new Date(ds);
+ doc.text(`Date: ${parseSafe(v.violation_date || v.created_at).toLocaleDateString()}`, margin + 5, y + 44);
+ doc.text(`Due: ${v.due_date ? parseSafe(v.due_date).toLocaleDateString() : '-'}`, margin + 5, y + 52);
+
+ // Photo Thumbnail
+ if (v.photo_url) {
+ const imgData = await loadImage(v.photo_url);
+ if (imgData) {
+ const thumbH = 60;
+ const thumbW = (imgData.width / imgData.height) * thumbH;
+ // Keep aspect ratio but limit width
+ const finalW = Math.min(thumbW, 80);
+ const finalH = (imgData.height / imgData.width) * finalW;
+
+ doc.addImage(imgData.dataURL, 'JPEG', pageWidth - margin - finalW - 5, y + 5, finalW, finalH);
+ }
+ }
+
+ y += 75;
+ }
+
+ doc.save('Violation_Report.pdf');
+ toast({ title: "Report Generated", description: "Your PDF report is ready." });
+ setIsOpen(false);
+
+ } catch (err) {
+ console.error(err);
+ toast({ variant: 'destructive', title: "Error", description: "Failed to generate report." });
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+
+
+ Generate Report
+
+
+
+ Generate Violation Report
+
+
+
+
+ Status
+ setFilters({...filters, status: val})}>
+
+
+ All Statuses
+ Open
+ Resolved
+ Recommended for Fining
+ Fined
+
+
+
+
+ {loading && } Download PDF Report
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/components/ViolationReportPdfExport.jsx b/src/components/ViolationReportPdfExport.jsx
new file mode 100644
index 0000000..93cb530
--- /dev/null
+++ b/src/components/ViolationReportPdfExport.jsx
@@ -0,0 +1,77 @@
+import React from 'react';
+import { Button } from '@/components/ui/button';
+import { FileDown, Loader2, XCircle } from 'lucide-react';
+import { generateViolationsPDF } from '@/lib/violationPdfGenerator';
+import { cn } from '@/lib/utils';
+import { useToast } from '@/components/ui/use-toast';
+import { useViolationReportOptimization } from '@/hooks/useViolationReportOptimization';
+
+export function ViolationReportPdfExport({
+ violations = [],
+ clientName,
+ dateRange,
+ buttonVariant = "outline",
+ buttonClassName
+}) {
+ const { toast } = useToast();
+ const { isGenerating, progress, startGeneration, cancelGeneration } = useViolationReportOptimization();
+
+ const handleExport = async () => {
+ if (isGenerating) return;
+
+ if (!violations || violations.length === 0) {
+ toast({ variant: "destructive", title: "No Data", description: "No violations to export." });
+ return;
+ }
+
+ try {
+ // Safe fallback for clientName if missing
+ const effectiveClientName = clientName || (violations[0]?.clients?.name) || 'Association Report';
+
+ const settings = {
+ layout: 'grid',
+ pageSize: 'letter',
+ includeImages: true,
+ includeTimeline: true,
+ clientName: effectiveClientName,
+ dateRange
+ };
+
+ const doc = await startGeneration(violations, settings, generateViolationsPDF);
+ if (doc) {
+ const dateStr = new Date().toISOString().split('T')[0];
+ doc.save(`Violation_Report_${dateStr}.pdf`);
+ toast({ title: "Success", description: "Report generated successfully." });
+ }
+ } catch (error) {
+ console.error("Quick export error:", error);
+ if (error?.message !== 'Report generation cancelled') {
+ toast({
+ variant: "destructive",
+ title: "Export Error",
+ description: error?.message || "Failed to generate PDF report. Please try again."
+ });
+ }
+ }
+ };
+
+ if (isGenerating) {
+ return (
+
+ Cancel ({progress}%)
+
+ );
+ }
+
+ return (
+
+
+ Export PDF
+
+ );
+}
\ No newline at end of file
diff --git a/src/components/ViolationStatusExportDialog.jsx b/src/components/ViolationStatusExportDialog.jsx
new file mode 100644
index 0000000..a6ef4f4
--- /dev/null
+++ b/src/components/ViolationStatusExportDialog.jsx
@@ -0,0 +1,96 @@
+
+import React, { useState } from 'react';
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
+import { Button } from '@/components/ui/button';
+import { Checkbox } from '@/components/ui/checkbox';
+import { Label } from '@/components/ui/label';
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
+import { FileDown, Loader2 } from 'lucide-react';
+import { useToast } from '@/components/ui/use-toast';
+import { generateViolationStatusPDF } from '@/lib/violationPdfGenerator';
+
+export default function ViolationStatusExportDialog({ open, onOpenChange, violations = [], preselectedStatus = 'all' }) {
+ const { toast } = useToast();
+ const [loading, setLoading] = useState(false);
+ const [includeTimeline, setIncludeTimeline] = useState(true);
+ const [includeImages, setIncludeImages] = useState(true);
+ const [statusFilter, setStatusFilter] = useState(preselectedStatus);
+
+ const handleExport = async () => {
+ setLoading(true);
+ try {
+ const doc = await generateViolationStatusPDF(violations, {
+ pageSize: 'letter',
+ clientName: violations[0]?.clients?.name || 'Association Status Report',
+ includeTimeline,
+ includeImages,
+ statusFilter
+ });
+
+ doc.save(`Status_Report_${new Date().toISOString().split('T')[0]}.pdf`);
+ toast({ title: "Export Successful", description: "Status report downloaded." });
+ onOpenChange(false);
+ } catch (error) {
+ console.error("Export Error:", error);
+ toast({ variant: "destructive", title: "Export Failed", description: "Could not generate report." });
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const filteredCount = statusFilter === 'all'
+ ? violations.length
+ : statusFilter === 'open'
+ ? violations.filter(v => ['open', 'escalated', 'recommended_for_fining'].includes((v.status || 'open').toLowerCase())).length
+ : violations.filter(v => (v.status || 'open').toLowerCase() === statusFilter).length;
+
+ return (
+
+
+
+ Export by Status
+
+ Generate a detailed PDF report organized by violation status.
+
+
+
+
+
+
Status to Export
+
+
+
+
+
+ All Statuses
+ Open
+ Resolved
+
+
+
+ Will export {filteredCount} violation{filteredCount !== 1 ? 's' : ''}.
+
+
+
+
+
+ Include Timeline & Activity History
+
+
+
+
+ Include Evidence Photos
+
+
+
+
+ onOpenChange(false)}>Cancel
+
+ {loading ? : }
+ Download Report
+
+
+
+
+ );
+}
diff --git a/src/components/ViolationStatusTimeline.jsx b/src/components/ViolationStatusTimeline.jsx
new file mode 100644
index 0000000..3c7a004
--- /dev/null
+++ b/src/components/ViolationStatusTimeline.jsx
@@ -0,0 +1,60 @@
+import React from 'react';
+import { cn } from '@/lib/utils';
+
+export function ViolationStatusTimeline({ currentStage, history }) {
+ const stages = ['first_notice', 'second_notice', 'third_final_notice', 'fined'];
+ // Map friendly labels
+ const labels = {
+ 'first_notice': '1st Notice',
+ 'second_notice': '2nd Notice',
+ 'third_final_notice': 'Final Notice',
+ 'fined': 'Fined'
+ };
+
+ // If status is resolved, we might want to show that too, but timeline is usually for progression
+ // If currentStage is not in the list (e.g. 'resolved'), show it as completed or outside
+ let currentIndex = stages.indexOf(currentStage);
+ if (currentIndex === -1) {
+ if (currentStage === 'resolved') currentIndex = stages.length; // All done? Or handled differently.
+ else currentIndex = 0; // Default start
+ }
+
+ return (
+
+
+ {/* Line Background */}
+
+
+ {/* Line Progress */}
+
+
+ {stages.map((stage, index) => {
+ const isCompleted = index <= currentIndex;
+ const isCurrent = index === currentIndex;
+
+ return (
+
+
+
+
+ {labels[stage] || stage}
+
+
+ );
+ })}
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/components/ViolationSummaryReport.jsx b/src/components/ViolationSummaryReport.jsx
new file mode 100644
index 0000000..e78d98a
--- /dev/null
+++ b/src/components/ViolationSummaryReport.jsx
@@ -0,0 +1,224 @@
+
+import React, { useState } from 'react';
+import { ChevronDown, ChevronRight, FileText, AlertCircle, CheckCircle, Clock } from 'lucide-react';
+import { ViolationReportPdfExport } from './ViolationReportPdfExport';
+import { Card } from '@/components/ui/card';
+import { Badge } from '@/components/ui/badge';
+import { formatDateSafe } from '@/lib/dateUtils';
+import { cn } from '@/lib/utils';
+
+export function ViolationSummaryReport({ violations, onExport, dateRange }) {
+ const uniqueClients = [...new Set(violations?.map(v => v.clients?.name).filter(Boolean))];
+ const clientName = uniqueClients.length === 1
+ ? uniqueClients[0]
+ : (uniqueClients.length > 1 ? "Multiple Associations" : "Homeowners Association");
+
+ const generationDate = new Date().toLocaleDateString();
+
+ return (
+
+
+
+
Violation Summary Report
+
+ {violations?.length || 0} Records Found
+ •
+ {clientName}
+
+ {dateRange && (dateRange.start || dateRange.end) && (
+
+ Date Filter: {dateRange.start ? formatDateSafe(dateRange.start) : 'Start'} - {dateRange.end ? formatDateSafe(dateRange.end) : 'Present'}
+
+ )}
+
+
+
+
+
+
+
Violation Summary Report
+
+
{clientName}
+
+
Generated: {generationDate}
+ {dateRange && (dateRange.start || dateRange.end) && (
+
+ Range: {dateRange.start ? formatDateSafe(dateRange.start) : 'Start'} to {dateRange.end ? formatDateSafe(dateRange.end) : 'Present'}
+
+ )}
+
+
+
+
+
+ {(!violations || violations.length === 0) ? (
+
+
+
No violations found
+
Try adjusting your filters.
+
+ ) : (
+
+ {violations.map((violation, index) => (
+
+ ))}
+
+ )}
+
+
+
+ );
+}
+
+function ViolationReportCard({ violation }) {
+ const [isOpen, setIsOpen] = useState(true);
+
+ const getStatusColor = (status) => {
+ switch((status || '').toLowerCase()) {
+ case 'open': return 'bg-red-100 text-red-700 border-red-200';
+ case 'resolved': return 'bg-green-100 text-green-700 border-green-200';
+ case 'closed': return 'bg-slate-100 text-slate-700 border-slate-200';
+ default: return 'bg-blue-100 text-blue-700 border-blue-200';
+ }
+ };
+
+ const statusColor = getStatusColor(violation.status);
+
+ const ownerName = violation.allOwnerNames || violation.property_owners?.owner_name || `${violation.owners?.first_name || ''} ${violation.owners?.last_name || ''}`.trim() || 'Unknown Owner';
+ const propertyAddress = violation.property_owners?.property_address || violation.owners?.property_address || violation.address || 'Unknown Address';
+
+ let article = violation.article;
+ let section = violation.section;
+ let citation = violation.citation;
+ let requested_action = violation.requested_action; // Remedy
+ let action = violation.action; // HOA Action
+ let previousStatus = violation.previous_status;
+
+ if (Array.isArray(violation.notice_history) && violation.notice_history.length > 0) {
+ const sortedHistory = [...violation.notice_history].sort((a, b) => new Date(b.date || 0) - new Date(a.date || 0));
+ const latest = sortedHistory[0];
+
+ if (!article) article = latest.article || latest.article_section;
+ if (!section) section = latest.section;
+ if (!citation) citation = latest.citation;
+ if (!requested_action) requested_action = latest.requested_action;
+ if (!action) action = latest.action;
+
+ if (!previousStatus && sortedHistory.length > 1) {
+ previousStatus = sortedHistory[1].status;
+ }
+ }
+
+ const displayArticle = article || 'N/A';
+ const displaySection = section || 'N/A';
+ const displayCitation = citation || 'N/A';
+ const displayReqAction = requested_action || 'N/A';
+ const displayAction = action || 'N/A';
+ const displayPrevStatus = previousStatus || '-';
+
+ return (
+
+ setIsOpen(!isOpen)}
+ >
+
+
+ {isOpen ? : }
+
+
+
{propertyAddress}
+
VIOLATION DETAILS
+
+
+
+
+
+ {violation.status || 'Open'}
+
+
+ Due: {formatDateSafe(violation.due_date)}
+
+
+
+
+ {isOpen && (
+
+
+
+
Property Address
+
{propertyAddress}
+
+
Owner
+
{ownerName}
+
+
Violation Type
+
{violation.violation_type || '-'}
+
+
Article / Section
+
+ Art. {displayArticle}
+ Sec. {displaySection}
+
+
+
Citation
+
{displayCitation}
+
+
Requested Action
+
{displayReqAction}
+
+
Action (HOA)
+
{displayAction}
+
+
+
+
+
+
Previous Status
+
+
+ {displayPrevStatus}
+
+
+
+
Current Status
+
+ {violation.status === 'resolved' ?
:
}
+ {violation.status || 'Open'}
+
+
+
+
Violation Date
+
{formatDateSafe(violation.violation_date)}
+
+
+
Due Date
+
{formatDateSafe(violation.due_date)}
+
+
+
+ {violation.photo_url && (
+
+
Photo Evidence
+
+
+
+
+ )}
+
+
+ )}
+
+ );
+}
diff --git a/src/components/ViolationTableView.jsx b/src/components/ViolationTableView.jsx
new file mode 100644
index 0000000..11307c3
--- /dev/null
+++ b/src/components/ViolationTableView.jsx
@@ -0,0 +1,160 @@
+import React, { useState, useMemo } from 'react';
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
+import { Checkbox } from '@/components/ui/checkbox';
+import { Badge } from '@/components/ui/badge';
+import { Button } from '@/components/ui/button';
+import { format } from 'date-fns';
+import { MoreHorizontal, Trash2, ArrowUpDown } from 'lucide-react';
+import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, DropdownMenuSeparator } from '@/components/ui/dropdown-menu';
+import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from '@/components/ui/alert-dialog';
+import { supabase } from '@/integrations/supabase/client';
+import { useToast } from '@/components/ui/use-toast';
+
+export function ViolationTableView({ violations, selectedIds, onToggleSelect, onToggleSelectAll, onViewDetails, onRefresh }) {
+ const { toast } = useToast();
+ const [violationToDelete, setViolationToDelete] = useState(null);
+ const [sortConfig, setSortConfig] = useState({ key: 'created_at', direction: 'desc' });
+ const isAllSelected = violations.length > 0 && selectedIds.length === violations.length;
+
+ const handleSort = (key) => {
+ setSortConfig((prev) => ({
+ key,
+ direction: prev.key === key && prev.direction === 'asc' ? 'desc' : 'asc',
+ }));
+ };
+
+ const getSortValue = (v, key) => {
+ switch (key) {
+ case 'type': return (v.violation_type || v.title || '').toLowerCase();
+ case 'address': return (v.owners?.property_address || v.address || '').toLowerCase();
+ case 'status': return (v.status || '').toLowerCase();
+ case 'stage': return (v.stage || '').toLowerCase();
+ case 'association': return (v.associations?.name || '').toLowerCase();
+ case 'violation_date': return new Date(v.violation_date || v.created_at).getTime();
+ case 'created_at': return new Date(v.created_at).getTime();
+ case 'due_date': return v.due_date ? new Date(v.due_date).getTime() : 0;
+ default: return '';
+ }
+ };
+
+ const sortedViolations = useMemo(() => {
+ const items = [...violations];
+ if (!sortConfig.key) return items;
+ items.sort((a, b) => {
+ const av = getSortValue(a, sortConfig.key);
+ const bv = getSortValue(b, sortConfig.key);
+ if (av < bv) return sortConfig.direction === 'asc' ? -1 : 1;
+ if (av > bv) return sortConfig.direction === 'asc' ? 1 : -1;
+ return 0;
+ });
+ return items;
+ }, [violations, sortConfig]);
+
+ const SortBtn = ({ k, label }) => (
+ handleSort(k)} className="h-7 px-1 -ml-1 font-medium text-xs">
+ {label}
+
+ );
+
+ const getStageLabel = (stage) => {
+ switch(stage) {
+ case 'first_notice': return '1st Notice';
+ case 'second_notice': return '2nd Notice';
+ case 'third_final': return 'Final';
+ default: return stage || 'N/A';
+ }
+ };
+
+ const handleDelete = async () => {
+ if (!violationToDelete) return;
+ try {
+ const { error } = await supabase.from('violations').delete().eq('id', violationToDelete.id);
+ if (error) throw error;
+ toast({ title: "Violation Deleted" });
+ if (onRefresh) onRefresh();
+ } catch (error) {
+ toast({ variant: "destructive", title: "Error", description: "Failed to delete violation." });
+ } finally {
+ setViolationToDelete(null);
+ }
+ };
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+ Tags
+
+
+
+
+
+ Actions
+
+
+
+ {sortedViolations.length === 0 ? (
+ No violations found.
+ ) : (
+ sortedViolations.map((v) => {
+ const isSelected = selectedIds.includes(v.id);
+ return (
+
+ onToggleSelect(v.id)} className="w-4 h-4" />
+ {v.violation_type || v.title}
+ {v.owners?.property_address || v.address || '-'}
+
+ {v.status}
+
+
+
+ {(v.tags || []).slice(0, 3).map((tag, idx) => (
+ {tag}
+ ))}
+ {(v.tags || []).length > 3 && +{v.tags.length - 3} }
+
+
+ {getStageLabel(v.stage)}
+ {format(new Date(v.created_at), 'MMM d, yy')}
+ {format(new Date(v.violation_date || v.created_at), 'MMM d, yy')}
+ {v.due_date ? format(new Date(v.due_date), 'MMM d, yy') : '-'}
+ {v.associations?.name || '-'}
+
+
+
+
+ onViewDetails(v)}>View Details
+
+ { e.stopPropagation(); setViolationToDelete(v); }}> Delete
+
+
+
+
+ );
+ })
+ )}
+
+
+
+
+
+ !open && setViolationToDelete(null)}>
+
+ Delete Violation Are you sure? This action cannot be undone.
+ Cancel Delete
+
+
+ >
+ );
+}
+
+export default ViolationTableView;
diff --git a/src/components/ViolationTimelineEntryDialog.jsx b/src/components/ViolationTimelineEntryDialog.jsx
new file mode 100644
index 0000000..77269d5
--- /dev/null
+++ b/src/components/ViolationTimelineEntryDialog.jsx
@@ -0,0 +1,41 @@
+import React, { useState, useEffect } from 'react';
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
+import { Button } from '@/components/ui/button';
+import { Label } from '@/components/ui/label';
+import { Input } from '@/components/ui/input';
+import { Textarea } from '@/components/ui/textarea';
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
+
+const TIMELINE_STAGES = ["First Notice", "Second Notice", "Third & Final Notice", "Fined", "Resolved", "Escalated", "Owner Response", "Legal Action", "Other"];
+
+export default function ViolationTimelineEntryDialog({ open, onOpenChange, entry, onSave, loading }) {
+ const [formData, setFormData] = useState({ date: new Date().toISOString().split('T')[0], stage: 'Other', description: '' });
+
+ useEffect(() => {
+ if (entry) { setFormData({ date: entry.date || new Date().toISOString().split('T')[0], stage: entry.stage || 'Other', description: entry.description || '' }); }
+ else { setFormData({ date: new Date().toISOString().split('T')[0], stage: 'Other', description: '' }); }
+ }, [entry, open]);
+
+ const handleSubmit = (e) => { e.preventDefault(); onSave(formData); };
+
+ return (
+
+
+ {entry ? 'Edit Timeline Entry' : 'Add Timeline Entry'}
+
+
+
Date setFormData(p => ({ ...p, date: e.target.value }))} required />
+
Event Type
+ setFormData(p => ({ ...p, stage: val }))}>
+
+ {TIMELINE_STAGES.map(s => ({s} ))}
+
+
+
+ Description setFormData(p => ({ ...p, description: e.target.value }))} rows={4} required />
+ onOpenChange(false)}>Cancel {loading ? 'Saving...' : 'Save'}
+
+
+
+ );
+}
diff --git a/src/components/ViolationTimelineExportButton.jsx b/src/components/ViolationTimelineExportButton.jsx
new file mode 100644
index 0000000..b45f03c
--- /dev/null
+++ b/src/components/ViolationTimelineExportButton.jsx
@@ -0,0 +1,66 @@
+import React, { useState } from 'react';
+import { Button } from '@/components/ui/button';
+import { FileDown, Loader2 } from 'lucide-react';
+import jsPDF from 'jspdf';
+import { format } from 'date-fns';
+import { useToast } from '@/components/ui/use-toast';
+import { prepareTimelineImages, calculateImageDimensions } from '@/lib/violationTimelineImageUtils';
+
+export default function ViolationTimelineExportButton({ violation, entries = [] }) {
+ const [loading, setLoading] = useState(false);
+ const { toast } = useToast();
+
+ const handleExport = async () => {
+ if (!violation || entries.length === 0) {
+ toast({ title: "No Data", description: "No timeline entries to export.", variant: "destructive" });
+ return;
+ }
+ setLoading(true);
+ try {
+ const sortedEntries = [...entries].sort((a, b) => new Date(a.date || a.created_at) - new Date(b.date || b.created_at));
+ const { enrichedEntries, successCount } = await prepareTimelineImages(sortedEntries, () => {});
+ const doc = new jsPDF();
+ const pageWidth = doc.internal.pageSize.getWidth();
+ const pageHeight = doc.internal.pageSize.getHeight();
+ const margin = 20;
+ let y = margin;
+
+ doc.setFontSize(18); doc.setFont('helvetica', 'bold'); doc.text("Violation Activity Timeline", margin, y); y += 8;
+ doc.setFontSize(10); doc.setFont('helvetica', 'normal');
+ doc.text(`Property: ${violation.address || "Unknown"}`, margin, y); y += 5;
+ doc.text(`Generated: ${format(new Date(), 'MMM d, yyyy')}`, margin, y); y += 10;
+ doc.line(margin, y, pageWidth - margin, y); y += 10;
+
+ for (const entry of enrichedEntries) {
+ const dateStr = format(new Date(entry.date || entry.created_at), 'MMM d, yyyy');
+ const desc = entry.description || 'No description';
+ const descLines = doc.splitTextToSize(desc, pageWidth - margin * 2 - 10);
+ const blockHeight = 20 + descLines.length * 5 + (entry.imageData ? 100 : 0);
+ if (y + blockHeight > pageHeight - margin) { doc.addPage(); y = margin; }
+ doc.setFontSize(9); doc.setFont('helvetica', 'bold'); doc.text(dateStr, margin, y); y += 6;
+ doc.setFont('helvetica', 'normal'); doc.text(descLines, margin + 5, y); y += descLines.length * 5 + 5;
+ if (entry.imageData) {
+ try {
+ const dims = calculateImageDimensions(entry.imageData.width, entry.imageData.height, 150, 100);
+ doc.addImage(entry.imageData.data, entry.imageData.format, margin + 5, y, dims.width, dims.height);
+ y += dims.height + 5;
+ } catch (e) { /* skip */ }
+ }
+ doc.line(margin, y, pageWidth - margin, y); y += 10;
+ }
+
+ const filename = `Timeline_${(violation.address || 'export').replace(/[^a-z0-9]/gi, '_')}_${format(new Date(), 'yyyy-MM-dd')}.pdf`;
+ doc.save(filename);
+ toast({ title: "Export Complete", description: `Downloaded with ${successCount} images.` });
+ } catch (error) {
+ toast({ title: "Export Failed", variant: "destructive" });
+ } finally { setLoading(false); }
+ };
+
+ return (
+
+ {loading ? : }
+ Export PDF
+
+ );
+}
diff --git a/src/components/ViolationTimelineImportDialog.jsx b/src/components/ViolationTimelineImportDialog.jsx
new file mode 100644
index 0000000..143edeb
--- /dev/null
+++ b/src/components/ViolationTimelineImportDialog.jsx
@@ -0,0 +1,76 @@
+import React, { useState, useEffect } from 'react';
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
+import { Button } from '@/components/ui/button';
+import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
+import { Label } from '@/components/ui/label';
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
+import { Upload, FileUp, ListPlus, Trash2, Loader2, AlertCircle, CheckCircle } from 'lucide-react';
+import ViolationTimelineImportForm from './ViolationTimelineImportForm';
+import { useViolationTimelineImport } from '@/hooks/useViolationTimelineImport';
+import { supabase } from '@/integrations/supabase/client';
+import { useToast } from '@/components/ui/use-toast';
+import { cn } from '@/lib/utils';
+import { validateTimelineEntry } from '@/lib/violationUtils';
+
+export default function ViolationTimelineImportDialog({ open, onOpenChange, preSelectedViolationId, onSuccess }) {
+ const { toast } = useToast();
+ const [activeTab, setActiveTab] = useState('manual');
+ const [selectedViolationId, setSelectedViolationId] = useState(preSelectedViolationId || '');
+ const [violations, setViolations] = useState([]);
+ const [pendingEntries, setPendingEntries] = useState([]);
+ const { saveEntries, loading, importErrors } = useViolationTimelineImport(() => { onOpenChange(false); setPendingEntries([]); if (onSuccess) onSuccess(); });
+
+ useEffect(() => {
+ if (open && !preSelectedViolationId) {
+ supabase.from('violations').select('id, address, violation_type, created_at').order('created_at', { ascending: false }).limit(50)
+ .then(({ data }) => { if (data) setViolations(data); });
+ } else if (preSelectedViolationId) { setSelectedViolationId(preSelectedViolationId); }
+ }, [open, preSelectedViolationId]);
+
+ const handleManualAdd = (entry) => setPendingEntries(prev => [...prev, entry]);
+ const handleRemoveEntry = (index) => setPendingEntries(prev => prev.filter((_, i) => i !== index));
+
+ const handleImport = () => {
+ if (!selectedViolationId) { toast({ variant: "destructive", title: "Selection Required" }); return; }
+ saveEntries(selectedViolationId, pendingEntries);
+ };
+
+ return (
+
+
+ Import Timeline Entries Add historical entries to the violation timeline.
+
+ {!preSelectedViolationId && (
+
Select Violation Target
+
+ {violations.map(v => ({v.address} - {v.violation_type} ))}
+
+
+ )}
+
+ {pendingEntries.length > 0 && (
+
+
Pending ({pendingEntries.length}) setPendingEntries([])} className="text-xs text-destructive">Clear All
+
+
Date Status Description
+ {pendingEntries.map((entry, idx) => {
+ const issues = validateTimelineEntry(entry);
+ return ( 0 && "bg-destructive/5")}>
+ {entry.entry_date} {entry.status}
+ {entry.description}{issues.length > 0 && }
+ handleRemoveEntry(idx)}>
+ );
+ })}
+
+
+ )}
+
+
+ onOpenChange(false)}>Cancel
+ {loading ? : }Import {pendingEntries.length} Entries
+
+
+
+ );
+}
diff --git a/src/components/ViolationTimelineImportForm.jsx b/src/components/ViolationTimelineImportForm.jsx
new file mode 100644
index 0000000..273d3c7
--- /dev/null
+++ b/src/components/ViolationTimelineImportForm.jsx
@@ -0,0 +1,41 @@
+import React, { useState } from 'react';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import { Textarea } from '@/components/ui/textarea';
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
+import { VALID_STATUSES } from '@/lib/violationUtils';
+import { Plus } from 'lucide-react';
+import { useToast } from '@/components/ui/use-toast';
+
+export default function ViolationTimelineImportForm({ onAddEntry }) {
+ const { toast } = useToast();
+ const [formData, setFormData] = useState({ entry_date: '', status: '', description: '', recorded_by_name: '' });
+
+ const handleSubmit = (e) => {
+ e.preventDefault();
+ if (!formData.entry_date || !formData.status || !formData.description || !formData.recorded_by_name) {
+ toast({ variant: "destructive", title: "Missing Fields" }); return;
+ }
+ onAddEntry({ ...formData });
+ setFormData({ entry_date: '', status: '', description: '', recorded_by_name: '' });
+ };
+
+ return (
+
+
Add Entry Manually
+
+
Date * setFormData(p => ({ ...p, entry_date: e.target.value }))} />
+
Status *
+ setFormData(p => ({ ...p, status: val }))}>
+
+ {VALID_STATUSES.map(s => ({s} ))}
+
+
+
Recorded By * setFormData(p => ({ ...p, recorded_by_name: e.target.value }))} placeholder="Name" />
+
Description * setFormData(p => ({ ...p, description: e.target.value }))} rows={3} />
+
+
Add to Queue
+
+ );
+}
diff --git a/src/components/ViolationTimelineManager.jsx b/src/components/ViolationTimelineManager.jsx
new file mode 100644
index 0000000..53c7766
--- /dev/null
+++ b/src/components/ViolationTimelineManager.jsx
@@ -0,0 +1,65 @@
+import React from 'react';
+import { format } from 'date-fns';
+import { Circle, Clock, Edit2, Trash2, User } from 'lucide-react';
+import { Button } from '@/components/ui/button';
+import { Badge } from '@/components/ui/badge';
+import { cn } from '@/lib/utils';
+import { useAuth } from '@/contexts/AuthContext';
+import { parseLocalDate } from '@/lib/dateUtils';
+
+export default function ViolationTimelineManager({ entries = [], onEdit, onDelete }) {
+ const { userRole } = useAuth();
+ const canManage = ['admin', 'manager', 'employee'].includes(userRole?.role);
+
+ const sortedEntries = [...entries].sort((a, b) => parseLocalDate(b.date || b.created_at) - parseLocalDate(a.date || a.created_at));
+
+ const getStageColor = (stage) => {
+ switch (stage) {
+ case 'First Notice': return 'bg-blue-100 text-blue-700 border-blue-200';
+ case 'Second Notice': return 'bg-orange-100 text-orange-700 border-orange-200';
+ case 'Third & Final Notice': return 'bg-red-100 text-red-700 border-red-200';
+ case 'Fined': return 'bg-slate-800 text-white border-slate-700';
+ case 'Resolved': return 'bg-green-100 text-green-700 border-green-200';
+ case 'Escalated': return 'bg-purple-100 text-purple-700 border-purple-200';
+ default: return 'bg-muted text-muted-foreground';
+ }
+ };
+
+ if (sortedEntries.length === 0) {
+ return ();
+ }
+
+ return (
+
+ {sortedEntries.map((entry) => {
+ const rawDate = entry.date || entry.created_at;
+ const parsedDate = rawDate ? parseLocalDate(rawDate) : null;
+ const isValidDate = parsedDate && !isNaN(parsedDate.getTime());
+ const eventLabel = entry.stage || entry.status || entry.action;
+ const description = entry.description || entry.details || 'No description';
+ const recordedBy = entry.recorded_by_name || entry.created_by || entry.metadata?.performed_by_name;
+ return (
+
+
+
+
+
+ {isValidDate ? format(parsedDate, 'MMM d, yyyy') : 'No date'}
+ {eventLabel && {eventLabel} }
+
+ {canManage && (
+
+ {onEdit && onEdit(entry)}> }
+ {onDelete && onDelete(entry.id)}> }
+
+ )}
+
+
{description}
+ {recordedBy &&
{typeof recordedBy === 'object' ? recordedBy.email : recordedBy}
}
+
+
+ );
+ })}
+
+ );
+}
diff --git a/src/components/ViolationTypeDialog.jsx b/src/components/ViolationTypeDialog.jsx
new file mode 100644
index 0000000..648b93c
--- /dev/null
+++ b/src/components/ViolationTypeDialog.jsx
@@ -0,0 +1,265 @@
+import React, { useState, useEffect, useRef, useMemo } from 'react';
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
+import { Button } from '@/components/ui/button';
+import { Label } from '@/components/ui/label';
+import { Input } from '@/components/ui/input';
+import { Textarea } from '@/components/ui/textarea';
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
+import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
+import { Badge } from '@/components/ui/badge';
+import { supabase } from '@/integrations/supabase/client';
+import { useToast } from '@/components/ui/use-toast';
+import { Loader2, RotateCcw, Eye, Pencil } from 'lucide-react';
+
+const STAGE_KEYS = ['First Notice', 'Second Notice', 'Third & Final Notice'];
+
+const DEFAULT_TEMPLATES = {
+ 'First Notice': `During a recent inspection of the community, we noted that your property is out of compliance with {{associationName}}'s Covenants, Conditions, and Restrictions (CC&Rs). Your property violates the governing documents, including, but not limited to, the referenced section below. This notice is being sent as an initial friendly reminder asking that you correct the situation listed below. Please take the necessary steps to correct the situation by {{dueDate}} to avoid further action.`,
+ 'Second Notice': `This letter serves as a SECOND NOTICE regarding the violation at your property. A recent inspection has confirmed that the condition previously cited has not been corrected. As a member of the {{associationName}}, you are required to maintain your property in accordance with the community's governing documents. Please rectify this violation by {{dueDate}} to avoid further escalation of this matter.`,
+ 'Third & Final Notice': `This is a FINAL NOTICE regarding the ongoing violation on your property. Despite previous communications, the violation remains uncorrected. Failure to remedy this situation by {{dueDate}} may result in the imposition of fines, suspension of use rights, or legal action as authorized by the {{associationName}}'s governing documents and Florida Statutes. Please treat this matter with urgency.`,
+};
+
+const VARIABLES = [
+ { key: 'ownerName', label: 'Owner Name', sample: 'Jane Smith' },
+ { key: 'address', label: 'Property Address', sample: '123 Main St' },
+ { key: 'associationName', label: 'Association Name', sample: 'Sunset HOA' },
+ { key: 'dueDate', label: 'Due Date', sample: 'January 31, 2026' },
+];
+
+const fillPreview = (tpl) =>
+ VARIABLES.reduce((acc, v) => acc.replaceAll(`{{${v.key}}}`, v.sample), tpl || '');
+
+export default function ViolationTypeDialog({ open, onOpenChange, violationType, associationId, onSuccess }) {
+ const { toast } = useToast();
+ const [loading, setLoading] = useState(false);
+ const [activeStage, setActiveStage] = useState('First Notice');
+ const [previewMode, setPreviewMode] = useState(false);
+ const textareaRefs = useRef({});
+ const [formData, setFormData] = useState({
+ category: '', article_section: '', citation: '', requested_action: '',
+ cure_days: 14, stage_count: 3,
+ letter_templates: { 'First Notice': '', 'Second Notice': '', 'Third & Final Notice': '' },
+ });
+
+ useEffect(() => {
+ if (violationType) {
+ const tpl = violationType.letter_templates || {};
+ setFormData({
+ category: violationType.category || '',
+ article_section: violationType.article_section || '',
+ citation: violationType.citation || '',
+ requested_action: violationType.requested_action || '',
+ cure_days: violationType.cure_days ?? 14,
+ stage_count: violationType.stage_count ?? 3,
+ letter_templates: {
+ 'First Notice': tpl['First Notice'] || '',
+ 'Second Notice': tpl['Second Notice'] || '',
+ 'Third & Final Notice': tpl['Third & Final Notice'] || '',
+ },
+ });
+ } else {
+ setFormData({
+ category: '', article_section: '', citation: '', requested_action: '',
+ cure_days: 14, stage_count: 3,
+ letter_templates: { 'First Notice': '', 'Second Notice': '', 'Third & Final Notice': '' },
+ });
+ }
+ setActiveStage('First Notice');
+ setPreviewMode(false);
+ }, [violationType, open]);
+
+ const handleSubmit = async (e) => {
+ e.preventDefault();
+ if (!associationId) { toast({ variant: 'destructive', title: 'Error', description: 'No association context found.' }); return; }
+ setLoading(true);
+ try {
+ const payload = {
+ association_id: associationId,
+ category: formData.category,
+ article_section: formData.article_section,
+ citation: formData.citation,
+ requested_action: formData.requested_action,
+ cure_days: Math.max(1, parseInt(formData.cure_days, 10) || 14),
+ stage_count: Math.min(3, Math.max(1, parseInt(formData.stage_count, 10) || 3)),
+ letter_templates: formData.letter_templates,
+ };
+ let error;
+ if (violationType) {
+ ({ error } = await supabase.from('violation_types').update(payload).eq('id', violationType.id));
+ } else {
+ ({ error } = await supabase.from('violation_types').insert([payload]));
+ }
+ if (error) throw error;
+ toast({ title: 'Success', description: `Violation type ${violationType ? 'updated' : 'created'}.` });
+ onSuccess?.();
+ onOpenChange(false);
+ } catch (err) {
+ console.error(err);
+ toast({ variant: 'destructive', title: 'Error', description: err?.message || 'Failed to save violation type.' });
+ } finally { setLoading(false); }
+ };
+
+ const stageCount = Math.min(3, Math.max(1, parseInt(formData.stage_count, 10) || 1));
+ const visibleStages = STAGE_KEYS.slice(0, stageCount);
+
+ useEffect(() => {
+ if (!visibleStages.includes(activeStage)) setActiveStage(visibleStages[0]);
+ }, [stageCount]); // eslint-disable-line
+
+ const setStageTemplate = (stage, value) => {
+ setFormData(p => ({ ...p, letter_templates: { ...p.letter_templates, [stage]: value } }));
+ };
+
+ const insertVariable = (stage, key) => {
+ const ta = textareaRefs.current[stage];
+ const token = `{{${key}}}`;
+ const current = formData.letter_templates[stage] || '';
+ if (!ta) { setStageTemplate(stage, current + token); return; }
+ const start = ta.selectionStart ?? current.length;
+ const end = ta.selectionEnd ?? current.length;
+ const next = current.slice(0, start) + token + current.slice(end);
+ setStageTemplate(stage, next);
+ requestAnimationFrame(() => {
+ ta.focus();
+ const pos = start + token.length;
+ ta.setSelectionRange(pos, pos);
+ });
+ };
+
+ const loadDefault = (stage) => setStageTemplate(stage, DEFAULT_TEMPLATES[stage] || '');
+ const clearStage = (stage) => setStageTemplate(stage, '');
+
+ return (
+
+
+
+ {violationType ? 'Edit Violation Type' : 'Add Violation Type'}
+ Define a violation category, cure timeline, stages, and per-stage letter templates.
+
+
+ Category / Name * setFormData(p => ({...p, category: e.target.value}))} placeholder="e.g. Landscaping Non-compliance" required />
+ Article / Section setFormData(p => ({...p, article_section: e.target.value}))} placeholder="e.g. Article V, Section 3" />
+ Citation setFormData(p => ({...p, citation: e.target.value}))} placeholder="Relevant CC&R language" rows={3} />
+ Requested Action setFormData(p => ({...p, requested_action: e.target.value}))} placeholder="Standard remedy required" rows={3} />
+
+
+
+
Days until violation expires
+
setFormData(p => ({...p, cure_days: e.target.value}))} />
+
Used to calculate the due date when generating notices.
+
+
+
Number of stages
+
setFormData(p => ({...p, stage_count: parseInt(v, 10)}))}>
+
+
+ 1 stage
+ 2 stages
+ 3 stages
+
+
+
How many notices this violation can escalate through.
+
+
+
+
+
+
+
Letter Templates
+
+ Customize the body paragraph used in generated notices for each stage. Leave blank to use the system default.
+
+
+
setPreviewMode(p => !p)}>
+ {previewMode ? <> Edit> : <> Preview>}
+
+
+
+
+
+ {visibleStages.map((stage) => (
+
+ {stage}
+ {formData.letter_templates[stage]?.trim() ? (
+
+ ) : null}
+
+ ))}
+
+
+ {visibleStages.map((stage) => {
+ const value = formData.letter_templates[stage] || '';
+ const effective = value.trim() ? value : DEFAULT_TEMPLATES[stage];
+ const isCustom = !!value.trim();
+ return (
+
+
+
+ {isCustom ? 'Custom template' : 'Using system default'}
+
+
+ loadDefault(stage)}>
+ Load default
+
+ {isCustom && (
+ clearStage(stage)}>
+ Reset to default
+
+ )}
+
+
+
+ {!previewMode && (
+
+
+ Insert:
+ {VARIABLES.map(v => (
+ insertVariable(stage, v.key)}
+ >
+ {`{{${v.key}}}`}
+
+ ))}
+
+
{ textareaRefs.current[stage] = el; }}
+ rows={10}
+ placeholder={DEFAULT_TEMPLATES[stage]}
+ value={value}
+ onChange={(e) => setStageTemplate(stage, e.target.value)}
+ className="font-mono text-sm leading-relaxed"
+ />
+
+ Variables: {VARIABLES.map(v => {`{{${v.key}}}`})}
+
+
+ )}
+
+ {previewMode && (
+
+ {fillPreview(effective)}
+
+ Preview shown with sample data: {VARIABLES.map(v => `${v.label} = "${v.sample}"`).join(' • ')}
+
+
+ )}
+
+ );
+ })}
+
+
+
+
+ onOpenChange(false)}>Cancel
+ {loading ? <> Saving...> : violationType ? 'Update' : 'Create'}
+
+
+
+
+ );
+}
diff --git a/src/components/ViolationTypeManager.jsx b/src/components/ViolationTypeManager.jsx
new file mode 100644
index 0000000..5a5dbff
--- /dev/null
+++ b/src/components/ViolationTypeManager.jsx
@@ -0,0 +1,93 @@
+import React, { useState, useEffect } from 'react';
+import { supabase } from '@/integrations/supabase/client';
+import { useToast } from '@/components/ui/use-toast';
+import { Button } from '@/components/ui/button';
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
+import { AlertCircle, Plus, Pencil, Trash2, Loader2, FileText } from 'lucide-react';
+import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from '@/components/ui/alert-dialog';
+import ViolationTypeDialog from './ViolationTypeDialog';
+
+export default function ViolationTypeManager({ associationId }) {
+ const { toast } = useToast();
+ const [types, setTypes] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [editingType, setEditingType] = useState(null);
+ const [isDialogOpen, setIsDialogOpen] = useState(false);
+ const [deleteId, setDeleteId] = useState(null);
+
+ const fetchTypes = async () => {
+ if (!associationId) return;
+ setLoading(true);
+ try {
+ const { data, error } = await supabase.from('violation_types').select('*').eq('association_id', associationId).order('category');
+ if (error) throw error;
+ setTypes(data || []);
+ } catch (err) {
+ toast({ variant: 'destructive', title: 'Error', description: 'Could not load violation types.' });
+ } finally { setLoading(false); }
+ };
+
+ useEffect(() => { fetchTypes(); }, [associationId]);
+
+ const handleDelete = async () => {
+ if (!deleteId) return;
+ try {
+ const { error } = await supabase.from('violation_types').delete().eq('id', deleteId);
+ if (error) throw error;
+ toast({ title: 'Deleted' });
+ fetchTypes();
+ } catch (err) {
+ toast({ variant: 'destructive', title: 'Error', description: 'Failed to delete.' });
+ } finally { setDeleteId(null); }
+ };
+
+ if (!associationId) {
+ return (Please select an association to manage violation types.
);
+ }
+
+ return (
+
+
+
Defined Violation Types Standard violations for quick selection when logging new violations.
+
{ setEditingType(null); setIsDialogOpen(true); }}> Add Type
+
+
+ {loading ? (
+
+ ) : types.length === 0 ? (
+
+
No violation types defined yet.
+
+ ) : (
+
+
+ Category Article/Section Cure Days Stages Requested Action Actions
+
+ {types.map(t => (
+
+ {t.category}
+ {t.article_section || '-'}
+ {t.cure_days ?? 14}
+ {t.stage_count ?? 3}
+ {t.requested_action || '-'}
+
+ { setEditingType(t); setIsDialogOpen(true); }}>
+ setDeleteId(t.id)}>
+
+
+ ))}
+
+
+
+ )}
+
+
+
!open && setDeleteId(null)}>
+
+ Delete Violation Type This will remove this type. Existing violations won't be affected.
+ Cancel Delete
+
+
+
+ );
+}
diff --git a/src/components/ViolationsTable.jsx b/src/components/ViolationsTable.jsx
new file mode 100644
index 0000000..9ea4f15
--- /dev/null
+++ b/src/components/ViolationsTable.jsx
@@ -0,0 +1,174 @@
+import React, { useState, useMemo } from 'react';
+import {
+ Table, TableBody, TableCell, TableHead, TableHeader, TableRow
+} from '@/components/ui/table';
+import { Badge } from '@/components/ui/badge';
+import { ArrowUpDown, AlertCircle } from 'lucide-react';
+import { Button } from '@/components/ui/button';
+import { format } from 'date-fns';
+
+export default function ViolationsTable({
+ violations,
+ onRowClick
+}) {
+ const [sortConfig, setSortConfig] = useState({ key: 'violation_date', direction: 'desc' });
+
+ const statusColors = {
+ first: 'bg-yellow-100 text-yellow-800',
+ second: 'bg-orange-100 text-orange-800',
+ third: 'bg-red-100 text-red-800',
+ fined: 'bg-slate-800 text-white',
+ resolved: 'bg-green-100 text-green-800',
+ escalated: 'bg-purple-100 text-purple-800',
+ closed: 'bg-green-100 text-green-800',
+ new: 'bg-blue-100 text-blue-800',
+ open: 'bg-red-100 text-red-800',
+ pending: 'bg-yellow-100 text-yellow-800'
+ };
+
+ const severityColors = {
+ high: 'bg-red-100 text-red-800',
+ medium: 'bg-orange-100 text-orange-800',
+ low: 'bg-blue-100 text-blue-800'
+ };
+
+ const handleSort = (key) => {
+ let direction = 'asc';
+ if (sortConfig.key === key && sortConfig.direction === 'asc') {
+ direction = 'desc';
+ }
+ setSortConfig({ key, direction });
+ };
+
+ const sortedViolations = useMemo(() => {
+ let sortableItems = [...violations];
+ if (sortConfig.key !== null) {
+ sortableItems.sort((a, b) => {
+ let aValue = a[sortConfig.key];
+ let bValue = b[sortConfig.key];
+
+ // Resolve nested values for sorting
+ if (sortConfig.key === 'property') {
+ aValue = a.property_owners?.property_address || a.address || '';
+ bValue = b.property_owners?.property_address || b.address || '';
+ } else if (sortConfig.key === 'unit') {
+ aValue = a.unit_info?.property_address || a.property_owners?.unit_id || '';
+ bValue = b.unit_info?.property_address || b.property_owners?.unit_id || '';
+ } else if (sortConfig.key === 'assigned') {
+ aValue = a.assigned_user?.full_name || '';
+ bValue = b.assigned_user?.full_name || '';
+ }
+
+ if (aValue < bValue) {
+ return sortConfig.direction === 'asc' ? -1 : 1;
+ }
+ if (aValue > bValue) {
+ return sortConfig.direction === 'asc' ? 1 : -1;
+ }
+ return 0;
+ });
+ }
+ return sortableItems;
+ }, [violations, sortConfig]);
+
+ if (!violations || violations.length === 0) {
+ return (
+
+
+
No violations found
+
Adjust your filters or date range.
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+ handleSort('property')} className="font-semibold text-slate-700 hover:bg-slate-200/50 h-8 px-2 -ml-2">
+ Property
+
+
+
+ handleSort('unit')} className="font-semibold text-slate-700 hover:bg-slate-200/50 h-8 px-2 -ml-2">
+ Unit
+
+
+
+ handleSort('violation_type')} className="font-semibold text-slate-700 hover:bg-slate-200/50 h-8 px-2 -ml-2">
+ Type
+
+
+ Description
+
+ handleSort('violation_date')} className="font-semibold text-slate-700 hover:bg-slate-200/50 h-8 px-2 -ml-2">
+ Date Reported
+
+
+
+ handleSort('status')} className="font-semibold text-slate-700 hover:bg-slate-200/50 h-8 px-2 -ml-2">
+ Status
+
+
+
+ handleSort('priority')} className="font-semibold text-slate-700 hover:bg-slate-200/50 h-8 px-2 -ml-2">
+ Severity
+
+
+
+ handleSort('assigned')} className="font-semibold text-slate-700 hover:bg-slate-200/50 h-8 px-2 -ml-2">
+ Assigned To
+
+
+
+
+
+ {sortedViolations.map((v) => (
+ onRowClick(v)}
+ className="hover:bg-slate-50/80 cursor-pointer transition-colors"
+ >
+
+ {v.property_owners?.property_address || v.address || 'Unknown'}
+
+
+ {v.unit_info?.property_address || v.property_owners?.unit_id || '-'}
+
+
+ {v.violation_type || 'General'}
+
+
+ {v.description || v.notes || '-'}
+
+
+ {v.violation_date ? format(new Date(v.violation_date), 'MMM d, yyyy') : format(new Date(v.created_at), 'MMM d, yyyy')}
+
+
+
+ {v.status || 'Open'}
+
+
+
+ {v.priority ? (
+
+ {v.priority}
+
+ ) : (
+ -
+ )}
+
+
+ {v.assigned_user?.full_name || 'Unassigned'}
+
+
+ ))}
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/components/WorkflowTemplateBuilder.jsx b/src/components/WorkflowTemplateBuilder.jsx
new file mode 100644
index 0000000..4ba2dda
--- /dev/null
+++ b/src/components/WorkflowTemplateBuilder.jsx
@@ -0,0 +1,277 @@
+
+import React, { useState, useEffect } from 'react';
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from '@/components/ui/dialog';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { Textarea } from '@/components/ui/textarea';
+import { Label } from '@/components/ui/label';
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
+import { DragDropContext, Droppable, Draggable } from '@hello-pangea/dnd';
+import { GripVertical, Plus, Trash2, Loader2, Save } from 'lucide-react';
+import { useWorkflowTemplates } from '@/hooks/useWorkflowTemplates';
+import { useToast } from '@/hooks/use-toast';
+import { Alert, AlertDescription } from '@/components/ui/alert';
+
+export default function WorkflowTemplateBuilder({ open, onOpenChange, client, user, onSuccess, editTemplate = null }) {
+ const [name, setName] = useState('');
+ const [description, setDescription] = useState('');
+ const [tasks, setTasks] = useState([]);
+ const [error, setError] = useState('');
+
+ const { createTemplate, loading } = useWorkflowTemplates();
+ const { toast } = useToast();
+
+ useEffect(() => {
+ if (open) {
+ setError('');
+ if (editTemplate) {
+ setName(editTemplate.name || '');
+ setDescription(editTemplate.description || '');
+ if (editTemplate.workflow_template_tasks) {
+ const sortedTasks = [...editTemplate.workflow_template_tasks].sort((a,b) => a.task_sequence - b.task_sequence);
+ setTasks(sortedTasks.map(t => ({
+ id: t.id || `temp-${Math.random()}`,
+ task_name: t.task_name,
+ task_description: t.task_description || '',
+ days_until_due: t.days_until_due || 0,
+ priority: t.priority || 'medium',
+ assigned_to: t.assigned_to
+ })));
+ } else {
+ setTasks([]);
+ }
+ } else {
+ setName('');
+ setDescription('');
+ setTasks([]);
+ }
+ }
+ }, [open, editTemplate]);
+
+ const handleAddTask = () => {
+ setTasks([...tasks, {
+ id: `temp-${Date.now()}`,
+ task_name: '',
+ task_description: '',
+ days_until_due: 0,
+ priority: 'medium',
+ assigned_to: null
+ }]);
+ };
+
+ const handleTaskChange = (index, field, value) => {
+ const newTasks = [...tasks];
+ newTasks[index][field] = value;
+ setTasks(newTasks);
+ };
+
+ const handleRemoveTask = (index) => {
+ const newTasks = [...tasks];
+ newTasks.splice(index, 1);
+ setTasks(newTasks);
+ };
+
+ const onDragEnd = (result) => {
+ if (!result.destination) return;
+ const items = Array.from(tasks);
+ const [reorderedItem] = items.splice(result.source.index, 1);
+ items.splice(result.destination.index, 0, reorderedItem);
+ setTasks(items);
+ };
+
+ const validateForm = () => {
+ if (!name.trim()) return "Template name is required.";
+ if (tasks.length === 0) return "Please add at least one task to the workflow.";
+ for (let i = 0; i < tasks.length; i++) {
+ if (!tasks[i].task_name.trim()) return `Task #${i + 1} must have a name.`;
+ }
+ return null;
+ };
+
+ const handleSave = async () => {
+ setError('');
+
+ try {
+ const validationError = validateForm();
+ if (validationError) {
+ setError(validationError);
+ return;
+ }
+
+ const templateData = {
+ name: name.trim(),
+ description: description.trim(),
+ client_id: client?.id,
+ };
+
+ const cleanTasks = tasks.map(t => ({
+ task_name: t.task_name,
+ task_description: t.task_description,
+ days_until_due: t.days_until_due,
+ priority: t.priority,
+ assigned_to: t.assigned_to
+ }));
+
+ const success = await createTemplate(templateData, cleanTasks);
+
+ if (success) {
+ toast({
+ title: "Success",
+ description: "Template saved successfully",
+ });
+
+ if (onSuccess) onSuccess();
+
+ onOpenChange(false);
+ setName('');
+ setDescription('');
+ setTasks([]);
+ }
+ } catch (err) {
+ console.error('WorkflowTemplateBuilder.handleSave error:', err);
+ setError("An unexpected error occurred. Please try again.");
+ }
+ };
+
+ return (
+
+
+
+ {editTemplate ? 'Edit Workflow Template' : 'Create Workflow Template'}
+ Define a reusable set of tasks for your team.
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+
+
+ Template Name *
+ setName(e.target.value)}
+ placeholder="e.g. New Homeowner Onboarding"
+ className="font-medium"
+ />
+
+
+ Description
+ setDescription(e.target.value)} placeholder="Describe the purpose of this workflow..." />
+
+
+
+
+
+ Workflow Tasks
+ {tasks.length} tasks defined
+
+
+ Add Task
+
+
+
+ {tasks.length === 0 ? (
+
+
No tasks added yet.
+
Add your first task
+
+ ) : (
+
+
+ {(provided) => (
+
+ {tasks.map((task, index) => (
+
+ {(provided) => (
+
+
+
+
+
+
+
+ Task Name *
+ handleTaskChange(index, 'task_name', e.target.value)}
+ className="h-9 text-sm"
+ />
+
+
+ Due In (Days)
+ handleTaskChange(index, 'days_until_due', parseInt(e.target.value) || 0)}
+ className="h-9 text-sm"
+ />
+
+
+ Priority
+ handleTaskChange(index, 'priority', val)}
+ >
+
+
+
+
+ Low
+ Medium
+ High
+ Urgent
+
+
+
+
+ handleRemoveTask(index)}
+ title="Remove Task"
+ >
+
+
+
+
+
+ )}
+
+ ))}
+ {provided.placeholder}
+
+ )}
+
+
+ )}
+
+
+
+
+
+ onOpenChange(false)}>Cancel
+
+ {loading ? : }
+ {editTemplate ? 'Update Template' : 'Save Template'}
+
+
+
+
+ );
+}
diff --git a/src/components/arc/ArcApplicationDetail.tsx b/src/components/arc/ArcApplicationDetail.tsx
new file mode 100644
index 0000000..6fb2f45
--- /dev/null
+++ b/src/components/arc/ArcApplicationDetail.tsx
@@ -0,0 +1,40 @@
+import { Badge } from "@/components/ui/badge";
+import { formatDateLongEST } from "@/lib/timezoneUtils";
+import VotingAndComments from "@/components/shared/VotingAndComments";
+
+interface Props {
+ application: any;
+ onStatusChange?: () => void;
+ viewerIsOwner?: boolean;
+}
+
+export default function ArcApplicationDetail({ application, onStatusChange, viewerIsOwner = false }: Props) {
+ return (
+
+
+
+
Status
{application.status || "submitted"}
+
Submitted
{application.submitted_date ? formatDateLongEST(application.submitted_date) : application.created_at ? formatDateLongEST(application.created_at) : "N/A"}
+
+ {application.description && (
+
+
Description
+
{application.description}
+
+ )}
+ {application.decision_notes && (
+
+
Decision Notes
+
{application.decision_notes}
+
+ )}
+
+
+ );
+}
diff --git a/src/components/arc/RecordArcMemberVote.tsx b/src/components/arc/RecordArcMemberVote.tsx
new file mode 100644
index 0000000..18497be
--- /dev/null
+++ b/src/components/arc/RecordArcMemberVote.tsx
@@ -0,0 +1,277 @@
+import { useEffect, useState } from "react";
+import { supabase } from "@/integrations/supabase/client";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, SelectGroup, SelectLabel } from "@/components/ui/select";
+import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
+import { useToast } from "@/hooks/use-toast";
+import { useAuth } from "@/contexts/AuthContext";
+import { ThumbsUp, ThumbsDown, Loader2, ShieldCheck, Users, Plus, Trash2 } from "lucide-react";
+
+interface Props {
+ entityType: string;
+ entityId: string;
+ associationId?: string | null;
+ onRecorded?: () => void;
+}
+
+type ArcMember = { user_id: string; full_name: string | null; email: string | null };
+type CommitteeMember = { id: string; name: string; email: string | null };
+
+const NAME_ONLY = "__name_only__";
+const COMMITTEE_PREFIX = "committee:";
+
+export default function RecordArcMemberVote({ entityType, entityId, associationId, onRecorded }: Props) {
+ const { isAdmin, user } = useAuth();
+ const { toast } = useToast();
+ const [members, setMembers] = useState([]);
+ const [committee, setCommittee] = useState([]);
+ const [selectedUserId, setSelectedUserId] = useState("");
+ const [voterName, setVoterName] = useState("");
+ const [loading, setLoading] = useState(false);
+ const [manageOpen, setManageOpen] = useState(false);
+ const [newName, setNewName] = useState("");
+ const [newEmail, setNewEmail] = useState("");
+ const [adding, setAdding] = useState(false);
+
+ const loadCommittee = async () => {
+ if (!associationId) {
+ setCommittee([]);
+ return;
+ }
+ const { data } = await supabase
+ .from("arc_committee_members")
+ .select("id, name, email")
+ .eq("association_id", associationId)
+ .eq("is_active", true)
+ .order("name");
+ setCommittee((data ?? []) as CommitteeMember[]);
+ };
+
+ useEffect(() => {
+ if (!isAdmin) return;
+ (async () => {
+ const { data: roles } = await supabase
+ .from("user_roles")
+ .select("user_id")
+ .eq("role", "arc_member");
+ let ids = [...new Set((roles ?? []).map((r) => r.user_id).filter(Boolean) as string[])];
+ if (ids.length && associationId) {
+ const [{ data: owners }, { data: boards }] = await Promise.all([
+ supabase.from("owners").select("user_id").eq("association_id", associationId).neq("status", "archived").in("user_id", ids),
+ supabase.from("board_members").select("user_id").eq("association_id", associationId).in("user_id", ids),
+ ]);
+ const allowed = new Set([
+ ...((owners ?? []).map((o: any) => o.user_id).filter(Boolean)),
+ ...((boards ?? []).map((b: any) => b.user_id).filter(Boolean)),
+ ]);
+ ids = ids.filter((id) => allowed.has(id));
+ }
+ if (ids.length) {
+ const { data: profiles } = await supabase
+ .from("profiles")
+ .select("user_id, full_name, email")
+ .in("user_id", ids);
+ setMembers((profiles ?? []) as ArcMember[]);
+ } else {
+ setMembers([]);
+ }
+ await loadCommittee();
+ })();
+ }, [isAdmin, associationId]);
+
+ if (!isAdmin) return null;
+
+ const addCommitteeMember = async () => {
+ if (!associationId || !newName.trim()) return;
+ setAdding(true);
+ try {
+ const { error } = await supabase.from("arc_committee_members").insert({
+ association_id: associationId,
+ name: newName.trim(),
+ email: newEmail.trim() || null,
+ created_by: user?.id ?? null,
+ });
+ if (error) throw error;
+ setNewName("");
+ setNewEmail("");
+ await loadCommittee();
+ toast({ title: "Committee member added" });
+ } catch (err: any) {
+ toast({ title: "Error", description: err.message, variant: "destructive" });
+ } finally {
+ setAdding(false);
+ }
+ };
+
+ const removeCommitteeMember = async (id: string) => {
+ const { error } = await supabase.from("arc_committee_members").delete().eq("id", id);
+ if (error) {
+ toast({ title: "Error", description: error.message, variant: "destructive" });
+ return;
+ }
+ await loadCommittee();
+ };
+
+ const submit = async (vote: "approve" | "deny") => {
+ if (!selectedUserId) {
+ toast({ title: "Select a member", description: "Choose an ARC member or enter a name.", variant: "destructive" });
+ return;
+ }
+ const isNameOnly = selectedUserId === NAME_ONLY;
+ const isCommittee = selectedUserId.startsWith(COMMITTEE_PREFIX);
+ if (isNameOnly && !voterName.trim()) {
+ toast({ title: "Enter a name", description: "Provide the voter's name.", variant: "destructive" });
+ return;
+ }
+ setLoading(true);
+ try {
+ const payload: any = {
+ entity_type: entityType,
+ entity_id: entityId,
+ vote,
+ recorded_by: user?.id ?? null,
+ };
+ let resolvedName = "";
+ if (isNameOnly) {
+ resolvedName = voterName.trim();
+ } else if (isCommittee) {
+ const id = selectedUserId.slice(COMMITTEE_PREFIX.length);
+ resolvedName = committee.find((c) => c.id === id)?.name || "Committee Member";
+ }
+
+ if (isNameOnly || isCommittee) {
+ payload.user_id = null;
+ payload.voter_name = resolvedName;
+ await supabase
+ .from("entity_votes")
+ .delete()
+ .eq("entity_type", entityType)
+ .eq("entity_id", entityId)
+ .is("user_id", null)
+ .ilike("voter_name", resolvedName);
+ const { error } = await supabase.from("entity_votes").insert(payload);
+ if (error) throw error;
+ } else {
+ payload.user_id = selectedUserId;
+ const { error } = await supabase
+ .from("entity_votes")
+ .upsert(payload, { onConflict: "entity_type,entity_id,user_id" });
+ if (error) throw error;
+ }
+ const member = members.find((m) => m.user_id === selectedUserId);
+ const label = resolvedName || member?.full_name || member?.email || "Member";
+ toast({ title: "Vote recorded", description: `${label}: ${vote}` });
+ onRecorded?.();
+ } catch (err: any) {
+ toast({ title: "Error", description: err.message, variant: "destructive" });
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+
+
+
+ Admin: Record vote on behalf of ARC member
+
+ {associationId && (
+
+
+
+ Manage Committee
+
+
+
+
+ ARC Committee Members
+
+ Add committee members for this association. They don't need accounts — use them to record historical votes.
+
+
+
+ {committee.length === 0 ? (
+
No committee members yet.
+ ) : (
+ committee.map((c) => (
+
+
+
{c.name}
+ {c.email &&
{c.email}
}
+
+
removeCommitteeMember(c.id)}>
+
+
+
+ ))
+ )}
+
+
+ setNewName(e.target.value)} placeholder="Member name *" />
+ setNewEmail(e.target.value)} placeholder="Email (optional)" />
+
+
+ setManageOpen(false)}>Close
+
+ {adding ? "Adding…" : "Add Member"}
+
+
+
+
+ )}
+
+
+
+
+
+
+
+ {members.length > 0 && (
+
+ Users
+ {members.map((m) => (
+
+ {m.full_name || m.email || m.user_id.slice(0, 8)}
+
+ ))}
+
+ )}
+ {committee.length > 0 && (
+
+ Committee Roster
+ {committee.map((c) => (
+
+ {c.name}{c.email ? ` (${c.email})` : ""}
+
+ ))}
+
+ )}
+
+ + Enter name (one-time)
+
+
+
+ {selectedUserId === NAME_ONLY && (
+ setVoterName(e.target.value)}
+ placeholder="Voter name"
+ className="h-8 text-xs w-[180px]"
+ />
+ )}
+ submit("approve")} className="gap-1 h-8">
+ {loading ? : } Approve
+
+ submit("deny")} className="gap-1 h-8">
+ {loading ? : } Deny
+
+
+ {members.length === 0 && committee.length === 0 && selectedUserId !== NAME_ONLY && (
+
+ No ARC members yet — click Manage Committee to add members for this association, or use "Enter name" for a one-time vote.
+
+ )}
+
+ );
+}
diff --git a/src/components/association/AmenitiesManager.tsx b/src/components/association/AmenitiesManager.tsx
new file mode 100644
index 0000000..0ed1ac7
--- /dev/null
+++ b/src/components/association/AmenitiesManager.tsx
@@ -0,0 +1,745 @@
+import { useState, useEffect } from "react";
+import { supabase } from "@/integrations/supabase/client";
+import { useToast } from "@/hooks/use-toast";
+import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Textarea } from "@/components/ui/textarea";
+import { Switch } from "@/components/ui/switch";
+import { Badge } from "@/components/ui/badge";
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
+import { Plus, Trash2, Pencil, MapPin, CalendarDays, FileText, GripVertical, Eye, Loader2, X, DollarSign, ClipboardList } from "lucide-react";
+import GoogleMapPicker, { type MapPinData } from "./GoogleMapPicker";
+import ManageAmenityBookingsDialog from "./ManageAmenityBookingsDialog";
+
+interface Props {
+ associationId: string;
+}
+
+interface FormField {
+ id: string;
+ label: string;
+ type: "text" | "email" | "phone" | "textarea" | "select" | "signature" | "date" | "checkbox" | "paragraph";
+ required: boolean;
+ options?: string[];
+ content?: string;
+}
+
+interface Amenity {
+ id: string;
+ name: string;
+ description: string | null;
+ amenity_type: string;
+ cover_image_url: string | null;
+ map_config: any;
+ booking_config: any;
+ form_fields: FormField[];
+ is_active: boolean;
+ show_on_public_page?: boolean;
+ show_on_homeowner_portal?: boolean;
+ visible_to_roles?: string[];
+ sort_order: number;
+}
+
+const ROLE_VISIBILITY_OPTIONS: { value: string; label: string }[] = [
+ { value: "homeowner", label: "Homeowner" },
+ { value: "board_member", label: "Board Member" },
+ { value: "master_board_member", label: "Master Board Member" },
+ { value: "arc_member", label: "ARC Member" },
+ { value: "fining_member", label: "Fining Member" },
+ { value: "rv_boat_lot", label: "RV/Boat Lot" },
+ { value: "management", label: "Management" },
+ { value: "association_management", label: "Association Management" },
+ { value: "legal", label: "Legal" },
+];
+
+interface RentalFeeOption {
+ id: string;
+ label: string;
+ amount: number;
+}
+
+const amenityTypes = [
+ { value: "map", label: "Map Location", icon: MapPin, desc: "Pin on Google Maps with name & description" },
+ { value: "booking", label: "Calendar Booking", icon: CalendarDays, desc: "Visitors can request time slot bookings" },
+ { value: "rental_calendar", label: "Rental Calendar", icon: CalendarDays, desc: "Rental booking calendar with public blocked availability" },
+ { value: "meeting_calendar", label: "Meeting Calendar", icon: ClipboardList, desc: "Meeting booking calendar with private blocked availability" },
+ { value: "form", label: "Form", icon: FileText, desc: "Custom form with signature support" },
+];
+
+const fieldTypes = [
+ { value: "text", label: "Text" },
+ { value: "email", label: "Email" },
+ { value: "phone", label: "Phone" },
+ { value: "textarea", label: "Long Text" },
+ { value: "date", label: "Date" },
+ { value: "select", label: "Dropdown" },
+ { value: "checkbox", label: "Checkbox / Agreement" },
+ { value: "signature", label: "Signature" },
+ { value: "paragraph", label: "Text Paragraph (display only)" },
+];
+
+function FormFieldBuilder({ fields, onChange }: { fields: FormField[]; onChange: (f: FormField[]) => void }) {
+ const addField = () => {
+ onChange([...fields, {
+ id: Math.random().toString(36).substr(2, 9),
+ label: "",
+ type: "text",
+ required: false,
+ }]);
+ };
+
+ const updateField = (id: string, updates: Partial) => {
+ onChange(fields.map(f => f.id === id ? { ...f, ...updates } : f));
+ };
+
+ const removeField = (id: string) => {
+ onChange(fields.filter(f => f.id !== id));
+ };
+
+ return (
+
+ {fields.map((field, idx) => (
+
+
{idx + 1}
+
+
updateField(field.id, { label: e.target.value })}
+ className="text-sm"
+ />
+
updateField(field.id, { type: v as any })}>
+
+
+ {fieldTypes.map(ft => {ft.label} )}
+
+
+ {field.type === "paragraph" && (
+
+ updateField(field.id, { content: e.target.value })}
+ rows={4}
+ className="text-sm"
+ />
+
+ )}
+ {field.type === "select" && (
+
+ updateField(field.id, { options: e.target.value.split(",").map(s => s.trim()).filter(Boolean) })}
+ className="text-sm"
+ />
+
+ )}
+ {field.type === "checkbox" && (
+
+ updateField(field.id, { label: e.target.value })}
+ className="text-sm"
+ />
+
+ )}
+ {field.type !== "paragraph" && (
+
+ updateField(field.id, { required: v })} />
+ Required
+
+ )}
+
+
removeField(field.id)}>
+
+
+
+ ))}
+
+ Add Field
+
+
+ );
+}
+
+function RentalFeeOptionsBuilder({ options, onChange }: { options: RentalFeeOption[]; onChange: (options: RentalFeeOption[]) => void }) {
+ const addOption = () => onChange([...options, { id: Math.random().toString(36).slice(2, 9), label: "", amount: 0 }]);
+ const updateOption = (id: string, updates: Partial) => onChange(options.map(option => option.id === id ? { ...option, ...updates } : option));
+ const removeOption = (id: string) => onChange(options.filter(option => option.id !== id));
+
+ return (
+
+ );
+}
+
+export default function AmenitiesManager({ associationId }: Props) {
+ const { toast } = useToast();
+ const db = supabase as any;
+
+ const [amenities, setAmenities] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [dialogOpen, setDialogOpen] = useState(false);
+ const [saving, setSaving] = useState(false);
+ const [editingId, setEditingId] = useState(null);
+ const [bookingsDialogAmenity, setBookingsDialogAmenity] = useState(null);
+
+ // Form state
+ const [name, setName] = useState("");
+ const [description, setDescription] = useState("");
+ const [amenityType, setAmenityType] = useState("map");
+ const [coverImageUrl, setCoverImageUrl] = useState("");
+ const [isActive, setIsActive] = useState(true);
+ const [showOnPublicPage, setShowOnPublicPage] = useState(true);
+ const [showOnHomeownerPortal, setShowOnHomeownerPortal] = useState(true);
+ const [visibleToRoles, setVisibleToRoles] = useState([]);
+
+ // Map config - multiple pins
+ const [mapPins, setMapPins] = useState([]);
+ const [mapZoom, setMapZoom] = useState(16);
+ const [mapLockedView, setMapLockedView] = useState<{ lat: number; lng: number; zoom: number } | null>(null);
+ const [reservationFee, setReservationFee] = useState("");
+ const [reservationFormFields, setReservationFormFields] = useState([]);
+
+ // Booking config
+ const [bookingSlotDuration, setBookingSlotDuration] = useState("60");
+ const [bookingStartTime, setBookingStartTime] = useState("09:00");
+ const [bookingEndTime, setBookingEndTime] = useState("17:00");
+ const [securityDeposit, setSecurityDeposit] = useState("");
+ const [rentalFeeOptions, setRentalFeeOptions] = useState([]);
+
+ // Form fields
+ const [formFields, setFormFields] = useState([]);
+
+ // Signable PDF documents (templates attached to amenity)
+ const [signableDocuments, setSignableDocuments] = useState<{ id: string; name: string; url: string; required: boolean }[]>([]);
+ const [uploadingDoc, setUploadingDoc] = useState(false);
+
+ const handleSignableDocUpload = async (file: File) => {
+ if (file.type !== "application/pdf") {
+ toast({ title: "PDF only", description: "Please upload a PDF file.", variant: "destructive" });
+ return;
+ }
+ setUploadingDoc(true);
+ const path = `templates/${associationId}/${Date.now()}-${file.name.replace(/[^a-zA-Z0-9._-]/g, "_")}`;
+ const { error: upErr } = await supabase.storage.from("amenity-documents").upload(path, file, { upsert: false, contentType: "application/pdf" });
+ if (upErr) {
+ setUploadingDoc(false);
+ toast({ title: "Upload failed", description: upErr.message, variant: "destructive" });
+ return;
+ }
+ const { data: pub } = supabase.storage.from("amenity-documents").getPublicUrl(path);
+ setSignableDocuments(prev => [...prev, { id: Math.random().toString(36).slice(2, 10), name: file.name, url: pub.publicUrl, required: true }]);
+ setUploadingDoc(false);
+ };
+
+ useEffect(() => { loadAmenities(); }, [associationId]);
+
+ const loadAmenities = async () => {
+ setLoading(true);
+ const { data, error } = await db
+ .from("amenities")
+ .select("*")
+ .eq("association_id", associationId)
+ .order("sort_order");
+ if (error) toast({ title: "Error", description: error.message, variant: "destructive" });
+ else setAmenities(data || []);
+ setLoading(false);
+ };
+
+ const resetForm = () => {
+ setEditingId(null);
+ setName("");
+ setDescription("");
+ setAmenityType("map");
+ setCoverImageUrl("");
+ setIsActive(true);
+ setShowOnPublicPage(true);
+ setShowOnHomeownerPortal(true);
+ setVisibleToRoles([]);
+ setMapPins([]);
+ setMapZoom(16);
+ setMapLockedView(null);
+ setReservationFee("");
+ setReservationFormFields([]);
+ setBookingSlotDuration("60");
+ setBookingStartTime("09:00");
+ setBookingEndTime("17:00");
+ setSecurityDeposit("");
+ setRentalFeeOptions([]);
+ setFormFields([]);
+ setSignableDocuments([]);
+ };
+
+ const openCreate = () => { resetForm(); setDialogOpen(true); };
+
+ const openEdit = (a: Amenity) => {
+ setEditingId(a.id);
+ setName(a.name);
+ setDescription(a.description || "");
+ setAmenityType(a.amenity_type);
+ setCoverImageUrl(a.cover_image_url || "");
+ setIsActive(a.is_active);
+ setShowOnPublicPage(a.show_on_public_page ?? true);
+ setShowOnHomeownerPortal(a.show_on_homeowner_portal ?? true);
+ setVisibleToRoles(Array.isArray((a as any).visible_to_roles) ? (a as any).visible_to_roles : []);
+ const mc = a.map_config || {};
+ if (Array.isArray(mc.pins)) {
+ setMapPins(mc.pins);
+ } else if (mc.lat && mc.lng) {
+ setMapPins([{ id: "legacy", label: mc.label || a.name, lat: mc.lat.toString(), lng: mc.lng.toString(), status: "available" }]);
+ } else {
+ setMapPins([]);
+ }
+ setMapZoom(mc.zoom ?? 16);
+ setMapLockedView(mc.locked_view && typeof mc.locked_view.lat === "number" && typeof mc.locked_view.lng === "number" ? mc.locked_view : null);
+ setReservationFormFields(Array.isArray(mc.reservation_form_fields) ? mc.reservation_form_fields : []);
+ const bc = a.booking_config || {};
+ setBookingSlotDuration(bc.slot_duration?.toString() || "60");
+ setBookingStartTime(bc.start_time || "09:00");
+ setBookingEndTime(bc.end_time || "17:00");
+ setReservationFee((a.amenity_type === "rental_calendar" ? bc.rental_fee : mc.reservation_fee)?.toString() || "");
+ setSecurityDeposit(bc.security_deposit?.toString() || "");
+ setRentalFeeOptions(Array.isArray(bc.fee_options) ? bc.fee_options : []);
+ setFormFields(Array.isArray(a.form_fields) ? a.form_fields : []);
+ setSignableDocuments(Array.isArray((a as any).signable_documents) ? (a as any).signable_documents : []);
+ setDialogOpen(true);
+ };
+
+ const handleSave = async () => {
+ if (!name.trim()) {
+ toast({ title: "Name required", variant: "destructive" });
+ return;
+ }
+ setSaving(true);
+
+ const payload: any = {
+ association_id: associationId,
+ name: name.trim(),
+ description: description || null,
+ amenity_type: amenityType,
+ cover_image_url: coverImageUrl || null,
+ is_active: isActive,
+ show_on_public_page: showOnPublicPage,
+ show_on_homeowner_portal: showOnHomeownerPortal,
+ visible_to_roles: visibleToRoles,
+ map_config: amenityType === "map" ? {
+ pins: mapPins,
+ zoom: mapZoom,
+ locked_view: mapLockedView,
+ reservation_fee: reservationFee ? parseFloat(reservationFee) : null,
+ reservation_form_fields: reservationFormFields,
+ } : {},
+ booking_config: ["booking", "rental_calendar", "meeting_calendar"].includes(amenityType) ? {
+ slot_duration: parseInt(bookingSlotDuration) || 60,
+ start_time: bookingStartTime,
+ end_time: bookingEndTime,
+ rental_fee: amenityType === "rental_calendar" && reservationFee ? parseFloat(reservationFee) : null,
+ security_deposit: amenityType === "rental_calendar" && securityDeposit ? parseFloat(securityDeposit) : null,
+ fee_options: amenityType === "rental_calendar" ? rentalFeeOptions.filter(option => option.label.trim() && option.amount > 0) : undefined,
+ pass_processing_fees: amenityType === "rental_calendar" ? true : undefined,
+ } : {},
+ form_fields: ["form", "rental_calendar", "meeting_calendar"].includes(amenityType) ? formFields : [],
+ signable_documents: ["booking", "rental_calendar", "meeting_calendar"].includes(amenityType) ? signableDocuments : [],
+ updated_at: new Date().toISOString(),
+ };
+
+ let error;
+ if (editingId) {
+ ({ error } = await db.from("amenities").update(payload).eq("id", editingId));
+ } else {
+ payload.sort_order = amenities.length;
+ ({ error } = await db.from("amenities").insert(payload));
+ }
+
+ setSaving(false);
+ if (error) {
+ toast({ title: "Error", description: error.message, variant: "destructive" });
+ } else {
+ toast({ title: editingId ? "Amenity updated" : "Amenity created" });
+ setDialogOpen(false);
+ loadAmenities();
+ }
+ };
+
+ const handleDelete = async (id: string) => {
+ const { error } = await db.from("amenities").delete().eq("id", id);
+ if (error) toast({ title: "Error", description: error.message, variant: "destructive" });
+ else { toast({ title: "Amenity deleted" }); loadAmenities(); }
+ };
+
+ const togglePublicDisplay = async (amenity: Amenity, checked: boolean) => {
+ setAmenities(prev => prev.map(a => a.id === amenity.id ? { ...a, show_on_public_page: checked } : a));
+ const { error } = await db.from("amenities").update({ show_on_public_page: checked, updated_at: new Date().toISOString() }).eq("id", amenity.id);
+ if (error) {
+ setAmenities(prev => prev.map(a => a.id === amenity.id ? { ...a, show_on_public_page: amenity.show_on_public_page } : a));
+ toast({ title: "Error", description: error.message, variant: "destructive" });
+ }
+ };
+
+ const toggleHomeownerDisplay = async (amenity: Amenity, checked: boolean) => {
+ setAmenities(prev => prev.map(a => a.id === amenity.id ? { ...a, show_on_homeowner_portal: checked } : a));
+ const { error } = await db.from("amenities").update({ show_on_homeowner_portal: checked, updated_at: new Date().toISOString() }).eq("id", amenity.id);
+ if (error) {
+ setAmenities(prev => prev.map(a => a.id === amenity.id ? { ...a, show_on_homeowner_portal: amenity.show_on_homeowner_portal } : a));
+ toast({ title: "Error", description: error.message, variant: "destructive" });
+ }
+ };
+
+ const typeInfo = amenityTypes.find(t => t.value === amenityType);
+
+ if (loading) {
+ return Loading amenities...
;
+ }
+
+ return (
+
+
+
+
Amenities
+
+ Manage amenities and choose which ones display on the public page and homeowner portal.
+
+
+
+ Add Amenity
+
+
+
+ {amenities.length === 0 ? (
+
+
+
+ No amenities yet. Add map locations, booking calendars, or forms.
+
+
+ ) : (
+
+
+
+
+ Name
+ Type
+ Status
+ Public Page
+ Homeowner View
+ Actions
+
+
+
+ {amenities.map(a => {
+ const ti = amenityTypes.find(t => t.value === a.amenity_type);
+ const Icon = ti?.icon || MapPin;
+ return (
+
+
+
+
+
+
{a.name}
+ {a.description &&
{a.description}
}
+
+
+
+
+ {ti?.label || a.amenity_type}
+
+
+
+ {a.is_active ? "Active" : "Inactive"}
+
+
+
+ togglePublicDisplay(a, checked)} disabled={a.amenity_type === "meeting_calendar"} />
+
+
+ toggleHomeownerDisplay(a, checked)} />
+
+
+
+ {["booking", "rental_calendar", "meeting_calendar"].includes(a.amenity_type) && (
+
setBookingsDialogAmenity(a)}>
+
+
+ )}
+
openEdit(a)}>
+
+
+
handleDelete(a.id)}>
+
+
+
+
+
+ );
+ })}
+
+
+
+ )}
+
+ {/* Create/Edit Dialog */}
+
+
+
+ {editingId ? "Edit Amenity" : "Add Amenity"}
+
+
+
+ {/* Basic Info */}
+
+
+ Name *
+ setName(e.target.value)} placeholder="Pool, Clubhouse, Tennis Court..." />
+
+
+ Description
+ setDescription(e.target.value)} rows={2} placeholder="Brief description shown to visitors" />
+
+
+
Type
+
+
+
+ {amenityTypes.map(t => (
+
+
+ {t.label}
+
+
+ ))}
+
+
+
{typeInfo?.desc}
+
+
+ Cover Image URL
+ setCoverImageUrl(e.target.value)} placeholder="https://..." />
+
+
+
+
+ Active
+
+
+
+ {amenityType !== "meeting_calendar" && (
+
+
+
Show on Public Page
+
Turn this off to hide the amenity from this association’s public page.
+
+
+
+ )}
+
+
+
+
Show on Homeowner View
+
Turn this off to hide the amenity from homeowners in this association.
+
+
+
+
+
+
+
Calendar Visible To Roles
+
Select which portal roles can view this amenity's calendar. Staff (admin/manager/employee) always see all amenities.
+
+
+ {ROLE_VISIBILITY_OPTIONS.map(opt => {
+ const checked = visibleToRoles.includes(opt.value);
+ return (
+ setVisibleToRoles(prev => checked ? prev.filter(r => r !== opt.value) : [...prev, opt.value])}
+ className={`text-xs px-3 py-1.5 rounded-full border transition-colors ${checked ? "bg-primary text-primary-foreground border-primary" : "bg-background text-muted-foreground border-border hover:bg-muted"}`}
+ >
+ {opt.label}
+
+ );
+ })}
+
+
+
+ {/* Map Config - Multiple Pins */}
+ {amenityType === "map" && (
+
+ )}
+
+ {/* Booking Config */}
+ {["booking", "rental_calendar", "meeting_calendar"].includes(amenityType) && (
+
+
+ Booking Configuration
+
+
+
+ Slot Duration (min)
+ setBookingSlotDuration(e.target.value)} type="number" />
+
+
+ Available From
+ setBookingStartTime(e.target.value)} type="time" />
+
+
+ Available Until
+ setBookingEndTime(e.target.value)} type="time" />
+
+ {amenityType === "rental_calendar" && (
+
+
+
+ Selectable Fee Dropdown
+
+
+
Stripe checkout includes selected fees and passes processing fees to the renter.
+
+ )}
+
+
+ )}
+
+ {/* Signable PDF documents */}
+ {["booking", "rental_calendar", "meeting_calendar"].includes(amenityType) && (
+
+
+ Signable Documents
+ Upload PDFs (waivers, rental agreements). Bookers must sign each one before confirming.
+
+
+ {signableDocuments.length === 0 && (
+ No documents attached yet.
+ )}
+ {signableDocuments.map(doc => (
+
+
+
{doc.name}
+
+ setSignableDocuments(prev => prev.map(d => d.id === doc.id ? { ...d, required: e.target.checked } : d))} />
+ Required to sign
+
+
+
setSignableDocuments(prev => prev.filter(d => d.id !== doc.id))}>
+
+
+
+ ))}
+
+
{ const f = e.target.files?.[0]; if (f) { handleSignableDocUpload(f); e.target.value = ""; } }} />
+ {uploadingDoc &&
Uploading...
}
+
+
+
+ )}
+
+ {/* Form Builder */}
+ {["form", "rental_calendar", "meeting_calendar"].includes(amenityType) && (
+
+
+ {amenityType === "rental_calendar" ? "Rental Intake Form" : amenityType === "meeting_calendar" ? "Meeting Request Form" : "Form Fields"}
+ Build the custom intake fields. Signature fields render a signature pad.
+
+
+
+
+
+ )}
+
+
+
+ setDialogOpen(false)}>Cancel
+
+ {saving && }
+ {editingId ? "Update" : "Create"} Amenity
+
+
+
+
+
+
{ if (!v) setBookingsDialogAmenity(null); }}
+ amenity={bookingsDialogAmenity}
+ />
+
+ );
+}
diff --git a/src/components/association/AnnualMeetingTab.tsx b/src/components/association/AnnualMeetingTab.tsx
new file mode 100644
index 0000000..8235278
--- /dev/null
+++ b/src/components/association/AnnualMeetingTab.tsx
@@ -0,0 +1,437 @@
+import { useEffect, useState, useCallback } from "react";
+import { supabase } from "@/integrations/supabase/client";
+import { useToast } from "@/hooks/use-toast";
+import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Button } from "@/components/ui/button";
+import { Save, Loader2, FileDown, Users, Percent, Calculator, MapPin, Clock } from "lucide-react";
+import jsPDF from "jspdf";
+
+interface AnnualMeetingTabProps {
+ associationId: string;
+ associationName: string;
+}
+
+const CHECKLIST_SECTIONS = [
+ {
+ title: "PREPARATION & PLANNING",
+ items: [
+ "Arrange for meeting location",
+ "Establish agenda",
+ "Check-in with Board President to:",
+ "Confirm meeting agenda",
+ "Determine if President, officer, committees and guest speakers will be presenting",
+ "Determine meeting chairperson",
+ "Determine who is eligible to vote",
+ "Evaluate the Board members terms of office and expired terms",
+ "Determine time limits for unit owner participation",
+ "Review election procedures",
+ "Review sign-in procedures",
+ "Review quorum requirement",
+ "Complete notice of meeting",
+ "Complete mailing",
+ "Include nomination notices",
+ "Include Notices of Intent and Candidate Information Sheet",
+ "Include certificate of designated voters",
+ "Proxies, ballots and secret ballots",
+ "Review last annual meeting draft minutes",
+ "Review delinquencies to determine members in good standing",
+ "Review current financial and provide copies to Board",
+ "Prepare voter registration sign-in sheet",
+ "Review policy of how meetings will be conducted",
+ "Complete pre-annual meeting touch base",
+ ],
+ },
+ {
+ title: "MEETING MATERIALS",
+ items: [
+ "Annual meeting folder",
+ "Affidavit of mailing",
+ "Copy of annual meeting notice",
+ "Extra meeting agendas",
+ "Copies of draft meeting minutes",
+ "Copies of current financial",
+ "Associations Governing Documents & Amendments",
+ "Copy of State Statutes",
+ "Robert Rules of Order cheat sheet",
+ "Extra blank ballots and ballot box",
+ "Extra blank proxies and blank certificates of designated voters",
+ "Completed proxies",
+ "Voter registration sign-in sheet",
+ "Committee sign-up sheet",
+ "Nomination Notices",
+ "Notice of Intent and Candidate Information Sheets",
+ "Additional handouts per Board request",
+ ],
+ },
+ {
+ title: "MEETING CHECKLIST",
+ items: [
+ "Set-up annual meeting",
+ "Table for Board",
+ "Chairs for owners",
+ "Designated area for sign-in",
+ "Assign minute taking",
+ "Assign inspectors of election",
+ "Verify and confirm quorum",
+ "Oversee voter registration, sign-in and distribution of ballots",
+ "Be prepared for nomination of candidates from the floor",
+ "Follow meeting agenda",
+ "Follow Roberts Rules of Order",
+ "Monitor meeting time",
+ "Tally and announce vote results",
+ "Schedule organizational meeting",
+ "Clean-up after meeting is adjourned",
+ ],
+ },
+ {
+ title: "POST MEETING ACTION",
+ items: [
+ "Complete post-annual meeting touch base",
+ "Draft annual meeting minutes and distribute to the board for review",
+ "Upload approved annual meeting minutes into Buildium",
+ "Scan and save voter registration, proxies, and ballots",
+ "Once the Organization Meeting is held, complete the Board of Directors file update",
+ "Confirm new board members are added to Buildium",
+ "Deliver Quick Start Guide to new Board members with requested Resource Guides",
+ ],
+ },
+];
+
+export default function AnnualMeetingTab({ associationId, associationName }: AnnualMeetingTabProps) {
+ const { toast } = useToast();
+ const [loading, setLoading] = useState(true);
+ const [saving, setSaving] = useState(false);
+ const [meetingId, setMeetingId] = useState(null);
+ const [totalUnits, setTotalUnits] = useState(0);
+ const [quorumPercent, setQuorumPercent] = useState(0);
+ const [meetingDate, setMeetingDate] = useState("");
+ const [meetingTime, setMeetingTime] = useState("");
+ const [meetingLocation, setMeetingLocation] = useState("");
+ const [notes, setNotes] = useState("");
+
+ const quorumNeeded = Math.ceil((totalUnits * quorumPercent) / 100);
+
+ const fetchMeeting = useCallback(async () => {
+ setLoading(true);
+ const { data } = await supabase
+ .from("annual_meetings")
+ .select("*")
+ .eq("association_id", associationId)
+ .maybeSingle();
+
+ if (data) {
+ setMeetingId(data.id);
+ setTotalUnits((data as any).total_units || 0);
+ setQuorumPercent(Number((data as any).quorum_percent) || 0);
+ setMeetingDate((data as any).meeting_date || "");
+ setMeetingTime((data as any).meeting_time || "");
+ setMeetingLocation((data as any).meeting_location || "");
+ setNotes((data as any).notes || "");
+ }
+ setLoading(false);
+ }, [associationId]);
+
+ useEffect(() => { fetchMeeting(); }, [fetchMeeting]);
+
+ const handleSave = async () => {
+ setSaving(true);
+ const payload = {
+ association_id: associationId,
+ total_units: totalUnits,
+ quorum_percent: quorumPercent,
+ quorum_needed: quorumNeeded,
+ meeting_date: meetingDate || null,
+ meeting_time: meetingTime || null,
+ meeting_location: meetingLocation || null,
+ notes: notes || null,
+ };
+
+ let error;
+ if (meetingId) {
+ const res = await supabase.from("annual_meetings").update(payload as any).eq("id", meetingId);
+ error = res.error;
+ } else {
+ const res = await supabase.from("annual_meetings").insert(payload as any).select().single();
+ error = res.error;
+ if (res.data) setMeetingId(res.data.id);
+ }
+
+ if (error) {
+ toast({ title: "Error", description: error.message, variant: "destructive" });
+ } else {
+ toast({ title: "Annual meeting saved" });
+ }
+ setSaving(false);
+ };
+
+ const formatTime12 = (t: string) => {
+ if (!t) return "";
+ const [h, m] = t.split(":").map(Number);
+ const ampm = h >= 12 ? "PM" : "AM";
+ const h12 = h % 12 || 12;
+ return `${h12}:${m.toString().padStart(2, "0")} ${ampm}`;
+ };
+
+ const exportPdf = () => {
+ const doc = new jsPDF({ unit: "pt", format: "letter" });
+ const pageW = doc.internal.pageSize.getWidth();
+ const pageH = doc.internal.pageSize.getHeight();
+ const marginL = 50;
+ const marginR = 50;
+ const contentW = pageW - marginL - marginR;
+ let y = 50;
+
+ const checkPage = (needed: number) => {
+ if (y + needed > pageH - 50) {
+ // page border on current page
+ doc.setDrawColor(0);
+ doc.setLineWidth(1.5);
+ doc.rect(20, 20, pageW - 40, pageH - 40);
+ doc.addPage();
+ y = 50;
+ }
+ };
+
+ // Header block with accent line
+ doc.setFillColor(30, 58, 95);
+ doc.rect(20, 20, pageW - 40, 4, "F");
+
+ y = 55;
+ doc.setFont("helvetica", "bold");
+ doc.setFontSize(18);
+ doc.setTextColor(30, 58, 95);
+ doc.text(associationName.toUpperCase(), pageW / 2, y, { align: "center" });
+ y += 24;
+
+ doc.setFontSize(14);
+ doc.setTextColor(80, 80, 80);
+ doc.text("Annual Meeting Checklist", pageW / 2, y, { align: "center" });
+ y += 8;
+
+ // Divider
+ doc.setDrawColor(30, 58, 95);
+ doc.setLineWidth(0.75);
+ doc.line(marginL, y, pageW - marginR, y);
+ y += 18;
+
+ // Meeting details box
+ doc.setFillColor(245, 247, 250);
+ doc.setDrawColor(200, 205, 215);
+ doc.setLineWidth(0.5);
+ const boxH = 70;
+ doc.roundedRect(marginL, y - 4, contentW, boxH, 4, 4, "FD");
+
+ doc.setFontSize(10);
+ doc.setFont("helvetica", "bold");
+ doc.setTextColor(30, 58, 95);
+ const col1 = marginL + 12;
+ const col2 = marginL + contentW / 2 + 12;
+ let detailY = y + 14;
+
+ doc.text("Date:", col1, detailY);
+ doc.setFont("helvetica", "normal");
+ doc.setTextColor(50, 50, 50);
+ doc.text(meetingDate || "TBD", col1 + 60, detailY);
+
+ doc.setFont("helvetica", "bold");
+ doc.setTextColor(30, 58, 95);
+ doc.text("Time:", col2, detailY);
+ doc.setFont("helvetica", "normal");
+ doc.setTextColor(50, 50, 50);
+ doc.text(formatTime12(meetingTime) || "TBD", col2 + 60, detailY);
+
+ detailY += 18;
+ doc.setFont("helvetica", "bold");
+ doc.setTextColor(30, 58, 95);
+ doc.text("Location:", col1, detailY);
+ doc.setFont("helvetica", "normal");
+ doc.setTextColor(50, 50, 50);
+ doc.text(meetingLocation || "TBD", col1 + 60, detailY);
+
+ detailY += 18;
+ doc.setFont("helvetica", "bold");
+ doc.setTextColor(30, 58, 95);
+ doc.text("Total Units:", col1, detailY);
+ doc.setFont("helvetica", "normal");
+ doc.setTextColor(50, 50, 50);
+ doc.text(String(totalUnits), col1 + 60, detailY);
+
+ doc.setFont("helvetica", "bold");
+ doc.setTextColor(30, 58, 95);
+ doc.text("Quorum:", col2, detailY);
+ doc.setFont("helvetica", "normal");
+ doc.setTextColor(50, 50, 50);
+ doc.text(`${quorumPercent}% (${quorumNeeded} owners)`, col2 + 60, detailY);
+
+ y += boxH + 16;
+ doc.setTextColor(0, 0, 0);
+
+ // Sections
+ CHECKLIST_SECTIONS.forEach((section) => {
+ checkPage(36);
+
+ // Section header with accent bar
+ doc.setFillColor(30, 58, 95);
+ doc.rect(marginL, y - 2, 3, 14, "F");
+ doc.setFont("helvetica", "bold");
+ doc.setFontSize(11);
+ doc.setTextColor(30, 58, 95);
+ doc.text(section.title, marginL + 10, y + 9);
+ y += 20;
+
+ doc.setDrawColor(200, 205, 215);
+ doc.setLineWidth(0.5);
+ doc.line(marginL, y - 4, marginL + contentW, y - 4);
+ y += 4;
+
+ doc.setFont("helvetica", "normal");
+ doc.setFontSize(10);
+ doc.setTextColor(40, 40, 40);
+
+ section.items.forEach((item) => {
+ checkPage(18);
+ doc.setDrawColor(150, 150, 150);
+ doc.setLineWidth(0.5);
+ doc.rect(marginL + 4, y - 7, 8, 8);
+ const lines = doc.splitTextToSize(item, contentW - 24);
+ doc.text(lines, marginL + 18, y);
+ y += lines.length * 13 + 4;
+ });
+
+ y += 8;
+ });
+
+ // Page border on last page
+ doc.setDrawColor(0);
+ doc.setLineWidth(1.5);
+ doc.rect(20, 20, pageW - 40, pageH - 40);
+
+ // Add page borders to all previous pages
+ const totalPages = doc.getNumberOfPages();
+ for (let i = 1; i < totalPages; i++) {
+ doc.setPage(i);
+ doc.setDrawColor(0);
+ doc.setLineWidth(1.5);
+ doc.rect(20, 20, pageW - 40, pageH - 40);
+ }
+
+ doc.save(`${associationName.replace(/[^a-zA-Z0-9]/g, "_")}_Annual_Meeting_Checklist.pdf`);
+ toast({ title: "PDF exported" });
+ };
+
+ if (loading) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+ Annual Meeting Configuration
+
+ Set up meeting details and quorum requirements
+
+
+ {/* Meeting Details */}
+
+
+ {/* Quorum */}
+
+
Quorum Requirements
+
+
+
+ Total Units
+
+ setTotalUnits(Number(e.target.value) || 0)} />
+
+
+
+ Quorum %
+
+
setQuorumPercent(Number(e.target.value) || 0)} />
+
+
+
+ Owners Needed for Quorum
+
+
+ {quorumNeeded}
+
+
+
+
+
+
+ Notes
+ setNotes(e.target.value)} placeholder="Optional notes..." />
+
+
+
+
+ {saving ? : }
+ Save
+
+
+ Export PDF Checklist
+
+
+
+
+
+ {/* Checklist Preview */}
+
+
+ Checklist Preview
+ This checklist will be included in the PDF export
+
+
+ {CHECKLIST_SECTIONS.map((section) => (
+
+
+
+ {section.title}
+
+
+ {section.items.map((item, i) => (
+
+ ☐
+ {item}
+
+ ))}
+
+
+ ))}
+
+
+
+ );
+}
diff --git a/src/components/association/AssociationDirectoryManager.tsx b/src/components/association/AssociationDirectoryManager.tsx
new file mode 100644
index 0000000..0c12c7c
--- /dev/null
+++ b/src/components/association/AssociationDirectoryManager.tsx
@@ -0,0 +1,329 @@
+import { useEffect, useMemo, useState } from "react";
+import { z } from "zod";
+import { supabase } from "@/integrations/supabase/client";
+import { useAuth } from "@/contexts/AuthContext";
+import { useToast } from "@/hooks/use-toast";
+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 { Textarea } from "@/components/ui/textarea";
+import { Switch } from "@/components/ui/switch";
+import { Badge } from "@/components/ui/badge";
+import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
+import { Building2, Mail, Phone, Plus, Search, Trash2, Pencil, Loader2, Users, Eye, EyeOff } from "lucide-react";
+
+const directorySchema = z.object({
+ display_name: z.string().trim().min(1, "Name is required").max(150),
+ title: z.string().trim().max(150).optional(),
+ company: z.string().trim().max(150).optional(),
+ email: z.string().trim().max(255).optional().refine((value) => !value || z.string().email().safeParse(value).success, "Enter a valid email"),
+ phone: z.string().trim().max(50).optional(),
+ address: z.string().trim().max(255).optional(),
+ category: z.string().trim().min(1, "Category is required").max(80),
+ notes: z.string().trim().max(1000).optional(),
+ is_published: z.boolean(),
+});
+
+type DirectoryEntry = {
+ id: string;
+ association_id: string;
+ owner_id: string | null;
+ display_name: string;
+ title: string | null;
+ company: string | null;
+ email: string | null;
+ phone: string | null;
+ address: string | null;
+ category: string;
+ notes: string | null;
+ is_owner_opt_in: boolean;
+ is_published: boolean;
+ created_at: string;
+};
+
+type DirectoryForm = z.infer;
+
+const emptyForm: DirectoryForm = {
+ display_name: "",
+ title: "",
+ company: "",
+ email: "",
+ phone: "",
+ address: "",
+ category: "Resident",
+ notes: "",
+ is_published: true,
+};
+
+interface Props {
+ associationId: string;
+ mode?: "admin" | "readonly";
+ title?: string;
+ description?: string;
+}
+
+export default function AssociationDirectoryManager({ associationId, mode = "admin", title = "Directory", description = "Post and manage association directory information." }: Props) {
+ const { toast } = useToast();
+ const { user } = useAuth();
+ const [entries, setEntries] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [saving, setSaving] = useState(false);
+ const [dialogOpen, setDialogOpen] = useState(false);
+ const [editing, setEditing] = useState(null);
+ const [form, setForm] = useState(emptyForm);
+ const [query, setQuery] = useState("");
+ const [errors, setErrors] = useState>({});
+
+ const canManage = mode === "admin";
+
+ useEffect(() => {
+ void loadEntries();
+ }, [associationId]);
+
+ const loadEntries = async () => {
+ setLoading(true);
+ let queryBuilder = (supabase as any)
+ .from("association_directory_entries")
+ .select("*")
+ .eq("association_id", associationId)
+ .order("category")
+ .order("display_name");
+
+ if (!canManage) queryBuilder = queryBuilder.eq("is_published", true);
+
+ const { data, error } = await queryBuilder;
+ if (error) toast({ title: "Directory load failed", description: error.message, variant: "destructive" });
+ else setEntries(data || []);
+ setLoading(false);
+ };
+
+ const filteredEntries = useMemo(() => {
+ const needle = query.trim().toLowerCase();
+ if (!needle) return entries;
+ return entries.filter((entry) => [entry.display_name, entry.title, entry.company, entry.email, entry.phone, entry.category]
+ .filter(Boolean)
+ .some((value) => String(value).toLowerCase().includes(needle)));
+ }, [entries, query]);
+
+ const groupedEntries = useMemo(() => filteredEntries.reduce>((acc, entry) => {
+ const category = entry.category || "Directory";
+ acc[category] = [...(acc[category] || []), entry];
+ return acc;
+ }, {}), [filteredEntries]);
+
+ const openCreate = () => {
+ setEditing(null);
+ setForm(emptyForm);
+ setErrors({});
+ setDialogOpen(true);
+ };
+
+ const openEdit = (entry: DirectoryEntry) => {
+ setEditing(entry);
+ setForm({
+ display_name: entry.display_name,
+ title: entry.title || "",
+ company: entry.company || "",
+ email: entry.email || "",
+ phone: entry.phone || "",
+ address: entry.address || "",
+ category: entry.category || "Resident",
+ notes: entry.notes || "",
+ is_published: entry.is_published,
+ });
+ setErrors({});
+ setDialogOpen(true);
+ };
+
+ const setField = (key: K, value: DirectoryForm[K]) => setForm((prev) => ({ ...prev, [key]: value }));
+
+ const validate = () => {
+ const result = directorySchema.safeParse(form);
+ if (result.success) {
+ setErrors({});
+ return result.data;
+ }
+ const nextErrors: Record = {};
+ result.error.issues.forEach((issue) => { nextErrors[String(issue.path[0])] = issue.message; });
+ setErrors(nextErrors);
+ return null;
+ };
+
+ const handleSave = async () => {
+ const valid = validate();
+ if (!valid) return;
+
+ setSaving(true);
+ const payload = {
+ association_id: associationId,
+ created_by: user?.id || null,
+ display_name: valid.display_name,
+ title: valid.title || null,
+ company: valid.company || null,
+ email: valid.email || null,
+ phone: valid.phone || null,
+ address: valid.address || null,
+ category: valid.category,
+ notes: valid.notes || null,
+ is_published: valid.is_published,
+ is_owner_opt_in: false,
+ };
+
+ const { error } = editing
+ ? await (supabase as any).from("association_directory_entries").update(payload).eq("id", editing.id)
+ : await (supabase as any).from("association_directory_entries").insert(payload);
+
+ setSaving(false);
+ if (error) {
+ toast({ title: "Directory save failed", description: error.message, variant: "destructive" });
+ return;
+ }
+ toast({ title: editing ? "Directory entry updated" : "Directory entry added" });
+ setDialogOpen(false);
+ void loadEntries();
+ };
+
+ const handleDelete = async (entry: DirectoryEntry) => {
+ const { error } = await (supabase as any).from("association_directory_entries").delete().eq("id", entry.id);
+ if (error) toast({ title: "Delete failed", description: error.message, variant: "destructive" });
+ else {
+ toast({ title: "Directory entry deleted" });
+ setEntries((prev) => prev.filter((item) => item.id !== entry.id));
+ }
+ };
+
+ const togglePublished = async (entry: DirectoryEntry, checked: boolean) => {
+ setEntries((prev) => prev.map((item) => item.id === entry.id ? { ...item, is_published: checked } : item));
+ const { error } = await (supabase as any).from("association_directory_entries").update({ is_published: checked }).eq("id", entry.id);
+ if (error) {
+ setEntries((prev) => prev.map((item) => item.id === entry.id ? entry : item));
+ toast({ title: "Visibility update failed", description: error.message, variant: "destructive" });
+ }
+ };
+
+ return (
+
+
+
+
{title}
+
{description}
+
+ {canManage &&
Add Entry}
+
+
+
+
+ setQuery(event.target.value)} placeholder="Search directory..." className="pl-9" maxLength={100} />
+
+
+ {loading ? (
+
Loading directory...
+ ) : filteredEntries.length === 0 ? (
+
No directory entries found.
+ ) : canManage ? (
+
+
+
+
+ Name
+ Category
+ Contact
+ Source
+ Published
+ Actions
+
+
+
+ {filteredEntries.map((entry) => (
+
+
+ {entry.category}
+
+ {entry.is_owner_opt_in ? "Owner opt-in" : "Admin"}
+ togglePublished(entry, checked)} />
+
+
+
openEdit(entry)}>
+
handleDelete(entry)}>
+
+
+
+ ))}
+
+
+
+ ) : (
+
+ {Object.entries(groupedEntries).map(([category, categoryEntries]) => (
+
+ {category}
+
+ {categoryEntries.map((entry) => )}
+
+
+ ))}
+
+ )}
+
+
+
+ {editing ? "Edit Directory Entry" : "Add Directory Entry"}
+
+
setField("display_name", event.target.value)} maxLength={150} />
+
+ setField("category", value)}>
+
+
+ {['Resident', 'Board Member', 'Committee', 'Vendor', 'Management', 'Emergency', 'Other'].map((category) => {category} )}
+
+
+
+
setField("title", event.target.value)} maxLength={150} />
+
setField("company", event.target.value)} maxLength={150} />
+
setField("email", event.target.value)} maxLength={255} />
+
setField("phone", event.target.value)} maxLength={50} />
+
setField("address", event.target.value)} maxLength={255} />
+
setField("notes", event.target.value)} maxLength={1000} rows={3} />
+
+
Published {form.is_published ? : }
+
setField("is_published", checked)} />
+
+
+
+ setDialogOpen(false)}>Cancel
+ {saving && }Save Entry
+
+
+
+
+ );
+}
+
+function Field({ label, error, className, children }: { label: string; error?: string; className?: string; children: React.ReactNode }) {
+ return {label} {children}{error &&
{error}
}
;
+}
+
+function EntryName({ entry }: { entry: DirectoryEntry }) {
+ return {entry.display_name}
{(entry.title || entry.company) &&
{[entry.title, entry.company].filter(Boolean).join(" • ")}
}
;
+}
+
+function EntryContact({ entry }: { entry: DirectoryEntry }) {
+ return {entry.email &&
{entry.email}
}{entry.phone &&
{entry.phone}
}{!entry.email && !entry.phone &&
— }
;
+}
+
+function DirectoryCard({ entry }: { entry: DirectoryEntry }) {
+ return (
+
+
+
+ {entry.address &&
{entry.address}
}
+ {entry.notes &&
{entry.notes}
}
+
+ );
+}
diff --git a/src/components/association/GoogleMapPicker.tsx b/src/components/association/GoogleMapPicker.tsx
new file mode 100644
index 0000000..8291022
--- /dev/null
+++ b/src/components/association/GoogleMapPicker.tsx
@@ -0,0 +1,285 @@
+import { useEffect, useRef, useState, useCallback } from "react";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
+import { Slider } from "@/components/ui/slider";
+import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
+import { MapPin, Trash2, Link as LinkIcon } from "lucide-react";
+import { L, geocodeAddress, circleDivIcon, addMapboxSatelliteLayer } from "@/lib/leafletMaps";
+
+export interface MapPinData {
+ id: string;
+ label: string;
+ lat: string;
+ lng: string;
+ status: "available" | "unavailable";
+ linked_amenity_id?: string;
+}
+
+interface Props {
+ pins: MapPinData[];
+ onChange: (pins: MapPinData[]) => void;
+ zoom?: number;
+ onZoomChange?: (zoom: number) => void;
+ formAmenities: { id: string; name: string }[];
+ lockedView?: { lat: number; lng: number; zoom: number } | null;
+ onLockedViewChange?: (v: { lat: number; lng: number; zoom: number } | null) => void;
+}
+
+export default function GoogleMapPicker({ pins, onChange, zoom = 16, onZoomChange, formAmenities, lockedView, onLockedViewChange }: Props) {
+ const mapRef = useRef(null);
+ const mapInstance = useRef(null);
+ const markersRef = useRef([]);
+ const [searchQuery, setSearchQuery] = useState("");
+ const [searching, setSearching] = useState(false);
+ const pinsRef = useRef(pins);
+ pinsRef.current = pins;
+
+ const syncMarkers = useCallback((currentPins: MapPinData[]) => {
+ if (!mapInstance.current) return;
+ markersRef.current.forEach(m => m.remove());
+ markersRef.current = [];
+
+ const valid = currentPins.filter(p => {
+ const lat = parseFloat(p.lat);
+ const lng = parseFloat(p.lng);
+ return !isNaN(lat) && !isNaN(lng);
+ });
+
+ valid.forEach(pin => {
+ const lat = parseFloat(pin.lat);
+ const lng = parseFloat(pin.lng);
+ const marker = L.marker([lat, lng], {
+ title: pin.label || "Pin",
+ icon: circleDivIcon({
+ color: pin.status === "available" ? "#16a34a" : "#dc2626",
+ // Smaller pins to match the public amenity view
+ size: 18,
+ label: pin.label || "",
+ fontSize: "9px",
+ }),
+ }).addTo(mapInstance.current!);
+ markersRef.current.push(marker);
+ });
+
+ // If a locked view is configured, respect it instead of auto-fitting bounds.
+ if (lockedView && typeof lockedView.lat === "number" && typeof lockedView.lng === "number") {
+ mapInstance.current.setView([lockedView.lat, lockedView.lng], lockedView.zoom ?? 16);
+ return;
+ }
+
+ if (valid.length > 0) {
+ const bounds = L.latLngBounds(valid.map(p => [parseFloat(p.lat), parseFloat(p.lng)] as [number, number]));
+ if (valid.length === 1) {
+ mapInstance.current.setView(bounds.getCenter(), 16);
+ } else {
+ mapInstance.current.fitBounds(bounds, { padding: [40, 40] });
+ }
+ }
+ }, [lockedView]);
+
+ useEffect(() => {
+ if (!mapRef.current || mapInstance.current) return;
+
+ const defaultCenter: [number, number] = pins.length > 0 && pins[0].lat
+ ? [parseFloat(pins[0].lat), parseFloat(pins[0].lng)]
+ : [28.5383, -81.3792];
+
+ const map = L.map(mapRef.current, {
+ center: defaultCenter,
+ zoom: zoom,
+ zoomControl: true,
+ });
+
+ void addMapboxSatelliteLayer(map);
+
+ map.on("zoomend", () => onZoomChange?.(map.getZoom()));
+
+ map.on("click", (e: L.LeafletMouseEvent) => {
+ const newPin: MapPinData = {
+ id: Math.random().toString(36).substr(2, 9),
+ label: "",
+ lat: e.latlng.lat.toFixed(6),
+ lng: e.latlng.lng.toFixed(6),
+ status: "available",
+ };
+ onChange([...pinsRef.current, newPin]);
+ });
+
+ mapInstance.current = map;
+ syncMarkers(pins);
+
+ return () => {
+ map.remove();
+ mapInstance.current = null;
+ markersRef.current = [];
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ useEffect(() => {
+ syncMarkers(pins);
+ }, [pins, syncMarkers]);
+
+ const handleSearch = async () => {
+ if (!searchQuery.trim() || !mapInstance.current) return;
+ setSearching(true);
+ const result = await geocodeAddress(searchQuery);
+ setSearching(false);
+ if (result) {
+ mapInstance.current.setView([result.lat, result.lng], 17);
+ }
+ };
+
+ const updatePin = (id: string, updates: Partial) => {
+ onChange(pins.map(p => p.id === id ? { ...p, ...updates } : p));
+ };
+
+ const removePin = (id: string) => {
+ onChange(pins.filter(p => p.id !== id));
+ };
+
+ return (
+
+
+
+ Map Pins
+
+
+ Click on the map to drop pins. Configure each pin below.
+
+
+
+
+ setSearchQuery(e.target.value)}
+ onKeyDown={e => e.key === "Enter" && (e.preventDefault(), handleSearch())}
+ className="text-sm"
+ />
+
+ {searching ? "..." : "Search"}
+
+
+
+
+
+ {onLockedViewChange && (
+
+
+ {lockedView
+ ? <>🔒 Locked view saved · zoom {lockedView.zoom}>
+ : <>Pan/zoom the map to your preferred framing, then save it as the default view.>}
+
+
+ {
+ if (!mapInstance.current) return;
+ const c = mapInstance.current.getCenter();
+ const z = mapInstance.current.getZoom();
+ onLockedViewChange({ lat: c.lat, lng: c.lng, zoom: z });
+ onZoomChange?.(z);
+ }}
+ >
+ 💾 Save view
+
+ {lockedView && (
+ onLockedViewChange(null)}
+ >
+ Reset
+
+ )}
+
+
+ )}
+
+
+
+ Default Zoom Level
+ {zoom}
+
+
onZoomChange?.(v)}
+ min={1}
+ max={19}
+ step={1}
+ />
+
+
+ {pins.length > 0 && (
+
+
+ {pins.length} pin{pins.length !== 1 ? "s" : ""} placed
+
+ {pins.map((pin, idx) => (
+
+
{idx + 1}
+
+
updatePin(pin.id, { label: e.target.value })}
+ className="text-sm"
+ />
+
+ {pin.lat}, {pin.lng}
+
+
+
updatePin(pin.id, { status: v as "available" | "unavailable" })}
+ >
+
+
+ ✅ Available
+ 🚫 Unavailable
+
+
+
+ {pin.status === "available" && formAmenities.length > 0 && (
+
updatePin(pin.id, { linked_amenity_id: v === "none" ? undefined : v })}
+ >
+
+
+
+
+
+
+
+ No linked form
+ {formAmenities.map(fa => (
+ {fa.name}
+ ))}
+
+
+ )}
+
+
+
removePin(pin.id)}
+ type="button"
+ >
+
+
+
+ ))}
+
+ )}
+
+
+ );
+}
diff --git a/src/components/association/ManageAmenityBookingsDialog.tsx b/src/components/association/ManageAmenityBookingsDialog.tsx
new file mode 100644
index 0000000..e4cb198
--- /dev/null
+++ b/src/components/association/ManageAmenityBookingsDialog.tsx
@@ -0,0 +1,140 @@
+import { useEffect, useState } from "react";
+import { supabase } from "@/integrations/supabase/client";
+import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
+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 { useToast } from "@/hooks/use-toast";
+import { Trash2, Plus, Loader2 } from "lucide-react";
+
+interface Props {
+ open: boolean;
+ onOpenChange: (v: boolean) => void;
+ amenity: { id: string; name: string; association_id: string } | null;
+}
+
+export default function ManageAmenityBookingsDialog({ open, onOpenChange, amenity }: Props) {
+ const { toast } = useToast();
+ const [loading, setLoading] = useState(false);
+ const [bookings, setBookings] = useState([]);
+ const [newDate, setNewDate] = useState("");
+ const [newLabel, setNewLabel] = useState("");
+ const [saving, setSaving] = useState(false);
+
+ const load = async () => {
+ if (!amenity) return;
+ setLoading(true);
+ const { data } = await (supabase as any)
+ .from("amenity_bookings")
+ .select("id, booking_date, start_time, end_time, status, title, guest_name")
+ .eq("amenity_id", amenity.id)
+ .gte("booking_date", new Date(Date.now() - 30 * 86400000).toISOString().slice(0, 10))
+ .order("booking_date", { ascending: true });
+ setBookings(data || []);
+ setLoading(false);
+ };
+
+ useEffect(() => {
+ if (open && amenity) load();
+ }, [open, amenity?.id]);
+
+ const addBlock = async () => {
+ if (!amenity || !newDate) return;
+ setSaving(true);
+ const { error } = await (supabase as any).from("amenity_bookings").insert({
+ amenity_id: amenity.id,
+ association_id: amenity.association_id,
+ booking_date: newDate,
+ booking_type: "block",
+ title: newLabel || "Booked",
+ guest_name: newLabel || "Manually Blocked",
+ guest_email: "blocked@internal",
+ status: "blocked",
+ });
+ setSaving(false);
+ if (error) {
+ toast({ variant: "destructive", title: "Error", description: error.message });
+ } else {
+ setNewDate("");
+ setNewLabel("");
+ load();
+ }
+ };
+
+ const remove = async (id: string) => {
+ const { error } = await (supabase as any).from("amenity_bookings").delete().eq("id", id);
+ if (error) {
+ toast({ variant: "destructive", title: "Error", description: error.message });
+ } else {
+ setBookings((prev) => prev.filter((b) => b.id !== id));
+ }
+ };
+
+ return (
+
+
+
+ Manage Bookings — {amenity?.name}
+
+ Add manual booked dates that will display as unavailable on the public calendar.
+
+
+
+
+
+
Add booked date
+
+
+ {saving ? : }
+ Add Booked Date
+
+
+
+
+
Upcoming bookings
+ {loading ? (
+
+ ) : bookings.length === 0 ? (
+
No bookings yet.
+ ) : (
+
+ {bookings.map((b) => (
+
+
+
{b.booking_date}
+
+ {b.title || b.guest_name || "—"}
+
+
+
+
+ {(b.status || "").replace(/_/g, " ")}
+
+ remove(b.id)}>
+
+
+
+
+ ))}
+
+ )}
+
+
+
+
+ onOpenChange(false)}>Close
+
+
+
+ );
+}
diff --git a/src/components/association/PublicPageSettings.tsx b/src/components/association/PublicPageSettings.tsx
new file mode 100644
index 0000000..e54b669
--- /dev/null
+++ b/src/components/association/PublicPageSettings.tsx
@@ -0,0 +1,668 @@
+import { useState, useEffect } from "react";
+import { supabase } from "@/integrations/supabase/client";
+import { useToast } from "@/hooks/use-toast";
+import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Textarea } from "@/components/ui/textarea";
+import { Switch } from "@/components/ui/switch";
+import { Button } from "@/components/ui/button";
+import { Slider } from "@/components/ui/slider";
+import { Globe, ExternalLink, Save, Loader2, Image, Palette, Calendar, BookOpen, Copy, FileText, MapPin, Plus, X, Images, Megaphone, LayoutPanelLeft, Type } from "lucide-react";
+import ContentBlocksEditor from "@/components/public-content/ContentBlocksEditor";
+import type { ContentBlock } from "@/components/public-content/contentBlockTypes";
+import { sanitizeBlocks } from "@/components/public-content/contentBlockTypes";
+import { PUBLIC_PAGE_FONTS } from "@/components/public-content/PublicPageTheme";
+
+interface Props {
+ associationId: string;
+ associationName: string;
+}
+
+function slugify(text: string): string {
+ return text
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, "-")
+ .replace(/(^-|-$)/g, "");
+}
+
+export default function PublicPageSettings({ associationId, associationName }: Props) {
+ const { toast } = useToast();
+ const db = supabase as any;
+
+ const [loading, setLoading] = useState(true);
+ const [saving, setSaving] = useState(false);
+ const [pageId, setPageId] = useState(null);
+
+ const [slug, setSlug] = useState("");
+ const [isPublished, setIsPublished] = useState(false);
+ const [welcomeMessage, setWelcomeMessage] = useState("");
+ const [coverImageUrl, setCoverImageUrl] = useState("");
+ const [heroImages, setHeroImages] = useState([]);
+ const [newHeroImageUrl, setNewHeroImageUrl] = useState("");
+ const [accentColor, setAccentColor] = useState("#2563EB");
+ const [logoWidth, setLogoWidth] = useState(200);
+ const [calendarEnabled, setCalendarEnabled] = useState(false);
+ const [bookingEnabled, setBookingEnabled] = useState(false);
+ const [documentsEnabled, setDocumentsEnabled] = useState(false);
+ const [amenitiesEnabled, setAmenitiesEnabled] = useState(false);
+ const [announcementsEnabled, setAnnouncementsEnabled] = useState(false);
+ const [customLinks, setCustomLinks] = useState<{ label: string; url: string }[]>([]);
+ const [pageTitle, setPageTitle] = useState("");
+ const [seoDescription, setSeoDescription] = useState("");
+ const [newLinkLabel, setNewLinkLabel] = useState("");
+ const [newLinkUrl, setNewLinkUrl] = useState("");
+ const [bodyBlocks, setBodyBlocks] = useState([]);
+ const [sidebarBlocks, setSidebarBlocks] = useState([]);
+ const [sidebarEnabled, setSidebarEnabled] = useState(false);
+ const [sidebarTitle, setSidebarTitle] = useState("");
+ const [bgColor, setBgColor] = useState("#FFFFFF");
+ const [sectionBgColor, setSectionBgColor] = useState("#F9FAFB");
+ const [textColor, setTextColor] = useState("#111827");
+ const [headingColor, setHeadingColor] = useState("#0F172A");
+ const [fontFamily, setFontFamily] = useState("Inter");
+ const [headingFontFamily, setHeadingFontFamily] = useState("Inter");
+ const [footerBgColor, setFooterBgColor] = useState("#111827");
+ const [footerTextColor, setFooterTextColor] = useState("#FFFFFF");
+
+ useEffect(() => {
+ loadSettings();
+ }, [associationId]);
+
+ const loadSettings = async () => {
+ setLoading(true);
+ const { data } = await db
+ .from("association_public_pages")
+ .select("*")
+ .eq("association_id", associationId)
+ .maybeSingle();
+
+ if (data) {
+ setPageId(data.id);
+ setSlug(data.slug || "");
+ setIsPublished(data.is_published || false);
+ setWelcomeMessage(data.welcome_message || "");
+ setCoverImageUrl(data.cover_image_url || "");
+ const imgs = Array.isArray(data.hero_images) ? data.hero_images : [];
+ setHeroImages(imgs.filter((u: string) => typeof u === "string" && u.trim()));
+ setAccentColor(data.accent_color || "#2563EB");
+ setLogoWidth(data.logo_width || 200);
+ setPageTitle(data.page_title || "");
+ setSeoDescription(data.seo_description || "");
+ const mods = typeof data.modules_enabled === "string" ? JSON.parse(data.modules_enabled) : data.modules_enabled;
+ setCalendarEnabled(mods?.calendar || false);
+ setBookingEnabled(mods?.booking || false);
+ setDocumentsEnabled(mods?.documents || false);
+ setAmenitiesEnabled(mods?.amenities || false);
+ setAnnouncementsEnabled(mods?.announcements || false);
+ const links = Array.isArray(data.custom_links) ? data.custom_links : [];
+ setCustomLinks(links.filter((l: any) => l?.label && l?.url));
+ setBodyBlocks(sanitizeBlocks(data.body_blocks));
+ setSidebarBlocks(sanitizeBlocks(data.sidebar_blocks));
+ setSidebarEnabled(Boolean(data.sidebar_enabled));
+ setSidebarTitle(data.sidebar_title || "");
+ setBgColor(data.bg_color || "#FFFFFF");
+ setSectionBgColor(data.section_bg_color || "#F9FAFB");
+ setTextColor(data.text_color || "#111827");
+ setHeadingColor(data.heading_color || "#0F172A");
+ setFontFamily(data.font_family || "Inter");
+ setHeadingFontFamily(data.heading_font_family || data.font_family || "Inter");
+ setFooterBgColor(data.footer_bg_color || "#111827");
+ setFooterTextColor(data.footer_text_color || "#FFFFFF");
+ } else {
+ setSlug(slugify(associationName));
+ }
+ setLoading(false);
+ };
+
+ const handleSave = async () => {
+ if (!slug.trim()) {
+ toast({ title: "Slug is required", variant: "destructive" });
+ return;
+ }
+
+ setSaving(true);
+ const payload = {
+ association_id: associationId,
+ slug: slug.trim(),
+ is_published: isPublished,
+ welcome_message: welcomeMessage || null,
+ cover_image_url: coverImageUrl || null,
+ hero_images: heroImages,
+ accent_color: accentColor,
+ logo_width: logoWidth,
+ page_title: pageTitle.trim() || null,
+ seo_description: seoDescription.trim() || null,
+ modules_enabled: { calendar: calendarEnabled, booking: bookingEnabled, documents: documentsEnabled, amenities: amenitiesEnabled, announcements: announcementsEnabled },
+ custom_links: customLinks,
+ body_blocks: bodyBlocks,
+ sidebar_blocks: sidebarBlocks,
+ sidebar_enabled: sidebarEnabled,
+ sidebar_title: sidebarTitle.trim() || null,
+ bg_color: bgColor,
+ section_bg_color: sectionBgColor,
+ text_color: textColor,
+ heading_color: headingColor,
+ font_family: fontFamily,
+ heading_font_family: headingFontFamily,
+ footer_bg_color: footerBgColor,
+ footer_text_color: footerTextColor,
+ updated_at: new Date().toISOString(),
+ };
+
+ let error;
+ if (pageId) {
+ ({ error } = await db.from("association_public_pages").update(payload).eq("id", pageId));
+ } else {
+ const res = await db.from("association_public_pages").insert(payload).select().single();
+ error = res.error;
+ if (res.data) setPageId(res.data.id);
+ }
+
+ setSaving(false);
+ if (error) {
+ toast({ title: "Error saving", description: error.message, variant: "destructive" });
+ } else {
+ toast({ title: "Public page settings saved" });
+ }
+ };
+
+ const publicUrl = `${window.location.origin}/community/${slug}`;
+
+ const copyLink = () => {
+ navigator.clipboard.writeText(publicUrl);
+ toast({ title: "Link copied to clipboard" });
+ };
+
+ if (loading) {
+ return Loading...
;
+ }
+
+ return (
+
+
+
+
+ Public Community Page
+
+
+ Configure a public-facing page for this association.
+
+
+
+ {saving ? : }
+ Save
+
+
+
+ {/* Publish + Slug */}
+
+
+
+
+
Published
+
Make this page visible to the public
+
+
+
+
+
+
URL Slug
+
+
+ /community/
+
+
setSlug(slugify(e.target.value))}
+ className="rounded-l-none"
+ placeholder="my-community"
+ />
+
+
+
+ {isPublished && slug && (
+
+ )}
+
+
+
+ {/* SEO / Page Title */}
+
+
+
+ Page Title (SEO)
+ setPageTitle(e.target.value)}
+ placeholder={associationName}
+ />
+
+
+
Search Preview Text
+
setSeoDescription(e.target.value.slice(0, 160))}
+ placeholder="Brief description shown in search engine results"
+ rows={3}
+ />
+ {seoDescription.length}/160 characters
+
+
+ Used by search engines. Defaults to the association name and welcome text if left blank.
+
+
+
+
+ {/* Appearance */}
+
+
+
+ Appearance
+
+
+
+
+
+
+
Cover/Banner Image URL (single)
+
setCoverImageUrl(e.target.value)}
+ placeholder="https://... (used when no carousel images are set)"
+ />
+
Fallback if no carousel images below.
+
+
+
+
+ Hero Image Carousel
+
+
Add multiple images for an auto-rotating hero banner. Paste URLs from your Media Library.
+
+ {heroImages.length > 0 && (
+
+ {heroImages.map((url, i) => (
+
+
+
+
+
{url}
+
setHeroImages(heroImages.filter((_, j) => j !== i))}
+ >
+
+
+
+ ))}
+
+ )}
+
+
+
setNewHeroImageUrl(e.target.value)}
+ placeholder="https://... paste image URL"
+ className="flex-1"
+ onKeyDown={(e) => {
+ if (e.key === "Enter" && newHeroImageUrl.trim()) {
+ e.preventDefault();
+ setHeroImages([...heroImages, newHeroImageUrl.trim()]);
+ setNewHeroImageUrl("");
+ }
+ }}
+ />
+
{
+ setHeroImages([...heroImages, newHeroImageUrl.trim()]);
+ setNewHeroImageUrl("");
+ }}
+ >
+ Add
+
+
+
+
+
+
Logo Width: {logoWidth}px
+
setLogoWidth(v[0])}
+ min={60}
+ max={400}
+ step={10}
+ className="max-w-xs"
+ />
+ The association logo from the Overview tab is used.
+
+
+
+
+ {/* Public Page Branding (applies to all public pages for this association) */}
+
+
+
+ Public Page Branding
+
+
+ Colors and fonts applied to every public-facing page for this association (community, amenities, RV waitlist, etc.).
+
+
+
+
+
+
+
+ Body Font
+ setFontFamily(e.target.value)}
+ className="h-9 w-full rounded-md border border-input bg-background px-2 text-sm"
+ >
+ {PUBLIC_PAGE_FONTS.map((f) => (
+ {f}
+ ))}
+
+
+
+ Heading Font
+ setHeadingFontFamily(e.target.value)}
+ className="h-9 w-full rounded-md border border-input bg-background px-2 text-sm"
+ >
+ {PUBLIC_PAGE_FONTS.map((f) => (
+ {f}
+ ))}
+
+
+
+
+
+
Preview
+
+ Welcome to {associationName}
+
+
This is how body text will appear on your public pages.
+
+ ● Accent — section / card background sample
+
+
+
+
Footer Preview
+
Connect with {associationName}
+
123 Main Street • contact@example.com
+
+
+ Tip: For accent / button / icon color use the "Accent Color" field in the Appearance section above.
+
+
+
+
+ {/* Welcome Message */}
+
+
+
+ Welcome Message
+ Shown below the hero section.
+
+
+ setWelcomeMessage(e.target.value)}
+ rows={4}
+ placeholder="Welcome to our community! Here you'll find important information..."
+ />
+
+
+
+ {/* Custom Links */}
+
+
+
+ Custom Links
+
+ Add custom links that appear in the navigation bar of the community page.
+
+
+ {customLinks.length > 0 && (
+
+ {customLinks.map((link, i) => (
+
+ {link.label}
+ {link.url}
+ setCustomLinks(customLinks.filter((_, j) => j !== i))}
+ >
+
+
+
+ ))}
+
+ )}
+
+
+
+
+ {/* Custom Body Content */}
+
+
+
+ Custom Page Content
+
+
+ Add your own headings, paragraphs, images, links, and callouts. These appear in the main area of the public community page, between the welcome message and other modules.
+
+
+
+
+
+
+
+ {/* Sidebar */}
+
+
+
+ Information Sidebar
+
+
+ Optional sidebar shown beside the main content (e.g. office hours, contacts, helpful links).
+
+
+
+
+
+
Show sidebar
+
Toggle the information sidebar on the public page.
+
+
+
+ {sidebarEnabled && (
+ <>
+
+ Sidebar Title (optional)
+ setSidebarTitle(e.target.value)}
+ placeholder="e.g. Quick Info"
+ />
+
+
+ >
+ )}
+
+
+
+ {/* Modules */}
+
+
+ Modules
+ Toggle which sections appear on the public page.
+
+
+
+
+
+
+
Documents
+
Show documents marked as "Public" for download
+
+
+
+
+
+
+
+
+
+
Calendar / Events
+
Show upcoming events from this association's calendar
+
+
+
+
+
+
+
+
+
+
Amenities
+
Show amenity cards (map, booking, forms) from the Amenities tab
+
+
+
+
+
+
+
+
+
+
Announcements
+
Show announcements with "Public" visibility on the community page
+
+
+
+
+
+
+
+
+
+
Amenity Booking (Legacy)
+
Use the Amenities module above instead
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/association/ZohoFeesTab.tsx b/src/components/association/ZohoFeesTab.tsx
new file mode 100644
index 0000000..818a2ad
--- /dev/null
+++ b/src/components/association/ZohoFeesTab.tsx
@@ -0,0 +1,938 @@
+import { useState, useEffect } from "react";
+import { supabase } from "@/integrations/supabase/client";
+import { useToast } from "@/hooks/use-toast";
+import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Switch } from "@/components/ui/switch";
+import { Badge } from "@/components/ui/badge";
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
+import { Separator } from "@/components/ui/separator";
+import {
+ Loader2, Save, RefreshCw, DollarSign, Percent, Clock,
+ ArrowUpFromLine, ArrowDownToLine, CheckCircle2, XCircle, AlertTriangle, Tag
+} from "lucide-react";
+import { format } from "date-fns";
+
+interface Props {
+ associationId: string;
+ associationName: string;
+}
+
+interface FeeRules {
+ id?: string;
+ interest_enabled: boolean;
+ interest_rate: number;
+ interest_grace_days: number;
+ interest_compound: string;
+ interest_apply_to: string;
+ late_fee_enabled: boolean;
+ late_fee_type: string;
+ late_fee_amount: number;
+ late_fee_trigger_days: number;
+ late_fee_max: number | null;
+ late_fee_recurring: boolean;
+ auto_apply_enabled: boolean;
+ auto_apply_schedule: string;
+ auto_apply_day: number;
+ push_to_zoho: boolean;
+}
+
+interface SyncSettings {
+ id?: string;
+ sync_invoices: boolean;
+ sync_payments: boolean;
+ sync_contacts: boolean;
+ sync_bills: boolean;
+ sync_journal_entries: boolean;
+ auto_sync_enabled: boolean;
+}
+
+interface AccountMapping {
+ id: string;
+ chart_of_account_id: string;
+ zoho_account_id: string;
+ zoho_account_name: string | null;
+ account_name?: string;
+ account_number?: string;
+}
+
+interface CustomerMapping {
+ id: string;
+ owner_id: string | null;
+ unit_id: string | null;
+ zoho_customer_id: string;
+ zoho_customer_name: string | null;
+ owner_name?: string;
+ unit_number?: string;
+}
+
+interface SyncLogEntry {
+ id: string;
+ sync_type: string;
+ direction: string;
+ status: string;
+ record_count: number;
+ error_message: string | null;
+ created_at: string;
+}
+
+const DEFAULT_FEE_RULES: FeeRules = {
+ interest_enabled: false,
+ interest_rate: 0,
+ interest_grace_days: 30,
+ interest_compound: "monthly",
+ interest_apply_to: "assessments",
+ late_fee_enabled: false,
+ late_fee_type: "flat",
+ late_fee_amount: 0,
+ late_fee_trigger_days: 15,
+ late_fee_max: null,
+ late_fee_recurring: false,
+ auto_apply_enabled: false,
+ auto_apply_schedule: "monthly",
+ auto_apply_day: 1,
+ push_to_zoho: true,
+};
+
+const DEFAULT_SYNC_SETTINGS: SyncSettings = {
+ sync_invoices: true,
+ sync_payments: true,
+ sync_contacts: true,
+ sync_bills: false,
+ sync_journal_entries: false,
+ auto_sync_enabled: true,
+};
+
+export default function ZohoFeesTab({ associationId, associationName }: Props) {
+ const { toast } = useToast();
+ const [loading, setLoading] = useState(true);
+ const [saving, setSaving] = useState(false);
+
+ // Fee rules
+ const [feeRules, setFeeRules] = useState(DEFAULT_FEE_RULES);
+
+ // Sync settings
+ const [syncSettings, setSyncSettings] = useState(DEFAULT_SYNC_SETTINGS);
+
+ // Account mappings
+ const [accountMappings, setAccountMappings] = useState([]);
+ const [localAccounts, setLocalAccounts] = useState<{ id: string; account_name: string; account_number: string }[]>([]);
+ const [newAccountMapping, setNewAccountMapping] = useState({ chart_of_account_id: "", zoho_account_id: "", zoho_account_name: "" });
+
+ // Customer mappings (unit-based — in Zoho the customer name = property street address)
+ const [customerMappings, setCustomerMappings] = useState([]);
+ const [units, setUnits] = useState<{ id: string; unit_number: string; address: string | null }[]>([]);
+ const [newCustomerMapping, setNewCustomerMapping] = useState({ unit_id: "", zoho_customer_id: "", zoho_customer_name: "" });
+
+ // Sync log
+ const [syncLog, setSyncLog] = useState([]);
+
+ // Reporting tags
+ const [reportingTags, setReportingTags] = useState([]); // from Zoho
+ const [tagMappings, setTagMappings] = useState([]); // saved mappings
+ const [loadingTags, setLoadingTags] = useState(false);
+ const [manualTagMapping, setManualTagMapping] = useState({
+ zoho_tag_id: "",
+ zoho_tag_name: "",
+ zoho_tag_option_id: "",
+ zoho_option_name: "",
+ });
+
+ useEffect(() => {
+ fetchAll();
+ }, [associationId]);
+
+ const fetchAll = async () => {
+ setLoading(true);
+ const [feeRes, syncRes, acctMapRes, custMapRes, logRes, coaRes, unitsRes] = await Promise.all([
+ supabase.from("association_fee_rules").select("*").eq("association_id", associationId).maybeSingle(),
+ supabase.from("zoho_sync_settings").select("*").eq("association_id", associationId).maybeSingle(),
+ supabase.from("zoho_account_mappings").select("*, chart_of_accounts(account_name, account_number)").eq("association_id", associationId),
+ supabase.from("zoho_customer_mappings").select("*, units(unit_number, address)").eq("association_id", associationId),
+ supabase.from("zoho_sync_log").select("*").eq("association_id", associationId).order("created_at", { ascending: false }).limit(20),
+ supabase.from("chart_of_accounts").select("id, account_name, account_number").eq("association_id", associationId).order("account_number"),
+ supabase.from("units").select("id, unit_number, address").eq("association_id", associationId).order("unit_number"),
+ ]);
+
+ if (feeRes.data) setFeeRules({ ...DEFAULT_FEE_RULES, ...feeRes.data });
+ if (syncRes.data) setSyncSettings({ ...DEFAULT_SYNC_SETTINGS, ...syncRes.data });
+ if (acctMapRes.data) {
+ setAccountMappings(acctMapRes.data.map((m: any) => ({
+ ...m,
+ account_name: m.chart_of_accounts?.account_name,
+ account_number: m.chart_of_accounts?.account_number,
+ })));
+ }
+ if (custMapRes.data) {
+ setCustomerMappings(custMapRes.data.map((m: any) => ({
+ ...m,
+ unit_number: m.units?.unit_number,
+ unit_address: m.units?.address,
+ })));
+ }
+ if (logRes.data) setSyncLog(logRes.data as SyncLogEntry[]);
+ if (coaRes.data) setLocalAccounts(coaRes.data);
+ if (unitsRes.data) setUnits(unitsRes.data);
+ setLoading(false);
+
+ // Fetch reporting tag mappings
+ fetchReportingTagMappings();
+ };
+
+ const fetchReportingTagMappings = async () => {
+ const { data, error } = await supabase.functions.invoke("zoho-books", {
+ body: { action: "get_reporting_tag_mappings", params: { association_id: associationId } },
+ });
+ if (!error && data?.data) setTagMappings(data.data);
+ };
+
+ const fetchReportingTags = async () => {
+ setLoadingTags(true);
+ const { data, error } = await supabase.functions.invoke("zoho-books", {
+ body: { action: "list_reporting_tags", params: { association_id: associationId } },
+ });
+
+ if (!error && data?.data) {
+ const tags = data.data;
+ setReportingTags(tags);
+
+ const totalOptions = tags.reduce((sum: number, tag: any) => sum + ((tag.options || []).length), 0);
+ if (tags.length === 0) {
+ toast({ title: "No reporting tags found" });
+ } else if (totalOptions === 0) {
+ toast({
+ title: "Tags loaded, but no options were returned",
+ description: "The backend could read your Zoho tags, but Zoho did not return any selectable tag options.",
+ });
+ }
+ } else {
+ toast({ title: "Could not fetch reporting tags", variant: "destructive" });
+ }
+
+ setLoadingTags(false);
+ };
+
+ const saveTagMapping = async (tag: any, optionId: string) => {
+ const option = (tag.options || []).find(
+ (o: any) => String(o.tag_option_id ?? o.option_id) === optionId
+ );
+ const { error } = await supabase.functions.invoke("zoho-books", {
+ body: {
+ action: "save_reporting_tag_mapping",
+ params: {
+ association_id: associationId,
+ zoho_tag_id: String(tag.tag_id),
+ zoho_tag_option_id: optionId,
+ zoho_tag_name: tag.tag_name,
+ zoho_option_name: option?.option_name || null,
+ },
+ },
+ });
+ if (error) {
+ toast({ title: "Error saving tag mapping", variant: "destructive" });
+ } else {
+ toast({ title: "Reporting tag mapping saved" });
+ fetchReportingTagMappings();
+ }
+ };
+
+ const saveManualTagMapping = async () => {
+ if (!manualTagMapping.zoho_tag_id.trim() || !manualTagMapping.zoho_tag_option_id.trim()) {
+ toast({
+ title: "Tag ID and option ID are required",
+ variant: "destructive",
+ });
+ return;
+ }
+
+ const { error } = await supabase.functions.invoke("zoho-books", {
+ body: {
+ action: "save_reporting_tag_mapping",
+ params: {
+ association_id: associationId,
+ zoho_tag_id: manualTagMapping.zoho_tag_id.trim(),
+ zoho_tag_option_id: manualTagMapping.zoho_tag_option_id.trim(),
+ zoho_tag_name: manualTagMapping.zoho_tag_name.trim() || null,
+ zoho_option_name: manualTagMapping.zoho_option_name.trim() || null,
+ },
+ },
+ });
+
+ if (error) {
+ toast({ title: "Error saving manual tag mapping", variant: "destructive" });
+ return;
+ }
+
+ toast({ title: "Manual reporting tag mapping saved" });
+ setManualTagMapping({
+ zoho_tag_id: "",
+ zoho_tag_name: "",
+ zoho_tag_option_id: "",
+ zoho_option_name: "",
+ });
+ fetchReportingTagMappings();
+ };
+
+ const saveFeeRules = async () => {
+ setSaving(true);
+ const payload = { ...feeRules, association_id: associationId };
+ delete (payload as any).id;
+
+ const { error } = feeRules.id
+ ? await supabase.from("association_fee_rules").update(payload).eq("id", feeRules.id)
+ : await supabase.from("association_fee_rules").insert(payload);
+
+ if (error) {
+ toast({ title: "Error saving fee rules", description: error.message, variant: "destructive" });
+ } else {
+ toast({ title: "Fee rules saved" });
+ fetchAll();
+ }
+ setSaving(false);
+ };
+
+ const saveSyncSettings = async () => {
+ setSaving(true);
+ const payload = { ...syncSettings, association_id: associationId };
+ delete (payload as any).id;
+
+ const { error } = syncSettings.id
+ ? await supabase.from("zoho_sync_settings").update(payload).eq("id", syncSettings.id)
+ : await supabase.from("zoho_sync_settings").insert(payload);
+
+ if (error) {
+ toast({ title: "Error saving sync settings", description: error.message, variant: "destructive" });
+ } else {
+ toast({ title: "Sync settings saved" });
+ fetchAll();
+ }
+ setSaving(false);
+ };
+
+ const addAccountMapping = async () => {
+ if (!newAccountMapping.chart_of_account_id || !newAccountMapping.zoho_account_id) return;
+ const { error } = await supabase.from("zoho_account_mappings").insert({
+ association_id: associationId,
+ chart_of_account_id: newAccountMapping.chart_of_account_id,
+ zoho_account_id: newAccountMapping.zoho_account_id,
+ zoho_account_name: newAccountMapping.zoho_account_name || null,
+ });
+ if (error) {
+ toast({ title: "Error", description: error.message, variant: "destructive" });
+ } else {
+ setNewAccountMapping({ chart_of_account_id: "", zoho_account_id: "", zoho_account_name: "" });
+ fetchAll();
+ }
+ };
+
+ const removeAccountMapping = async (id: string) => {
+ await supabase.from("zoho_account_mappings").delete().eq("id", id);
+ fetchAll();
+ };
+
+ const addCustomerMapping = async () => {
+ if (!newCustomerMapping.unit_id || !newCustomerMapping.zoho_customer_id) return;
+ // Auto-fill Zoho customer name from unit address if not provided
+ const selectedUnit = units.find(u => u.id === newCustomerMapping.unit_id);
+ const customerName = newCustomerMapping.zoho_customer_name || selectedUnit?.address || null;
+ const { error } = await supabase.from("zoho_customer_mappings").insert({
+ association_id: associationId,
+ unit_id: newCustomerMapping.unit_id,
+ zoho_customer_id: newCustomerMapping.zoho_customer_id,
+ zoho_customer_name: customerName,
+ });
+ if (error) {
+ toast({ title: "Error", description: error.message, variant: "destructive" });
+ } else {
+ setNewCustomerMapping({ unit_id: "", zoho_customer_id: "", zoho_customer_name: "" });
+ fetchAll();
+ }
+ };
+
+ const removeCustomerMapping = async (id: string) => {
+ await supabase.from("zoho_customer_mappings").delete().eq("id", id);
+ fetchAll();
+ };
+
+ if (loading) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+ {/* ── Interest & Late Fee Rules ── */}
+
+
+
+
+ Interest & Late Fee Rules
+
+ Configure how interest and late fees are calculated for {associationName}. These charges can be auto-applied and pushed to Zoho.
+
+
+ {/* Interest Section */}
+
+
+
+
Interest Charges
+
Apply interest on outstanding balances
+
+
setFeeRules({ ...feeRules, interest_enabled: v })}
+ />
+
+ {feeRules.interest_enabled && (
+
+
+ APR Rate (%)
+ setFeeRules({ ...feeRules, interest_rate: Number(e.target.value) })}
+ className="text-sm"
+ />
+
+
+ Grace Period (days)
+ setFeeRules({ ...feeRules, interest_grace_days: Number(e.target.value) })}
+ className="text-sm"
+ />
+
+
+ Compounding
+ setFeeRules({ ...feeRules, interest_compound: v })}>
+
+
+ Daily
+ Monthly
+ Quarterly
+
+
+
+
+ Apply To
+ setFeeRules({ ...feeRules, interest_apply_to: v })}>
+
+
+ Assessments Only
+ Assessments & Fees
+ All Charges
+
+
+
+
+ )}
+
+
+
+
+ {/* Late Fee Section */}
+
+
+
+
Late Fees
+
Charge late fees on overdue accounts
+
+
setFeeRules({ ...feeRules, late_fee_enabled: v })}
+ />
+
+ {feeRules.late_fee_enabled && (
+
+
+ Fee Type
+ setFeeRules({ ...feeRules, late_fee_type: v })}>
+
+
+ Flat Amount ($)
+ Percentage (%)
+
+
+
+
+ {feeRules.late_fee_type === "flat" ? "Amount ($)" : "Percentage (%)"}
+ setFeeRules({ ...feeRules, late_fee_amount: Number(e.target.value) })}
+ className="text-sm"
+ />
+
+
+ Trigger (days past due)
+ setFeeRules({ ...feeRules, late_fee_trigger_days: Number(e.target.value) })}
+ className="text-sm"
+ />
+
+ {feeRules.late_fee_type === "percentage" && (
+
+ Max Cap ($)
+ setFeeRules({ ...feeRules, late_fee_max: e.target.value ? Number(e.target.value) : null })}
+ className="text-sm"
+ placeholder="No cap"
+ />
+
+ )}
+
+ setFeeRules({ ...feeRules, late_fee_recurring: v })}
+ />
+ Apply every month while overdue
+
+
+ )}
+
+
+
+
+ {/* Auto-apply Schedule */}
+
+
+
+
Auto-Apply Schedule
+
Automatically generate interest and late fee charges
+
+
setFeeRules({ ...feeRules, auto_apply_enabled: v })}
+ />
+
+ {feeRules.auto_apply_enabled && (
+
+
+ Frequency
+ setFeeRules({ ...feeRules, auto_apply_schedule: v })}>
+
+
+ Monthly
+ Quarterly
+
+
+
+
+ Day of Month
+ setFeeRules({ ...feeRules, auto_apply_day: Number(e.target.value) })}
+ className="text-sm"
+ />
+
+
+ setFeeRules({ ...feeRules, push_to_zoho: v })}
+ />
+ Push to Zoho automatically
+
+
+ )}
+
+
+
+
+ {saving ? : }
+ Save Fee Rules
+
+
+
+
+
+ {/* ── Sync Settings ── */}
+
+
+
+
+ Zoho Sync Settings
+
+ Control which types of data sync between this association and Zoho Books.
+
+
+
+ {([
+ { key: "sync_invoices", label: "Invoices / Charges" },
+ { key: "sync_payments", label: "Payments" },
+ { key: "sync_contacts", label: "Contacts (Owners/Vendors)" },
+ { key: "sync_bills", label: "Bills / AP" },
+ { key: "sync_journal_entries", label: "Journal Entries" },
+ { key: "auto_sync_enabled", label: "Auto-Sync on Save" },
+ ] as const).map(({ key, label }) => (
+
+ setSyncSettings({ ...syncSettings, [key]: v })}
+ />
+ {label}
+
+ ))}
+
+
+
+ {saving ? : }
+ Save Sync Settings
+
+
+
+
+
+ {/* ── Account Mappings ── */}
+
+
+
+
+ Chart of Accounts Mapping
+
+ Map your local chart of accounts to their Zoho Books equivalents.
+
+
+ {accountMappings.length > 0 && (
+
+
+
+ Local Account
+ Zoho Account ID
+ Zoho Account Name
+
+
+
+
+ {accountMappings.map((m) => (
+
+ {m.account_number} — {m.account_name}
+ {m.zoho_account_id}
+ {m.zoho_account_name || "—"}
+
+ removeAccountMapping(m.id)}>
+
+
+
+
+ ))}
+
+
+ )}
+
+
+ Local Account
+ setNewAccountMapping({ ...newAccountMapping, chart_of_account_id: v })}>
+
+
+ {localAccounts.map((a) => (
+ {a.account_number} — {a.account_name}
+ ))}
+
+
+
+
+ Zoho Account ID
+ setNewAccountMapping({ ...newAccountMapping, zoho_account_id: e.target.value })}
+ className="text-sm w-[180px]"
+ placeholder="e.g. 982000..."
+ />
+
+
+ Zoho Account Name
+ setNewAccountMapping({ ...newAccountMapping, zoho_account_name: e.target.value })}
+ className="text-sm w-[180px]"
+ placeholder="Optional label"
+ />
+
+
+ Add Mapping
+
+
+
+
+
+ {/* ── Customer Mappings (Unit Address = Zoho Customer Name) ── */}
+
+
+
+
+ Unit → Zoho Customer Mapping
+
+ In Zoho, the customer name is the property street address. Select a unit to map its address to a Zoho customer.
+
+
+ {customerMappings.length > 0 && (
+
+
+
+ Unit #
+ Property Address
+ Zoho Customer ID
+ Zoho Customer Name
+
+
+
+
+ {customerMappings.map((m: any) => (
+
+ {m.unit_number || "—"}
+ {m.unit_address || "—"}
+ {m.zoho_customer_id}
+ {m.zoho_customer_name || "—"}
+
+ removeCustomerMapping(m.id)}>
+
+
+
+
+ ))}
+
+
+ )}
+
+
+ Unit (Property Address)
+ {
+ const unit = units.find(u => u.id === v);
+ setNewCustomerMapping({
+ ...newCustomerMapping,
+ unit_id: v,
+ zoho_customer_name: unit?.address || "",
+ });
+ }}
+ >
+
+
+ {units.map((u) => (
+
+ {u.unit_number} — {u.address || "No address"}
+
+ ))}
+
+
+
+
+ Zoho Customer ID
+ setNewCustomerMapping({ ...newCustomerMapping, zoho_customer_id: e.target.value })}
+ className="text-sm w-[180px]"
+ placeholder="e.g. 982000..."
+ />
+
+
+ Zoho Customer Name (Address)
+ setNewCustomerMapping({ ...newCustomerMapping, zoho_customer_name: e.target.value })}
+ className="text-sm w-[220px]"
+ placeholder="Auto-filled from unit address"
+ />
+
+
+ Add Mapping
+
+
+
+
+
+ {/* ── Reporting Tags ── */}
+
+
+
+
+ Zoho Reporting Tags
+
+ Map this association to Zoho reporting tags so invoices and payments are tagged correctly.
+
+
+ {tagMappings.length > 0 && (
+
+
Current Mappings
+ {tagMappings.map((m: any) => (
+
+ {m.zoho_tag_name || m.zoho_tag_id}
+ →
+ {m.zoho_option_name || m.zoho_tag_option_id}
+
+ ))}
+
+ )}
+
+
+
+
Manual Mapping
+
+ If Zoho won’t load the dropdown options, enter the tag and option details manually and save them here.
+
+
+
+
+
+
+
+
+ Save Manual Mapping
+
+
+ {loadingTags ? : }
+ {reportingTags.length === 0 ? "Load Tags from Zoho" : "Refresh Tags"}
+
+
+
+
+ {reportingTags.length > 0 && (
+
+ {reportingTags.map((tag: any) => {
+ const currentMapping = tagMappings.find((m: any) => String(m.zoho_tag_id) === String(tag.tag_id));
+ return (
+
+ {tag.tag_name}
+ saveTagMapping(tag, v)}
+ >
+
+
+
+
+ {(tag.options || []).map((opt: any) => {
+ const optionId = String(opt.tag_option_id ?? opt.option_id);
+ return (
+
+ {opt.option_name}
+
+ );
+ })}
+
+
+
+ );
+ })}
+
+ )}
+
+
+
+ {/* ── Sync Log ── */}
+
+
+
+
+ Sync History
+
+ Recent sync activity for this association.
+
+
+ {syncLog.length === 0 ? (
+ No sync activity recorded yet.
+ ) : (
+
+
+
+ Date
+ Type
+ Direction
+ Records
+ Status
+ Error
+
+
+
+ {syncLog.map((entry) => (
+
+ {format(new Date(entry.created_at), "MMM d, h:mm a")}
+ {entry.sync_type}
+
+ {entry.direction === "push" ? (
+ Push
+ ) : (
+ Pull
+ )}
+
+ {entry.record_count}
+
+ {entry.status === "success" ? (
+ Success
+ ) : entry.status === "error" ? (
+ Error
+ ) : (
+ Partial
+ )}
+
+ {entry.error_message || "—"}
+
+ ))}
+
+
+ )}
+
+
+
+ );
+}
diff --git a/src/components/board/BoardSidebar.tsx b/src/components/board/BoardSidebar.tsx
new file mode 100644
index 0000000..d97cab5
--- /dev/null
+++ b/src/components/board/BoardSidebar.tsx
@@ -0,0 +1,104 @@
+import { NavLink, useLocation } from "react-router-dom";
+import {
+ FolderKanban, Calendar, FileText, BarChart3, CheckSquare,
+ Gavel, Scale, UserCheck, Users, Home, Car, Megaphone, FileSearch,
+ LayoutDashboard, MessageCircle, AlertTriangle, FileEdit, BookOpen, Wallet, Sparkles,
+} from "lucide-react";
+import { cn } from "@/lib/utils";
+import { ScrollArea } from "@/components/ui/scroll-area";
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
+import { useBoardAssociations } from "@/contexts/BoardAssociationContext";
+
+const boardNavItems = [
+ { title: "Dashboard", href: "/homeowner", icon: LayoutDashboard, end: true },
+ { title: "Messages", href: "/homeowner/board/messages", icon: MessageCircle },
+ { title: "Projects", href: "/homeowner/board/projects", icon: FolderKanban },
+ { title: "Calendar", href: "/homeowner/board/calendar", icon: Calendar },
+ { title: "Documents", href: "/homeowner/board/documents", icon: FileText },
+ { title: "Collab Docs", href: "/homeowner/board/collaborative-docs", icon: FileEdit },
+ { title: "Status Updates", href: "/homeowner/board/status-updates", icon: BarChart3 },
+ { title: "Tasks", href: "/homeowner/board/tasks", icon: CheckSquare },
+ { title: "ARC Applications", href: "/homeowner/board/arc-applications", icon: Gavel },
+ { title: "Violations", href: "/homeowner/board/violations", icon: AlertTriangle },
+ { title: "Bids & Quotes", href: "/homeowner/board/bids-quotes", icon: Scale },
+ { title: "Bill Approvals", href: "/homeowner/board/bill-approvals", icon: UserCheck },
+ { title: "Submit Invoice", href: "/homeowner/board/submit-invoice", icon: Sparkles },
+ { title: "Board Votes", href: "/homeowner/board/board-votes", icon: Users },
+ { title: "Elections", href: "/homeowner/board/elections", icon: Users },
+ { title: "Client Requests", href: "/homeowner/board/client-requests", icon: FileSearch },
+ { title: "Homeowner Requests", href: "/homeowner/board/homeowner-requests", icon: Home },
+ { title: "Parking", href: "/homeowner/board/parking", icon: Car },
+ { title: "Announcements", href: "/homeowner/board/announcements", icon: Megaphone },
+ { title: "Financial Overview", href: "/homeowner/board/financial-overview", icon: Wallet },
+ { title: "Financial Reports", href: "/homeowner/board/financial-reports", icon: BarChart3 },
+ { title: "Board Resources", href: "/homeowner/board/resources", icon: BookOpen },
+];
+
+function AssociationSelector() {
+ const { associations, selectedAssociationId, setSelectedAssociationId } = useBoardAssociations();
+
+ if (associations.length <= 1) return null;
+
+ return (
+
+
+ Association
+
+
+
+
+
+
+ {associations.map((a) => (
+
+ {a.name}
+
+ ))}
+
+
+
+ );
+}
+
+interface BoardSidebarProps {
+ mobile?: boolean;
+}
+
+export default function BoardSidebar({ mobile }: BoardSidebarProps) {
+ const location = useLocation();
+
+ const navContent = (
+ <>
+
+
+
Board Menu
+
+
+
+ {boardNavItems.map((item) => {
+ const Icon = item.icon;
+ const isActive = item.end ? location.pathname === item.href : location.pathname.startsWith(item.href);
+ return (
+
+ {isActive && }
+
+ {item.title}
+
+ );
+ })}
+
+
+ >
+ );
+
+ if (mobile) {
+ return {navContent}
;
+ }
+
+ return (
+
+ );
+}
diff --git a/src/components/board/BoardTopNav.tsx b/src/components/board/BoardTopNav.tsx
new file mode 100644
index 0000000..2b0eff5
--- /dev/null
+++ b/src/components/board/BoardTopNav.tsx
@@ -0,0 +1,375 @@
+import { useState, useEffect, useRef } from "react";
+import { useNavigate, useLocation, NavLink } from "react-router-dom";
+import {
+ FolderKanban, FileText, Users, LayoutDashboard, MessageCircle,
+ Menu, ChevronDown, LogOut, User, Receipt, DollarSign,
+} from "lucide-react";
+import { homeownerFunctionItems } from "@/lib/portalVisibilityConfig";
+import { useHomeowner } from "@/contexts/HomeownerContext";
+import { cn } from "@/lib/utils";
+import { Button } from "@/components/ui/button";
+import { Badge } from "@/components/ui/badge";
+import { Avatar, AvatarFallback } from "@/components/ui/avatar";
+import {
+ DropdownMenu, DropdownMenuContent, DropdownMenuItem,
+ DropdownMenuSeparator, DropdownMenuTrigger
+} from "@/components/ui/dropdown-menu";
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
+import { useBoardAssociations } from "@/contexts/BoardAssociationContext";
+import { supabase } from "@/integrations/supabase/client";
+import { MessagesIconButton } from "@/components/MessagesIconButton";
+import { NotificationBell } from "@/components/NotificationBell";
+import { useAuth } from "@/contexts/AuthContext";
+import acmIcon from "@/assets/acm-icon.png";
+import { boardFunctionItems } from "@/lib/portalVisibilityConfig";
+import { usePortalVisibility } from "@/hooks/usePortalVisibility";
+
+interface BoardTopNavProps {
+ displayName: string;
+ initials: string;
+ userEmail?: string;
+ onSignOut: () => void;
+}
+
+interface MenuItem {
+ title: string;
+ url: string;
+ icon: React.ComponentType<{ className?: string }>;
+}
+
+interface MenuColumn {
+ heading: string;
+ items: MenuItem[];
+}
+
+interface MenuSection {
+ label: string;
+ triggerIcon: React.ComponentType<{ className?: string }>;
+ columns: MenuColumn[];
+}
+
+const sectionIcons = {
+ Operations: FolderKanban,
+ Governance: Users,
+ Documents: FileText,
+ Communication: MessageCircle,
+};
+
+function buildBoardMenuSections(visibleKeys: (key: string) => boolean): MenuSection[] {
+ const sections = new Map>();
+
+ boardFunctionItems.filter((item) => visibleKeys(item.key)).forEach((item) => {
+ if (!sections.has(item.section)) sections.set(item.section, new Map());
+ const groups = sections.get(item.section)!;
+ if (!groups.has(item.group)) groups.set(item.group, []);
+ groups.get(item.group)!.push({ title: item.title, url: item.url, icon: item.icon });
+ });
+
+ return Array.from(sections.entries()).map(([label, groups]) => ({
+ label,
+ triggerIcon: sectionIcons[label as keyof typeof sectionIcons] || FolderKanban,
+ columns: Array.from(groups.entries()).map(([heading, items]) => ({ heading, items })),
+ }));
+}
+
+
+/* ── Mega Menu Panel ── */
+function MegaMenuPanel({ section, onNavigate }: { section: MenuSection; onNavigate: (url: string) => void }) {
+ const location = useLocation();
+ return (
+
+ {section.columns.map((col) => (
+
+
+ {col.heading}
+
+ {col.items.map((item) => {
+ const isActive = location.pathname.startsWith(item.url);
+ return (
+
onNavigate(item.url)}
+ className={cn(
+ "flex items-center gap-2.5 w-full rounded-md px-2 py-1.5 text-[13px] transition-colors text-left",
+ "hover:bg-accent/60",
+ isActive ? "bg-primary/10 text-primary font-medium" : "text-foreground/80"
+ )}
+ >
+
+ {item.title}
+
+ );
+ })}
+
+ ))}
+
+ );
+}
+
+/* ── Mobile Nav Drawer ── */
+function MobileNavDrawer({ sections, onNavigate, open, onClose }: {
+ sections: MenuSection[];
+ onNavigate: (url: string) => void;
+ open: boolean;
+ onClose: () => void;
+}) {
+ const location = useLocation();
+ if (!open) return null;
+
+ return (
+ <>
+
+
+
+ Board Menu
+ ✕
+
+
+
{ onNavigate("/homeowner"); onClose(); }}
+ className={cn(
+ "flex items-center gap-2.5 w-full px-4 py-2 text-[13px] hover:bg-accent/60",
+ location.pathname === "/homeowner" && "bg-primary/10 text-primary font-medium"
+ )}
+ >
+ Dashboard
+
+ {sections.map((section) => (
+
+
+ {section.label}
+
+ {section.columns.flatMap((col) => col.items).map((item) => {
+ const isActive = location.pathname.startsWith(item.url);
+ return (
+
{ onNavigate(item.url); onClose(); }}
+ className={cn(
+ "flex items-center gap-2.5 w-full px-4 py-1.5 text-[13px] hover:bg-accent/60",
+ isActive ? "bg-primary/10 text-primary font-medium" : "text-foreground/80"
+ )}
+ >
+
+ {item.title}
+
+ );
+ })}
+
+ ))}
+
+
+ >
+ );
+}
+
+/* ── Association Selector (inline) ── */
+function AssociationSelectorInline() {
+ const { associations, selectedAssociationId, setSelectedAssociationId } = useBoardAssociations();
+ if (associations.length <= 1) return null;
+ return (
+
+
+
+
+
+ {associations.map((a) => (
+ {a.name}
+ ))}
+
+
+ );
+}
+
+/* ── Main Component ── */
+export default function BoardTopNav({ displayName, initials, userEmail, onSignOut }: BoardTopNavProps) {
+ const navigate = useNavigate();
+ const location = useLocation();
+ const { user } = useAuth();
+ const { selectedAssociationId } = useBoardAssociations();
+ const { canSee } = usePortalVisibility("board", selectedAssociationId);
+ const visibleBoardMenuSections = buildBoardMenuSections(canSee);
+ const { association: homeownerAssociation } = useHomeowner();
+ const { canSee: canSeeHomeowner } = usePortalVisibility("homeowner", homeownerAssociation?.id);
+ const homeownerNavItems = homeownerFunctionItems.filter((item) => canSeeHomeowner(item.key));
+ const [activeMenu, setActiveMenu] = useState(null);
+ const [mobileNavOpen, setMobileNavOpen] = useState(false);
+ const menuTimeoutRef = useRef>();
+
+ useEffect(() => {
+ setActiveMenu(null);
+ }, [location.pathname]);
+
+ const handleMenuEnter = (label: string) => {
+ if (menuTimeoutRef.current) clearTimeout(menuTimeoutRef.current);
+ setActiveMenu(label);
+ };
+
+ const handleMenuLeave = () => {
+ menuTimeoutRef.current = setTimeout(() => setActiveMenu(null), 150);
+ };
+
+ const handleNavigate = (url: string) => {
+ setActiveMenu(null);
+ navigate(url);
+ };
+
+ return (
+ <>
+
+
+ {/* Mobile drawer */}
+ navigate(url)}
+ open={mobileNavOpen}
+ onClose={() => setMobileNavOpen(false)}
+ />
+ >
+ );
+}
diff --git a/src/components/bulk-workflow/BulkApplyWorkflowDialog.tsx b/src/components/bulk-workflow/BulkApplyWorkflowDialog.tsx
new file mode 100644
index 0000000..689b91f
--- /dev/null
+++ b/src/components/bulk-workflow/BulkApplyWorkflowDialog.tsx
@@ -0,0 +1,152 @@
+import { useState, useEffect } from "react";
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog";
+import { Button } from "@/components/ui/button";
+import { Label } from "@/components/ui/label";
+import { Input } from "@/components/ui/input";
+import { Textarea } from "@/components/ui/textarea";
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
+import { Loader2, Play } from "lucide-react";
+import { useWorkflowTemplates } from "@/hooks/useWorkflowTemplates";
+import { useAuth } from "@/contexts/AuthContext";
+import { toast as sonnerToast } from "sonner";
+
+interface BulkUnit {
+ unitId: string;
+ unitNumber: string;
+ associationId: string;
+ associationName: string;
+}
+
+interface BulkApplyWorkflowDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ units: BulkUnit[];
+ onSuccess?: () => void;
+}
+
+export default function BulkApplyWorkflowDialog({ open, onOpenChange, units, onSuccess }: BulkApplyWorkflowDialogProps) {
+ const { user } = useAuth();
+ const { templates, loading, getTemplates, applyTemplate } = useWorkflowTemplates();
+ const [selectedTemplateId, setSelectedTemplateId] = useState("");
+ const [description, setDescription] = useState("");
+ const [applying, setApplying] = useState(false);
+ const [progress, setProgress] = useState(0);
+
+ useEffect(() => {
+ if (open) {
+ getTemplates();
+ setSelectedTemplateId("");
+ setDescription("");
+ setProgress(0);
+ }
+ }, [open, getTemplates]);
+
+ useEffect(() => {
+ if (selectedTemplateId) {
+ const tmpl = templates.find((t: any) => t.id === selectedTemplateId);
+ if (tmpl) {
+ setDescription(tmpl.description || "");
+ }
+ }
+ }, [selectedTemplateId, templates]);
+
+ const selectedTemplate = templates.find((t: any) => t.id === selectedTemplateId);
+
+ const handleApply = async () => {
+ if (!selectedTemplateId || units.length === 0) return;
+ setApplying(true);
+ setProgress(0);
+
+ let successCount = 0;
+ let failCount = 0;
+
+ for (let i = 0; i < units.length; i++) {
+ const unit = units[i];
+ const title = `${selectedTemplate?.name || "Workflow"} - Unit ${unit.unitNumber}`;
+
+ const parentTaskData = {
+ title,
+ description,
+ association_id: unit.associationId,
+ client_id: unit.associationId,
+ created_by: user?.id,
+ assigned_to: null,
+ };
+
+ try {
+ const success = await applyTemplate(selectedTemplateId, parentTaskData, unit.unitId);
+ if (success) successCount++;
+ else failCount++;
+ } catch {
+ failCount++;
+ }
+ setProgress(i + 1);
+ }
+
+ setApplying(false);
+
+ if (failCount === 0) {
+ sonnerToast.success(`Workflow applied to ${successCount} unit(s) successfully`);
+ } else {
+ sonnerToast.warning(`Applied to ${successCount} unit(s), ${failCount} failed`);
+ }
+
+ onOpenChange(false);
+ onSuccess?.();
+ };
+
+ return (
+
+
+
+
+
+ Apply Workflow in Bulk
+
+
+ Apply a workflow template to {units.length} selected unit(s).
+
+
+
+
+ Workflow Template *
+
+
+
+
+
+ {templates.map((t: any) => (
+ {t.name}
+ ))}
+
+
+
+
+ Description (optional)
+ setDescription(e.target.value)} placeholder="Additional notes..." rows={3} />
+
+ {applying && (
+
+
+ Processing {progress} of {units.length}…
+
+
+
+ )}
+
+
+ onOpenChange(false)} disabled={applying}>Cancel
+
+ {applying && }
+ Apply to {units.length} Unit(s)
+
+
+
+
+ );
+}
diff --git a/src/components/collaborative/CollaborativeDocumentEditor.tsx b/src/components/collaborative/CollaborativeDocumentEditor.tsx
new file mode 100644
index 0000000..eda6a52
--- /dev/null
+++ b/src/components/collaborative/CollaborativeDocumentEditor.tsx
@@ -0,0 +1,269 @@
+import { useState, useEffect, useRef, useCallback } from "react";
+import { supabase } from "@/integrations/supabase/client";
+import { useAuth } from "@/contexts/AuthContext";
+import { useToast } from "@/hooks/use-toast";
+import { Button } from "@/components/ui/button";
+import { Badge } from "@/components/ui/badge";
+import { ScrollArea } from "@/components/ui/scroll-area";
+import { ArrowLeft, Lock, Unlock, Save, Clock, User } from "lucide-react";
+import { format } from "date-fns";
+import ReactQuill from "react-quill-new";
+import "react-quill-new/dist/quill.snow.css";
+
+// Assign a consistent color to each user
+const MEMBER_COLORS = [
+ "#3b82f6", "#ef4444", "#22c55e", "#f59e0b", "#8b5cf6",
+ "#ec4899", "#06b6d4", "#f97316", "#6366f1", "#14b8a6",
+];
+
+interface Props {
+ document: any;
+ onBack: () => void;
+ associations: any[];
+}
+
+export default function CollaborativeDocumentEditor({ document: doc, onBack, associations }: Props) {
+ const { user } = useAuth();
+ const { toast } = useToast();
+ const [content, setContent] = useState(doc.content || "");
+ const [editHistory, setEditHistory] = useState([]);
+ const [isLocked, setIsLocked] = useState(false);
+ const [lockedByName, setLockedByName] = useState("");
+ const [isMyLock, setIsMyLock] = useState(false);
+ const [saving, setSaving] = useState(false);
+ const [userProfile, setUserProfile] = useState(null);
+ const [userColorMap, setUserColorMap] = useState>({});
+ const quillRef = useRef(null);
+
+ useEffect(() => {
+ fetchLatestDoc();
+ fetchEditHistory();
+ fetchUserProfile();
+ const channel = supabase
+ .channel(`collab-doc-${doc.id}`)
+ .on("postgres_changes", { event: "*", schema: "public", table: "collaborative_documents", filter: `id=eq.${doc.id}` }, () => fetchLatestDoc())
+ .on("postgres_changes", { event: "INSERT", schema: "public", table: "document_edit_history", filter: `document_id=eq.${doc.id}` }, () => fetchEditHistory())
+ .subscribe();
+ return () => { supabase.removeChannel(channel); };
+ }, [doc.id]);
+
+ const fetchUserProfile = async () => {
+ if (!user) return;
+ const { data } = await supabase.from("profiles").select("full_name").eq("user_id", user.id).maybeSingle();
+ setUserProfile(data);
+ };
+
+ const fetchLatestDoc = async () => {
+ const { data } = await supabase.from("collaborative_documents").select("*").eq("id", doc.id).single();
+ if (data) {
+ if (!isMyLock) setContent(data.content || "");
+ setIsLocked(!!data.locked_by);
+ setIsMyLock(data.locked_by === user?.id);
+ if (data.locked_by && data.locked_by !== user?.id) {
+ const { data: profile } = await supabase.from("profiles").select("full_name").eq("user_id", data.locked_by).maybeSingle();
+ setLockedByName(profile?.full_name || "Another member");
+ } else {
+ setLockedByName("");
+ }
+ }
+ };
+
+ const fetchEditHistory = async () => {
+ const { data } = await supabase
+ .from("document_edit_history")
+ .select("*")
+ .eq("document_id", doc.id)
+ .order("created_at", { ascending: false })
+ .limit(50);
+ if (data) {
+ setEditHistory(data);
+ // Build color map from unique users
+ const map: Record = {};
+ const uniqueUsers = [...new Set(data.map((e) => e.user_id))];
+ uniqueUsers.forEach((uid, i) => {
+ map[uid] = MEMBER_COLORS[i % MEMBER_COLORS.length];
+ });
+ setUserColorMap(map);
+ }
+ };
+
+ const acquireLock = async () => {
+ // Check if already locked by someone else
+ const { data: current } = await supabase.from("collaborative_documents").select("locked_by, locked_at, content").eq("id", doc.id).single();
+ if (current?.locked_by && current.locked_by !== user?.id) {
+ // Check if lock is stale (>15 min)
+ const lockedAt = new Date(current.locked_at).getTime();
+ if (Date.now() - lockedAt < 15 * 60 * 1000) {
+ toast({ title: "Document is locked", description: "Another board member is currently editing.", variant: "destructive" });
+ return;
+ }
+ }
+ // Refresh content before locking
+ if (current) setContent(current.content || "");
+ const { error } = await supabase.from("collaborative_documents").update({ locked_by: user?.id, locked_at: new Date().toISOString() }).eq("id", doc.id);
+ if (error) toast({ title: "Error", description: error.message, variant: "destructive" });
+ else {
+ setIsLocked(true);
+ setIsMyLock(true);
+ }
+ };
+
+ const releaseLock = async (silent?: boolean) => {
+ const { error } = await supabase.from("collaborative_documents").update({ locked_by: null, locked_at: null }).eq("id", doc.id);
+ if (error && !silent) toast({ title: "Error", description: error.message, variant: "destructive" });
+ setIsLocked(false);
+ setIsMyLock(false);
+ };
+
+ const handleSave = async () => {
+ if (!isMyLock) return;
+ setSaving(true);
+ const userName = userProfile?.full_name || user?.email || "Unknown";
+ const { error: updateErr } = await supabase.from("collaborative_documents").update({ content, updated_at: new Date().toISOString() }).eq("id", doc.id);
+ if (updateErr) {
+ toast({ title: "Error saving", description: updateErr.message, variant: "destructive" });
+ setSaving(false);
+ return;
+ }
+ // Log edit
+ const color = userColorMap[user?.id || ""] || MEMBER_COLORS[0];
+ await supabase.from("document_edit_history").insert({
+ document_id: doc.id,
+ user_id: user?.id,
+ user_name: userName,
+ user_color: color,
+ change_summary: "Edited the document",
+ content_snapshot: content.substring(0, 500),
+ });
+ toast({ title: "Saved successfully" });
+ setSaving(false);
+ };
+
+ const handleSaveAndRelease = async () => {
+ await handleSave();
+ await releaseLock();
+ };
+
+ const assocName = associations.find((a) => a.id === doc.association_id)?.name || "";
+
+ const quillModules = {
+ toolbar: [
+ [{ header: [1, 2, 3, false] }],
+ ["bold", "italic", "underline", "strike"],
+ [{ color: [] }, { background: [] }],
+ [{ list: "ordered" }, { list: "bullet" }],
+ [{ align: [] }],
+ ["blockquote", "code-block"],
+ ["link"],
+ ["clean"],
+ ],
+ };
+
+ return (
+
+ {/* Header */}
+
+
+
+
+
{doc.title}
+
{assocName}
+
+
+
+ {isLocked && !isMyLock && (
+
+ Locked by {lockedByName}
+
+ )}
+ {isMyLock && (
+ <>
+
+ You are editing
+
+
+ Save
+
+
+ Save & Release
+
+ >
+ )}
+ {!isLocked && (
+
+ Start Editing
+
+ )}
+
+
+
+ {/* Editor + History */}
+
+ {/* Editor */}
+
+
+
+
+ {/* Edit Log Sidebar */}
+
+
+
+ Edit Log
+
+
+
+
+ {editHistory.length === 0 ? (
+
No edits yet.
+ ) : (
+ editHistory.map((entry) => (
+
+
+
+
+ {entry.user_name}
+
+
{entry.change_summary}
+
{format(new Date(entry.created_at), "MMM d, h:mm a")}
+
+
+ ))
+ )}
+
+
+
+ {/* Active members legend */}
+ {Object.keys(userColorMap).length > 0 && (
+
+
CONTRIBUTORS
+
+ {editHistory
+ .reduce((acc: any[], e) => {
+ if (!acc.find((a: any) => a.user_id === e.user_id)) acc.push(e);
+ return acc;
+ }, [])
+ .map((e: any) => (
+
+ ))}
+
+
+ )}
+
+
+
+ );
+}
diff --git a/src/components/collaborative/CollaborativeDocumentsPage.tsx b/src/components/collaborative/CollaborativeDocumentsPage.tsx
new file mode 100644
index 0000000..a2f6a16
--- /dev/null
+++ b/src/components/collaborative/CollaborativeDocumentsPage.tsx
@@ -0,0 +1,178 @@
+import { useState, useEffect } from "react";
+import { supabase } from "@/integrations/supabase/client";
+import { useAuth } from "@/contexts/AuthContext";
+import { useToast } from "@/hooks/use-toast";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Card, CardContent } from "@/components/ui/card";
+import { Badge } from "@/components/ui/badge";
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
+import { Plus, FileEdit, Lock, Search, Trash2 } from "lucide-react";
+import { format } from "date-fns";
+import CollaborativeDocumentEditor from "./CollaborativeDocumentEditor";
+
+interface Props {
+ boardAssociationIds?: string[];
+}
+
+export default function CollaborativeDocumentsPage({ boardAssociationIds }: Props) {
+ const { user } = useAuth();
+ const { toast } = useToast();
+ const [documents, setDocuments] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [search, setSearch] = useState("");
+ const [showCreate, setShowCreate] = useState(false);
+ const [newTitle, setNewTitle] = useState("");
+ const [newAssocId, setNewAssocId] = useState("");
+ const [associations, setAssociations] = useState([]);
+ const [selectedDoc, setSelectedDoc] = useState(null);
+
+ useEffect(() => {
+ fetchDocuments();
+ fetchAssociations();
+ }, [user]);
+
+ const fetchAssociations = async () => {
+ let query = supabase.from("associations").select("id, name").order("name");
+ if (boardAssociationIds?.length) {
+ query = query.in("id", boardAssociationIds);
+ }
+ const { data } = await query;
+ setAssociations(data || []);
+ };
+
+ const fetchDocuments = async () => {
+ setLoading(true);
+ let query = supabase
+ .from("collaborative_documents")
+ .select("*")
+ .order("updated_at", { ascending: false });
+ if (boardAssociationIds?.length) {
+ query = query.in("association_id", boardAssociationIds);
+ }
+ const { data, error } = await query;
+ if (error) toast({ title: "Error", description: error.message, variant: "destructive" });
+ else setDocuments(data || []);
+ setLoading(false);
+ };
+
+ const handleCreate = async () => {
+ if (!newTitle.trim() || !newAssocId) return;
+ const { error } = await supabase.from("collaborative_documents").insert({
+ title: newTitle.trim(),
+ association_id: newAssocId,
+ created_by: user?.id,
+ });
+ if (error) {
+ toast({ title: "Error", description: error.message, variant: "destructive" });
+ } else {
+ toast({ title: "Document created" });
+ setShowCreate(false);
+ setNewTitle("");
+ setNewAssocId("");
+ fetchDocuments();
+ }
+ };
+
+ const handleDelete = async (id: string) => {
+ if (!confirm("Delete this document and all its history?")) return;
+ const { error } = await supabase.from("collaborative_documents").delete().eq("id", id);
+ if (error) toast({ title: "Error", description: error.message, variant: "destructive" });
+ else fetchDocuments();
+ };
+
+ const filtered = documents.filter(
+ (d) => !search || d.title?.toLowerCase().includes(search.toLowerCase())
+ );
+
+ if (selectedDoc) {
+ return (
+ { setSelectedDoc(null); fetchDocuments(); }}
+ associations={associations}
+ />
+ );
+ }
+
+ const getAssocName = (id: string) => associations.find((a) => a.id === id)?.name || "—";
+
+ return (
+
+
+
+
+ Collaborative Documents
+
+
Create and edit documents together with board members.
+
+
setShowCreate(true)} className="gap-2">
+ New Document
+
+
+
+
+
+ setSearch(e.target.value)} />
+
+
+ {loading ? (
+
+ ) : filtered.length === 0 ? (
+
No collaborative documents yet.
+ ) : (
+
+ {filtered.map((doc) => (
+
setSelectedDoc(doc)}>
+
+
+
+
+
{doc.title}
+
{getAssocName(doc.association_id)} · Updated {format(new Date(doc.updated_at), "MMM d, yyyy h:mm a")}
+
+
+
+ {doc.locked_by && (
+
+ Editing
+
+ )}
+ { e.stopPropagation(); handleDelete(doc.id); }}>
+
+
+
+
+
+ ))}
+
+ )}
+
+
+
+ New Collaborative Document
+
+
+ Title
+ setNewTitle(e.target.value)} placeholder="Document title" />
+
+
+ Association
+
+
+
+ {associations.map((a) => {a.name} )}
+
+
+
+
+
+ setShowCreate(false)}>Cancel
+ Create
+
+
+
+
+ );
+}
diff --git a/src/components/dashboard/AppSidebar.tsx b/src/components/dashboard/AppSidebar.tsx
new file mode 100644
index 0000000..bd41748
--- /dev/null
+++ b/src/components/dashboard/AppSidebar.tsx
@@ -0,0 +1,410 @@
+import {
+ LayoutDashboard, Users, Home, AlertTriangle, Gavel, FileText,
+ FolderKanban, CheckSquare, DollarSign, Scale, Receipt,
+ BarChart3, Calendar, FileBox, FilePlus, FileSignature,
+ ChevronDown, LogOut, Building2, Bell,
+ ClipboardList, Megaphone, MessageCircle, BookOpen, FileCode,
+ Phone, Send, Mail, MailOpen, AtSign, ServerCog,
+ Bot, CalendarOff, FileSearch, UserCog, Car, StickyNote,
+ ListChecks, CreditCard, Vote, UserCheck,
+ PieChart, Shield, BellRing, Database, ImageIcon, Clock, Upload, Landmark, PenLine, ClipboardCheck, Inbox, Calculator
+} from "lucide-react";
+import { NavLink } from "@/components/NavLink";
+import sidebarLogo from "@/assets/favicon-logo.png";
+import { useLocation, useNavigate } from "react-router-dom";
+import { supabase } from "@/integrations/supabase/client";
+import {
+ Sidebar, SidebarContent, SidebarGroup, SidebarGroupContent,
+ SidebarMenu, SidebarMenuButton, SidebarMenuItem,
+ SidebarFooter, useSidebar,
+} from "@/components/ui/sidebar";
+import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
+import { Button } from "@/components/ui/button";
+import { ScrollArea } from "@/components/ui/scroll-area";
+import { Badge } from "@/components/ui/badge";
+import { cn } from "@/lib/utils";
+import { useAuth } from "@/contexts/AuthContext";
+import { useTickerCounts } from "@/contexts/TickerContext";
+
+type NavItemDef = { title: string; url: string; icon: React.ElementType; tickerKey?: string };
+
+type SectionDef = {
+ label: string;
+ items?: NavItemDef[];
+ subsections?: { label: string; items: NavItemDef[] }[];
+ defaultOpen?: boolean;
+};
+
+const coreItems: NavItemDef[] = [
+ { title: "Dashboard", url: "/dashboard", icon: LayoutDashboard },
+ { title: "Accounting", url: "/dashboard/accounting", icon: Calculator },
+];
+
+const sections: SectionDef[] = [
+ {
+ label: "Community Management",
+ items: [
+ { title: "Associations", url: "/dashboard/associations", icon: Building2 },
+ { title: "Directory", url: "/dashboard/directory", icon: Home },
+ { title: "Committees", url: "/dashboard/directory?tab=committees", icon: Users },
+ ],
+ subsections: [
+ {
+ label: "Board & Governance",
+ items: [
+ { title: "Board Members", url: "/dashboard/board-members", icon: UserCheck },
+ { title: "Board Resources", url: "/dashboard/board-resources", icon: BookOpen },
+ { title: "Board Votes", url: "/dashboard/board-votes", icon: Vote, tickerKey: "board_votes" },
+ { title: "Elections", url: "/dashboard/elections", icon: Vote },
+ ],
+ },
+ {
+ label: "Architectural & Compliance",
+ items: [
+ { title: "Violations", url: "/dashboard/violations", icon: AlertTriangle },
+ { title: "ARC Applications", url: "/dashboard/arc-applications", icon: Gavel, tickerKey: "arc_applications" },
+ { title: "ARC Inbound Emails", url: "/dashboard/arc-inbound", icon: Inbox },
+ { title: "Parking", url: "/dashboard/parking", icon: Car },
+ { title: "RV / Boat Lots", url: "/dashboard/rv-boat-lots", icon: Car },
+ ],
+ },
+ {
+ label: "Requests & Cases",
+ items: [
+ { title: "Homeowner Requests", url: "/dashboard/homeowner-requests", icon: MessageCircle, tickerKey: "homeowner_requests" },
+ { title: "Client Requests", url: "/dashboard/client-requests", icon: ClipboardList },
+ { title: "Legal Matters", url: "/dashboard/legal-matters", icon: Scale },
+ ],
+ },
+ ],
+ defaultOpen: true,
+ },
+ {
+ label: "Operations",
+ subsections: [
+ {
+ label: "Projects & Tasks",
+ items: [
+ { title: "Projects", url: "/dashboard/projects", icon: FolderKanban, tickerKey: "projects" },
+ { title: "Tasks", url: "/dashboard/tasks", icon: CheckSquare, tickerKey: "tasks" },
+ { title: "Checklists", url: "/dashboard/checklists", icon: ListChecks },
+ ],
+ },
+ {
+ label: "Scheduling & Activity",
+ items: [
+ { title: "Calendar", url: "/dashboard/calendar", icon: Calendar },
+ { title: "Reminders", url: "/dashboard/reminders", icon: Bell, tickerKey: "reminders" },
+ { title: "Blocked Dates", url: "/dashboard/blocked-dates", icon: CalendarOff },
+ ],
+ },
+ {
+ label: "Field & Service Work",
+ items: [
+ { title: "Inspections", url: "/dashboard/inspections", icon: ClipboardList },
+ { title: "Communication Log", url: "/dashboard/call-log", icon: Phone },
+ ],
+ },
+ {
+ label: "Updates & Tracking",
+ items: [
+ { title: "Owner Updates", url: "/dashboard/owner-updates", icon: StickyNote },
+ { title: "Status Updates", url: "/dashboard/status-updates", icon: BarChart3 },
+ { title: "Association Reports", url: "/dashboard/reports", icon: BarChart3 },
+ ],
+ },
+ {
+ label: "Vendor & Approvals",
+ items: [
+ { title: "Vendors", url: "/dashboard/vendors", icon: Users },
+ { title: "Bids & Quotes", url: "/dashboard/bids-quotes", icon: Scale },
+ { title: "Bill Approvals", url: "/dashboard/bill-approvals", icon: Shield, tickerKey: "bill_approvals" },
+ ],
+ },
+ ],
+ },
+ {
+ label: "Financial Management",
+ subsections: [
+ {
+ label: "Overview & Planning",
+ items: [
+ { title: "Financial Overview", url: "/dashboard/financial-overview", icon: PieChart },
+ { title: "Financial Reports", url: "/dashboard/financial-reports", icon: BarChart3 },
+ { title: "Recent Ledger Updates", url: "/dashboard/recent-ledger-updates", icon: Clock },
+ { title: "Budget Management", url: "/dashboard/budget-management", icon: BarChart3 },
+ ],
+ },
+ {
+ label: "Receivables",
+ items: [
+ { title: "Outstanding Balances", url: "/dashboard/outstanding-balances", icon: DollarSign },
+ { title: "Bulk Charges", url: "/dashboard/bulk-charges", icon: DollarSign },
+ { title: "Collections", url: "/dashboard/collections", icon: DollarSign },
+ { title: "Payments", url: "/dashboard/payments", icon: CreditCard },
+ { title: "Estoppels", url: "/dashboard/estoppels", icon: FileText },
+ { title: "Payment Plans", url: "/dashboard/payment-plans", icon: DollarSign },
+ ],
+ },
+ {
+ label: "Payables",
+ items: [
+ { title: "Payables", url: "/dashboard/payables", icon: Receipt },
+ ],
+ },
+ ],
+ },
+ {
+ label: "Communication",
+ subsections: [
+ {
+ label: "Messaging",
+ items: [
+ { title: "Messages", url: "/dashboard/messages", icon: MessageCircle },
+ { title: "Compose Email", url: "/dashboard/compose-email", icon: Send },
+ ],
+ },
+ {
+ label: "Notifications",
+ items: [
+ { title: "Notify Board", url: "/dashboard/notify-board", icon: Megaphone },
+ { title: "Notify Owners", url: "/dashboard/notify-owners", icon: BellRing },
+ ],
+ },
+ {
+ label: "Email Management",
+ items: [
+ { title: "Email History", url: "/dashboard/email-history", icon: Mail },
+ { title: "Email Templates", url: "/dashboard/email-templates", icon: FileCode },
+ { title: "Email Senders", url: "/dashboard/email-senders", icon: AtSign },
+ { title: "Email Routing", url: "/dashboard/email-routing", icon: MailOpen },
+ ],
+ },
+ ],
+ },
+ {
+ label: "Documents & Content",
+ items: [
+ { title: "Documents", url: "/dashboard/documents", icon: FileBox },
+ { title: "Forms & Letters", url: "/dashboard/forms-letters", icon: FilePlus },
+ { title: "Avria Sign", url: "/dashboard/avria-sign", icon: FileSignature },
+ { title: "Announcements", url: "/dashboard/announcements", icon: Megaphone },
+ { title: "Media Library", url: "/dashboard/media", icon: ImageIcon },
+ ],
+ },
+ {
+ label: "Admin & System",
+ subsections: [
+ {
+ label: "Users & Access",
+ items: [
+ { title: "User Management", url: "/dashboard/user-management", icon: UserCog },
+ { title: "Sign-up Codes", url: "/dashboard/signup-codes", icon: UserCog },
+ { title: "Bulk Updates", url: "/dashboard/bulk-updates", icon: Upload },
+ { title: "Compliance Checklists", url: "/dashboard/compliance-checklists", icon: ClipboardCheck },
+ ],
+ },
+ {
+ label: "Accounting Setup",
+ items: [
+ { title: "Bank Accounts", url: "/dashboard/bank-accounts", icon: Landmark },
+ { title: "Chart of Accounts", url: "/dashboard/chart-of-accounts", icon: BookOpen },
+ ],
+ },
+ {
+ label: "Company Accounting",
+ items: [
+ { title: "Company Bank Accounts", url: "/dashboard/company-bank-accounts", icon: Landmark },
+ { title: "Company Bank Register", url: "/dashboard/company-bank-register", icon: BookOpen },
+ { title: "Company Checks", url: "/dashboard/company-checks", icon: PenLine },
+ ],
+ },
+ {
+ label: "Billing & Invoicing",
+ items: [
+ { title: "Billable Expenses", url: "/dashboard/billable-expenses", icon: Receipt },
+ { title: "Invoice Clients", url: "/dashboard/invoice-clients", icon: FileText },
+ { title: "Client Invoices", url: "/dashboard/client-invoices", icon: FileText },
+ { title: "Invoice Tracking", url: "/dashboard/invoice-tracking", icon: FileSearch },
+ ],
+ },
+ {
+ label: "Data Management",
+ items: [
+ { title: "Time Tracking", url: "/dashboard/time-tracking", icon: Clock },
+ { title: "Data Migration", url: "/dashboard/data-migration", icon: Database },
+ { title: "Migration Fields", url: "/dashboard/migration-fields", icon: Database },
+ ],
+ },
+ ],
+ },
+];
+
+/* ── Components ────────────────────── */
+
+function NavItem({ item, collapsed }: { item: NavItemDef; collapsed: boolean }) {
+ const location = useLocation();
+ const isActive = location.pathname === item.url;
+ const counts = useTickerCounts();
+ const count = item.tickerKey ? counts[item.tickerKey as keyof typeof counts] : 0;
+
+ return (
+
+
+
+ {isActive && (
+
+ )}
+
+ {!collapsed && (
+ <>
+ {item.title}
+ {count > 0 && (
+
+ {count > 99 ? "99+" : count}
+
+ )}
+ >
+ )}
+ {collapsed && count > 0 && (
+
+ )}
+
+
+
+ );
+}
+
+function SubsectionGroup({ label, items, collapsed }: { label: string; items: NavItemDef[]; collapsed: boolean }) {
+ return (
+
+ {!collapsed && (
+
+ {label}
+
+ )}
+
+ {items.map((item) => (
+
+ ))}
+
+
+ );
+}
+
+function SidebarSection({ section }: { section: SectionDef }) {
+ const { state } = useSidebar();
+ const collapsed = state === "collapsed";
+ const location = useLocation();
+
+ const allItems = [
+ ...(section.items ?? []),
+ ...(section.subsections ?? []).flatMap((s) => s.items),
+ ];
+ const hasActive = allItems.some((i) => location.pathname === i.url);
+
+ return (
+
+
+
+ {!collapsed && {section.label} }
+ {!collapsed && }
+
+
+
+ {section.items && (
+
+ {section.items.map((item) => (
+
+ ))}
+
+ )}
+ {section.subsections?.map((sub) => (
+
+ ))}
+
+
+
+
+ );
+}
+
+export function AppSidebar() {
+ const { state } = useSidebar();
+ const collapsed = state === "collapsed";
+ const navigate = useNavigate();
+ const { isAdmin } = useAuth();
+
+ // Filter Admin & System section for non-admins
+ const visibleSections = sections.filter((s) => {
+ if (s.label === "Admin & System" && !isAdmin) return false;
+ return true;
+ });
+
+ const handleSignOut = async () => {
+ await supabase.auth.signOut();
+ navigate("/");
+ };
+
+ return (
+
+
+
+
+
+
+ {!collapsed && (
+
+ Community Cloud
+ Management Platform
+
+ )}
+
+
+
+
+
+
+
+
+
+ {coreItems.map((item) => (
+
+ ))}
+
+
+
+
+ {visibleSections.map((section) => (
+
+ ))}
+
+
+
+
+
+
+
+ {!collapsed && Sign Out }
+
+
+
+ );
+}
diff --git a/src/components/dashboard/AutopayManagementCard.tsx b/src/components/dashboard/AutopayManagementCard.tsx
new file mode 100644
index 0000000..7b464b4
--- /dev/null
+++ b/src/components/dashboard/AutopayManagementCard.tsx
@@ -0,0 +1,274 @@
+import { useState, useEffect } from "react";
+import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
+import { Button } from "@/components/ui/button";
+import { Badge } from "@/components/ui/badge";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog";
+import { CreditCard, Zap, Loader2, Play, XCircle, Users } from "lucide-react";
+import { supabase } from "@/integrations/supabase/client";
+import { toast } from "sonner";
+
+interface Enrollment {
+ id: string;
+ association_id: string;
+ owner_id: string | null;
+ unit_id: string | null;
+ payment_method_type: string;
+ is_active: boolean;
+ created_at: string;
+ owners?: { first_name: string; last_name: string } | null;
+ units?: { unit_number: string } | null;
+ associations?: { name: string } | null;
+}
+
+export function AutopayManagementCard() {
+ const [enrollments, setEnrollments] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [processDialogOpen, setProcessDialogOpen] = useState(false);
+ const [processing, setProcessing] = useState(false);
+ const [selectedAssociation, setSelectedAssociation] = useState("");
+ const [amountDollars, setAmountDollars] = useState("");
+ const [description, setDescription] = useState("Monthly Assessment");
+ const [associations, setAssociations] = useState<{ id: string; name: string }[]>([]);
+
+ useEffect(() => {
+ loadEnrollments();
+ }, []);
+
+ const loadEnrollments = async () => {
+ setLoading(true);
+ try {
+ const { data } = await supabase
+ .from("autopay_enrollments")
+ .select("*, owners(first_name, last_name), units(unit_number), associations(name)")
+ .eq("is_active", true)
+ .order("created_at", { ascending: false });
+
+ setEnrollments((data as Enrollment[]) || []);
+
+ // Get unique associations
+ const assocMap = new Map();
+ (data || []).forEach((e: any) => {
+ if (e.association_id && e.associations?.name) {
+ assocMap.set(e.association_id, e.associations.name);
+ }
+ });
+ setAssociations(Array.from(assocMap.entries()).map(([id, name]) => ({ id, name })));
+ } catch (err) {
+ console.error(err);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const cancelEnrollment = async (id: string) => {
+ const { error } = await supabase
+ .from("autopay_enrollments")
+ .update({ is_active: false })
+ .eq("id", id);
+
+ if (error) {
+ toast.error("Failed to cancel");
+ } else {
+ toast.success("Autopay cancelled");
+ loadEnrollments();
+ }
+ };
+
+ const processAutopay = async () => {
+ if (!selectedAssociation || !amountDollars) {
+ toast.error("Select association and enter amount");
+ return;
+ }
+
+ const amountCents = Math.round(parseFloat(amountDollars) * 100);
+ if (isNaN(amountCents) || amountCents <= 0) {
+ toast.error("Invalid amount");
+ return;
+ }
+
+ setProcessing(true);
+ try {
+ const session = (await supabase.auth.getSession()).data.session;
+ const projectId = import.meta.env.VITE_SUPABASE_PROJECT_ID;
+
+ const res = await fetch(
+ `https://${projectId}.supabase.co/functions/v1/process-autopay`,
+ {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${session?.access_token}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ association_id: selectedAssociation,
+ amount_cents: amountCents,
+ description,
+ }),
+ }
+ );
+
+ const data = await res.json();
+ if (data.error && !data.results) {
+ toast.error(data.error);
+ } else {
+ const s = data.summary;
+ toast.success(
+ `Processed ${s.total} payment(s): ${s.succeeded} succeeded, ${s.failed} failed`
+ );
+ setProcessDialogOpen(false);
+ }
+ } catch (err: any) {
+ toast.error(err.message || "Failed to process");
+ } finally {
+ setProcessing(false);
+ }
+ };
+
+ const filteredEnrollments = selectedAssociation
+ ? enrollments.filter((e) => e.association_id === selectedAssociation)
+ : enrollments;
+
+ return (
+ <>
+
+
+
+
+
+ Autopay Enrollments
+
+
+ {enrollments.length} active enrollment{enrollments.length !== 1 ? "s" : ""}
+
+
+
setProcessDialogOpen(true)}
+ disabled={enrollments.length === 0}
+ >
+ Process Payments
+
+
+
+
+ {loading ? (
+
+
+
+ ) : enrollments.length === 0 ? (
+
+
+
No autopay enrollments
+
+ ) : (
+
+ {enrollments.slice(0, 10).map((e) => (
+
+
+
+ {e.owners
+ ? `${e.owners.first_name} ${e.owners.last_name}`
+ : "Unknown Owner"}
+ {e.units ? ` — Unit ${e.units.unit_number}` : ""}
+
+
+
+
+ {e.payment_method_type === "us_bank_account" ? "ACH" : "Card"}
+
+
+ {e.associations?.name}
+
+
+
+
cancelEnrollment(e.id)}
+ >
+
+
+
+ ))}
+
+ )}
+
+
+
+ {/* Process Autopay Dialog */}
+
+
+
+ Process Autopay Charges
+
+ Charge all enrolled members for the selected association
+
+
+
+
+
Association
+
+
+
+
+
+ {associations.map((a) => (
+
+ {a.name}
+
+ ))}
+
+
+ {selectedAssociation && (
+
+ {filteredEnrollments.length} enrolled member{filteredEnrollments.length !== 1 ? "s" : ""} will be charged
+
+ )}
+
+
+ Amount ($)
+ setAmountDollars(e.target.value)}
+ />
+
+
+ Description
+ setDescription(e.target.value)}
+ placeholder="Monthly Assessment"
+ />
+
+
+
+ setProcessDialogOpen(false)}>
+ Cancel
+
+
+ {processing ? (
+
+ ) : (
+
+ )}
+ Charge All
+
+
+
+
+ >
+ );
+}
diff --git a/src/components/dashboard/BillApprovalsCard.tsx b/src/components/dashboard/BillApprovalsCard.tsx
new file mode 100644
index 0000000..3e7f25a
--- /dev/null
+++ b/src/components/dashboard/BillApprovalsCard.tsx
@@ -0,0 +1,366 @@
+import { useEffect, useState } from "react";
+import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
+import { ArrowRight, FileCheck, Inbox, Loader2, Receipt } from "lucide-react";
+import { supabase } from "@/integrations/supabase/client";
+import { useNavigate } from "react-router-dom";
+import { formatDistanceToNow } from "date-fns";
+
+interface PendingBill {
+ id: string;
+ source_invoice_id?: string | null;
+ invoice_number: string | null;
+ amount: number | null;
+ due_date: string | null;
+ created_at: string;
+ associations?: { name: string } | null;
+ vendor_name?: string | null;
+ source: "bill" | "invoice";
+}
+
+interface InboundEmail {
+ id: string;
+ subject: string | null;
+ from_email: string | null;
+ status: string;
+ created_at: string;
+ associations?: { name: string } | null;
+}
+
+type PendingBillRow = Omit;
+type PendingBillDbRow = Omit & {
+ notes?: string | null;
+ vendors?: { name: string | null } | null;
+ invoices?: {
+ vendor_name: string | null;
+ invoice_number: string | null;
+ amount: number | null;
+ due_date: string | null;
+ created_at: string | null;
+ } | null;
+};
+type BillApprovalStatusRow = { bill_id: string | null; status: string | null };
+
+const fmtMoney = (n: number | null) =>
+ typeof n === "number"
+ ? n.toLocaleString("en-US", { style: "currency", currency: "USD" })
+ : "—";
+
+const getBillDisplayKey = (bill: PendingBill) => {
+ if (bill.source === "invoice") return `invoice:${bill.id}`;
+ if (bill.source_invoice_id) return `invoice:${bill.source_invoice_id}`;
+
+ const associationName = bill.associations?.name || "";
+ const vendorName = bill.vendor_name || "";
+ const invoiceNumber = bill.invoice_number || "";
+ const amount = typeof bill.amount === "number" ? bill.amount.toFixed(2) : "";
+
+ if (associationName || vendorName || invoiceNumber || amount) {
+ return [associationName, vendorName, invoiceNumber, amount].join("|").toLowerCase();
+ }
+
+ return `bill:${bill.id}`;
+};
+
+const dedupeBillsForDisplay = (items: PendingBill[]) => {
+ const seen = new Set();
+ return items.filter((bill) => {
+ const key = getBillDisplayKey(bill);
+ if (seen.has(key)) return false;
+ seen.add(key);
+ return true;
+ });
+};
+
+const normalizePendingBill = (bill: PendingBillDbRow): PendingBill => ({
+ id: bill.id,
+ source_invoice_id: bill.source_invoice_id,
+ invoice_number: bill.invoices?.invoice_number ?? bill.invoice_number,
+ amount: bill.invoices?.amount ?? bill.amount,
+ due_date: bill.invoices?.due_date ?? bill.due_date,
+ created_at: bill.created_at,
+ associations: bill.associations,
+ vendor_name: bill.invoices?.vendor_name ?? bill.vendors?.name ?? bill.notes ?? null,
+ source: "bill",
+});
+
+export function BillApprovalsCard() {
+ const navigate = useNavigate();
+ const [tab, setTab] = useState<"approvals" | "inbox">("approvals");
+ const [bills, setBills] = useState([]);
+ const [pendingCount, setPendingCount] = useState(0);
+ const [emails, setEmails] = useState([]);
+ const [inboxCount, setInboxCount] = useState(0);
+ const [loading, setLoading] = useState(true);
+
+ useEffect(() => {
+ const fetchAll = async () => {
+ setLoading(true);
+
+ // Pending bills (source of truth — what BillApprovalsPage shows)
+ const [
+ { data: pendingBillsData, count: pendingBillsCount },
+ { data: allBillApprovals },
+ ] = await Promise.all([
+ supabase
+ .from("bills")
+ .select("id, source_invoice_id, invoice_number, amount, due_date, created_at, notes, associations(name), vendors(name), invoices:source_invoice_id(vendor_name, invoice_number, amount, due_date, created_at)", { count: "exact" })
+ .eq("status", "pending")
+ .order("created_at", { ascending: false })
+ .limit(10),
+ // Pull every approval row keyed to a pending bill so we can detect
+ // "stuck" bills (status=pending but every approval is already
+ // approved/denied). Those should not contribute to the badge.
+ supabase
+ .from("bill_approvals")
+ .select("bill_id, status")
+ .not("bill_id", "is", null),
+ ]);
+
+ const pendingBillRows = (pendingBillsData || []) as unknown as PendingBillDbRow[];
+ const billApprovalStatusRows = (allBillApprovals || []) as BillApprovalStatusRow[];
+ const collected: PendingBill[] = pendingBillRows.map(normalizePendingBill);
+
+ // Group approvals by bill_id and figure out which pending bills are
+ // truly actionable (have ≥1 pending approval, or none at all).
+ const approvalsByBill = new Map();
+ billApprovalStatusRows.forEach((a) => {
+ if (!a.bill_id) return;
+ const arr = approvalsByBill.get(a.bill_id) || [];
+ if (a.status) arr.push(a.status);
+ approvalsByBill.set(a.bill_id, arr);
+ });
+ const stuckBillIds: string[] = [];
+ let actionablePendingBills = 0;
+ pendingBillRows.forEach((b) => {
+ const statuses = approvalsByBill.get(b.id);
+ if (!statuses || statuses.length === 0) {
+ // No approvers configured — still counts as actionable
+ actionablePendingBills += 1;
+ return;
+ }
+ const hasPending = statuses.some((s) => s === "pending");
+ if (hasPending) {
+ actionablePendingBills += 1;
+ } else {
+ stuckBillIds.push(b.id);
+ }
+ });
+ // Use the full bills-pending count when only the first 10 were
+ // fetched, minus the stuck rows we identified in that page.
+ const totalPending = pendingBillsCount || 0;
+ const inferredActionable =
+ totalPending - (pendingBillsData || []).length + actionablePendingBills;
+ setPendingCount(Math.max(0, inferredActionable));
+
+ // Heal stuck pending bills in the background (no await) so the badge
+ // matches reality on the next render.
+ if (stuckBillIds.length > 0) {
+ const healed: { id: string; newStatus: string }[] = [];
+ stuckBillIds.forEach((id) => {
+ const statuses = approvalsByBill.get(id) || [];
+ const newStatus = statuses.some((s) => s === "denied") ? "denied" : "approved";
+ healed.push({ id, newStatus });
+ });
+ const approvedIds = healed.filter((h) => h.newStatus === "approved").map((h) => h.id);
+ const deniedIds = healed.filter((h) => h.newStatus === "denied").map((h) => h.id);
+ if (approvedIds.length) {
+ supabase
+ .from("bills")
+ .update({ status: "approved", updated_at: new Date().toISOString() })
+ .in("id", approvedIds);
+ }
+ if (deniedIds.length) {
+ supabase
+ .from("bills")
+ .update({ status: "denied", updated_at: new Date().toISOString() })
+ .in("id", deniedIds);
+ }
+ }
+
+ collected.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
+ const stuckSet = new Set(stuckBillIds);
+ const displayBills = dedupeBillsForDisplay(collected.filter((b) => !stuckSet.has(b.id)));
+ setBills(displayBills.slice(0, 5));
+
+ if ((pendingBillsData || []).length < 10) {
+ setPendingCount(displayBills.length);
+ }
+
+ // Inbound bill emails (inbox)
+ const [{ data: inboxData }, { count }] = await Promise.all([
+ supabase
+ .from("inbound_bill_emails")
+ .select("id, subject, from_email, status, created_at, associations(name)")
+ .neq("status", "rejected")
+ .neq("status", "processed")
+ .order("created_at", { ascending: false })
+ .limit(5),
+ supabase
+ .from("inbound_bill_emails")
+ .select("*", { count: "exact", head: true })
+ .eq("status", "pending"),
+ ]);
+ setEmails((inboxData || []) as InboundEmail[]);
+ setInboxCount(count || 0);
+
+ setLoading(false);
+ };
+
+ fetchAll();
+
+ const ch = supabase
+ .channel("bill-approvals-card")
+ .on("postgres_changes", { event: "*", schema: "public", table: "bills" }, fetchAll)
+ .on("postgres_changes", { event: "*", schema: "public", table: "bill_approvals" }, fetchAll)
+ .on("postgres_changes", { event: "*", schema: "public", table: "inbound_bill_emails" }, fetchAll)
+ .subscribe();
+
+ return () => {
+ supabase.removeChannel(ch);
+ };
+ }, []);
+
+ return (
+
+
+
+
+
+ Bill Approvals
+ {pendingCount + inboxCount > 0 && (
+
+ {pendingCount + inboxCount}
+
+ )}
+
+ Pending approvals & inbound bills
+
+
+ navigate(tab === "approvals" ? "/dashboard/bill-approvals-list" : "/dashboard/inbound-bills")
+ }
+ >
+ View all
+
+
+
+
+ setTab(v as "approvals" | "inbox")} className="h-full flex flex-col">
+
+
+ Approvals
+ {pendingCount > 0 && (
+
+ {pendingCount}
+
+ )}
+
+
+ Inbox
+ {inboxCount > 0 && (
+
+ {inboxCount}
+
+ )}
+
+
+
+
+ {loading ? (
+
+
+
+ ) : bills.length === 0 ? (
+
+
+
No pending approvals
+
+ ) : (
+
+ {bills.map((bill) => (
+
+ navigate(
+ bill.source === "invoice"
+ ? "/dashboard/bill-approvals-list"
+ : `/dashboard/bill-approvals/${bill.id}`,
+ )
+ }
+ >
+
+
+
+
+
+ {bill.vendor_name || bill.invoice_number || "Bill"}
+ {bill.invoice_number && bill.vendor_name && (
+ · #{bill.invoice_number}
+ )}
+
+
+ {fmtMoney(bill.amount)}
+
+ {bill.associations?.name || "—"} ·{" "}
+ {formatDistanceToNow(new Date(bill.created_at), { addSuffix: true })}
+
+
+
+
+ ))}
+
+ )}
+
+
+
+ {loading ? (
+
+
+
+ ) : emails.length === 0 ? (
+
+ ) : (
+
+ {emails.map((email) => (
+
navigate("/dashboard/inbound-bills")}
+ >
+
+
+
+
+
+ {email.subject || "(no subject)"}
+
+
+
+ {email.status}
+
+
+ {email.from_email || "Unknown"} ·{" "}
+ {formatDistanceToNow(new Date(email.created_at), { addSuffix: true })}
+
+
+
+
+ ))}
+
+ )}
+
+
+
+
+ );
+}
diff --git a/src/components/dashboard/ClientSidebar.tsx b/src/components/dashboard/ClientSidebar.tsx
new file mode 100644
index 0000000..de37562
--- /dev/null
+++ b/src/components/dashboard/ClientSidebar.tsx
@@ -0,0 +1,72 @@
+import { LogOut } from "lucide-react";
+import { NavLink } from "@/components/NavLink";
+import { useNavigate } from "react-router-dom";
+import { supabase } from "@/integrations/supabase/client";
+import {
+ Sidebar, SidebarContent, SidebarGroup, SidebarGroupContent,
+ SidebarMenu, SidebarMenuButton, SidebarMenuItem,
+ SidebarFooter, useSidebar,
+} from "@/components/ui/sidebar";
+import { Button } from "@/components/ui/button";
+import { clientFunctionItems } from "@/lib/portalVisibilityConfig";
+import { usePortalVisibility } from "@/hooks/usePortalVisibility";
+
+export function ClientSidebar() {
+ const { state } = useSidebar();
+ const collapsed = state === "collapsed";
+ const navigate = useNavigate();
+ const { canSee } = usePortalVisibility("client");
+ const visibleItems = clientFunctionItems.filter((item) => canSee(item.key));
+
+ const handleSignOut = async () => {
+ await supabase.auth.signOut();
+ navigate("/");
+ };
+
+ return (
+
+
+
+
+ A
+
+ {!collapsed && (
+
+ ACM
+ Client Portal
+
+ )}
+
+
+
+
+
+ {visibleItems.map((item) => (
+
+
+
+
+ {!collapsed && {item.title} }
+
+
+
+ ))}
+
+
+
+
+
+
+
+
+ {!collapsed && Sign Out }
+
+
+
+ );
+}
diff --git a/src/components/dashboard/CustomizableDashboard.tsx b/src/components/dashboard/CustomizableDashboard.tsx
new file mode 100644
index 0000000..a931a3a
--- /dev/null
+++ b/src/components/dashboard/CustomizableDashboard.tsx
@@ -0,0 +1,346 @@
+import { useState, useEffect, useCallback, useRef } from "react";
+import ResponsiveGridLayout from "react-grid-layout";
+import "react-grid-layout/css/styles.css";
+import "react-resizable/css/styles.css";
+import { supabase } from "@/integrations/supabase/client";
+import { useAuth } from "@/contexts/AuthContext";
+import { Card } from "@/components/ui/card";
+import { Button } from "@/components/ui/button";
+import { Badge } from "@/components/ui/badge";
+import {
+ Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription,
+} from "@/components/ui/dialog";
+import { Plus, Lock, Unlock, GripVertical, X, RotateCcw } from "lucide-react";
+import { useToast } from "@/hooks/use-toast";
+import { useIsMobile } from "@/hooks/use-mobile";
+import { CARD_CATALOG, getCardDef } from "./widgets/cardRegistry";
+import StatWidget from "./widgets/StatWidget";
+import TableWidget from "./widgets/TableWidget";
+import ChartWidget from "./widgets/ChartWidget";
+import QuickActionsWidget from "./widgets/QuickActionsWidget";
+import StatusUpdatesWidget from "./widgets/StatusUpdatesWidget";
+import {
+ type LayoutItem,
+ enrichDashboardLayout,
+ generateDashboardLayout,
+ normalizeSavedDashboardLayout,
+} from "./layoutUtils";
+
+const DEFAULT_CARDS = [
+ "stat_pending_arc", "stat_open_violations", "stat_collections", "stat_active_owners", "stat_open_requests",
+ "chart_community_health", "chart_recent_activity",
+ "table_tasks", "table_projects", "table_reminders",
+ "table_stripe", "table_form_inbox",
+ "status_updates",
+ "quick_actions",
+];
+
+function renderWidget(cardId: string) {
+ const def = getCardDef(cardId);
+ if (!def) return Unknown
;
+
+ switch (def.type) {
+ case "stat":
+ return ;
+ case "chart":
+ return ;
+ case "table":
+ return ;
+ case "quick_action":
+ return ;
+ case "status_updates":
+ return ;
+ default:
+ return null;
+ }
+}
+
+export function CustomizableDashboard() {
+ const { user } = useAuth();
+ const { toast } = useToast();
+ const isMobile = useIsMobile();
+ const [cards, setCards] = useState(DEFAULT_CARDS);
+ const [layouts, setLayouts] = useState(() => generateDashboardLayout(DEFAULT_CARDS));
+ const [isEditing, setIsEditing] = useState(false);
+ const [addDialogOpen, setAddDialogOpen] = useState(false);
+ const [loaded, setLoaded] = useState(false);
+ const [saving, setSaving] = useState(false);
+ const containerRef = useRef(null);
+ const [containerWidth, setContainerWidth] = useState(1200);
+
+ const saveLayoutItem = useCallback(async (newLayouts: LayoutItem[], newCards: string[]) => {
+ if (!user?.id) return;
+ setSaving(true);
+ try {
+ await (supabase.from("user_dashboard_layouts" as any) as any).upsert({
+ user_id: user.id,
+ layout: newLayouts,
+ cards: newCards,
+ updated_at: new Date().toISOString(),
+ }, { onConflict: "user_id" });
+ } catch (err) {
+ console.error("Failed to save layout:", err);
+ } finally {
+ setSaving(false);
+ }
+ }, [user?.id]);
+
+ useEffect(() => {
+ if (!loaded) return;
+
+ const el = containerRef.current;
+ if (!el) return;
+
+ const updateWidth = () => {
+ const nextWidth = Math.floor(el.getBoundingClientRect().width);
+ if (nextWidth > 0) {
+ setContainerWidth((current) => (current === nextWidth ? current : nextWidth));
+ }
+ };
+
+ updateWidth();
+
+ const ro = new ResizeObserver(() => {
+ window.requestAnimationFrame(updateWidth);
+ });
+
+ ro.observe(el);
+ window.addEventListener("resize", updateWidth);
+
+ return () => {
+ ro.disconnect();
+ window.removeEventListener("resize", updateWidth);
+ };
+ }, [loaded]);
+
+ useEffect(() => {
+ if (!user?.id) return;
+ const load = async () => {
+ const { data } = await supabase
+ .from("user_dashboard_layouts" as any)
+ .select("layout, cards")
+ .eq("user_id", user.id)
+ .maybeSingle();
+
+ if (data && (data as any).cards && (data as any).layout) {
+ const savedCards = (data as any).cards as string[];
+ const savedLayoutItems = (data as any).layout as LayoutItem[];
+ const { layout: normalizedLayouts, didUpgradeLegacyLayout } = normalizeSavedDashboardLayout(savedCards, savedLayoutItems);
+ setCards(savedCards);
+ setLayouts(normalizedLayouts);
+
+ if (didUpgradeLegacyLayout) {
+ void saveLayoutItem(normalizedLayouts, savedCards);
+ }
+ }
+ setLoaded(true);
+ };
+ load();
+ }, [user?.id, saveLayoutItem]);
+
+ const handleLayoutChange = useCallback((newLayoutItems: LayoutItem[]) => {
+ const enriched = enrichDashboardLayout(newLayoutItems);
+ setLayouts(enriched);
+ if (isEditing) {
+ saveLayoutItem(enriched, cards);
+ }
+ }, [isEditing, cards, saveLayoutItem]);
+
+ const addCard = (cardId: string) => {
+ if (cards.includes(cardId)) return;
+ const def = getCardDef(cardId);
+ if (!def) return;
+
+ const newCards = [...cards, cardId];
+ const maxY = layouts.reduce((max, l) => Math.max(max, l.y + l.h), 0);
+ const newLayoutItem: LayoutItem = {
+ i: cardId,
+ x: 0,
+ y: maxY,
+ w: def.defaultW,
+ h: def.defaultH,
+ minW: def.minW,
+ minH: def.minH,
+ };
+ const newLayouts = [...layouts, newLayoutItem];
+ setCards(newCards);
+ setLayouts(newLayouts);
+ saveLayoutItem(newLayouts, newCards);
+ setAddDialogOpen(false);
+ toast({ title: `Added "${def.title}" card` });
+ };
+
+ const removeCard = (cardId: string) => {
+ const newCards = cards.filter((c) => c !== cardId);
+ const newLayouts = layouts.filter((l) => l.i !== cardId);
+ setCards(newCards);
+ setLayouts(newLayouts);
+ saveLayoutItem(newLayouts, newCards);
+ toast({ title: "Card removed" });
+ };
+
+ const resetLayoutItem = () => {
+ const newCards = [...DEFAULT_CARDS];
+ const newLayouts = generateDashboardLayout(DEFAULT_CARDS);
+ setCards(newCards);
+ setLayouts(newLayouts);
+ saveLayoutItem(newLayouts, newCards);
+ toast({ title: "Dashboard reset to default" });
+ };
+
+ const availableCards = CARD_CATALOG.filter((c) => !cards.includes(c.id));
+
+ if (!loaded) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+
+
Dashboard
+
+ {isEditing ? "Drag cards to reposition, resize from corners" : "Click the lock icon to customize"}
+
+
+
+ {isEditing && (
+ <>
+
setAddDialogOpen(true)}>
+ Add Card
+
+
+ Reset
+
+ >
+ )}
+
setIsEditing(!isEditing)}
+ >
+ {isEditing ? : }
+ {isEditing ? "Done Editing" : "Customize"}
+
+ {saving &&
Saving... }
+
+
+
+
+ {isMobile ? (
+
+ {cards.map((cardId) => {
+ const layoutItem = layouts.find((item) => item.i === cardId);
+ const minHeight = ((layoutItem?.h ?? getCardDef(cardId)?.defaultH ?? 3) * 60) + 12;
+
+ return (
+
+ {isEditing && (
+
+
+
+ {getCardDef(cardId)?.title}
+
+
{
+ e.stopPropagation();
+ removeCard(cardId);
+ }}
+ className="rounded p-0.5 text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
+ >
+
+
+
+ )}
+
+ {renderWidget(cardId)}
+
+
+ );
+ })}
+
+ ) : (
+
+ {cards.map((cardId) => (
+
+
+ {isEditing && (
+
+
+
+ {getCardDef(cardId)?.title}
+
+
{
+ e.stopPropagation();
+ removeCard(cardId);
+ }}
+ className="rounded p-0.5 text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
+ >
+
+
+
+ )}
+
+ {renderWidget(cardId)}
+
+
+
+ ))}
+
+ )}
+
+
+
+
+
+ Add Dashboard Card
+ Choose a card to add to your dashboard
+
+
+ {availableCards.length === 0 ? (
+
All cards have been added
+ ) : (
+ availableCards.map((card) => (
+
addCard(card.id)}
+ >
+
+
+
+
+
{card.title}
+
{card.description}
+
+ {card.type.replace("_", " ")}
+
+ ))
+ )}
+
+
+
+
+ );
+}
diff --git a/src/components/dashboard/DashboardHeader.tsx b/src/components/dashboard/DashboardHeader.tsx
new file mode 100644
index 0000000..0034ec5
--- /dev/null
+++ b/src/components/dashboard/DashboardHeader.tsx
@@ -0,0 +1,467 @@
+import { useState, useEffect, useRef, useCallback } from "react";
+import { useNavigate } from "react-router-dom";
+import { supabase } from "@/integrations/supabase/client";
+import { SidebarTrigger } from "@/components/ui/sidebar";
+import { Input } from "@/components/ui/input";
+import { Button } from "@/components/ui/button";
+import { Search, Settings, DollarSign, Users, Phone, Plus, User, Eye, LogOut, Shield, Building2, Home, FileText, AlertTriangle, Loader2, Database, MessageCircle, Landmark, ExternalLink } from "lucide-react";
+import { Avatar, AvatarFallback } from "@/components/ui/avatar";
+import { CallLogDialog } from "@/components/CallLogDialog";
+import { NotificationBell } from "@/components/NotificationBell";
+import { MessagesIconButton } from "@/components/MessagesIconButton";
+import { TimerPopover } from "@/components/dashboard/TimerPopover";
+import { DateCalculatorPopover } from "@/components/dashboard/DateCalculatorPopover";
+import UnitLedgerTransactionForm from "@/components/unit-profile/UnitLedgerTransactionForm";
+import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
+import {
+ DropdownMenu, DropdownMenuContent, DropdownMenuItem,
+ DropdownMenuSeparator, DropdownMenuTrigger
+} from "@/components/ui/dropdown-menu";
+import type { Enums } from "@/integrations/supabase/types";
+import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
+import { Label } from "@/components/ui/label";
+import { useToast } from "@/hooks/use-toast";
+
+interface DashboardHeaderProps {
+ userId?: string;
+ userEmail?: string;
+ fullName?: string;
+ roles?: Enums<"app_role">[];
+}
+
+interface SearchResult {
+ id: string;
+ type: "owner" | "unit" | "association" | "violation" | "invoice" | "shortcut";
+ title: string;
+ subtitle: string;
+ route: string;
+}
+
+const typeIcons: Record = {
+ owner: ,
+ unit: ,
+ association: ,
+ violation: ,
+ invoice: ,
+ shortcut: ,
+};
+
+export function DashboardHeader({ userEmail, fullName, roles = [], userId }: DashboardHeaderProps) {
+ const navigate = useNavigate();
+ const displayName = fullName || userEmail || "User";
+ const initials = fullName
+ ? fullName.split(" ").map((n) => n[0]).join("").toUpperCase().slice(0, 2)
+ : userEmail
+ ? userEmail.slice(0, 2).toUpperCase()
+ : "U";
+
+ const primaryRole = roles[0];
+ const roleLabel = primaryRole
+ ? primaryRole.charAt(0).toUpperCase() + primaryRole.slice(1)
+ : undefined;
+
+ const { toast } = useToast();
+ const [callLogOpen, setCallLogOpen] = useState(false);
+ const [chargePaymentOpen, setChargePaymentOpen] = useState(false);
+ const [profileOpen, setProfileOpen] = useState(false);
+ const [profileForm, setProfileForm] = useState({ full_name: fullName || "", email: userEmail || "" });
+ const [associations, setAssociations] = useState<{ id: string; name: string }[]>([]);
+
+ // Global search state
+ const [searchQuery, setSearchQuery] = useState("");
+ const [searchResults, setSearchResults] = useState([]);
+ const [searchLoading, setSearchLoading] = useState(false);
+ const [showResults, setShowResults] = useState(false);
+ const [selectedIndex, setSelectedIndex] = useState(-1);
+ const searchRef = useRef(null);
+ const debounceRef = useRef>();
+
+ useEffect(() => {
+ supabase.from("associations").select("id, name, zoho_organization_id").eq("status", "active").order("name").then(({ data }) => {
+ setAssociations(data || []);
+ });
+ }, []);
+
+ useEffect(() => {
+ setProfileForm({ full_name: fullName || "", email: userEmail || "" });
+ }, [fullName, userEmail]);
+
+ // Close dropdown on outside click
+ useEffect(() => {
+ const handleClickOutside = (e: MouseEvent) => {
+ if (searchRef.current && !searchRef.current.contains(e.target as Node)) {
+ setShowResults(false);
+ }
+ };
+ document.addEventListener("mousedown", handleClickOutside);
+ return () => document.removeEventListener("mousedown", handleClickOutside);
+ }, []);
+
+ const performSearch = useCallback(async (query: string) => {
+ if (query.length < 2) {
+ setSearchResults([]);
+ setShowResults(false);
+ return;
+ }
+ setSearchLoading(true);
+ setShowResults(true);
+
+ const normalizedQuery = query.trim().toLowerCase();
+ const term = `%${query}%`;
+ const results: SearchResult[] = [];
+
+ try {
+ const [ownersRes, unitsRes, assocsRes, violationsRes, invoicesRes] = await Promise.all([
+ supabase
+ .from("owners")
+ .select("id, first_name, last_name, associations(name), units(account_number)")
+ .or(`first_name.ilike.${term},last_name.ilike.${term}`)
+ .limit(5),
+ supabase
+ .from("units")
+ .select("id, unit_number, address, associations(name)")
+ .or(`unit_number.ilike.${term},address.ilike.${term}`)
+ .limit(5),
+ supabase
+ .from("associations")
+ .select("id, name, address")
+ .ilike("name", term)
+ .limit(5),
+ supabase
+ .from("violations")
+ .select("id, violation_type, status, associations(name)")
+ .or(`violation_type.ilike.${term},status.ilike.${term}`)
+ .limit(5),
+ supabase
+ .from("invoices")
+ .select("id, invoice_number, owner_name, associations(name)")
+ .or(`invoice_number.ilike.${term},owner_name.ilike.${term}`)
+ .limit(5),
+ ]);
+
+ (ownersRes.data || []).forEach((o: any) => {
+ results.push({
+ id: o.id,
+ type: "owner",
+ title: `${o.first_name || ""} ${o.last_name || ""}`.trim() || "Unknown",
+ subtitle: `${o.associations?.name || "No association"} • ${o.units?.account_number || "No acct"}`,
+ route: `/dashboard/owner-roster/${o.id}`,
+ });
+ });
+
+ (unitsRes.data || []).forEach((u: any) => {
+ results.push({
+ id: u.id,
+ type: "unit",
+ title: `Unit ${u.unit_number}`,
+ subtitle: `${u.associations?.name || ""} • ${u.address || ""}`.trim(),
+ route: `/dashboard/units/${u.id}`,
+ });
+ });
+
+ (assocsRes.data || []).forEach((a: any) => {
+ results.push({
+ id: a.id,
+ type: "association",
+ title: a.name,
+ subtitle: a.address || "No address",
+ route: `/dashboard/associations/${a.id}`,
+ });
+ });
+
+ (violationsRes.data || []).forEach((v: any) => {
+ results.push({
+ id: v.id,
+ type: "violation",
+ title: v.violation_type || "Violation",
+ subtitle: `${v.associations?.name || ""} • ${v.status || ""}`,
+ route: `/dashboard/violations`,
+ });
+ });
+
+ (invoicesRes.data || []).forEach((inv: any) => {
+ results.push({
+ id: inv.id,
+ type: "invoice",
+ title: `Invoice ${inv.invoice_number}`,
+ subtitle: `${inv.owner_name || ""} • ${inv.associations?.name || ""}`,
+ route: `/dashboard/invoices`,
+ });
+ });
+
+ const shouldShowMigrationShortcut = roles.includes("admin") && (
+ normalizedQuery.includes("migration") ||
+ normalizedQuery.includes("migrate") ||
+ normalizedQuery.includes("data import") ||
+ normalizedQuery.includes("import data")
+ );
+
+ if (shouldShowMigrationShortcut) {
+ results.unshift({
+ id: "data-migration-shortcut",
+ type: "shortcut",
+ title: "Data Migration",
+ subtitle: "Open the admin migration tool",
+ route: "/dashboard/data-migration",
+ });
+ }
+ } catch (err) {
+ console.error("Search error:", err);
+ }
+
+ setSearchResults(results);
+ setSelectedIndex(-1);
+ setSearchLoading(false);
+ }, [roles]);
+
+ const handleSearchChange = (e: React.ChangeEvent) => {
+ const val = e.target.value;
+ setSearchQuery(val);
+ if (debounceRef.current) clearTimeout(debounceRef.current);
+ debounceRef.current = setTimeout(() => performSearch(val), 300);
+ };
+
+ const handleSelectResult = (result: SearchResult) => {
+ setShowResults(false);
+ setSearchQuery("");
+ setSearchResults([]);
+ navigate(result.route);
+ };
+
+ const handleSearchKeyDown = (e: React.KeyboardEvent) => {
+ if (e.key === "Enter") {
+ e.preventDefault();
+ if (!showResults || searchResults.length === 0) return;
+ handleSelectResult(searchResults[selectedIndex >= 0 ? selectedIndex : 0]);
+ return;
+ }
+
+ if (!showResults || searchResults.length === 0) return;
+
+ if (e.key === "ArrowDown") {
+ e.preventDefault();
+ setSelectedIndex((prev) => Math.min(prev + 1, searchResults.length - 1));
+ } else if (e.key === "ArrowUp") {
+ e.preventDefault();
+ setSelectedIndex((prev) => Math.max(prev - 1, 0));
+ } else if (e.key === "Escape") {
+ setShowResults(false);
+ }
+ };
+
+ const handleSaveProfile = async () => {
+ if (!userId) return;
+ const { error } = await supabase
+ .from("profiles")
+ .update({ full_name: profileForm.full_name })
+ .eq("user_id", userId);
+ if (error) {
+ toast({ title: "Error", description: "Failed to update profile", variant: "destructive" });
+ } else {
+ toast({ title: "Profile updated" });
+ setProfileOpen(false);
+ }
+ };
+
+ const handleSignOut = async () => {
+ await supabase.auth.signOut();
+ navigate("/");
+ };
+
+ return (
+ <>
+
+
+
+
+
+ {/* Profile Dialog */}
+
+
+
+ Update Profile
+
+
+
+ Full Name
+ setProfileForm(prev => ({ ...prev, full_name: e.target.value }))}
+ className="h-8 text-[13px]"
+ />
+
+
+
Email
+
+
Email cannot be changed here.
+
+
+ setProfileOpen(false)}>Cancel
+ Save Changes
+
+
+
+
+ >
+ );
+}
diff --git a/src/components/dashboard/DashboardTopNav.tsx b/src/components/dashboard/DashboardTopNav.tsx
new file mode 100644
index 0000000..7bfa40e
--- /dev/null
+++ b/src/components/dashboard/DashboardTopNav.tsx
@@ -0,0 +1,867 @@
+import { useState, useEffect, useRef, useCallback } from "react";
+import { useNavigate, useLocation } from "react-router-dom";
+import { supabase } from "@/integrations/supabase/client";
+import { Input } from "@/components/ui/input";
+import { Button } from "@/components/ui/button";
+import {
+ Search, Settings, DollarSign, Users, Phone, Plus, User, Eye, LogOut, Shield,
+ Building2, Home, FileText, AlertTriangle, Loader2, Database, MessageCircle,
+ Landmark, ExternalLink, LayoutDashboard, Gavel, Scale, Receipt, BarChart3,
+ Calendar, FileBox, FilePlus, FileSignature, FolderKanban, CheckSquare,
+ ClipboardList, Megaphone, Mail, MailOpen, AtSign, ServerCog, Bot, CalendarOff,
+ FileSearch, UserCog, Car, StickyNote, ListChecks, CreditCard, Vote, UserCheck,
+ PieChart, BellRing, FileCode, Send, ImageIcon, Bell, Menu, ChevronDown, FileEdit, Clock, BookOpen, Upload, Inbox, ClipboardCheck, KeyRound, Briefcase, Calculator
+} from "lucide-react";
+import { Avatar, AvatarFallback } from "@/components/ui/avatar";
+import { CallLogDialog } from "@/components/CallLogDialog";
+import { NotificationBell } from "@/components/NotificationBell";
+import { MessagesIconButton } from "@/components/MessagesIconButton";
+import { EmailInboxDrawer } from "@/components/dashboard/EmailInboxDrawer";
+import { TimerPopover } from "@/components/dashboard/TimerPopover";
+import { DateCalculatorPopover } from "@/components/dashboard/DateCalculatorPopover";
+import UnitLedgerTransactionForm from "@/components/unit-profile/UnitLedgerTransactionForm";
+import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
+import {
+ DropdownMenu, DropdownMenuContent, DropdownMenuItem,
+ DropdownMenuSeparator, DropdownMenuTrigger,
+ DropdownMenuSub, DropdownMenuSubTrigger, DropdownMenuSubContent,
+} from "@/components/ui/dropdown-menu";
+import { Badge } from "@/components/ui/badge";
+import type { Enums } from "@/integrations/supabase/types";
+import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
+import { Label } from "@/components/ui/label";
+import { useToast } from "@/hooks/use-toast";
+import { useAuth } from "@/contexts/AuthContext";
+import { useTickerCounts } from "@/contexts/TickerContext";
+import { cn } from "@/lib/utils";
+import sidebarLogo from "@/assets/favicon-logo.png";
+
+interface DashboardTopNavProps {
+ userId?: string;
+ userEmail?: string;
+ fullName?: string;
+ roles?: Enums<"app_role">[];
+}
+
+interface SearchResult {
+ id: string;
+ type: "owner" | "unit" | "association" | "violation" | "invoice" | "shortcut";
+ title: string;
+ subtitle: string;
+ route: string;
+}
+
+const typeIcons: Record = {
+ owner: ,
+ unit: ,
+ association: ,
+ violation: ,
+ invoice: ,
+ shortcut: ,
+};
+
+/* ── Navigation Data ── */
+
+type NavItemDef = { title: string; url: string; icon: React.ElementType; tickerKey?: string };
+
+interface MegaMenuSection {
+ label: string;
+ triggerIcon: React.ElementType;
+ columns: { heading: string; items: NavItemDef[] }[];
+}
+
+const megaMenuSections: MegaMenuSection[] = [
+ {
+ label: "Community",
+ triggerIcon: Building2,
+ columns: [
+ {
+ heading: "Management",
+ items: [
+ { title: "Associations", url: "/dashboard/associations", icon: Building2 },
+ { title: "Directory", url: "/dashboard/directory", icon: Home },
+ ],
+ },
+ {
+ heading: "Board & Governance",
+ items: [
+ { title: "Board Members", url: "/dashboard/board-members", icon: UserCheck },
+ { title: "Board Resources", url: "/dashboard/board-resources", icon: BookOpen },
+ { title: "Board Votes", url: "/dashboard/board-votes", icon: Vote, tickerKey: "board_votes" },
+ { title: "Elections", url: "/dashboard/elections", icon: Vote },
+ ],
+ },
+ {
+ heading: "Compliance",
+ items: [
+ { title: "Violations", url: "/dashboard/violations", icon: AlertTriangle },
+ { title: "ARC Applications", url: "/dashboard/arc-applications", icon: Gavel, tickerKey: "arc_applications" },
+ { title: "Parking", url: "/dashboard/parking", icon: Car },
+ { title: "RV / Boat Lots", url: "/dashboard/rv-boat-lots", icon: Car },
+ ],
+ },
+ {
+ heading: "Requests & Cases",
+ items: [
+ { title: "Homeowner Requests", url: "/dashboard/homeowner-requests", icon: MessageCircle, tickerKey: "homeowner_requests" },
+ { title: "Client Requests", url: "/dashboard/client-requests", icon: ClipboardList },
+ { title: "Legal Matters", url: "/dashboard/legal-matters", icon: Scale },
+ ],
+ },
+ ],
+ },
+ {
+ label: "Operations",
+ triggerIcon: FolderKanban,
+ columns: [
+ {
+ heading: "Projects & Tasks",
+ items: [
+ { title: "Projects", url: "/dashboard/projects", icon: FolderKanban, tickerKey: "projects" },
+ { title: "Tasks", url: "/dashboard/tasks", icon: CheckSquare, tickerKey: "tasks" },
+ { title: "Checklists", url: "/dashboard/checklists", icon: ListChecks },
+ ],
+ },
+ {
+ heading: "Scheduling",
+ items: [
+ { title: "Calendar", url: "/dashboard/calendar", icon: Calendar },
+ { title: "Reminders", url: "/dashboard/reminders", icon: Bell, tickerKey: "reminders" },
+ { title: "Blocked Dates", url: "/dashboard/blocked-dates", icon: CalendarOff },
+ ],
+ },
+ {
+ heading: "Field & Updates",
+ items: [
+ { title: "Inspections", url: "/dashboard/inspections", icon: ClipboardList },
+ { title: "Communication Log", url: "/dashboard/call-log", icon: Phone },
+ { title: "Owner Updates", url: "/dashboard/owner-updates", icon: StickyNote },
+ { title: "Status Updates", url: "/dashboard/status-updates", icon: BarChart3 },
+ { title: "Association Reports", url: "/dashboard/reports", icon: BarChart3 },
+ ],
+ },
+ {
+ heading: "Vendors",
+ items: [
+ { title: "Vendors", url: "/dashboard/vendors", icon: Users },
+ { title: "Bids & Quotes", url: "/dashboard/bids-quotes", icon: Scale },
+ { title: "Bill Approvals", url: "/dashboard/bill-approvals", icon: Shield, tickerKey: "bill_approvals" },
+ ],
+ },
+ ],
+ },
+ {
+ label: "Finance",
+ triggerIcon: DollarSign,
+ columns: [
+ {
+ heading: "Overview",
+ items: [
+ { title: "Financial Overview", url: "/dashboard/financial-overview", icon: PieChart },
+ { title: "Financial Reports", url: "/dashboard/financial-reports", icon: BarChart3 },
+ { title: "Recent Ledger Updates", url: "/dashboard/recent-ledger-updates", icon: Clock },
+ { title: "Budget Management", url: "/dashboard/budget-management", icon: BarChart3 },
+ ],
+ },
+ {
+ heading: "Receivables",
+ items: [
+ { title: "Outstanding Balances", url: "/dashboard/outstanding-balances", icon: DollarSign },
+ { title: "Bulk Charges", url: "/dashboard/bulk-charges", icon: DollarSign },
+ { title: "Collections", url: "/dashboard/collections", icon: DollarSign },
+ { title: "Payments", url: "/dashboard/payments", icon: CreditCard },
+ { title: "Estoppels", url: "/dashboard/estoppels", icon: FileText },
+ { title: "Payment Plans", url: "/dashboard/payment-plans", icon: DollarSign },
+ ],
+ },
+ {
+ heading: "Payables",
+ items: [
+ { title: "Payables", url: "/dashboard/payables", icon: Receipt },
+ ],
+ },
+ ],
+ },
+ {
+ label: "Communication",
+ triggerIcon: MessageCircle,
+ columns: [
+ {
+ heading: "Messaging",
+ items: [
+ { title: "Messages", url: "/dashboard/messages", icon: MessageCircle },
+ { title: "Compose Email", url: "/dashboard/compose-email", icon: Send },
+ ],
+ },
+ {
+ heading: "Notifications",
+ items: [
+ { title: "Notify Board", url: "/dashboard/notify-board", icon: Megaphone },
+ { title: "Notify Owners", url: "/dashboard/notify-owners", icon: BellRing },
+ ],
+ },
+ {
+ heading: "Email Management",
+ items: [
+ { title: "Email History", url: "/dashboard/email-history", icon: Mail },
+ { title: "Email Templates", url: "/dashboard/email-templates", icon: FileCode },
+ { title: "Email Senders", url: "/dashboard/email-senders", icon: AtSign },
+ { title: "Email Routing", url: "/dashboard/email-routing", icon: MailOpen },
+ ],
+ },
+ ],
+ },
+ {
+ label: "Documents",
+ triggerIcon: FileBox,
+ columns: [
+ {
+ heading: "Content",
+ items: [
+ { title: "Documents", url: "/dashboard/documents", icon: FileBox },
+ { title: "Forms & Letters", url: "/dashboard/forms-letters", icon: FilePlus },
+ { title: "Avria Sign", url: "/dashboard/avria-sign", icon: FileSignature },
+ { title: "Announcements", url: "/dashboard/announcements", icon: Megaphone },
+ { title: "Media Library", url: "/dashboard/media", icon: ImageIcon },
+ { title: "Collab Docs", url: "/dashboard/collaborative-docs", icon: FileEdit },
+ ],
+ },
+ ],
+ },
+];
+
+const adminSection: MegaMenuSection = {
+ label: "Admin",
+ triggerIcon: UserCog,
+ columns: [
+ {
+ heading: "System",
+ items: [
+ { title: "User Management", url: "/dashboard/user-management", icon: UserCog },
+ { title: "Sign-up Codes", url: "/dashboard/signup-codes", icon: KeyRound },
+ { title: "Time Tracking", url: "/dashboard/time-tracking", icon: Clock },
+ { title: "Bulk Updates", url: "/dashboard/bulk-updates", icon: Upload },
+ { title: "Compliance Checklists", url: "/dashboard/compliance-checklists", icon: ClipboardCheck },
+ { title: "Data Migration", url: "/dashboard/data-migration", icon: Database },
+ { title: "Migration Fields", url: "/dashboard/migration-fields", icon: Database },
+ ],
+ },
+ {
+ heading: "Accounting Setup",
+ items: [
+ { title: "Bank Accounts", url: "/dashboard/bank-accounts", icon: Landmark },
+ { title: "Chart of Accounts", url: "/dashboard/chart-of-accounts", icon: BookOpen },
+ ],
+ },
+ {
+ heading: "Billing & Invoicing",
+ items: [
+ { title: "Billable Expenses", url: "/dashboard/billable-expenses", icon: Receipt },
+ { title: "Invoice Clients", url: "/dashboard/invoice-clients", icon: FileText },
+ { title: "Client Invoices", url: "/dashboard/client-invoices", icon: FileText },
+ { title: "Invoice Tracking", url: "/dashboard/invoice-tracking", icon: FileSearch },
+ ],
+ },
+ ],
+};
+
+/* ── Mega Menu Panel ── */
+
+function MegaMenuPanel({ section, onNavigate }: { section: MegaMenuSection; onNavigate: (url: string) => void }) {
+ const location = useLocation();
+ const counts = useTickerCounts();
+
+ return (
+
+ {section.columns.map((col) => (
+
+
+ {col.heading}
+
+ {col.items.map((item) => {
+ const isActive = location.pathname === item.url;
+ const count = item.tickerKey ? counts[item.tickerKey as keyof typeof counts] : 0;
+ return (
+
onNavigate(item.url)}
+ className={cn(
+ "flex items-center gap-2.5 w-full rounded-md px-2 py-1.5 text-[13px] transition-colors text-left",
+ "hover:bg-accent/60",
+ isActive ? "bg-primary/10 text-primary font-medium" : "text-foreground/80"
+ )}
+ >
+
+ {item.title}
+ {count > 0 && (
+
+ {count > 99 ? "99+" : count}
+
+ )}
+
+ );
+ })}
+
+ ))}
+
+ );
+}
+
+/* ── Mobile Nav Drawer ── */
+
+function MobileNavDrawer({ sections, onNavigate, open, onClose }: {
+ sections: MegaMenuSection[];
+ onNavigate: (url: string) => void;
+ open: boolean;
+ onClose: () => void;
+}) {
+ const location = useLocation();
+ const counts = useTickerCounts();
+
+ if (!open) return null;
+
+ return (
+ <>
+
+
+
+ Navigation
+ ✕
+
+
+
{ onNavigate("/dashboard"); onClose(); }}
+ className={cn(
+ "flex items-center gap-2.5 w-full px-4 py-2 text-[13px] hover:bg-accent/60",
+ location.pathname === "/dashboard" && "bg-primary/10 text-primary font-medium"
+ )}
+ >
+ Dashboard
+
+ {sections.map((section) => (
+
+
+ {section.label}
+
+ {section.columns.flatMap((col) => col.items).map((item) => {
+ const isActive = location.pathname === item.url;
+ const count = item.tickerKey ? counts[item.tickerKey as keyof typeof counts] : 0;
+ return (
+
{ onNavigate(item.url); onClose(); }}
+ className={cn(
+ "flex items-center gap-2.5 w-full px-4 py-1.5 text-[13px] hover:bg-accent/60",
+ isActive ? "bg-primary/10 text-primary font-medium" : "text-foreground/80"
+ )}
+ >
+
+ {item.title}
+ {count > 0 && (
+
+ {count > 99 ? "99+" : count}
+
+ )}
+
+ );
+ })}
+
+ ))}
+
+
+ >
+ );
+}
+
+/* ── Main Component ── */
+
+export function DashboardTopNav({ userEmail, fullName, roles = [], userId }: DashboardTopNavProps) {
+ const navigate = useNavigate();
+ const location = useLocation();
+ const { isAdmin, isStaff, realIsAdmin, setViewAsRole } = useAuth();
+ const { toast } = useToast();
+
+ const displayName = fullName || userEmail || "User";
+ const initials = fullName
+ ? fullName.split(" ").map((n) => n[0]).join("").toUpperCase().slice(0, 2)
+ : userEmail ? userEmail.slice(0, 2).toUpperCase() : "U";
+ const primaryRole = roles[0];
+ const roleLabel = primaryRole ? primaryRole.charAt(0).toUpperCase() + primaryRole.slice(1) : undefined;
+
+ const [callLogOpen, setCallLogOpen] = useState(false);
+ const [chargePaymentOpen, setChargePaymentOpen] = useState(false);
+ const [profileOpen, setProfileOpen] = useState(false);
+ const [profileForm, setProfileForm] = useState({ full_name: fullName || "", email: userEmail || "" });
+ const [associations, setAssociations] = useState<{ id: string; name: string }[]>([]);
+ const [mobileNavOpen, setMobileNavOpen] = useState(false);
+
+ // Search state
+ const [searchQuery, setSearchQuery] = useState("");
+ const [searchResults, setSearchResults] = useState([]);
+ const [searchLoading, setSearchLoading] = useState(false);
+ const [showResults, setShowResults] = useState(false);
+ const [selectedIndex, setSelectedIndex] = useState(-1);
+ const searchRef = useRef(null);
+ const debounceRef = useRef>();
+
+ // Mega menu hover state
+ const [activeMenu, setActiveMenu] = useState(null);
+ const menuTimeoutRef = useRef>();
+
+ const visibleSections = isAdmin
+ ? [...megaMenuSections, adminSection]
+ : megaMenuSections;
+
+ useEffect(() => {
+ supabase.from("associations").select("id, name, zoho_organization_id").eq("status", "active").order("name").then(({ data }) => {
+ setAssociations(data || []);
+ });
+ }, []);
+
+ useEffect(() => {
+ setProfileForm({ full_name: fullName || "", email: userEmail || "" });
+ }, [fullName, userEmail]);
+
+ // Close search on outside click
+ useEffect(() => {
+ const handleClickOutside = (e: MouseEvent) => {
+ if (searchRef.current && !searchRef.current.contains(e.target as Node)) {
+ setShowResults(false);
+ }
+ };
+ document.addEventListener("mousedown", handleClickOutside);
+ return () => document.removeEventListener("mousedown", handleClickOutside);
+ }, []);
+
+ // Close mega menu on route change
+ useEffect(() => {
+ setActiveMenu(null);
+ }, [location.pathname]);
+
+ const handleMenuEnter = (label: string) => {
+ if (menuTimeoutRef.current) clearTimeout(menuTimeoutRef.current);
+ setActiveMenu(label);
+ };
+
+ const handleMenuLeave = () => {
+ menuTimeoutRef.current = setTimeout(() => setActiveMenu(null), 150);
+ };
+
+ const handleNavigate = (url: string) => {
+ setActiveMenu(null);
+ navigate(url);
+ };
+
+ // Search logic (same as DashboardHeader)
+ const performSearch = useCallback(async (query: string) => {
+ if (query.length < 2) {
+ setSearchResults([]);
+ setShowResults(false);
+ return;
+ }
+ setSearchLoading(true);
+ setShowResults(true);
+ const term = `%${query}%`;
+ const results: SearchResult[] = [];
+ try {
+ const [ownersRes, unitsRes, assocsRes, violationsRes, invoicesRes] = await Promise.all([
+ supabase.from("owners").select("id, first_name, last_name, associations(name), units(account_number)").neq("status", "archived").or(`first_name.ilike.${term},last_name.ilike.${term}`).limit(5),
+ supabase.from("units").select("id, unit_number, address, associations(name)").or(`unit_number.ilike.${term},address.ilike.${term}`).limit(5),
+ supabase.from("associations").select("id, name, address").ilike("name", term).limit(5),
+ supabase.from("violations").select("id, violation_type, status, associations(name)").or(`violation_type.ilike.${term},status.ilike.${term}`).limit(5),
+ supabase.from("invoices").select("id, invoice_number, owner_name, associations(name)").or(`invoice_number.ilike.${term},owner_name.ilike.${term}`).limit(5),
+ ]);
+ (ownersRes.data || []).forEach((o: any) => results.push({ id: o.id, type: "owner", title: `${o.first_name || ""} ${o.last_name || ""}`.trim() || "Unknown", subtitle: `${o.associations?.name || "No association"} • ${o.units?.account_number || "No acct"}`, route: `/dashboard/owner-roster/${o.id}` }));
+ (unitsRes.data || []).forEach((u: any) => results.push({ id: u.id, type: "unit", title: `Unit ${u.unit_number}`, subtitle: `${u.associations?.name || ""} • ${u.address || ""}`.trim(), route: `/dashboard/units/${u.id}` }));
+ (assocsRes.data || []).forEach((a: any) => results.push({ id: a.id, type: "association", title: a.name, subtitle: a.address || "No address", route: `/dashboard/associations/${a.id}` }));
+ (violationsRes.data || []).forEach((v: any) => results.push({ id: v.id, type: "violation", title: v.violation_type || "Violation", subtitle: `${v.associations?.name || ""} • ${v.status || ""}`, route: `/dashboard/violations` }));
+ (invoicesRes.data || []).forEach((inv: any) => results.push({ id: inv.id, type: "invoice", title: `Invoice ${inv.invoice_number}`, subtitle: `${inv.owner_name || ""} • ${inv.associations?.name || ""}`, route: `/dashboard/invoices` }));
+
+ const normalizedQuery = query.trim().toLowerCase();
+ if (roles.includes("admin") && (normalizedQuery.includes("migration") || normalizedQuery.includes("migrate") || normalizedQuery.includes("data import"))) {
+ results.unshift({ id: "data-migration-shortcut", type: "shortcut", title: "Data Migration", subtitle: "Open the admin migration tool", route: "/dashboard/data-migration" });
+ }
+ } catch (err) { console.error("Search error:", err); }
+ setSearchResults(results);
+ setSelectedIndex(-1);
+ setSearchLoading(false);
+ }, [roles]);
+
+ const handleSearchChange = (e: React.ChangeEvent) => {
+ const val = e.target.value;
+ setSearchQuery(val);
+ if (debounceRef.current) clearTimeout(debounceRef.current);
+ debounceRef.current = setTimeout(() => performSearch(val), 300);
+ };
+
+ const handleSelectResult = (result: SearchResult) => {
+ setShowResults(false);
+ setSearchQuery("");
+ setSearchResults([]);
+ navigate(result.route);
+ };
+
+ const handleSearchKeyDown = (e: React.KeyboardEvent) => {
+ if (e.key === "Enter") {
+ e.preventDefault();
+ if (!showResults || searchResults.length === 0) return;
+ handleSelectResult(searchResults[selectedIndex >= 0 ? selectedIndex : 0]);
+ return;
+ }
+ if (!showResults || searchResults.length === 0) return;
+ if (e.key === "ArrowDown") { e.preventDefault(); setSelectedIndex((p) => Math.min(p + 1, searchResults.length - 1)); }
+ else if (e.key === "ArrowUp") { e.preventDefault(); setSelectedIndex((p) => Math.max(p - 1, 0)); }
+ else if (e.key === "Escape") { setShowResults(false); }
+ };
+
+ const handleSaveProfile = async () => {
+ if (!userId) return;
+ const { error } = await supabase.from("profiles").update({ full_name: profileForm.full_name }).eq("user_id", userId);
+ if (error) { toast({ title: "Error", description: "Failed to update profile", variant: "destructive" }); }
+ else { toast({ title: "Profile updated" }); setProfileOpen(false); }
+ };
+
+ const handleSignOut = async () => {
+ await supabase.auth.signOut();
+ navigate("/");
+ };
+
+ return (
+ <>
+
+
+ {/* Mobile drawer */}
+ navigate(url)}
+ open={mobileNavOpen}
+ onClose={() => setMobileNavOpen(false)}
+ />
+
+
+
+
+ {/* Profile Dialog */}
+
+
+
+ Update Profile
+
+
+
+ Full Name
+ setProfileForm(prev => ({ ...prev, full_name: e.target.value }))} className="h-8 text-[13px]" />
+
+
+
Email
+
+
Email cannot be changed here.
+
+
+ setProfileOpen(false)}>Cancel
+ Save Changes
+
+
+
+
+ >
+ );
+}
diff --git a/src/components/dashboard/DateCalculatorPopover.tsx b/src/components/dashboard/DateCalculatorPopover.tsx
new file mode 100644
index 0000000..b2014e8
--- /dev/null
+++ b/src/components/dashboard/DateCalculatorPopover.tsx
@@ -0,0 +1,148 @@
+import { useState, useMemo } from "react";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
+import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
+import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
+import { CalendarClock } from "lucide-react";
+import { addDays, addMonths, addYears, differenceInDays, differenceInBusinessDays, format, parseISO } from "date-fns";
+
+const todayStr = () => format(new Date(), "yyyy-MM-dd");
+
+export function DateCalculatorPopover() {
+ // Difference tab
+ const [from, setFrom] = useState(todayStr());
+ const [to, setTo] = useState(todayStr());
+
+ // Add/subtract tab
+ const [base, setBase] = useState(todayStr());
+ const [amount, setAmount] = useState(30);
+ const [unit, setUnit] = useState<"days" | "months" | "years">("days");
+ const [direction, setDirection] = useState<"add" | "subtract">("add");
+
+ const diff = useMemo(() => {
+ try {
+ const f = parseISO(from);
+ const t = parseISO(to);
+ return {
+ days: differenceInDays(t, f),
+ business: differenceInBusinessDays(t, f),
+ weeks: Math.floor(differenceInDays(t, f) / 7),
+ };
+ } catch {
+ return { days: 0, business: 0, weeks: 0 };
+ }
+ }, [from, to]);
+
+ const result = useMemo(() => {
+ try {
+ const b = parseISO(base);
+ const n = direction === "subtract" ? -amount : amount;
+ const d = unit === "days" ? addDays(b, n) : unit === "months" ? addMonths(b, n) : addYears(b, n);
+ return format(d, "EEEE, MMMM d, yyyy");
+ } catch {
+ return "—";
+ }
+ }, [base, amount, unit, direction]);
+
+ return (
+
+
+
+
+
+
+
+
+
+ Date Calculator
+
+
+
+
Date Calculator
+
+
+
+ Difference
+ Add / Subtract
+
+
+
+
+ From
+ setFrom(e.target.value)} className="h-8 text-[12px]" />
+
+
+ To
+ setTo(e.target.value)} className="h-8 text-[12px]" />
+
+
+
+ Calendar days
+ {diff.days}
+
+
+ Business days
+ {diff.business}
+
+
+ Weeks
+ {diff.weeks}
+
+
+
+
+
+
+ Start date
+ setBase(e.target.value)} className="h-8 text-[12px]" />
+
+
+
+ Action
+ setDirection(v as any)}>
+
+
+ Add
+ Subtract
+
+
+
+
+ Amount
+ setAmount(parseInt(e.target.value) || 0)}
+ className="h-8 text-[12px]"
+ />
+
+
+ Unit
+ setUnit(v as any)}>
+
+
+ Days
+ Months
+ Years
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/dashboard/EmailInboxDrawer.tsx b/src/components/dashboard/EmailInboxDrawer.tsx
new file mode 100644
index 0000000..505ed7c
--- /dev/null
+++ b/src/components/dashboard/EmailInboxDrawer.tsx
@@ -0,0 +1,368 @@
+import { useMemo, useState } from "react";
+import { formatDistanceToNow } from "date-fns";
+import { AlertCircle, ArrowLeft, CheckCircle, Inbox, Loader2, Mail, MessageCircle, RefreshCw, Send, UserPlus, X } from "lucide-react";
+import { supabase } from "@/integrations/supabase/client";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Textarea } from "@/components/ui/textarea";
+import { useToast } from "@/hooks/use-toast";
+import { cn } from "@/lib/utils";
+
+type EmailLog = {
+ id: string;
+ message_id: string | null;
+ template_name: string | null;
+ recipient_email: string | null;
+ status: string | null;
+ error_message: string | null;
+ created_at: string;
+};
+
+type InboxTicket = {
+ id: string;
+ source_id: string;
+ source_type: string;
+ title: string;
+ submitter_name: string | null;
+ submitter_email: string | null;
+ summary: string | null;
+ status: string;
+ created_at: string;
+};
+
+type TicketComment = {
+ id: string;
+ comment: string;
+ created_at: string;
+ user_id: string;
+ profile?: { full_name: string | null; email: string | null } | null;
+};
+
+const statusClass: Record = {
+ sent: "bg-success/10 text-success border-success/20",
+ dlq: "bg-destructive/10 text-destructive border-destructive/20",
+ failed: "bg-destructive/10 text-destructive border-destructive/20",
+ bounced: "bg-destructive/10 text-destructive border-destructive/20",
+ complained: "bg-destructive/10 text-destructive border-destructive/20",
+ suppressed: "bg-warning/10 text-warning border-warning/20",
+ pending: "bg-muted text-muted-foreground border-border",
+};
+
+const ticketStatusClass: Record = {
+ new: "bg-primary/10 text-primary border-primary/20",
+ in_progress: "bg-warning/10 text-warning border-warning/20",
+ closed: "bg-success/10 text-success border-success/20",
+};
+
+function latestByMessageId(rows: EmailLog[]) {
+ const seen = new Map();
+ rows.forEach((row) => {
+ const key = row.message_id || row.id;
+ const current = seen.get(key);
+ if (!current || new Date(row.created_at) > new Date(current.created_at)) seen.set(key, row);
+ });
+ return Array.from(seen.values()).sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
+}
+
+export function EmailInboxDrawer() {
+ const { toast } = useToast();
+ const [open, setOpen] = useState(false);
+ const [loading, setLoading] = useState(false);
+ const [commentsLoading, setCommentsLoading] = useState(false);
+ const [sendingReply, setSendingReply] = useState(false);
+ const [emails, setEmails] = useState([]);
+ const [tickets, setTickets] = useState([]);
+ const [selectedTicket, setSelectedTicket] = useState(null);
+ const [comments, setComments] = useState([]);
+ const [reply, setReply] = useState("");
+
+ const fetchInbox = async () => {
+ setLoading(true);
+ const [emailRes, ticketRes] = await Promise.all([
+ (supabase as any)
+ .from("email_send_log")
+ .select("id, message_id, template_name, recipient_email, status, error_message, created_at")
+ .order("created_at", { ascending: false })
+ .limit(200),
+ supabase
+ .from("form_inbox")
+ .select("id, source_id, source_type, title, submitter_name, submitter_email, summary, status, created_at")
+ .in("source_type", ["homeowner_ticket", "registration_request"])
+ .neq("status", "archived")
+ .order("created_at", { ascending: false })
+ .limit(50),
+ ]);
+
+ if (emailRes.error || ticketRes.error) {
+ setEmails([]);
+ setTickets([]);
+ toast({ variant: "destructive", title: "Inbox activity unavailable", description: emailRes.error?.message || ticketRes.error?.message });
+ } else {
+ setEmails(latestByMessageId((emailRes.data || []) as EmailLog[]));
+ setTickets((ticketRes.data || []) as InboxTicket[]);
+ }
+ setLoading(false);
+ };
+
+ const stats = useMemo(() => ({
+ total: emails.length + tickets.length,
+ sent: emails.filter((email) => email.status === "sent").length,
+ failed: tickets.length + emails.filter((email) => ["dlq", "failed", "bounced", "complained"].includes(email.status || "")).length,
+ }), [emails, tickets]);
+
+ const toggleOpen = () => {
+ setOpen((current) => {
+ const next = !current;
+ if (next) void fetchInbox();
+ return next;
+ });
+ };
+
+ const fetchComments = async (ticketId: string) => {
+ setCommentsLoading(true);
+ const { data, error } = await supabase
+ .from("entity_comments")
+ .select("id, comment, created_at, user_id")
+ .eq("entity_type", "homeowner_request")
+ .eq("entity_id", ticketId)
+ .order("created_at", { ascending: true });
+
+ if (error) {
+ setComments([]);
+ toast({ variant: "destructive", title: "Could not load replies", description: error.message });
+ } else {
+ const userIds = [...new Set((data || []).map((comment) => comment.user_id).filter(Boolean))];
+ const { data: profiles } = userIds.length
+ ? await supabase.from("profiles").select("user_id, full_name, email").in("user_id", userIds)
+ : { data: [] };
+ const profileMap = new Map((profiles || []).map((profile) => [profile.user_id, profile]));
+ setComments((data || []).map((comment) => ({ ...comment, profile: profileMap.get(comment.user_id) || null })) as TicketComment[]);
+ }
+ setCommentsLoading(false);
+ };
+
+ const openTicket = async (ticket: InboxTicket) => {
+ const nextTicket = ticket.status === "new" ? { ...ticket, status: "in_progress" } : ticket;
+ setSelectedTicket(nextTicket);
+ setReply("");
+ if (ticket.source_type === "registration_request") {
+ if (ticket.status === "new") {
+ setTickets((current) => current.map((item) => item.id === ticket.id ? nextTicket : item));
+ void supabase.from("form_inbox").update({ status: "in_progress", reviewed_at: new Date().toISOString() } as any).eq("id", ticket.id);
+ }
+ setComments([]);
+ return;
+ }
+ if (ticket.status === "new") {
+ setTickets((current) => current.map((item) => item.id === ticket.id ? nextTicket : item));
+ void supabase.from("form_inbox").update({ status: "in_progress", reviewed_at: new Date().toISOString() } as any).eq("id", ticket.id);
+ }
+ await fetchComments(ticket.source_id);
+ };
+
+ const closeTicket = async () => {
+ if (!selectedTicket) return;
+ const { error: inboxError } = await supabase
+ .from("form_inbox")
+ .update({ status: "closed", reviewed_at: new Date().toISOString() } as any)
+ .eq("id", selectedTicket.id);
+ const { error: ticketError } = selectedTicket.source_type === "homeowner_ticket"
+ ? await supabase.from("homeowner_requests").update({ status: "closed" } as any).eq("id", selectedTicket.source_id)
+ : { error: null };
+
+ if (inboxError || ticketError) {
+ toast({ variant: "destructive", title: "Ticket not closed", description: inboxError?.message || ticketError?.message });
+ return;
+ }
+
+ setTickets((current) => current.map((item) => item.id === selectedTicket.id ? { ...item, status: "closed" } : item));
+ setSelectedTicket((current) => current ? { ...current, status: "closed" } : current);
+ toast({ title: "Ticket closed" });
+ };
+
+ const sendReply = async () => {
+ if (!selectedTicket || !reply.trim()) return;
+ setSendingReply(true);
+ const { data: userRes } = await supabase.auth.getUser();
+ const userId = userRes.user?.id;
+ if (!userId) {
+ setSendingReply(false);
+ toast({ variant: "destructive", title: "Please sign in to reply" });
+ return;
+ }
+
+ const message = reply.trim();
+ const { error } = await supabase.from("entity_comments").insert({
+ entity_type: "homeowner_request",
+ entity_id: selectedTicket.source_id,
+ user_id: userId,
+ comment: message,
+ });
+
+ if (error) {
+ toast({ variant: "destructive", title: "Reply not sent", description: error.message });
+ } else {
+ setReply("");
+ toast({ title: "Reply sent", description: "The homeowner has been notified." });
+ if (selectedTicket.submitter_email) {
+ void supabase.functions.invoke("send-transactional-email", {
+ body: {
+ templateName: "ticket-response",
+ recipientEmail: selectedTicket.submitter_email,
+ idempotencyKey: `ticket-response-${selectedTicket.source_id}-${Date.now()}`,
+ templateData: {
+ ticketTitle: selectedTicket.title,
+ message,
+ homeownerName: selectedTicket.submitter_name || "Homeowner",
+ },
+ },
+ });
+ }
+ await fetchComments(selectedTicket.source_id);
+ }
+ setSendingReply(false);
+ };
+
+ return (
+ <>
+
+
+ Open in-app mail inbox
+
+
+ {open && (
+
+ )}
+ >
+ );
+}
\ No newline at end of file
diff --git a/src/components/dashboard/ExecutiveDashboard.tsx b/src/components/dashboard/ExecutiveDashboard.tsx
new file mode 100644
index 0000000..4a8a125
--- /dev/null
+++ b/src/components/dashboard/ExecutiveDashboard.tsx
@@ -0,0 +1,389 @@
+import { useState, useEffect } from "react";
+import { motion } from "framer-motion";
+import {
+ FileText, ShieldAlert, TrendingUp, Clock, ArrowRight, ArrowUpRight,
+ Plus, CheckSquare, FolderOpen, Bell, Megaphone, MessageCircle,
+ PieChart as PieChartIcon, DollarSign, BarChart3, Users,
+ Activity, Shield, CalendarClock, Layers
+} from "lucide-react";
+import { StripeTransactionsCard } from "./StripeTransactionsCard";
+import { FormInboxCard } from "./FormInboxCard";
+import { AutopayManagementCard } from "./AutopayManagementCard";
+import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
+import { Button } from "@/components/ui/button";
+import { Badge } from "@/components/ui/badge";
+import { useNavigate } from "react-router-dom";
+import { supabase } from "@/integrations/supabase/client";
+import { format, isPast, isToday, isTomorrow } from "date-fns";
+import { htmlToPlainText } from "@/lib/htmlTextUtils";
+
+const priorityColors: Record = {
+ high: "cc-badge-danger",
+ medium: "cc-badge-warning",
+ low: "cc-badge-info",
+};
+
+const statusColors: Record = {
+ active: "cc-badge-success",
+ pending: "cc-badge-warning",
+ completed: "cc-badge-neutral",
+ overdue: "cc-badge-danger",
+};
+
+export function ExecutiveDashboard() {
+ const navigate = useNavigate();
+ const [stats, setStats] = useState({
+ pendingARC: 0, openViolations: 0, delinquentUnits: 0, totalOwners: 0, openRequests: 0,
+ });
+ const [tasks, setTasks] = useState([]);
+ const [projects, setProjects] = useState([]);
+ const [reminders, setReminders] = useState([]);
+ const [announcements, setAnnouncements] = useState([]);
+ const [loading, setLoading] = useState(true);
+
+ useEffect(() => { fetchAll(); }, []);
+
+ const fetchAll = async () => {
+ try {
+ const [arcRes, violRes, unitsRes, ownersRes, reqRes, tasksRes, projectsRes, remindersRes, announcementsRes] = await Promise.all([
+ supabase.from("arc_applications").select("id", { count: "exact", head: true }).eq("status", "submitted"),
+ supabase.from("violations").select("id", { count: "exact", head: true }).eq("status", "open"),
+ supabase.from("units").select("id", { count: "exact", head: true }).eq("status", "delinquent"),
+ supabase.from("owners").select("id", { count: "exact", head: true }).eq("status", "active"),
+ supabase.from("client_requests").select("id", { count: "exact", head: true }).eq("status", "open"),
+ supabase.from("tasks").select("id, title, status, priority, due_date, assigned_to").neq("status", "completed").order("due_date", { ascending: true }).limit(8),
+ supabase.from("projects").select("id, title, status, priority, due_date, associations(name)").neq("status", "completed").order("due_date", { ascending: true }).limit(8),
+ supabase.from("reminders").select("id, title, due_date, status, description").neq("status", "dismissed").order("due_date", { ascending: true }).limit(8),
+ supabase.from("announcements").select("id, title, content, created_at, visibility, pinned").eq("status", "active").order("pinned", { ascending: false }).order("created_at", { ascending: false }).limit(3),
+ ]);
+ setStats({
+ pendingARC: arcRes.count || 0,
+ openViolations: violRes.count || 0,
+ delinquentUnits: unitsRes.count || 0,
+ totalOwners: ownersRes.count || 0,
+ openRequests: reqRes.count || 0,
+ });
+ setTasks(tasksRes.data || []);
+ setProjects(projectsRes.data || []);
+ setReminders(remindersRes.data || []);
+ setAnnouncements(announcementsRes.data || []);
+ } catch (err) {
+ console.error("Error fetching dashboard data:", err);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const kpiCards = [
+ { title: "Pending ARC", desc: "Needs review", value: stats.pendingARC, icon: FileText, color: "text-primary", bg: "bg-primary/10", path: "/dashboard/arc-applications" },
+ { title: "Open Violations", desc: "Active cases", value: stats.openViolations, icon: ShieldAlert, color: "text-destructive", bg: "bg-destructive/10", path: "/dashboard/violations" },
+ { title: "Collections", desc: "Delinquent units", value: stats.delinquentUnits, icon: DollarSign, color: "text-warning", bg: "bg-warning/10", path: "/dashboard/collections" },
+ { title: "Active Owners", desc: "Total registered", value: stats.totalOwners, icon: Users, color: "text-primary", bg: "bg-primary/5", path: "/dashboard/owner-roster" },
+ { title: "Open Requests", desc: "Awaiting response", value: stats.openRequests, icon: MessageCircle, color: "text-purple-600", bg: "bg-purple-500/10", path: "/dashboard/client-requests" },
+ ];
+
+ const formatDue = (dateStr: string | null) => {
+ if (!dateStr) return null;
+ const d = new Date(dateStr);
+ if (isToday(d)) return "Today";
+ if (isTomorrow(d)) return "Tomorrow";
+ if (isPast(d)) return "Overdue";
+ return format(d, "MMM d");
+ };
+
+ const dueBadge = (dateStr: string | null) => {
+ if (!dateStr) return null;
+ const d = new Date(dateStr);
+ const overdue = isPast(d) && !isToday(d);
+ const today = isToday(d);
+ return (
+
+ {formatDue(dateStr)}
+
+ );
+ };
+
+ const container = { hidden: { opacity: 0 }, show: { opacity: 1, transition: { staggerChildren: 0.05 } } };
+ const item = { hidden: { opacity: 0, y: 10 }, show: { opacity: 1, y: 0 } };
+
+ // Derived health metrics
+ const complianceRate = stats.totalOwners > 0 ? Math.round(((stats.totalOwners - stats.openViolations) / stats.totalOwners) * 100) : 100;
+ const delinquencyRate = stats.totalOwners > 0 ? Math.round((stats.delinquentUnits / stats.totalOwners) * 100) : 0;
+
+ return (
+
+ {/* Hero Banner */}
+
+
+
+
+
Executive Dashboard
+
Overview of all association operations, tasks, and compliance metrics.
+
+
+
navigate("/dashboard/reports")}>
+ Reports
+
+
navigate("/dashboard/arc-applications")}>
+ New Action
+
+
+
+
+
+ {/* KPI Cards */}
+
+ {kpiCards.map((card, idx) => (
+
+ navigate(card.path)}>
+
+
+ {loading ? "—" : card.value}
+ {card.title}
+ {card.desc}
+
+
+
+ ))}
+
+
+ {/* Admin Cards Row */}
+
+
+ {/* Community Health + Recent Activity */}
+
+ {/* Community Health */}
+
+
+
+ Community Health
+
+ Key performance indicators across your portfolio
+
+
+
+ } color="bg-emerald-50 text-emerald-700" />
+ } color="bg-amber-50 text-amber-700" />
+ } color="bg-primary/10 text-primary" />
+ } color="bg-purple-50 text-purple-700" />
+
+
+
+
+ {/* Recent Activity */}
+
+
+
+ Recent Activity
+
+ Latest events across all associations
+
+
+
+ {[
+ { text: "ARC Request Submitted", time: "2h ago", icon:
},
+ { text: "Violation Notice Sent", time: "Today", icon:
},
+ { text: "Payment Posted", time: "Today", icon:
},
+ { text: "New Owner Added", time: "Yesterday", icon:
},
+ ].map((entry, i) => (
+
+
{entry.icon}
+
+
{entry.time}
+
+ ))}
+
+
+ View All Activity
+
+
+
+
+
+ {/* Announcements */}
+ {announcements.length > 0 && (
+
+
+
+
Announcements
+
{announcements.length}
+
navigate("/dashboard/announcements")}>
+ View All
+
+
+
+ {announcements.map((ann) => (
+
navigate("/dashboard/announcements")}>
+
+
+
{ann.title}
+
{htmlToPlainText(ann.content)}
+
+
{format(new Date(ann.created_at), "MMM d")}
+
+
+ ))}
+
+
+ )}
+
+ {/* Ticket Panels */}
+
+
}
+ title="Tasks"
+ count={tasks.length}
+ items={tasks}
+ renderItem={(t: any) => (
+
navigate("/dashboard/tasks")}>
+
+
{t.title}
+
+ {t.priority && {t.priority} }
+ {t.assigned_to && {t.assigned_to} }
+
+
+ {dueBadge(t.due_date)}
+
+ )}
+ viewAllPath="/dashboard/tasks"
+ viewAllLabel="View All Tasks"
+ navigate={navigate}
+ />
+
+
}
+ title="Projects"
+ count={projects.length}
+ items={projects}
+ renderItem={(p: any) => (
+
navigate(`/dashboard/projects/${p.id}`)}>
+
+
{p.title}
+
+ {p.status}
+ {p.associations?.name && {p.associations.name} }
+
+
+ {dueBadge(p.due_date)}
+
+ )}
+ viewAllPath="/dashboard/projects"
+ viewAllLabel="View All Projects"
+ navigate={navigate}
+ />
+
+
}
+ title="Reminders"
+ count={reminders.length}
+ items={reminders}
+ renderItem={(r: any) => (
+
navigate("/dashboard/reminders")}>
+
+
{r.title}
+ {r.description &&
{r.description}
}
+
+ {dueBadge(r.due_date)}
+
+ )}
+ viewAllPath="/dashboard/reminders"
+ viewAllLabel="View All Reminders"
+ navigate={navigate}
+ />
+
+
+ {/* Quick Actions */}
+
+
+
+ Quick Actions
+
+ Common administrative tasks
+
+
+
+ {[
+ { label: "Generate Violation Notice", path: "/dashboard/violations", icon: ShieldAlert },
+ { label: "Review ARC Applications", path: "/dashboard/arc-applications", icon: FileText },
+ { label: "Add New Homeowner", path: "/dashboard/owner-roster", icon: Users },
+ { label: "Run Financial Report", path: "/dashboard/financial-reports", icon: DollarSign },
+ ].map((action) => (
+
navigate(action.path)}
+ >
+
+ {action.label}
+
+ ))}
+
+
+
+
+ );
+}
+
+/* ── Health Metric ────────────────────── */
+function HealthMetric({ label, value, icon, color }: { label: string; value: string; icon: React.ReactNode; color: string }) {
+ return (
+
+ );
+}
+
+/* ── Reusable Ticket Panel ────────────────────── */
+function TicketPanel({ icon, title, count, items, renderItem, viewAllPath, viewAllLabel, navigate }: {
+ icon: React.ReactNode;
+ title: string;
+ count: number;
+ items: any[];
+ renderItem: (item: any) => React.ReactNode;
+ viewAllPath: string;
+ viewAllLabel: string;
+ navigate: (path: string) => void;
+}) {
+ return (
+
+
+
+ {icon}
+ {title}
+
+ {count}
+
+
+ {items.length === 0 ? (
+ No {title.toLowerCase()}
+ ) : (
+
+ {items.map(renderItem)}
+
+ )}
+ navigate(viewAllPath)}>
+ {viewAllLabel}
+
+
+
+ );
+}
diff --git a/src/components/dashboard/FormInboxCard.tsx b/src/components/dashboard/FormInboxCard.tsx
new file mode 100644
index 0000000..1f88f94
--- /dev/null
+++ b/src/components/dashboard/FormInboxCard.tsx
@@ -0,0 +1,148 @@
+import { useState, useEffect } from "react";
+import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Inbox, ArrowRight, FileText, ShieldAlert, MessageCircle, Loader2, UserPlus } from "lucide-react";
+import { supabase } from "@/integrations/supabase/client";
+import { useNavigate } from "react-router-dom";
+import { formatDistanceToNow } from "date-fns";
+
+const sourceIcons: Record = {
+ public_form: FileText,
+ violation_response: ShieldAlert,
+ client_request: MessageCircle,
+ homeowner_ticket: MessageCircle,
+ registration_request: UserPlus,
+};
+
+const sourceLabels: Record = {
+ public_form: "Form",
+ violation_response: "Violation",
+ client_request: "Request",
+ homeowner_ticket: "Ticket",
+ registration_request: "Registration",
+};
+
+const statusStyles: Record = {
+ new: "cc-badge-danger",
+ in_progress: "cc-badge-warning",
+ resolved: "cc-badge-success",
+ archived: "cc-badge-neutral",
+};
+
+interface InboxItem {
+ id: string;
+ source_type: string;
+ title: string;
+ submitter_name: string | null;
+ submitter_email: string | null;
+ status: string;
+ created_at: string;
+}
+
+export function FormInboxCard() {
+ const [items, setItems] = useState([]);
+ const [newCount, setNewCount] = useState(0);
+ const [loading, setLoading] = useState(true);
+ const navigate = useNavigate();
+
+ useEffect(() => {
+ const fetchItems = async () => {
+ const [{ data, error }, { count }] = await Promise.all([
+ supabase
+ .from("form_inbox")
+ .select("id, source_type, title, submitter_name, submitter_email, status, created_at")
+ .neq("status", "archived")
+ .order("created_at", { ascending: false })
+ .limit(3),
+ supabase
+ .from("form_inbox")
+ .select("*", { count: "exact", head: true })
+ .eq("status", "new"),
+ ]);
+
+ if (!error && data) setItems(data);
+ setNewCount(count || 0);
+ setLoading(false);
+ };
+
+ fetchItems();
+
+ const channel = supabase
+ .channel("form-inbox-dashboard")
+ .on("postgres_changes", { event: "*", schema: "public", table: "form_inbox" }, () => {
+ fetchItems();
+ })
+ .subscribe();
+
+ return () => { supabase.removeChannel(channel); };
+ }, []);
+
+ return (
+
+
+
+
+
+ Form Inbox
+ {newCount > 0 && (
+
+ {newCount}
+
+ )}
+
+ Submissions & responses
+
+
navigate("/dashboard/form-inbox")}
+ >
+ View all
+
+
+
+
+ {loading ? (
+
+
+
+ ) : items.length === 0 ? (
+
+ ) : (
+
+ {items.map((item) => {
+ const Icon = sourceIcons[item.source_type] || FileText;
+ return (
+
navigate("/dashboard/form-inbox")}
+ >
+
+
+
+
+
{item.title}
+
+
+ {item.status === "in_progress" ? "In Progress" : item.status.charAt(0).toUpperCase() + item.status.slice(1)}
+
+
+ {item.submitter_name || item.submitter_email || "Anonymous"} · {formatDistanceToNow(new Date(item.created_at), { addSuffix: true })}
+
+
+
+
+ );
+ })}
+
+ )}
+
+
+ );
+}
diff --git a/src/components/dashboard/HomeownerSidebar.tsx b/src/components/dashboard/HomeownerSidebar.tsx
new file mode 100644
index 0000000..0ba3f56
--- /dev/null
+++ b/src/components/dashboard/HomeownerSidebar.tsx
@@ -0,0 +1,80 @@
+import {
+ LayoutDashboard, FileText, Receipt, DollarSign, CreditCard, UserCircle, LogOut, ShieldAlert
+} from "lucide-react";
+import { NavLink } from "@/components/NavLink";
+import { useNavigate } from "react-router-dom";
+import { supabase } from "@/integrations/supabase/client";
+import {
+ Sidebar, SidebarContent, SidebarGroup, SidebarGroupContent,
+ SidebarMenu, SidebarMenuButton, SidebarMenuItem,
+ SidebarFooter, useSidebar,
+} from "@/components/ui/sidebar";
+import { Button } from "@/components/ui/button";
+
+const homeownerItems = [
+ { title: "Home", url: "/homeowner", icon: LayoutDashboard },
+ { title: "My Profile", url: "/homeowner/profile", icon: UserCircle },
+ { title: "Ledger", url: "/homeowner/ledger", icon: Receipt },
+ { title: "Documents", url: "/homeowner/documents", icon: FileText },
+ { title: "Statements", url: "/homeowner/statements", icon: FileText },
+ { title: "Payments", url: "/homeowner/payments", icon: CreditCard },
+ { title: "Violations", url: "/homeowner/violations", icon: ShieldAlert },
+];
+
+export function HomeownerSidebar() {
+ const { state } = useSidebar();
+ const collapsed = state === "collapsed";
+ const navigate = useNavigate();
+
+ const handleSignOut = async () => {
+ await supabase.auth.signOut();
+ navigate("/");
+ };
+
+ return (
+
+
+
+
+ A
+
+ {!collapsed && (
+
+ ACM
+ Homeowner Portal
+
+ )}
+
+
+
+
+
+ {homeownerItems.map((item) => (
+
+
+
+
+ {!collapsed && {item.title} }
+
+
+
+ ))}
+
+
+
+
+
+
+
+
+ {!collapsed && Sign Out }
+
+
+
+ );
+}
diff --git a/src/components/dashboard/StripeTransactionsCard.tsx b/src/components/dashboard/StripeTransactionsCard.tsx
new file mode 100644
index 0000000..03237b9
--- /dev/null
+++ b/src/components/dashboard/StripeTransactionsCard.tsx
@@ -0,0 +1,176 @@
+import { useState, useEffect } from "react";
+import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
+import { Badge } from "@/components/ui/badge";
+import { CreditCard, ArrowUpRight, Loader2 } from "lucide-react";
+import { supabase } from "@/integrations/supabase/client";
+import { formatDateTimeCompactEST } from "@/lib/timezoneUtils";
+
+interface Transaction {
+ id: string;
+ amount: number;
+ currency: string;
+ status: string;
+ created: number;
+ description: string | null;
+ customer_name: string | null;
+ customer_email: string | null;
+ payment_method: string | null;
+}
+
+interface Account {
+ id: string;
+ name: string;
+ hasSecretKey: boolean;
+}
+
+export function StripeTransactionsCard() {
+ const [transactions, setTransactions] = useState([]);
+ const [accounts, setAccounts] = useState([]);
+ const AVRIA_MAPPING_ID = "04c535a6-51a1-49d0-bad0-3e0ea264cfa1";
+ const [selectedAccount, setSelectedAccount] = useState(AVRIA_MAPPING_ID);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ const fetchTransactions = async (mappingId?: string) => {
+ setLoading(true);
+ setError(null);
+ try {
+ const params: Record = { limit: "3" };
+ if (mappingId && mappingId !== "all") {
+ params.mapping_id = mappingId;
+ }
+
+ const queryString = new URLSearchParams(params).toString();
+ const { data: sessionData, error: sessionError } = await supabase.auth.getSession();
+ const accessToken = sessionData.session?.access_token;
+
+ if (sessionError || !accessToken) {
+ setError("Please sign in as an admin or manager to view Stripe transactions.");
+ setTransactions([]);
+ return;
+ }
+
+ const res = await fetch(
+ `${import.meta.env.VITE_SUPABASE_URL}/functions/v1/stripe-transactions?${queryString}`,
+ {
+ headers: {
+ Authorization: `Bearer ${accessToken}`,
+ "Content-Type": "application/json",
+ },
+ }
+ );
+
+ const result = await res.json().catch(() => ({}));
+
+ if (!res.ok || result.error) {
+ setError(result.error || `Unable to load Stripe transactions (${res.status})`);
+ setTransactions([]);
+ return;
+ }
+
+ setTransactions(result.transactions || []);
+ if (result.accounts && accounts.length === 0) {
+ setAccounts(result.accounts);
+ }
+ } catch (err: any) {
+ setError(err.message || "Failed to load transactions");
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ useEffect(() => {
+ fetchTransactions(selectedAccount);
+ }, [selectedAccount]);
+
+ const formatAmount = (amount: number, currency: string) => {
+ return new Intl.NumberFormat("en-US", {
+ style: "currency",
+ currency: currency.toUpperCase(),
+ }).format(amount / 100);
+ };
+
+ const statusBadge = (status: string) => {
+ const map: Record = {
+ succeeded: "cc-badge-success",
+ pending: "cc-badge-warning",
+ failed: "cc-badge-danger",
+ };
+ return (
+
+ {status}
+
+ );
+ };
+
+ return (
+
+
+
+
+
+ Stripe Transactions
+
+ Recent payment activity
+
+ {accounts.length > 1 && (
+
+
+
+
+
+ All Accounts
+ {accounts.map((acc) => (
+
+ {acc.name}
+
+ ))}
+
+
+ )}
+
+
+
+ {loading ? (
+
+
+
+ ) : error ? (
+
+ ) : transactions.length === 0 ? (
+
+
+
No recent transactions
+
+ ) : (
+
+ {transactions.map((tx) => (
+
+
+
+ {tx.customer_name || tx.customer_email || tx.description || "Payment"}
+
+
+ {statusBadge(tx.status)}
+
+ {formatDateTimeCompactEST(new Date(tx.created * 1000))}
+
+
+
+
+ {formatAmount(tx.amount, tx.currency)}
+
+
+ ))}
+
+ )}
+
+
+ );
+}
diff --git a/src/components/dashboard/TimerPopover.tsx b/src/components/dashboard/TimerPopover.tsx
new file mode 100644
index 0000000..5c45cc7
--- /dev/null
+++ b/src/components/dashboard/TimerPopover.tsx
@@ -0,0 +1,221 @@
+import { useState, useEffect, useRef } from "react";
+import { useNavigate } from "react-router-dom";
+import { supabase } from "@/integrations/supabase/client";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Textarea } from "@/components/ui/textarea";
+import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
+import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
+import { Timer, Play, Square, ExternalLink } from "lucide-react";
+import { useToast } from "@/hooks/use-toast";
+
+interface TimerPopoverProps {
+ userId?: string;
+ associations: { id: string; name: string }[];
+}
+
+const STORAGE_KEY = "active_time_entry";
+
+function formatElapsed(seconds: number) {
+ const h = Math.floor(seconds / 3600).toString().padStart(2, "0");
+ const m = Math.floor((seconds % 3600) / 60).toString().padStart(2, "0");
+ const s = (seconds % 60).toString().padStart(2, "0");
+ return `${h}:${m}:${s}`;
+}
+
+export function TimerPopover({ userId, associations }: TimerPopoverProps) {
+ const navigate = useNavigate();
+ const { toast } = useToast();
+ const [open, setOpen] = useState(false);
+ const [activeId, setActiveId] = useState(null);
+ const [startTime, setStartTime] = useState(null);
+ const [elapsed, setElapsed] = useState(0);
+ const [associationId, setAssociationId] = useState("");
+ const [description, setDescription] = useState("");
+ const intervalRef = useRef>();
+
+ // Restore from localStorage
+ useEffect(() => {
+ const raw = localStorage.getItem(STORAGE_KEY);
+ if (raw) {
+ try {
+ const data = JSON.parse(raw);
+ setActiveId(data.id);
+ setStartTime(data.startTime);
+ setAssociationId(data.associationId || "");
+ setDescription(data.description || "");
+ } catch {}
+ }
+ }, []);
+
+ // Tick every second when running
+ useEffect(() => {
+ if (startTime) {
+ const tick = () => setElapsed(Math.floor((Date.now() - startTime) / 1000));
+ tick();
+ intervalRef.current = setInterval(tick, 1000);
+ return () => clearInterval(intervalRef.current);
+ } else {
+ setElapsed(0);
+ }
+ }, [startTime]);
+
+ const handleStart = async () => {
+ if (!userId) return;
+ const now = new Date();
+ const { data, error } = await supabase
+ .from("time_entries")
+ .insert({
+ user_id: userId,
+ association_id: associationId || null,
+ description: description || null,
+ start_time: now.toISOString(),
+ is_running: true,
+ })
+ .select("id")
+ .single();
+ if (error || !data) {
+ toast({ title: "Failed to start timer", description: error?.message, variant: "destructive" });
+ return;
+ }
+ setActiveId(data.id);
+ setStartTime(now.getTime());
+ localStorage.setItem(
+ STORAGE_KEY,
+ JSON.stringify({ id: data.id, startTime: now.getTime(), associationId, description })
+ );
+ toast({ title: "Timer started" });
+ };
+
+ const handleStop = async () => {
+ if (!activeId || !startTime) return;
+ const end = new Date();
+ const duration = Math.floor((end.getTime() - startTime) / 1000);
+ const { error } = await supabase
+ .from("time_entries")
+ .update({
+ end_time: end.toISOString(),
+ duration_seconds: duration,
+ is_running: false,
+ association_id: associationId || null,
+ description: description || null,
+ })
+ .eq("id", activeId);
+ if (error) {
+ toast({ title: "Failed to stop timer", description: error.message, variant: "destructive" });
+ return;
+ }
+ setActiveId(null);
+ setStartTime(null);
+ setDescription("");
+ setAssociationId("");
+ localStorage.removeItem(STORAGE_KEY);
+ toast({ title: "Time entry saved", description: formatElapsed(duration) });
+ };
+
+ // Persist edits while running
+ useEffect(() => {
+ if (activeId && startTime) {
+ localStorage.setItem(
+ STORAGE_KEY,
+ JSON.stringify({ id: activeId, startTime, associationId, description })
+ );
+ }
+ }, [associationId, description, activeId, startTime]);
+
+ const isRunning = !!activeId;
+
+ return (
+
+
+
+
+
+
+ {isRunning && (
+ {formatElapsed(elapsed)}
+ )}
+
+
+
+ Time Tracker
+
+
+
+
+
Time Tracker
+ {
+ setOpen(false);
+ navigate("/dashboard/time-tracking");
+ }}
+ >
+ View all
+
+
+
+
+
+ {formatElapsed(elapsed)}
+
+
+ {isRunning ? "Running…" : "Stopped"}
+
+
+
+
+ Client
+
+
+
+
+
+ {associations.map((a) => (
+
+ {a.name}
+
+ ))}
+
+
+
+
+
+ Description
+ setDescription(e.target.value)}
+ placeholder="What are you working on?"
+ className="text-[12px] min-h-[60px] resize-none"
+ />
+
+
+ {!isRunning ? (
+
+ Start Timer
+
+ ) : (
+
+ Stop & Save
+
+ )}
+
+
+
+ );
+}
diff --git a/src/components/dashboard/layoutUtils.test.ts b/src/components/dashboard/layoutUtils.test.ts
new file mode 100644
index 0000000..17b1048
--- /dev/null
+++ b/src/components/dashboard/layoutUtils.test.ts
@@ -0,0 +1,66 @@
+import { describe, expect, it } from "vitest";
+import {
+ generateDashboardLayout,
+ generateLegacyDashboardLayout,
+ normalizeSavedDashboardLayout,
+ shouldUpgradeLegacyDashboardLayout,
+ type LayoutItem,
+} from "./layoutUtils";
+
+const DEFAULT_CARDS = [
+ "stat_pending_arc",
+ "stat_open_violations",
+ "stat_collections",
+ "stat_active_owners",
+ "stat_open_requests",
+ "chart_community_health",
+ "chart_recent_activity",
+ "table_tasks",
+ "table_projects",
+ "table_reminders",
+ "table_stripe",
+ "table_form_inbox",
+ "quick_actions",
+];
+
+function getRowWidths(layout: LayoutItem[]) {
+ const widthsByRow = new Map();
+
+ layout.forEach((item) => {
+ widthsByRow.set(item.y, (widthsByRow.get(item.y) ?? 0) + item.w);
+ });
+
+ return [...widthsByRow.values()];
+}
+
+describe("dashboard layout utils", () => {
+ it("stretches generated rows to use the full 12-column desktop grid", () => {
+ const layout = generateDashboardLayout(DEFAULT_CARDS);
+
+ expect(getRowWidths(layout)).toEqual([12, 12, 12, 12, 12]);
+ expect(layout.find((item) => item.i === "quick_actions")?.w).toBe(12);
+ });
+
+ it("recognizes legacy auto-layouts even when the grid has compacted their row offsets", () => {
+ const legacyLayout = generateLegacyDashboardLayout(DEFAULT_CARDS);
+ const compactedRowMap = new Map([
+ [0, 0],
+ [3, 3],
+ [6, 7],
+ [9, 12],
+ [12, 17],
+ ]);
+
+ const compactedLegacyLayout = legacyLayout.map((item) => ({
+ ...item,
+ y: compactedRowMap.get(item.y) ?? item.y,
+ }));
+
+ expect(shouldUpgradeLegacyDashboardLayout(DEFAULT_CARDS, legacyLayout)).toBe(true);
+ expect(shouldUpgradeLegacyDashboardLayout(DEFAULT_CARDS, compactedLegacyLayout)).toBe(true);
+
+ const normalized = normalizeSavedDashboardLayout(DEFAULT_CARDS, compactedLegacyLayout);
+ expect(normalized.didUpgradeLegacyLayout).toBe(true);
+ expect(getRowWidths(normalized.layout)).toEqual([12, 12, 12, 12, 12]);
+ });
+});
\ No newline at end of file
diff --git a/src/components/dashboard/layoutUtils.ts b/src/components/dashboard/layoutUtils.ts
new file mode 100644
index 0000000..af35dda
--- /dev/null
+++ b/src/components/dashboard/layoutUtils.ts
@@ -0,0 +1,206 @@
+import { getCardDef } from "./widgets/cardRegistry";
+
+export const DASHBOARD_GRID_COLUMNS = 12;
+
+export type LayoutItem = {
+ i: string;
+ x: number;
+ y: number;
+ w: number;
+ h: number;
+ minW?: number;
+ minH?: number;
+ maxW?: number;
+ maxH?: number;
+};
+
+type NormalizedLayoutResult = {
+ layout: LayoutItem[];
+ didUpgradeLegacyLayout: boolean;
+};
+
+type LegacyBlueprintItem = {
+ i: string;
+ rowIndex: number;
+ x: number;
+ w: number;
+ h: number;
+};
+
+function buildLegacyBlueprint(cards: string[]): LegacyBlueprintItem[] {
+ const blueprint: LegacyBlueprintItem[] = [];
+ let x = 0;
+ let rowIndex = 0;
+
+ cards.forEach((cardId) => {
+ const def = getCardDef(cardId);
+ if (!def) return;
+
+ if (x + def.defaultW > DASHBOARD_GRID_COLUMNS) {
+ x = 0;
+ rowIndex += 1;
+ }
+
+ blueprint.push({
+ i: cardId,
+ rowIndex,
+ x,
+ w: def.defaultW,
+ h: def.defaultH,
+ });
+
+ x += def.defaultW;
+
+ if (x >= DASHBOARD_GRID_COLUMNS) {
+ x = 0;
+ rowIndex += 1;
+ }
+ });
+
+ return blueprint;
+}
+
+function stretchRowToFillWidth(items: LayoutItem[]): LayoutItem[] {
+ const stretched = items.map((item) => ({ ...item }));
+ let remainingWidth = DASHBOARD_GRID_COLUMNS - stretched.reduce((total, item) => total + item.w, 0);
+
+ while (remainingWidth > 0) {
+ let expandedAnyItem = false;
+
+ for (const item of stretched) {
+ if (remainingWidth === 0) break;
+
+ const maxWidth = item.maxW ?? DASHBOARD_GRID_COLUMNS;
+ if (item.w >= maxWidth) continue;
+
+ item.w += 1;
+ remainingWidth -= 1;
+ expandedAnyItem = true;
+ }
+
+ if (!expandedAnyItem) break;
+ }
+
+ let currentX = 0;
+ return stretched.map((item) => {
+ const nextItem = { ...item, x: currentX };
+ currentX += item.w;
+ return nextItem;
+ });
+}
+
+export function enrichDashboardLayout(layout: LayoutItem[]): LayoutItem[] {
+ return layout.map((item) => {
+ const def = getCardDef(item.i);
+
+ return {
+ ...item,
+ minW: def?.minW ?? item.minW ?? 2,
+ minH: def?.minH ?? item.minH ?? 2,
+ maxW: def?.maxW ?? item.maxW,
+ maxH: def?.maxH ?? item.maxH,
+ };
+ });
+}
+
+export function generateLegacyDashboardLayout(cards: string[]): LayoutItem[] {
+ return buildLegacyBlueprint(cards).map((item) => {
+ const def = getCardDef(item.i);
+
+ return {
+ i: item.i,
+ x: item.x,
+ y: item.rowIndex * 3,
+ w: item.w,
+ h: item.h,
+ minW: def?.minW,
+ minH: def?.minH,
+ maxW: def?.maxW,
+ maxH: def?.maxH,
+ };
+ });
+}
+
+export function generateDashboardLayout(cards: string[]): LayoutItem[] {
+ const rows: LayoutItem[][] = [];
+ let currentRow: LayoutItem[] = [];
+ let currentRowWidth = 0;
+
+ cards.forEach((cardId) => {
+ const def = getCardDef(cardId);
+ if (!def) return;
+
+ const nextItem: LayoutItem = {
+ i: cardId,
+ x: 0,
+ y: 0,
+ w: def.defaultW,
+ h: def.defaultH,
+ minW: def.minW,
+ minH: def.minH,
+ maxW: def.maxW,
+ maxH: def.maxH,
+ };
+
+ if (currentRow.length > 0 && currentRowWidth + nextItem.w > DASHBOARD_GRID_COLUMNS) {
+ rows.push(stretchRowToFillWidth(currentRow));
+ currentRow = [nextItem];
+ currentRowWidth = nextItem.w;
+ return;
+ }
+
+ currentRow.push(nextItem);
+ currentRowWidth += nextItem.w;
+
+ if (currentRowWidth >= DASHBOARD_GRID_COLUMNS) {
+ rows.push(stretchRowToFillWidth(currentRow));
+ currentRow = [];
+ currentRowWidth = 0;
+ }
+ });
+
+ if (currentRow.length > 0) {
+ rows.push(stretchRowToFillWidth(currentRow));
+ }
+
+ let currentY = 0;
+
+ return rows.flatMap((row) => {
+ const rowHeight = Math.max(...row.map((item) => item.h), 1);
+ const positionedRow = row.map((item) => ({ ...item, y: currentY }));
+ currentY += rowHeight;
+ return positionedRow;
+ });
+}
+
+export function shouldUpgradeLegacyDashboardLayout(cards: string[], layout: LayoutItem[]): boolean {
+ const legacyBlueprint = buildLegacyBlueprint(cards);
+ if (legacyBlueprint.length === 0 || legacyBlueprint.length !== layout.length) {
+ return false;
+ }
+
+ const layoutById = new Map(layout.map((item) => [item.i, item]));
+ const uniqueRows = [...new Set(layout.map((item) => item.y))].sort((a, b) => a - b);
+ const rowRankByY = new Map(uniqueRows.map((y, index) => [y, index]));
+
+ return legacyBlueprint.every((expected) => {
+ const saved = layoutById.get(expected.i);
+ if (!saved) return false;
+
+ return (
+ saved.x === expected.x &&
+ saved.w === expected.w &&
+ saved.h === expected.h &&
+ rowRankByY.get(saved.y) === expected.rowIndex
+ );
+ });
+}
+
+export function normalizeSavedDashboardLayout(cards: string[], layout: LayoutItem[]): NormalizedLayoutResult {
+ const didUpgradeLegacyLayout = shouldUpgradeLegacyDashboardLayout(cards, layout);
+
+ return {
+ layout: didUpgradeLegacyLayout ? generateDashboardLayout(cards) : enrichDashboardLayout(layout),
+ didUpgradeLegacyLayout,
+ };
+}
\ No newline at end of file
diff --git a/src/components/dashboard/widgets/ChartWidget.tsx b/src/components/dashboard/widgets/ChartWidget.tsx
new file mode 100644
index 0000000..bedc52d
--- /dev/null
+++ b/src/components/dashboard/widgets/ChartWidget.tsx
@@ -0,0 +1,169 @@
+import { useState, useEffect } from "react";
+import { supabase } from "@/integrations/supabase/client";
+import { Shield, TrendingUp, Layers, CalendarClock, Activity, FileText, ShieldAlert, DollarSign, Users } from "lucide-react";
+import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
+import { formatShortDateEST } from "@/lib/timezoneUtils";
+
+interface Props {
+ cardId: string;
+}
+
+export default function ChartWidget({ cardId }: Props) {
+ if (cardId === "chart_community_health") return ;
+ if (cardId === "chart_recent_activity") return ;
+ return Unknown chart
;
+}
+
+function CommunityHealthWidget() {
+ const [data, setData] = useState({ compliance: 0, delinquency: 0, projects: 0, reminders: 0 });
+
+ useEffect(() => {
+ Promise.all([
+ supabase.from("owners").select("id", { count: "exact", head: true }).eq("status", "active"),
+ supabase.from("violations").select("id", { count: "exact", head: true }).eq("status", "open"),
+ supabase.from("units").select("id", { count: "exact", head: true }).eq("status", "delinquent"),
+ supabase.from("projects").select("id", { count: "exact", head: true }).neq("status", "completed"),
+ supabase.from("reminders").select("id", { count: "exact", head: true }).neq("status", "dismissed"),
+ ]).then(([owners, violations, units, projects, reminders]) => {
+ const total = owners.count || 1;
+ setData({
+ compliance: Math.round(((total - (violations.count || 0)) / total) * 100),
+ delinquency: Math.round(((units.count || 0) / total) * 100),
+ projects: projects.count || 0,
+ reminders: reminders.count || 0,
+ });
+ });
+ }, []);
+
+ const metrics = [
+ { label: "Compliance Rate", value: `${data.compliance}%`, icon: , bg: "bg-emerald-50" },
+ { label: "Delinquency Rate", value: `${data.delinquency}%`, icon: , bg: "bg-amber-50" },
+ { label: "Active Projects", value: String(data.projects), icon: , bg: "bg-primary/10" },
+ { label: "Upcoming Deadlines", value: String(data.reminders), icon: , bg: "bg-purple-50" },
+ ];
+
+ return (
+
+
+
+ Community Health
+
+
Key performance indicators
+
+
+ {metrics.map((m) => (
+
+
{m.icon}
+
+
{m.value}
+
{m.label}
+
+
+ ))}
+
+
+ );
+}
+
+function RecentActivityWidget() {
+ const [entries, setEntries] = useState<{ text: string; time: string; icon: React.ReactNode }[]>([]);
+ const [loading, setLoading] = useState(true);
+
+ useEffect(() => {
+ const fetchActivity = async () => {
+ const now = new Date();
+ const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000).toISOString();
+
+ const [violations, arcApps, payments, requests, announcements] = await Promise.all([
+ supabase.from("violations").select("id, title, category, created_at").gte("created_at", thirtyDaysAgo).order("created_at", { ascending: false }).limit(5),
+ supabase.from("arc_applications").select("id, title, created_at").gte("created_at", thirtyDaysAgo).order("created_at", { ascending: false }).limit(5),
+ supabase.from("admin_payments").select("id, description, amount, created_at").gte("created_at", thirtyDaysAgo).order("created_at", { ascending: false }).limit(5),
+ supabase.from("homeowner_requests").select("id, title, created_at").gte("created_at", thirtyDaysAgo).order("created_at", { ascending: false }).limit(5),
+ supabase.from("announcements").select("id, title, created_at").gte("created_at", thirtyDaysAgo).order("created_at", { ascending: false }).limit(5),
+ ]);
+
+ const items: { text: string; time: string; sortDate: string; icon: React.ReactNode }[] = [];
+
+ (violations.data || []).forEach((v) => items.push({
+ text: `Violation: ${v.category || v.title || "Notice"}`,
+ time: formatRelativeTime(v.created_at),
+ sortDate: v.created_at,
+ icon: ,
+ }));
+ (arcApps.data || []).forEach((a) => items.push({
+ text: `ARC: ${a.title}`,
+ time: formatRelativeTime(a.created_at),
+ sortDate: a.created_at,
+ icon: ,
+ }));
+ (payments.data || []).forEach((p) => items.push({
+ text: `Payment: $${p.amount.toFixed(2)}`,
+ time: formatRelativeTime(p.created_at),
+ sortDate: p.created_at,
+ icon: ,
+ }));
+ (requests.data || []).forEach((r: any) => items.push({
+ text: `Request: ${r.title || "New request"}`,
+ time: formatRelativeTime(r.created_at),
+ sortDate: r.created_at,
+ icon: ,
+ }));
+ (announcements.data || []).forEach((a) => items.push({
+ text: `Announcement: ${a.title}`,
+ time: formatRelativeTime(a.created_at),
+ sortDate: a.created_at,
+ icon: ,
+ }));
+
+ items.sort((a, b) => new Date(b.sortDate).getTime() - new Date(a.sortDate).getTime());
+ setEntries(items.slice(0, 8).map(({ text, time, icon }) => ({ text, time, icon })));
+ setLoading(false);
+ };
+
+ fetchActivity();
+ }, []);
+
+ return (
+
+
+
+
Latest events across associations
+
+ {loading ? (
+
+ ) : entries.length === 0 ? (
+
No recent activity
+ ) : (
+
+ {entries.map((entry, i) => (
+
+
{entry.icon}
+
+
{entry.time}
+
+ ))}
+
+ )}
+
+ );
+}
+
+function formatRelativeTime(dateStr: string): string {
+ const now = new Date();
+ const date = new Date(dateStr);
+ const diffMs = now.getTime() - date.getTime();
+ const diffMins = Math.floor(diffMs / 60000);
+ if (diffMins < 1) return "Just now";
+ if (diffMins < 60) return `${diffMins}m ago`;
+ const diffHours = Math.floor(diffMins / 60);
+ if (diffHours < 24) return `${diffHours}h ago`;
+ const diffDays = Math.floor(diffHours / 24);
+ if (diffDays < 7) return `${diffDays}d ago`;
+ return formatShortDateEST(dateStr);
+}
diff --git a/src/components/dashboard/widgets/QuickActionsWidget.tsx b/src/components/dashboard/widgets/QuickActionsWidget.tsx
new file mode 100644
index 0000000..3df18f4
--- /dev/null
+++ b/src/components/dashboard/widgets/QuickActionsWidget.tsx
@@ -0,0 +1,90 @@
+import { useState } from "react";
+import { useNavigate } from "react-router-dom";
+import { ShieldAlert, FileText, Users, DollarSign, BarChart3, Mail, Loader2 } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { supabase } from "@/integrations/supabase/client";
+import { useToast } from "@/hooks/use-toast";
+
+export default function QuickActionsWidget() {
+ const navigate = useNavigate();
+ const { toast } = useToast();
+ const [notifying, setNotifying] = useState(false);
+
+ const actions = [
+ { label: "Generate Violation Notice", path: "/dashboard/violations", icon: ShieldAlert },
+ { label: "Review ARC Applications", path: "/dashboard/arc-applications", icon: FileText },
+ { label: "Add New Homeowner", path: "/dashboard/owner-roster", icon: Users },
+ { label: "Run Financial Report", path: "/dashboard/financial-reports", icon: DollarSign },
+ ];
+
+ const handleNotifyBoardOfPendingVotes = async () => {
+ setNotifying(true);
+ try {
+ const { data: openVotes, error } = await supabase
+ .from("board_votes")
+ .select("id, title")
+ .eq("status", "open");
+ if (error) throw error;
+ if (!openVotes || openVotes.length === 0) {
+ toast({ title: "No pending votes", description: "There are no open board votes to notify about." });
+ return;
+ }
+ if (!confirm(`Email all board members about ${openVotes.length} pending vote${openVotes.length === 1 ? "" : "s"}?`)) {
+ return;
+ }
+ let totalSent = 0, totalFailed = 0;
+ for (const v of openVotes) {
+ const { data, error: invErr } = await supabase.functions.invoke("send-board-vote-invites", {
+ body: { board_vote_id: v.id, base_url: window.location.origin },
+ });
+ if (invErr || data?.error) {
+ totalFailed += 1;
+ } else {
+ totalSent += data?.sent ?? 0;
+ totalFailed += data?.failed ?? 0;
+ }
+ }
+ toast({
+ title: "Board notified",
+ description: `Sent ${totalSent} invitation${totalSent === 1 ? "" : "s"}${totalFailed ? `, ${totalFailed} failed` : ""}.`,
+ });
+ } catch (err: any) {
+ toast({ title: "Error", description: err.message || "Failed to notify board", variant: "destructive" });
+ } finally {
+ setNotifying(false);
+ }
+ };
+
+ return (
+
+
+
+ Quick Actions
+
+
Common administrative tasks
+
+
+ {actions.map((action) => (
+
navigate(action.path)}
+ >
+
+ {action.label}
+
+ ))}
+
+ {notifying ? : }
+ {notifying ? "Notifying board…" : "Notify Board of Pending Votes"}
+
+
+
+ );
+}
diff --git a/src/components/dashboard/widgets/StatWidget.tsx b/src/components/dashboard/widgets/StatWidget.tsx
new file mode 100644
index 0000000..30bcf32
--- /dev/null
+++ b/src/components/dashboard/widgets/StatWidget.tsx
@@ -0,0 +1,58 @@
+import { useState, useEffect } from "react";
+import { supabase } from "@/integrations/supabase/client";
+import { useNavigate } from "react-router-dom";
+import { getCardDef } from "./cardRegistry";
+
+interface Props {
+ cardId: string;
+}
+
+const STAT_CONFIG: Record; path: string }> = {
+ stat_pending_arc: { table: "arc_applications", filter: { status: "submitted" }, path: "/dashboard/arc-applications" },
+ stat_open_violations: { table: "violations", filter: { status: ["open", "escalated", "recommended_for_fining"] }, path: "/dashboard/violations" },
+ stat_collections: { table: "units", filter: { status: "delinquent" }, path: "/dashboard/collections" },
+ stat_active_owners: { table: "owners", filter: { status: "active" }, path: "/dashboard/owner-roster" },
+ stat_open_requests: { table: "client_requests", filter: { status: "open" }, path: "/dashboard/client-requests" },
+};
+
+export default function StatWidget({ cardId }: Props) {
+ const [count, setCount] = useState(null);
+ const navigate = useNavigate();
+ const def = getCardDef(cardId);
+ const config = STAT_CONFIG[cardId];
+
+ useEffect(() => {
+ if (!config) return;
+ const fetchCount = async () => {
+ let query = supabase.from(config.table as any).select("id", { count: "exact", head: true }) as any;
+ if (config.filter) {
+ Object.entries(config.filter).forEach(([k, v]) => {
+ if (Array.isArray(v)) {
+ query = query.in(k, v);
+ } else {
+ query = query.eq(k, v);
+ }
+ });
+ }
+ const { count: c } = await query;
+ setCount(c ?? 0);
+ };
+ fetchCount();
+ }, [cardId]);
+
+ if (!def || !config) return null;
+ const Icon = def.icon;
+
+ return (
+ navigate(config.path)}
+ >
+
+ {def.title}
+
+
+
{count ?? "—"}
+
+ );
+}
diff --git a/src/components/dashboard/widgets/StatusUpdatesWidget.tsx b/src/components/dashboard/widgets/StatusUpdatesWidget.tsx
new file mode 100644
index 0000000..6e99635
--- /dev/null
+++ b/src/components/dashboard/widgets/StatusUpdatesWidget.tsx
@@ -0,0 +1,165 @@
+import { useEffect, useState } from "react";
+import { supabase } from "@/integrations/supabase/client";
+import { useAuth } from "@/contexts/AuthContext";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Textarea } from "@/components/ui/textarea";
+import { Badge } from "@/components/ui/badge";
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
+import { Loader2, Megaphone, Plus, Send, X } from "lucide-react";
+import { useToast } from "@/hooks/use-toast";
+import { formatDateEST } from "@/lib/timezoneUtils";
+
+interface Association { id: string; name: string }
+interface StatusUpdate {
+ id: string;
+ title: string;
+ content: string | null;
+ created_at: string;
+ association_id: string;
+ associations?: { name: string } | null;
+}
+
+export default function StatusUpdatesWidget() {
+ const { user } = useAuth();
+ const { toast } = useToast();
+ const [associations, setAssociations] = useState([]);
+ const [updates, setUpdates] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [composing, setComposing] = useState(false);
+ const [submitting, setSubmitting] = useState(false);
+ const [associationId, setAssociationId] = useState("");
+ const [title, setTitle] = useState("");
+ const [content, setContent] = useState("");
+
+ const loadData = async () => {
+ setLoading(true);
+ const [{ data: assocs }, { data: ups }] = await Promise.all([
+ supabase.from("associations").select("id, name").eq("status", "active").order("name"),
+ supabase
+ .from("status_updates")
+ .select("id, title, content, created_at, association_id, associations(name)")
+ .order("created_at", { ascending: false })
+ .limit(5),
+ ]);
+ setAssociations((assocs as Association[]) || []);
+ setUpdates((ups as any) || []);
+ setLoading(false);
+ };
+
+ useEffect(() => { loadData(); }, []);
+
+ const reset = () => {
+ setTitle(""); setContent(""); setAssociationId(""); setComposing(false);
+ };
+
+ const handleSubmit = async () => {
+ if (!title.trim()) {
+ toast({ variant: "destructive", title: "Title required" });
+ return;
+ }
+ if (!associationId) {
+ toast({ variant: "destructive", title: "Select an association" });
+ return;
+ }
+ setSubmitting(true);
+ try {
+ const { error } = await supabase.from("status_updates").insert({
+ title: title.trim(),
+ content: content.trim() || null,
+ association_id: associationId,
+ created_by: user?.id ?? null,
+ });
+ if (error) throw error;
+ toast({ title: "Status update posted" });
+ reset();
+ await loadData();
+ } catch (err: any) {
+ toast({ variant: "destructive", title: "Failed to post", description: err.message });
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ return (
+
+
+
+
+
Status Updates
+
+ {!composing ? (
+
setComposing(true)}>
+ New
+
+ ) : (
+
+
+
+ )}
+
+
+ {composing && (
+
+
+
+
+
+
+ {associations.map((a) => (
+ {a.name}
+ ))}
+
+
+ setTitle(e.target.value)}
+ className="h-8 text-xs"
+ />
+ setContent(e.target.value)}
+ rows={3}
+ className="resize-none text-xs"
+ />
+
+ {submitting ? : }
+ Post Update
+
+
+ )}
+
+
+ {loading ? (
+
+
+
+ ) : updates.length === 0 ? (
+
No status updates yet
+ ) : (
+ updates.map((u) => (
+
+
+
{u.title}
+
+ {u.associations?.name ?? "—"}
+
+
+ {u.content && (
+
+ {(() => {
+ const doc = new DOMParser().parseFromString(u.content, "text/html");
+ return (doc.body.textContent || "").replace(/\s+/g, " ").trim();
+ })()}
+
+ )}
+
{formatDateEST(u.created_at)}
+
+ ))
+ )}
+
+
+ );
+}
diff --git a/src/components/dashboard/widgets/TableWidget.tsx b/src/components/dashboard/widgets/TableWidget.tsx
new file mode 100644
index 0000000..4ccb59e
--- /dev/null
+++ b/src/components/dashboard/widgets/TableWidget.tsx
@@ -0,0 +1,35 @@
+import { StripeTransactionsCard } from "../StripeTransactionsCard";
+import { FormInboxCard } from "../FormInboxCard";
+import { AutopayManagementCard } from "../AutopayManagementCard";
+import { BillApprovalsCard } from "../BillApprovalsCard";
+import { TasksListWidget } from "./lists/TasksListWidget";
+import { ProjectsListWidget } from "./lists/ProjectsListWidget";
+import { RemindersListWidget } from "./lists/RemindersListWidget";
+import { AnnouncementsListWidget } from "./lists/AnnouncementsListWidget";
+
+interface Props {
+ cardId: string;
+}
+
+export default function TableWidget({ cardId }: Props) {
+ switch (cardId) {
+ case "table_stripe":
+ return ;
+ case "table_autopay":
+ return ;
+ case "table_form_inbox":
+ return ;
+ case "table_bill_approvals":
+ return ;
+ case "table_tasks":
+ return ;
+ case "table_projects":
+ return ;
+ case "table_reminders":
+ return ;
+ case "table_announcements":
+ return ;
+ default:
+ return Unknown widget
;
+ }
+}
diff --git a/src/components/dashboard/widgets/cardRegistry.ts b/src/components/dashboard/widgets/cardRegistry.ts
new file mode 100644
index 0000000..3aae156
--- /dev/null
+++ b/src/components/dashboard/widgets/cardRegistry.ts
@@ -0,0 +1,52 @@
+import {
+ FileText, ShieldAlert, DollarSign, Users, MessageCircle,
+ Shield, Activity, CheckSquare, FolderOpen, Bell,
+ Megaphone, CreditCard, Inbox, BarChart3, TrendingUp,
+ CalendarClock, Layers, FileCheck, Megaphone as MegaphoneIcon, type LucideIcon
+} from "lucide-react";
+
+export type CardType = "stat" | "chart" | "table" | "quick_action" | "status_updates";
+
+export interface CardDefinition {
+ id: string;
+ type: CardType;
+ title: string;
+ description: string;
+ icon: LucideIcon;
+ defaultW: number;
+ defaultH: number;
+ minW: number;
+ minH: number;
+ maxW?: number;
+ maxH?: number;
+}
+
+export const CARD_CATALOG: CardDefinition[] = [
+ // ── Stat/KPI Cards ──
+ { id: "stat_pending_arc", type: "stat", title: "Pending ARC", description: "Applications awaiting review", icon: FileText, defaultW: 2, defaultH: 2, minW: 2, minH: 2 },
+ { id: "stat_open_violations", type: "stat", title: "Open Violations", description: "Active violation cases", icon: ShieldAlert, defaultW: 2, defaultH: 2, minW: 2, minH: 2 },
+ { id: "stat_collections", type: "stat", title: "Collections", description: "Delinquent units", icon: DollarSign, defaultW: 2, defaultH: 2, minW: 2, minH: 2 },
+ { id: "stat_active_owners", type: "stat", title: "Active Owners", description: "Total registered owners", icon: Users, defaultW: 2, defaultH: 2, minW: 2, minH: 2 },
+ { id: "stat_open_requests", type: "stat", title: "Open Requests", description: "Awaiting response", icon: MessageCircle, defaultW: 2, defaultH: 2, minW: 2, minH: 2 },
+
+ // ── Chart Cards ──
+ { id: "chart_community_health", type: "chart", title: "Community Health", description: "Compliance & delinquency rates", icon: Shield, defaultW: 6, defaultH: 4, minW: 3, minH: 2 },
+ { id: "chart_recent_activity", type: "chart", title: "Recent Activity", description: "Latest events feed", icon: Activity, defaultW: 6, defaultH: 4, minW: 3, minH: 2 },
+
+ // ── Table/List Cards ──
+ { id: "table_tasks", type: "table", title: "Tasks", description: "Open tasks and assignments", icon: CheckSquare, defaultW: 4, defaultH: 5, minW: 2, minH: 2 },
+ { id: "table_projects", type: "table", title: "Projects", description: "Active projects", icon: FolderOpen, defaultW: 4, defaultH: 5, minW: 2, minH: 2 },
+ { id: "table_reminders", type: "table", title: "Reminders", description: "Upcoming deadlines", icon: Bell, defaultW: 4, defaultH: 5, minW: 2, minH: 2 },
+ { id: "table_stripe", type: "table", title: "Stripe Transactions", description: "Recent payment activity", icon: CreditCard, defaultW: 6, defaultH: 5, minW: 3, minH: 2 },
+ { id: "table_form_inbox", type: "table", title: "Form Inbox", description: "Public form submissions", icon: Inbox, defaultW: 6, defaultH: 5, minW: 3, minH: 2 },
+ { id: "table_bill_approvals", type: "table", title: "Bill Approvals", description: "Pending approvals & inbound bills", icon: FileCheck, defaultW: 6, defaultH: 5, minW: 3, minH: 3 },
+ { id: "table_announcements", type: "table", title: "Announcements", description: "Active announcements", icon: Megaphone, defaultW: 6, defaultH: 4, minW: 3, minH: 2 },
+
+ // ── Quick Action Cards ──
+ { id: "quick_actions", type: "quick_action", title: "Quick Actions", description: "Common admin shortcuts", icon: BarChart3, defaultW: 6, defaultH: 3, minW: 2, minH: 2 },
+
+ // ── Status Updates ──
+ { id: "status_updates", type: "status_updates", title: "Status Updates", description: "Post updates to associations", icon: MegaphoneIcon, defaultW: 4, defaultH: 6, minW: 3, minH: 4 },
+];
+
+export const getCardDef = (cardId: string) => CARD_CATALOG.find(c => c.id === cardId);
diff --git a/src/components/dashboard/widgets/lists/AnnouncementsListWidget.tsx b/src/components/dashboard/widgets/lists/AnnouncementsListWidget.tsx
new file mode 100644
index 0000000..d4d5d27
--- /dev/null
+++ b/src/components/dashboard/widgets/lists/AnnouncementsListWidget.tsx
@@ -0,0 +1,53 @@
+import { useState, useEffect } from "react";
+import { supabase } from "@/integrations/supabase/client";
+import { useNavigate } from "react-router-dom";
+import { Megaphone, ArrowRight, Pin } from "lucide-react";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { formatDateTimeShortEST } from "@/lib/timezoneUtils";
+import { htmlToPlainText } from "@/lib/htmlTextUtils";
+
+export function AnnouncementsListWidget() {
+ const [announcements, setAnnouncements] = useState([]);
+ const navigate = useNavigate();
+
+ useEffect(() => {
+ const nowIso = new Date().toISOString();
+ supabase.from("announcements").select("id, title, content, created_at, visibility, pinned, expires_at")
+ .eq("status", "active")
+ .or(`expires_at.is.null,expires_at.gt.${nowIso}`)
+ .order("pinned", { ascending: false })
+ .order("created_at", { ascending: false }).limit(5)
+ .then(({ data }) => setAnnouncements(data || []));
+ }, []);
+
+ return (
+
+
+
+ Announcements
+
+
{announcements.length}
+
+
+ {announcements.length === 0 ? (
+
No announcements
+ ) : announcements.map((ann) => (
+
navigate("/dashboard/announcements")}>
+
+ {ann.pinned &&
}
+
{ann.title}
+
+
+
{htmlToPlainText(ann.content)}
+
{formatDateTimeShortEST(ann.created_at)}
+
+
+ ))}
+
+
navigate("/dashboard/announcements")}>
+ View All
+
+
+ );
+}
diff --git a/src/components/dashboard/widgets/lists/ProjectsListWidget.tsx b/src/components/dashboard/widgets/lists/ProjectsListWidget.tsx
new file mode 100644
index 0000000..52aaca5
--- /dev/null
+++ b/src/components/dashboard/widgets/lists/ProjectsListWidget.tsx
@@ -0,0 +1,48 @@
+import { useState, useEffect } from "react";
+import { supabase } from "@/integrations/supabase/client";
+import { useNavigate } from "react-router-dom";
+import { FolderOpen, ArrowRight } from "lucide-react";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { formatShortDateEST } from "@/lib/timezoneUtils";
+
+export function ProjectsListWidget() {
+ const [projects, setProjects] = useState([]);
+ const navigate = useNavigate();
+
+ useEffect(() => {
+ supabase.from("projects").select("id, title, status, priority, due_date, associations(name)")
+ .neq("status", "completed").order("due_date", { ascending: true }).limit(8)
+ .then(({ data }) => setProjects(data || []));
+ }, []);
+
+ return (
+
+
+
+ Projects
+
+
{projects.length}
+
+
+ {projects.length === 0 ? (
+
No projects
+ ) : projects.map((p) => (
+
navigate(`/dashboard/projects/${p.id}`)}>
+
+
{p.title}
+
+ {p.status}
+ {p.associations?.name && {p.associations.name} }
+
+
+ {p.due_date &&
{formatShortDateEST(p.due_date)} }
+
+ ))}
+
+
navigate("/dashboard/projects")}>
+ View All Projects
+
+
+ );
+}
diff --git a/src/components/dashboard/widgets/lists/RemindersListWidget.tsx b/src/components/dashboard/widgets/lists/RemindersListWidget.tsx
new file mode 100644
index 0000000..6e477fd
--- /dev/null
+++ b/src/components/dashboard/widgets/lists/RemindersListWidget.tsx
@@ -0,0 +1,45 @@
+import { useState, useEffect } from "react";
+import { supabase } from "@/integrations/supabase/client";
+import { useNavigate } from "react-router-dom";
+import { Bell, ArrowRight } from "lucide-react";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { formatShortDateEST } from "@/lib/timezoneUtils";
+
+export function RemindersListWidget() {
+ const [reminders, setReminders] = useState([]);
+ const navigate = useNavigate();
+
+ useEffect(() => {
+ supabase.from("reminders").select("id, title, due_date, status, description")
+ .neq("status", "dismissed").order("due_date", { ascending: true }).limit(8)
+ .then(({ data }) => setReminders(data || []));
+ }, []);
+
+ return (
+
+
+
+ Reminders
+
+
{reminders.length}
+
+
+ {reminders.length === 0 ? (
+
No reminders
+ ) : reminders.map((r) => (
+
navigate("/dashboard/reminders")}>
+
+
{r.title}
+ {r.description &&
{r.description}
}
+
+ {r.due_date &&
{formatShortDateEST(r.due_date)} }
+
+ ))}
+
+
navigate("/dashboard/reminders")}>
+ View All Reminders
+
+
+ );
+}
diff --git a/src/components/dashboard/widgets/lists/TasksListWidget.tsx b/src/components/dashboard/widgets/lists/TasksListWidget.tsx
new file mode 100644
index 0000000..374d315
--- /dev/null
+++ b/src/components/dashboard/widgets/lists/TasksListWidget.tsx
@@ -0,0 +1,47 @@
+import { useState, useEffect } from "react";
+import { supabase } from "@/integrations/supabase/client";
+import { useNavigate } from "react-router-dom";
+import { CheckSquare, ArrowRight } from "lucide-react";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { formatShortDateEST } from "@/lib/timezoneUtils";
+
+export function TasksListWidget() {
+ const [tasks, setTasks] = useState([]);
+ const navigate = useNavigate();
+
+ useEffect(() => {
+ supabase.from("tasks").select("id, title, status, priority, due_date, assigned_to")
+ .neq("status", "completed").order("due_date", { ascending: true }).limit(8)
+ .then(({ data }) => setTasks(data || []));
+ }, []);
+
+ return (
+
+
+
+ Tasks
+
+
{tasks.length}
+
+
+ {tasks.length === 0 ? (
+
No tasks
+ ) : tasks.map((t) => (
+
navigate("/dashboard/tasks")}>
+
+
{t.title}
+
+ {t.priority && {t.priority} }
+
+
+ {t.due_date &&
{formatShortDateEST(t.due_date)} }
+
+ ))}
+
+
navigate("/dashboard/tasks")}>
+ View All Tasks
+
+
+ );
+}
diff --git a/src/components/documents/GoogleDriveFolderPickerDialog.tsx b/src/components/documents/GoogleDriveFolderPickerDialog.tsx
new file mode 100644
index 0000000..5115bd8
--- /dev/null
+++ b/src/components/documents/GoogleDriveFolderPickerDialog.tsx
@@ -0,0 +1,130 @@
+import { useEffect, useState } from "react";
+import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
+import { Button } from "@/components/ui/button";
+import { Folder, ArrowLeft, Loader2, ChevronRight, HardDrive } from "lucide-react";
+
+interface DriveFile {
+ id: string;
+ name: string;
+ mimeType: string;
+}
+
+interface Props {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ driveFiles: DriveFile[];
+ browsing: boolean;
+ browseFiles: (folderId?: string) => Promise;
+ listSharedDrives: () => Promise;
+ onSelect: (folder: { id: string | null; name: string }) => void;
+}
+
+export default function GoogleDriveFolderPickerDialog({
+ open,
+ onOpenChange,
+ driveFiles,
+ browsing,
+ browseFiles,
+ listSharedDrives,
+ onSelect,
+}: Props) {
+ const [folderStack, setFolderStack] = useState<{ id: string; name: string }[]>([]);
+
+ useEffect(() => {
+ if (open) {
+ setFolderStack([]);
+ listSharedDrives();
+ }
+ }, [open, listSharedDrives]);
+
+ const currentFolder = folderStack[folderStack.length - 1] || null;
+ const folders = driveFiles.filter((file) => file.mimeType === "application/vnd.google-apps.folder");
+
+ const navigateToFolder = (folder: DriveFile) => {
+ setFolderStack((prev) => [...prev, { id: folder.id, name: folder.name }]);
+ browseFiles(folder.id);
+ };
+
+ const navigateBack = () => {
+ const nextStack = folderStack.slice(0, -1);
+ setFolderStack(nextStack);
+ if (nextStack.length === 0) listSharedDrives();
+ else browseFiles(nextStack[nextStack.length - 1].id);
+ };
+
+ const handleUseFolder = () => {
+ onSelect(currentFolder ? { id: currentFolder.id, name: currentFolder.name } : { id: null, name: "My Drive" });
+ onOpenChange(false);
+ };
+
+ return (
+
+
+
+ Choose Google Drive Folder
+ Select where this file should be saved.
+
+
+
+ { setFolderStack([]); listSharedDrives(); }} className="hover:text-foreground font-medium">
+ Shared Drives
+
+ {folderStack.map((folder, index) => (
+
+
+ {
+ const nextStack = folderStack.slice(0, index + 1);
+ setFolderStack(nextStack);
+ browseFiles(folder.id);
+ }}
+ className="hover:text-foreground truncate"
+ >
+ {folder.name}
+
+
+ ))}
+
+
+
+ {browsing ? (
+
+
+
+ ) : folders.length === 0 ? (
+
+ ) : (
+
+ {folders.map((folder) => (
+ navigateToFolder(folder)}
+ className="w-full flex items-center gap-3 px-4 py-2.5 hover:bg-muted/50 text-left transition-colors"
+ >
+
+ {folder.name}
+
+
+ ))}
+
+ )}
+
+
+
+ {folderStack.length > 0 && (
+
+ Back
+
+ )}
+ onOpenChange(false)}>Cancel
+
+ Save here
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/components/documents/GoogleDrivePickerDialog.tsx b/src/components/documents/GoogleDrivePickerDialog.tsx
new file mode 100644
index 0000000..a1b4b80
--- /dev/null
+++ b/src/components/documents/GoogleDrivePickerDialog.tsx
@@ -0,0 +1,295 @@
+import { useState, useEffect } from "react";
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog";
+import { Button } from "@/components/ui/button";
+import { Checkbox } from "@/components/ui/checkbox";
+import { Label } from "@/components/ui/label";
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
+import { Folder, File, ArrowLeft, Loader2, ChevronRight } from "lucide-react";
+import { Badge } from "@/components/ui/badge";
+
+interface DriveFile {
+ id: string;
+ name: string;
+ mimeType: string;
+ iconLink?: string;
+ webViewLink?: string;
+ size?: string;
+ modifiedTime?: string;
+}
+
+interface GoogleDrivePickerDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ driveFiles: DriveFile[];
+ browsing: boolean;
+ browseFiles: (folderId?: string) => Promise;
+ listSharedDrives: () => Promise;
+ onShare: (files: DriveFile[], visibility: string[], associationIds: string[]) => Promise;
+ associations: { id: string; name: string }[];
+}
+
+export default function GoogleDrivePickerDialog({
+ open,
+ onOpenChange,
+ driveFiles,
+ browsing,
+ browseFiles,
+ listSharedDrives,
+ onShare,
+ associations,
+}: GoogleDrivePickerDialogProps) {
+ const [selectedFiles, setSelectedFiles] = useState([]);
+ const [folderStack, setFolderStack] = useState<{ id: string; name: string }[]>([]);
+ const [visibility, setVisibility] = useState(["admin"]);
+ const [selectedAssociationIds, setSelectedAssociationIds] = useState([]);
+ const [sharing, setSharing] = useState(false);
+ const [step, setStep] = useState<"browse" | "configure">("browse");
+
+ useEffect(() => {
+ if (open) {
+ listSharedDrives();
+ setSelectedFiles([]);
+ setFolderStack([]);
+ setVisibility(["admin"]);
+ setSelectedAssociationIds([]);
+ setStep("browse");
+ }
+ }, [open]);
+
+ const navigateToFolder = (file: DriveFile) => {
+ setFolderStack(prev => [...prev, { id: file.id, name: file.name }]);
+ browseFiles(file.id);
+ };
+
+ const navigateBack = () => {
+ const newStack = [...folderStack];
+ newStack.pop();
+ setFolderStack(newStack);
+ if (newStack.length === 0) {
+ listSharedDrives();
+ } else {
+ browseFiles(newStack[newStack.length - 1].id);
+ }
+ };
+
+ const toggleFile = (file: DriveFile) => {
+ setSelectedFiles(prev => {
+ const exists = prev.find(f => f.id === file.id);
+ if (exists) return prev.filter(f => f.id !== file.id);
+ return [...prev, file];
+ });
+ };
+
+ const isFolder = (f: DriveFile) => f.mimeType === "application/vnd.google-apps.folder";
+
+ const toggleVisibility = (role: string) => {
+ setVisibility(prev =>
+ prev.includes(role) ? prev.filter(r => r !== role) : [...prev, role]
+ );
+ };
+
+ const toggleAssociation = (id: string) => {
+ setSelectedAssociationIds(prev =>
+ prev.includes(id) ? prev.filter(a => a !== id) : [...prev, id]
+ );
+ };
+
+ const handleShare = async () => {
+ if (selectedFiles.length === 0) return;
+ setSharing(true);
+ await onShare(selectedFiles, visibility, selectedAssociationIds);
+ setSharing(false);
+ onOpenChange(false);
+ };
+
+ return (
+
+
+
+
+ {step === "browse" ? "Select Files from Google Drive" : "Configure Sharing"}
+
+
+ {step === "browse"
+ ? "Browse your Drive and select files or folders to share."
+ : "Choose who can see these files."}
+
+
+
+ {step === "browse" ? (
+ <>
+ {/* Breadcrumb */}
+
+ { setFolderStack([]); listSharedDrives(); }}
+ className="hover:text-foreground font-medium"
+ >
+ Shared Drives
+
+ {folderStack.map((f, i) => (
+
+
+ {
+ const newStack = folderStack.slice(0, i + 1);
+ setFolderStack(newStack);
+ browseFiles(f.id);
+ }}
+ className="hover:text-foreground"
+ >
+ {f.name}
+
+
+ ))}
+
+
+ {/* File list */}
+
+ {browsing ? (
+
+
+
+ ) : driveFiles.length === 0 ? (
+
+
+
This folder is empty
+
+ ) : (
+
+ {driveFiles.map(file => {
+ const folder = isFolder(file);
+ const selected = selectedFiles.some(f => f.id === file.id);
+
+ return (
+
+
toggleFile(file)}
+ />
+ folder ? navigateToFolder(file) : toggleFile(file)}
+ >
+ {file.iconLink ? (
+
+ ) : folder ? (
+
+ ) : (
+
+ )}
+
{file.name}
+ {folder &&
}
+
+
+ );
+ })}
+
+ )}
+
+
+ {/* Selection summary */}
+ {selectedFiles.length > 0 && (
+
+ {selectedFiles.length} item{selectedFiles.length !== 1 ? "s" : ""} selected
+
+ )}
+
+
+ onOpenChange(false)}>Cancel
+ setStep("configure")}
+ disabled={selectedFiles.length === 0}
+ >
+ Next: Configure Sharing
+
+
+ >
+ ) : (
+ <>
+ {/* Selected files summary */}
+
+
+
Selected Files
+
+ {selectedFiles.map(f => (
+
+ {isFolder(f) ? "📁 " : "📄 "}{f.name}
+
+ ))}
+
+
+
+ {/* Visibility */}
+
+
Who can see these files?
+
+ {[
+ { value: "admin", label: "Admin / Staff" },
+ { value: "board_member", label: "Board Members" },
+ { value: "homeowner", label: "Homeowners" },
+ ].map(role => (
+
+ toggleVisibility(role.value)}
+ disabled={role.value === "admin"}
+ />
+ {role.label}
+ {role.value === "admin" && (
+ (always visible)
+ )}
+
+ ))}
+
+
+
+ {/* Association filter */}
+ {(visibility.includes("board_member") || visibility.includes("homeowner")) && (
+
+
+ Share with which associations?
+
+
+ Only users belonging to selected associations will see these files.
+
+
+ {associations.map(a => (
+
+ toggleAssociation(a.id)}
+ />
+ {a.name}
+
+ ))}
+
+
+ )}
+
+
+
+ setStep("browse")}>
+ Back
+
+
+ {sharing ? : null}
+ {sharing ? "Sharing..." : `Share ${selectedFiles.length} Item${selectedFiles.length !== 1 ? "s" : ""}`}
+
+
+ >
+ )}
+
+
+ );
+}
diff --git a/src/components/documents/SaveToDocumentsDialog.jsx b/src/components/documents/SaveToDocumentsDialog.jsx
new file mode 100644
index 0000000..425b93c
--- /dev/null
+++ b/src/components/documents/SaveToDocumentsDialog.jsx
@@ -0,0 +1,263 @@
+import { useState, useEffect } from "react";
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
+import { Loader2, FolderUp, HardDrive } from "lucide-react";
+import { supabase } from "@/integrations/supabase/client";
+import { useToast } from "@/hooks/use-toast";
+import { useDocumentSummary } from "@/hooks/useDocumentSummary";
+import { buildSecureStorageKey } from "@/lib/secureStorageNaming";
+import { useGoogleDrive } from "@/hooks/useGoogleDrive";
+import GoogleDriveFolderPickerDialog from "@/components/documents/GoogleDriveFolderPickerDialog";
+
+const defaultFolders = [
+ "Uncategorized", "Bank Statements", "Collections", "Financials", "Insurance",
+ "Litigation", "Management Reports", "Meetings", "New Owner Files",
+ "Owner Correspondence", "Reports",
+];
+
+/**
+ * SaveToDocumentsDialog
+ *
+ * Props:
+ * - open / onOpenChange
+ * - generatePdfBlob: async () => Blob — called to produce the PDF
+ * - defaultTitle: string
+ * - associationId: string | null — pre-selected association
+ * - associations: { id, name }[] — if not provided, will fetch
+ */
+export default function SaveToDocumentsDialog({
+ open,
+ onOpenChange,
+ generatePdfBlob,
+ defaultTitle = "",
+ associationId = null,
+ associations: externalAssociations,
+ residentFileMode = false,
+ unitId = "",
+ unitNumber = "",
+ unitAddress = "",
+}) {
+ const { toast } = useToast();
+ const { generateSummary } = useDocumentSummary();
+ const drive = useGoogleDrive();
+ const [saving, setSaving] = useState(false);
+ const [title, setTitle] = useState("");
+ const [folder, setFolder] = useState("Owner Correspondence");
+ const [selectedAssociation, setSelectedAssociation] = useState("");
+ const [associations, setAssociations] = useState([]);
+ const [customFolders, setCustomFolders] = useState([]);
+ const [saveToDrive, setSaveToDrive] = useState(false);
+ const [driveFolder, setDriveFolder] = useState(null);
+ const [drivePickerOpen, setDrivePickerOpen] = useState(false);
+ const [resolvedAddress, setResolvedAddress] = useState("");
+
+ // Auto-recognize the unit's street address. Prefer an address explicitly
+ // passed in; otherwise look it up from the units table by unit_id so the
+ // Resident Files folder is named by address instead of an opaque UUID.
+ useEffect(() => {
+ if (!residentFileMode) { setResolvedAddress(""); return; }
+ if (unitAddress) { setResolvedAddress(unitAddress); return; }
+ if (!unitId) { setResolvedAddress(""); return; }
+ let cancelled = false;
+ supabase.from("units").select("address, unit_number").eq("id", unitId).maybeSingle()
+ .then(({ data }) => { if (!cancelled) setResolvedAddress(data?.address || data?.unit_number || ""); });
+ return () => { cancelled = true; };
+ }, [residentFileMode, unitId, unitAddress]);
+
+ // Folder name uses the address when available, then unit number, then UUID.
+ const residentFolderName = resolvedAddress || unitAddress || unitNumber || unitId;
+ const residentFolder = residentFolderName ? `Resident Files/${residentFolderName}` : "Resident Files";
+
+ useEffect(() => {
+ if (open) {
+ setTitle(defaultTitle);
+ setSelectedAssociation(associationId || "");
+ setSaveToDrive(false);
+ setDriveFolder(null);
+ if (residentFileMode) setFolder(residentFolder);
+ // Load custom folders from localStorage
+ try {
+ const stored = localStorage.getItem("doc_custom_folders");
+ if (stored) setCustomFolders(JSON.parse(stored));
+ } catch { setCustomFolders([]); }
+ }
+ }, [open, defaultTitle, associationId, residentFileMode, residentFolder]);
+
+ useEffect(() => {
+ if (externalAssociations) {
+ setAssociations(externalAssociations);
+ } else if (open) {
+ supabase.from("associations").select("id, name").eq("status", "active").order("name")
+ .then(({ data }) => setAssociations(data || []));
+ }
+ }, [open, externalAssociations]);
+
+ const allFolders = [...new Set([...defaultFolders, ...customFolders])].sort();
+
+ const handleSave = async () => {
+ if (!title.trim()) {
+ toast({ title: "Title required", description: "Please enter a document title.", variant: "destructive" });
+ return;
+ }
+ if (!selectedAssociation) {
+ toast({ title: "Association required", description: "Please select an association.", variant: "destructive" });
+ return;
+ }
+
+ setSaving(true);
+ try {
+ // Generate PDF blob
+ const blob = await generatePdfBlob();
+ if (!blob) throw new Error("Failed to generate PDF");
+
+ // Display name preserved on the documents row; storage key is randomized
+ // so the public URL doesn't expose the document's real name.
+ const displayFileName = `${title.replace(/[^a-zA-Z0-9._ -]+/g, "_").trim()}.pdf`;
+ const filePath = buildSecureStorageKey(
+ "doc.pdf",
+ `documents/${selectedAssociation}`,
+ );
+
+ const { error: uploadError } = await supabase.storage
+ .from("files")
+ .upload(filePath, blob, { contentType: "application/pdf" });
+ if (uploadError) throw uploadError;
+
+ const { data: urlData } = supabase.storage.from("files").getPublicUrl(filePath);
+
+ // Create document record
+ const { data: { user } } = await supabase.auth.getUser();
+ const { data: insertData, error: insertError } = await supabase.from("documents").insert({
+ title: title.trim(),
+ association_id: selectedAssociation,
+ category: folder === "Uncategorized" ? "general" : folder,
+ file_name: displayFileName,
+ file_url: urlData.publicUrl,
+ file_size: blob.size,
+ uploaded_by: user?.id || null,
+ }).select("id").single();
+ if (insertError) throw insertError;
+
+ if (saveToDrive) {
+ if (!drive.isConnected) throw new Error("Connect Google Drive before saving there.");
+ const driveFile = new File([blob], displayFileName, { type: "application/pdf" });
+ const result = await drive.uploadFile(driveFile, driveFolder?.id || undefined);
+ if (!result.success) throw new Error("Document saved locally, but Google Drive upload failed.");
+ }
+
+ // Trigger AI summary in background for management reports
+ const folderLower = (folder || "").toLowerCase();
+ if (folderLower.includes("management") || folderLower.includes("report")) {
+ generateSummary(insertData.id, urlData.publicUrl, title.trim());
+ }
+
+ toast({ title: "Saved to Documents", description: saveToDrive ? `"${title}" saved to Documents and Google Drive.` : `"${title}" saved to ${folder}.` });
+ onOpenChange(false);
+ } catch (err) {
+ console.error("Error saving to documents:", err);
+ toast({ title: "Error", description: err.message || "Failed to save document.", variant: "destructive" });
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ return (
+
+
+
+
+
+ {residentFileMode ? "Save to Resident File" : "Save to Documents"}
+
+
+ {residentFileMode
+ ? `This document will be saved under "Resident Files / ${residentFolderName || "(no unit)"}" inside the selected association.`
+ : "Save this file to the Documents library for easy access."}
+
+
+
+
+
+ Document Title
+ setTitle(e.target.value)} placeholder="e.g. Late Notice - Unit 101" />
+
+
+
+ Association
+
+
+
+
+
+ {associations.map((a) => (
+ {a.name}
+ ))}
+
+
+
+
+
+ Folder
+ {residentFileMode ? (
+
+ ) : (
+
+
+
+
+
+ {allFolders.map((f) => (
+ {f}
+ ))}
+
+
+ )}
+
+
+
+
+
+
+ Save to Google Drive
+
+
+ {driveFolder?.name || "Choose a Drive folder for this PDF."}
+
+
+
{ setSaveToDrive(true); setDrivePickerOpen(true); }} disabled={!drive.isConnected || saving}>
+ Choose
+
+
+ {!drive.isConnected &&
Connect Google Drive from Documents before saving files there.
}
+ {driveFolder && (
+
{ setSaveToDrive(false); setDriveFolder(null); }}>
+ Remove Google Drive save
+
+ )}
+
+
+
+
+ onOpenChange(false)} disabled={saving}>Cancel
+
+ {saving && }
+ {residentFileMode ? "Save to Resident File" : "Save to Documents"}
+
+
+
+
+ { setDriveFolder(selected); setSaveToDrive(true); }}
+ />
+
+ );
+}
diff --git a/src/components/elections/ElectionCertificationPDF.jsx b/src/components/elections/ElectionCertificationPDF.jsx
new file mode 100644
index 0000000..07ffc41
--- /dev/null
+++ b/src/components/elections/ElectionCertificationPDF.jsx
@@ -0,0 +1,200 @@
+import jsPDF from 'jspdf';
+import autoTable from 'jspdf-autotable';
+import { format } from 'date-fns';
+
+function generateHash(election, tallies) {
+ const data = `${election.id}-${JSON.stringify(tallies)}-${Date.now()}`;
+ let hash = 0;
+ for (let i = 0; i < data.length; i++) {
+ const char = data.charCodeAt(i);
+ hash = ((hash << 5) - hash) + char;
+ hash |= 0;
+ }
+ return Math.abs(hash).toString(16).toUpperCase().padStart(8, '0');
+}
+
+export function generateCertificationPDF({ election, positions, candidates, voters, tallies }) {
+ const doc = new jsPDF();
+ const pageW = doc.internal.pageSize.getWidth();
+ const pageH = doc.internal.pageSize.getHeight();
+ let y = 20;
+
+ // Navy header bar
+ doc.setFillColor(30, 58, 95);
+ doc.rect(0, 0, pageW, 35, 'F');
+
+ doc.setTextColor(255, 255, 255);
+ doc.setFontSize(18);
+ doc.setFont('helvetica', 'bold');
+ doc.text('CERTIFICATE OF ELECTION', pageW / 2, 18, { align: 'center' });
+ doc.setFontSize(9);
+ doc.setFont('helvetica', 'normal');
+ doc.text('Official Certified Results', pageW / 2, 28, { align: 'center' });
+
+ doc.setTextColor(0, 0, 0);
+ y = 45;
+
+ // Association name
+ doc.setFontSize(14);
+ doc.setFont('helvetica', 'bold');
+ doc.text(election.association?.name || 'Association', pageW / 2, y, { align: 'center' });
+ y += 8;
+
+ doc.setFontSize(12);
+ doc.setFont('helvetica', 'normal');
+ doc.text(election.title, pageW / 2, y, { align: 'center' });
+ y += 12;
+
+ // Election details
+ doc.setFontSize(9);
+ const votedOnlineCount = voters.filter(v => v.has_voted).length;
+ const totalParticipation = votedOnlineCount + (election.proxies_count || 0) + (election.in_person_count || 0);
+ const totalTallyVotes = Object.values(tallies).reduce((a, b) => a + b, 0);
+
+ const details = [
+ ['Election Type', election.election_type?.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase()) || 'Board of Directors'],
+ ['Voting Period', `${election.voting_start ? format(new Date(election.voting_start), 'MMM d, yyyy') : 'N/A'} — ${election.voting_end ? format(new Date(election.voting_end), 'MMM d, yyyy') : 'N/A'}`],
+ ['Date Certified', format(new Date(), 'MMMM d, yyyy')],
+ ['Eligible Voters', String(voters.length)],
+ ['Online Votes', String(votedOnlineCount)],
+ ['Proxies', String(election.proxies_count || 0)],
+ ['In-Person Ballots', String(totalTallyVotes)],
+ ['Total Participation', String(totalParticipation + totalTallyVotes)],
+ ['Quorum Required', election.quorum_required > 0 ? String(election.quorum_required) : 'N/A'],
+ ['Quorum Met', election.quorum_required > 0 ? ((totalParticipation + totalTallyVotes) >= election.quorum_required ? 'YES' : 'NO') : 'N/A'],
+ ];
+
+ autoTable(doc, {
+ startY: y,
+ body: details,
+ theme: 'plain',
+ styles: { fontSize: 9, cellPadding: 2 },
+ columnStyles: {
+ 0: { fontStyle: 'bold', cellWidth: 50 },
+ 1: { cellWidth: 'auto' },
+ },
+ margin: { left: 30, right: 30 },
+ });
+ y = doc.lastAutoTable.finalY + 10;
+
+ // Separator
+ doc.setDrawColor(30, 58, 95);
+ doc.setLineWidth(0.8);
+ doc.line(30, y, pageW - 30, y);
+ y += 10;
+
+ // Results heading
+ doc.setFontSize(13);
+ doc.setFont('helvetica', 'bold');
+ doc.text('CERTIFIED RESULTS', pageW / 2, y, { align: 'center' });
+ y += 10;
+
+ // Results per position
+ positions.forEach(pos => {
+ const posCandidates = candidates.filter(c => c.position_id === pos.id);
+ const posTotal = posCandidates.reduce((sum, c) => sum + (tallies[c.id] || 0), 0);
+
+ // Sort candidates by votes descending
+ const sorted = [...posCandidates].sort((a, b) => (tallies[b.id] || 0) - (tallies[a.id] || 0));
+ const winners = sorted.slice(0, pos.seats_available);
+
+ const body = sorted.map((c, idx) => {
+ const count = tallies[c.id] || 0;
+ const pct = posTotal > 0 ? Math.round((count / posTotal) * 100) : 0;
+ const isWinner = winners.some(w => w.id === c.id) && count > 0;
+ return [
+ isWinner ? `★ ${c.candidate_name}` : ` ${c.candidate_name}`,
+ String(count),
+ `${pct}%`,
+ isWinner ? 'ELECTED' : '',
+ ];
+ });
+
+ if (y > 230) { doc.addPage(); y = 20; }
+
+ autoTable(doc, {
+ startY: y,
+ head: [[`${pos.title} (${pos.seats_available} seat${pos.seats_available > 1 ? 's' : ''})`, 'Votes', '%', 'Result']],
+ body,
+ theme: 'grid',
+ headStyles: { fillColor: [30, 58, 95], fontSize: 9, fontStyle: 'bold' },
+ bodyStyles: { fontSize: 9 },
+ columnStyles: {
+ 3: { fontStyle: 'bold', textColor: [30, 58, 95] },
+ },
+ margin: { left: 30, right: 30 },
+ didParseCell: function(data) {
+ if (data.section === 'body' && data.row.raw[3] === 'ELECTED') {
+ data.cell.styles.fillColor = [235, 245, 255];
+ }
+ },
+ });
+ y = doc.lastAutoTable.finalY + 8;
+ });
+
+ // Compliance section
+ if (y > 220) { doc.addPage(); y = 20; }
+ y += 5;
+ doc.setDrawColor(30, 58, 95);
+ doc.setLineWidth(0.5);
+ doc.line(30, y, pageW - 30, y);
+ y += 8;
+
+ doc.setFontSize(10);
+ doc.setFont('helvetica', 'bold');
+ doc.text('CERTIFICATION STATEMENT', 30, y);
+ y += 7;
+
+ doc.setFontSize(8);
+ doc.setFont('helvetica', 'normal');
+ const certLines = [
+ 'I hereby certify that the above election results are true and accurate to the best of my knowledge.',
+ 'All votes were counted in accordance with the governing documents of the association and',
+ 'applicable state statutes (FL §718.112 / §720.306). Ballot secrecy was maintained throughout',
+ 'the election process. An independent audit trail exists recording participation without',
+ 'revealing individual vote choices.',
+ '',
+ `Verification Hash: ${generateHash(election, tallies)}`,
+ ];
+ certLines.forEach(line => { doc.text(line, 30, y); y += 4.5; });
+
+ // Signature lines
+ y += 15;
+ if (y > 260) { doc.addPage(); y = 30; }
+
+ doc.setLineWidth(0.3);
+ doc.setDrawColor(0, 0, 0);
+
+ // Left signature
+ doc.line(30, y, 95, y);
+ y += 5;
+ doc.setFontSize(8);
+ doc.text('Election Inspector / Secretary', 30, y);
+
+ // Right signature
+ doc.line(pageW / 2 + 15, y - 5, pageW - 30, y - 5);
+ doc.text('Date', pageW / 2 + 15, y);
+
+ y += 12;
+ doc.line(30, y, 95, y);
+ y += 5;
+ doc.text('Association President / Chair', 30, y);
+
+ doc.line(pageW / 2 + 15, y - 5, pageW - 30, y - 5);
+ doc.text('Date', pageW / 2 + 15, y);
+
+ // Footer
+ const totalPages = doc.internal.getNumberOfPages();
+ for (let i = 1; i <= totalPages; i++) {
+ doc.setPage(i);
+ doc.setFontSize(7);
+ doc.setTextColor(150, 150, 150);
+ doc.text(`Page ${i} of ${totalPages}`, pageW / 2, pageH - 10, { align: 'center' });
+ doc.text(`Generated: ${format(new Date(), 'MMM d, yyyy h:mm a')}`, pageW - 15, pageH - 10, { align: 'right' });
+ doc.setTextColor(0, 0, 0);
+ }
+
+ const fileName = `Election_Certification_${election.title.replace(/\s+/g, '_')}_${format(new Date(), 'yyyy-MM-dd')}.pdf`;
+ doc.save(fileName);
+ return fileName;
+}
\ No newline at end of file
diff --git a/src/components/elections/ElectionDetailDialog.jsx b/src/components/elections/ElectionDetailDialog.jsx
new file mode 100644
index 0000000..3a1ccd8
--- /dev/null
+++ b/src/components/elections/ElectionDetailDialog.jsx
@@ -0,0 +1,545 @@
+import React, { useState, useEffect, useCallback } from 'react';
+import { useToast } from '@/hooks/use-toast';
+import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
+import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
+import { Button } from '@/components/ui/button';
+import { Badge } from '@/components/ui/badge';
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import { Textarea } from '@/components/ui/textarea';
+import { ScrollArea } from '@/components/ui/scroll-area';
+import { Progress } from '@/components/ui/progress';
+import { Plus, Trash2, Users, Vote, Shield, BarChart3, Lock, Unlock, CheckCircle2, XCircle, Loader2, UserPlus, Send, Download } from 'lucide-react';
+import { useElections } from '@/hooks/useElections';
+import { supabase } from '@/integrations/supabase/client';
+import { format } from 'date-fns';
+import ElectionReportGenerator from './ElectionReportGenerator';
+import ElectionNoticeGenerator from './ElectionNoticeGenerator';
+import ElectionTallyCounter from './ElectionTallyCounter';
+import { generateCertificationPDF } from './ElectionCertificationPDF';
+
+export default function ElectionDetailDialog({ open, onOpenChange, election, onUpdate }) {
+ const {
+ fetchPositions, createPosition, deletePosition,
+ fetchAllCandidates, createCandidate, deleteCandidate,
+ fetchEligibleVoters, addEligibleVoters, updateVoterConsent,
+ fetchResults, fetchAuditLog, updateElection, loading
+ } = useElections();
+ const { toast } = useToast();
+
+ const [tab, setTab] = useState('positions');
+ const [positions, setPositions] = useState([]);
+ const [candidates, setCandidates] = useState([]);
+ const [voters, setVoters] = useState([]);
+ const [results, setResults] = useState([]);
+ const [auditLog, setAuditLog] = useState([]);
+ const [newPosition, setNewPosition] = useState('');
+ const [newCandidate, setNewCandidate] = useState({ name: '', bio: '', positionId: '' });
+ const [addingVoters, setAddingVoters] = useState(false);
+ const [sendingInvites, setSendingInvites] = useState(false);
+
+ const handleSendInvites = async () => {
+ setSendingInvites(true);
+ try {
+ const baseUrl = window.location.origin;
+ const { data, error } = await supabase.functions.invoke('send-election-invites', {
+ body: { election_id: election.id, base_url: baseUrl }
+ });
+ if (error) throw error;
+ const msg = `Sent ${data.sent} invite(s)${data.failed ? `, ${data.failed} failed` : ''}`;
+ toast({ title: 'Invites Sent', description: msg });
+ loadData();
+ } catch (err) {
+ console.error(err);
+ toast({ variant: 'destructive', title: 'Error', description: 'Failed to send invites.' });
+ } finally {
+ setSendingInvites(false);
+ }
+ };
+
+ const loadData = useCallback(async () => {
+ if (!election?.id) return;
+ const [pos, cands, vot] = await Promise.all([
+ fetchPositions(election.id),
+ fetchAllCandidates(election.id),
+ fetchEligibleVoters(election.id),
+ ]);
+ setPositions(pos);
+ setCandidates(cands);
+ setVoters(vot);
+ }, [election?.id, fetchPositions, fetchAllCandidates, fetchEligibleVoters]);
+
+ useEffect(() => {
+ if (open && election?.id) loadData();
+ }, [open, election?.id, loadData]);
+
+ useEffect(() => {
+ if (tab === 'results' && election?.id) {
+ fetchResults(election.id).then(setResults);
+ }
+ }, [tab, election?.id, fetchResults]);
+
+ const handleAddPosition = async () => {
+ if (!newPosition.trim()) return;
+ await createPosition({ election_id: election.id, title: newPosition.trim(), sort_order: positions.length });
+ setNewPosition('');
+ loadData();
+ };
+
+ const handleAddCandidate = async () => {
+ if (!newCandidate.name.trim() || !newCandidate.positionId) return;
+ await createCandidate({
+ position_id: newCandidate.positionId,
+ candidate_name: newCandidate.name.trim(),
+ bio: newCandidate.bio || null,
+ sort_order: candidates.filter(c => c.position_id === newCandidate.positionId).length
+ });
+ setNewCandidate({ name: '', bio: '', positionId: '' });
+ loadData();
+ };
+
+ const handleAutoPopulateVoters = async () => {
+ setAddingVoters(true);
+ try {
+ const { data: owners } = await supabase
+ .from('owners')
+ .select('id, unit_id')
+ .eq('association_id', election.association_id)
+ .eq('status', 'active');
+
+ if (!owners?.length) return;
+
+ // Deduplicate by unit - one vote per unit
+ const unitMap = new Map();
+ owners.forEach(o => {
+ if (o.unit_id && !unitMap.has(o.unit_id)) {
+ unitMap.set(o.unit_id, o);
+ }
+ });
+
+ const existingUnits = new Set(voters.map(v => v.unit_id));
+ const newVoters = Array.from(unitMap.values())
+ .filter(o => !existingUnits.has(o.unit_id))
+ .map(o => ({
+ election_id: election.id,
+ owner_id: o.id,
+ unit_id: o.unit_id,
+ }));
+
+ if (newVoters.length > 0) {
+ await addEligibleVoters(newVoters);
+ loadData();
+ }
+ } finally {
+ setAddingVoters(false);
+ }
+ };
+
+ const handleToggleConsent = async (voterId, current) => {
+ await updateVoterConsent(voterId, !current);
+ loadData();
+ };
+
+ const handleToggleResultsLock = async () => {
+ await updateElection(election.id, { results_locked: !election.results_locked });
+ onUpdate();
+ };
+
+ const totalVoters = voters.length;
+ const votedCount = voters.filter(v => v.has_voted).length;
+ const consentCount = voters.filter(v => v.has_consent).length;
+ const quorumMet = election.quorum_required > 0
+ ? (votedCount + (election.proxies_count || 0) + (election.in_person_count || 0)) >= election.quorum_required
+ : true;
+ const totalParticipation = votedCount + (election.proxies_count || 0) + (election.in_person_count || 0);
+
+ // Tally results by position
+ const resultsByPosition = {};
+ positions.forEach(p => { resultsByPosition[p.id] = {}; });
+ results.forEach(b => {
+ if (!resultsByPosition[b.position_id]) resultsByPosition[b.position_id] = {};
+ resultsByPosition[b.position_id][b.candidate_id] = (resultsByPosition[b.position_id][b.candidate_id] || 0) + 1;
+ });
+
+ return (
+
+
+
+
+ {election.title}
+
+ {election.status}
+
+
+
+ Election details, voter participation, tally tools, and certified report exports.
+
+
+ {election.status === 'active' && (
+
+ {sendingInvites ? : }
+ Send Voting Links
+
+ )}
+
+
+
+
+
+
+ {/* Quick stats bar */}
+
+
+
{totalVoters}
+
Eligible
+
+
+
{consentCount}
+
Consented
+
+
+
{totalParticipation}
+
Participated
+
+
+
+ {quorumMet ? '✓' : '✗'}
+
+
Quorum {election.quorum_required > 0 ? `(${election.quorum_required})` : '(N/A)'}
+
+
+
+ {totalVoters > 0 && (
+
+
+ Participation: {votedCount}/{totalVoters} online votes
+ {totalVoters > 0 ? Math.round((votedCount / totalVoters) * 100) : 0}%
+
+
0 ? (votedCount / totalVoters) * 100 : 0} className="h-2" />
+
+ )}
+
+
+
+ Tally
+ Positions
+ Voters
+ Quorum
+ Results
+
+
+ {/* TALLY TAB */}
+
+
+ {election.status !== 'closed' ? (
+
+
Close voting before tallying in-person ballots.
+
+ ) : positions.length === 0 || candidates.length === 0 ? (
+
+
Add positions and candidates first.
+
+ ) : (
+ {
+ try {
+ console.log('Starting certification with tallies:', tallies);
+ // Lock results
+ const lockResult = await updateElection(election.id, { results_locked: true, status: 'closed' });
+ console.log('Lock result:', lockResult);
+ if (!lockResult) {
+ toast({ variant: 'destructive', title: 'Error', description: 'Failed to lock election results. Check permissions.' });
+ return;
+ }
+ // Generate certification PDF
+ console.log('Generating PDF...');
+ const fileName = generateCertificationPDF({
+ election,
+ positions,
+ candidates,
+ voters,
+ tallies,
+ });
+ console.log('PDF generated:', fileName);
+ toast({ title: 'Election Certified', description: `Results locked and certification downloaded: ${fileName}` });
+ onUpdate();
+ } catch (err) {
+ console.error('Certification error:', err);
+ console.error('Error stack:', err?.stack);
+ toast({ variant: 'destructive', title: 'Error', description: err?.message || 'Failed to certify election.' });
+ }
+ }}
+ />
+ )}
+
+
+
+ {/* POSITIONS & CANDIDATES TAB */}
+
+
+
+ {election.status === 'draft' && (
+
+
setNewPosition(e.target.value)} placeholder="Position title (e.g. President)" className="flex-1" onKeyDown={e => e.key === 'Enter' && (e.preventDefault(), handleAddPosition())} />
+
Add Position
+
+ )}
+
+ {positions.map(pos => {
+ const posCandidates = candidates.filter(c => c.position_id === pos.id);
+ return (
+
+
+
+
{pos.title}
+
+ {pos.seats_available} seat(s)
+ {election.status === 'draft' && (
+ { await deletePosition(pos.id); loadData(); }}>
+
+
+ )}
+
+
+
+
+ {posCandidates.map(c => (
+
+
+
{c.candidate_name}
+ {c.bio &&
{c.bio}
}
+
+ {election.status === 'draft' && (
+
{ await deleteCandidate(c.id); loadData(); }}>
+
+
+ )}
+
+ ))}
+ {election.status === 'draft' && (
+
+ setNewCandidate({ name: e.target.value, bio: newCandidate.positionId === pos.id ? newCandidate.bio : '', positionId: pos.id })}
+ className="flex-1 h-8 text-sm"
+ onKeyDown={e => {
+ if (e.key === 'Enter') {
+ e.preventDefault();
+ if (newCandidate.positionId === pos.id && newCandidate.name.trim()) {
+ handleAddCandidate();
+ }
+ }
+ }}
+ />
+ {
+ if (newCandidate.positionId === pos.id && newCandidate.name.trim()) {
+ handleAddCandidate();
+ }
+ }}
+ >
+ Add
+
+
+ )}
+
+
+ );
+ })}
+
+ {!positions.length && (
+
+ No positions added yet. Add positions above to get started.
+
+ )}
+
+
+
+
+ {/* VOTERS TAB */}
+
+
+
+ {election.status !== 'closed' && (
+
+ {addingVoters ? : }
+ Auto-Populate from Owner Roster
+
+ )}
+
+
+
+
+
+ Owner
+ Unit
+ Consent
+ Voted
+
+
+
+ {voters.map(v => (
+
+
+ {v.owner?.first_name} {v.owner?.last_name}
+
+ {v.owner?.email}
+
+ {v.unit?.unit_number || v.unit?.address || '—'}
+
+ handleToggleConsent(v.id, v.has_consent)}
+ className={v.has_consent ? 'text-primary' : 'text-muted-foreground'}
+ >
+ {v.has_consent ? : }
+
+
+
+ {v.has_voted ? (
+ Voted
+ ) : (
+ Pending
+ )}
+
+
+ ))}
+
+
+ {!voters.length && (
+
No eligible voters added.
+ )}
+
+
+
+
+
+ {/* QUORUM TAB */}
+
+
+
+
+
+ Quorum Tracking
+
+
+
Online Votes
+
{votedCount}
+
+
+ Proxies
+ updateElection(election.id, { proxies_count: parseInt(e.target.value) || 0 }).then(onUpdate)}
+ className="h-10 text-lg font-bold"
+ />
+
+
+ In-Person
+ updateElection(election.id, { in_person_count: parseInt(e.target.value) || 0 }).then(onUpdate)}
+ className="h-10 text-lg font-bold"
+ />
+
+
+
+
+ Total Participation
+ {totalParticipation}
+
+ {election.quorum_required > 0 && (
+
+
+
+ {totalParticipation}/{election.quorum_required} needed
+ {quorumMet ? ' — Quorum Met ✓' : ' — Quorum Not Yet Met'}
+
+
+ )}
+
+
+
+
+
+
+
+ {/* RESULTS TAB */}
+
+
+
+
+
Election Results
+
+ {election.results_locked ? : }
+ {election.results_locked ? 'Unlock Results' : 'Lock Results'}
+
+
+
+ {election.results_locked ? (
+
+
+
+ Results are locked. Unlock to view tallies.
+
+
+ ) : (
+ positions.map(pos => {
+ const posCandidates = candidates.filter(c => c.position_id === pos.id);
+ const posResults = resultsByPosition[pos.id] || {};
+ const totalPosVotes = Object.values(posResults).reduce((a, b) => a + b, 0);
+ return (
+
+
+ {pos.title}
+
+
+ {posCandidates.map(c => {
+ const count = posResults[c.id] || 0;
+ const pct = totalPosVotes > 0 ? Math.round((count / totalPosVotes) * 100) : 0;
+ return (
+
+
+ {c.candidate_name}
+ {count} votes ({pct}%)
+
+
+
+ );
+ })}
+ {!posCandidates.length && No candidates
}
+
+
+ );
+ })
+ )}
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/elections/ElectionNoticeGenerator.jsx b/src/components/elections/ElectionNoticeGenerator.jsx
new file mode 100644
index 0000000..712bd29
--- /dev/null
+++ b/src/components/elections/ElectionNoticeGenerator.jsx
@@ -0,0 +1,163 @@
+import React, { useState } from 'react';
+import { Button } from '@/components/ui/button';
+import { Printer, Loader2 } from 'lucide-react';
+import { format } from 'date-fns';
+import { useToast } from '@/hooks/use-toast';
+import QRCode from 'qrcode';
+
+export default function ElectionNoticeGenerator({ election, positions, candidates, voters }) {
+ const [generating, setGenerating] = useState(false);
+ const { toast } = useToast();
+
+ const generateNotices = async () => {
+ setGenerating(true);
+ try {
+ const votingUrl = `${window.location.origin}/vote/${election.id}`;
+ const assocName = election.association?.name || 'Your Association';
+ const votingStart = election.voting_start ? format(new Date(election.voting_start), 'MMMM d, yyyy h:mm a') : 'TBD';
+ const votingEnd = election.voting_end ? format(new Date(election.voting_end), 'MMMM d, yyyy h:mm a') : 'TBD';
+
+ // Build positions/candidates summary
+ const positionsSummary = positions.map(pos => {
+ const posCandidates = candidates.filter(c => c.position_id === pos.id);
+ return `
+
+
${pos.title} (${pos.seats_available} seat${pos.seats_available > 1 ? 's' : ''})
+
+ ${posCandidates.map(c => `${c.candidate_name}${c.bio ? ` — ${c.bio} ` : ''} `).join('')}
+ ${posCandidates.length === 0 ? 'No candidates listed yet ' : ''}
+
+
`;
+ }).join('');
+
+ // Generate QR codes for each voter
+ const voterPages = await Promise.all(voters.map(async (v, idx) => {
+ const ownerName = `${v.owner?.first_name || ''} ${v.owner?.last_name || ''}`.trim() || 'Owner';
+ const unitLabel = v.unit?.unit_number || v.unit?.address || 'N/A';
+ const tokenUrl = v.vote_token ? `${votingUrl}?token=${v.vote_token}` : votingUrl;
+
+ // Generate QR code as data URL
+ let qrDataUrl = '';
+ try {
+ qrDataUrl = await QRCode.toDataURL(tokenUrl, {
+ width: 160,
+ margin: 1,
+ color: { dark: '#1e3a5f', light: '#ffffff' }
+ });
+ } catch (e) {
+ console.error('QR generation failed:', e);
+ }
+
+ return `
+
+
+
+
+
Official Election Notice
+
${assocName}
+
+
+
To: ${ownerName}
+
Unit: ${unitLabel}
+
Date: ${format(new Date(), 'MMMM d, yyyy')}
+
+
+
${election.title}
+ ${election.description ? `
${election.description}
` : ''}
+
Voting Opens: ${votingStart}
+
Voting Closes: ${votingEnd}
+
+
+ ${positions.length > 0 ? `
+
Positions & Candidates
+ ${positionsSummary}
+ ` : ''}
+
+
+
YOUR ACCESS CODE
+
Use this code to cast your ballot online
+
+ ${v.access_code || '------'}
+
+
+
+
+
+
+
How to Vote
+
Option 1 — Scan QR Code: Use your phone camera to scan the code.
+
Option 2 — Visit Website:
+
+ Go to ${window.location.origin}/vote/${election.id}
+ Enter your 6-character access code above
+ Review candidates and submit your ballot
+
+
+ ${qrDataUrl ? `
+
+
+
Scan to Vote
+
+ ` : ''}
+
+
+
+
+
Important: Per Florida Statutes §718.112 / §720.306, electronic voting requires your prior consent. Your ballot is secret — only participation is recorded.
+
You may change your vote at any time before the voting deadline. For questions, contact your association manager.
+
+
+
+
`;
+ }));
+
+ const html = `
+
+
+Election Notices - ${election.title}
+
+
+
+ 🖨️ Print All Notices (${voters.length})
+ Close
+
+${voterPages.join('')}
+`;
+
+ const w = window.open('', '_blank');
+ w.document.write(html);
+ w.document.close();
+ toast({ title: 'Notices Generated', description: `${voters.length} voter notice(s) ready to print.` });
+ } catch (err) {
+ console.error('Notice generation error:', err);
+ toast({ variant: 'destructive', title: 'Error', description: 'Failed to generate notices.' });
+ } finally {
+ setGenerating(false);
+ }
+ };
+
+ return (
+
+ {generating ? : }
+ Print Voter Notices
+
+ );
+}
diff --git a/src/components/elections/ElectionReportGenerator.jsx b/src/components/elections/ElectionReportGenerator.jsx
new file mode 100644
index 0000000..717c038
--- /dev/null
+++ b/src/components/elections/ElectionReportGenerator.jsx
@@ -0,0 +1,259 @@
+import React, { useState } from 'react';
+import { Button } from '@/components/ui/button';
+import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
+import { FileText, Download, Loader2 } from 'lucide-react';
+import { format } from 'date-fns';
+import jsPDF from 'jspdf';
+import autoTable from 'jspdf-autotable';
+import { useToast } from '@/hooks/use-toast';
+
+export default function ElectionReportGenerator({ election, positions, candidates, voters, results }) {
+ const [generating, setGenerating] = useState(false);
+ const { toast } = useToast();
+
+ const resultsByPosition = {};
+ positions.forEach(p => { resultsByPosition[p.id] = {}; });
+ results.forEach(b => {
+ if (!resultsByPosition[b.position_id]) resultsByPosition[b.position_id] = {};
+ resultsByPosition[b.position_id][b.candidate_id] = (resultsByPosition[b.position_id][b.candidate_id] || 0) + 1;
+ });
+
+ const votedCount = voters.filter(v => v.has_voted).length;
+ const consentCount = voters.filter(v => v.has_consent).length;
+ const totalParticipation = votedCount + (election.proxies_count || 0) + (election.in_person_count || 0);
+
+ const generatePDF = async () => {
+ setGenerating(true);
+ try {
+ const doc = new jsPDF();
+ const pageW = doc.internal.pageSize.getWidth();
+ let y = 20;
+
+ // Title
+ doc.setFontSize(18);
+ doc.setFont('helvetica', 'bold');
+ doc.text('CERTIFIED ELECTION REPORT', pageW / 2, y, { align: 'center' });
+ y += 10;
+
+ doc.setFontSize(10);
+ doc.setFont('helvetica', 'normal');
+ doc.text('Compliant with Florida Statutes §718 / §720', pageW / 2, y, { align: 'center' });
+ y += 15;
+
+ // Election info
+ doc.setFontSize(12);
+ doc.setFont('helvetica', 'bold');
+ doc.text(election.title, 14, y);
+ y += 7;
+
+ doc.setFontSize(9);
+ doc.setFont('helvetica', 'normal');
+ const infoLines = [
+ `Association: ${election.association?.name || 'N/A'}`,
+ `Type: ${election.election_type?.replace('_', ' ').replace(/\b\w/g, l => l.toUpperCase()) || 'N/A'}`,
+ `Status: ${election.status?.toUpperCase()}`,
+ `Voting Period: ${election.voting_start ? format(new Date(election.voting_start), 'MMM d, yyyy h:mm a') : 'N/A'} — ${election.voting_end ? format(new Date(election.voting_end), 'MMM d, yyyy h:mm a') : 'N/A'}`,
+ `Report Generated: ${format(new Date(), 'MMM d, yyyy h:mm a')}`,
+ ];
+ infoLines.forEach(line => { doc.text(line, 14, y); y += 5; });
+ y += 5;
+
+ // Quorum summary
+ doc.setFontSize(11);
+ doc.setFont('helvetica', 'bold');
+ doc.text('PARTICIPATION & QUORUM', 14, y);
+ y += 7;
+
+ autoTable(doc, {
+ startY: y,
+ head: [['Metric', 'Count']],
+ body: [
+ ['Total Eligible Voters', String(voters.length)],
+ ['Electronic Consents', String(consentCount)],
+ ['Online Votes Cast', String(votedCount)],
+ ['Proxies', String(election.proxies_count || 0)],
+ ['In-Person', String(election.in_person_count || 0)],
+ ['Total Participation', String(totalParticipation)],
+ ['Quorum Required', election.quorum_required > 0 ? String(election.quorum_required) : 'N/A'],
+ ['Quorum Met', election.quorum_required > 0 ? (totalParticipation >= election.quorum_required ? 'YES ✓' : 'NO') : 'N/A'],
+ ],
+ theme: 'grid',
+ headStyles: { fillColor: [41, 65, 148], fontSize: 9 },
+ bodyStyles: { fontSize: 9 },
+ columnStyles: { 0: { fontStyle: 'bold' } },
+ margin: { left: 14, right: 14 },
+ });
+ y = doc.lastAutoTable.finalY + 10;
+
+ // Results per position
+ if (election.results_locked) {
+ doc.setFontSize(11);
+ doc.setFont('helvetica', 'bold');
+ doc.text('ELECTION RESULTS', 14, y);
+ y += 7;
+
+ positions.forEach(pos => {
+ const posCandidates = candidates.filter(c => c.position_id === pos.id);
+ const posResults = resultsByPosition[pos.id] || {};
+ const totalPosVotes = Object.values(posResults).reduce((a, b) => a + b, 0);
+
+ const body = posCandidates.map(c => {
+ const count = posResults[c.id] || 0;
+ const pct = totalPosVotes > 0 ? Math.round((count / totalPosVotes) * 100) : 0;
+ return [c.candidate_name, String(count), `${pct}%`];
+ });
+
+ if (y > 250) { doc.addPage(); y = 20; }
+
+ autoTable(doc, {
+ startY: y,
+ head: [[`${pos.title} (${pos.seats_available} seat${pos.seats_available > 1 ? 's' : ''})`, 'Votes', '%']],
+ body,
+ theme: 'grid',
+ headStyles: { fillColor: [41, 65, 148], fontSize: 9 },
+ bodyStyles: { fontSize: 9 },
+ margin: { left: 14, right: 14 },
+ });
+ y = doc.lastAutoTable.finalY + 8;
+ });
+ } else {
+ doc.setFontSize(10);
+ doc.text('Results have not been certified yet — tallies not included in this report.', 14, y);
+ y += 10;
+ }
+
+ // Certification footer
+ if (y > 240) { doc.addPage(); y = 20; }
+ y += 10;
+ doc.setDrawColor(41, 65, 148);
+ doc.setLineWidth(0.5);
+ doc.line(14, y, pageW - 14, y);
+ y += 8;
+ doc.setFontSize(10);
+ doc.setFont('helvetica', 'bold');
+ doc.text('CERTIFICATION', 14, y);
+ y += 6;
+ doc.setFont('helvetica', 'normal');
+ doc.setFontSize(8);
+ const certText = [
+ 'This report is generated from the electronic election system. All votes were cast via secret ballot.',
+ 'Voter identities are tracked separately from ballot selections to ensure anonymity per FL §718.112 / §720.306.',
+ 'An audit trail exists recording participation without revealing individual vote choices.',
+ '',
+ `Verification Hash: ${generateHash(election, voters, results)}`,
+ ];
+ certText.forEach(line => { doc.text(line, 14, y); y += 4.5; });
+
+ // Signature lines
+ y += 10;
+ doc.setLineWidth(0.3);
+ doc.line(14, y, 90, y);
+ doc.line(pageW / 2 + 10, y, pageW - 14, y);
+ y += 5;
+ doc.setFontSize(8);
+ doc.text('Election Inspector Signature', 14, y);
+ doc.text('Date', pageW / 2 + 10, y);
+
+ const fileName = `Election_Report_${election.title.replace(/\s+/g, '_')}_${format(new Date(), 'yyyy-MM-dd')}.pdf`;
+ doc.save(fileName);
+ toast({ title: 'Report Downloaded', description: `${fileName}` });
+ } catch (err) {
+ console.error('PDF generation error:', err);
+ toast({ variant: 'destructive', title: 'Error', description: 'Failed to generate PDF report.' });
+ } finally {
+ setGenerating(false);
+ }
+ };
+
+ const generateCSV = async () => {
+ try {
+ // Participation CSV
+ const participationRows = [
+ ['Election Report', election.title],
+ ['Association', election.association?.name || ''],
+ ['Generated', format(new Date(), 'yyyy-MM-dd HH:mm')],
+ [''],
+ ['PARTICIPATION'],
+ ['Metric', 'Count'],
+ ['Eligible Voters', voters.length],
+ ['Consented', consentCount],
+ ['Online Votes', votedCount],
+ ['Proxies', election.proxies_count || 0],
+ ['In-Person', election.in_person_count || 0],
+ ['Total', totalParticipation],
+ ['Quorum Required', election.quorum_required || 'N/A'],
+ ['Quorum Met', election.quorum_required > 0 ? (totalParticipation >= election.quorum_required ? 'Yes' : 'No') : 'N/A'],
+ [''],
+ ];
+
+ // Results
+ if (election.results_locked) {
+ participationRows.push(['RESULTS']);
+ positions.forEach(pos => {
+ const posCandidates = candidates.filter(c => c.position_id === pos.id);
+ const posResults = resultsByPosition[pos.id] || {};
+ const totalPosVotes = Object.values(posResults).reduce((a, b) => a + b, 0);
+ participationRows.push([`Position: ${pos.title}`]);
+ participationRows.push(['Candidate', 'Votes', 'Percentage']);
+ posCandidates.forEach(c => {
+ const count = posResults[c.id] || 0;
+ const pct = totalPosVotes > 0 ? Math.round((count / totalPosVotes) * 100) : 0;
+ participationRows.push([c.candidate_name, count, `${pct}%`]);
+ });
+ participationRows.push(['']);
+ });
+ }
+
+ // Voter roster (no vote choices)
+ participationRows.push(['VOTER ROSTER (Audit)']);
+ participationRows.push(['Owner', 'Unit', 'Consented', 'Voted', 'Voted At']);
+ voters.forEach(v => {
+ participationRows.push([
+ `${v.owner?.first_name || ''} ${v.owner?.last_name || ''}`.trim(),
+ v.unit?.unit_number || v.unit?.address || '',
+ v.has_consent ? 'Yes' : 'No',
+ v.has_voted ? 'Yes' : 'No',
+ v.voted_at ? format(new Date(v.voted_at), 'yyyy-MM-dd HH:mm') : '',
+ ]);
+ });
+
+ const csvContent = participationRows.map(row => row.map(cell => `"${String(cell).replace(/"/g, '""')}"`).join(',')).join('\n');
+ const { saveCsv } = await import('@/lib/saveFile');
+ await saveCsv(csvContent, `Election_Report_${election.title.replace(/\s+/g, '_')}_${format(new Date(), 'yyyy-MM-dd')}.csv`);
+ toast({ title: 'CSV Downloaded' });
+ } catch (err) {
+ console.error('CSV generation error:', err);
+ toast({ variant: 'destructive', title: 'Error', description: 'Failed to generate CSV.' });
+ }
+ };
+
+ return (
+
+
+
+ {generating ? : }
+ Export Report
+
+
+
+
+ Certified PDF Report
+
+
+ CSV Export
+
+
+
+ );
+}
+
+function generateHash(election, voters, results) {
+ const data = `${election.id}-${voters.length}-${results.length}-${Date.now()}`;
+ let hash = 0;
+ for (let i = 0; i < data.length; i++) {
+ const char = data.charCodeAt(i);
+ hash = ((hash << 5) - hash) + char;
+ hash |= 0;
+ }
+ return Math.abs(hash).toString(16).toUpperCase().padStart(8, '0');
+}
diff --git a/src/components/elections/ElectionSetupDialog.jsx b/src/components/elections/ElectionSetupDialog.jsx
new file mode 100644
index 0000000..29a6d3b
--- /dev/null
+++ b/src/components/elections/ElectionSetupDialog.jsx
@@ -0,0 +1,123 @@
+import React, { useState, useEffect } from 'react';
+import { useForm } from 'react-hook-form';
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from '@/components/ui/dialog';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import { Textarea } from '@/components/ui/textarea';
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
+import { Loader2 } from 'lucide-react';
+import { useElections } from '@/hooks/useElections';
+import { supabase } from '@/integrations/supabase/client';
+
+export default function ElectionSetupDialog({ open, onOpenChange, onSuccess }) {
+ const { createElection, loading } = useElections();
+ const [associations, setAssociations] = useState([]);
+ const [selectedAssociation, setSelectedAssociation] = useState('');
+
+ const { register, handleSubmit, reset, formState: { errors } } = useForm({
+ defaultValues: {
+ title: '',
+ description: '',
+ election_type: 'board_election',
+ voting_start: '',
+ voting_end: '',
+ quorum_required: 0,
+ }
+ });
+
+ useEffect(() => {
+ if (open) {
+ supabase.from('associations').select('id, name').eq('status', 'active').order('name')
+ .then(({ data }) => setAssociations(data || []));
+ } else {
+ reset();
+ setSelectedAssociation('');
+ }
+ }, [open, reset]);
+
+ const onSubmit = async (data) => {
+ if (!selectedAssociation) return;
+ const payload = {
+ ...data,
+ association_id: selectedAssociation,
+ quorum_required: parseInt(data.quorum_required) || 0,
+ voting_start: data.voting_start ? new Date(data.voting_start).toISOString() : null,
+ voting_end: data.voting_end ? new Date(data.voting_end).toISOString() : null,
+ status: 'draft'
+ };
+ const result = await createElection(payload);
+ if (result) {
+ onSuccess();
+ onOpenChange(false);
+ }
+ };
+
+ return (
+ !loading && onOpenChange(v)}>
+
+
+ Create New Election
+ Set up an election compliant with Florida Statutes §718/§720.
+
+
+
+ Association *
+
+
+
+ {associations.map(a => {a.name} )}
+
+
+
+
+
+ Election Title *
+
+ {errors.title && Required }
+
+
+
+ Description
+
+
+
+
+ Election Type
+ reset(prev => ({ ...prev, election_type: v }))}>
+
+
+ Board of Directors
+ Amendment Vote
+ Special Election
+
+
+
+
+
+
+
+ Quorum Required (# of voters)
+
+
+
+
+ onOpenChange(false)}>Cancel
+
+ {loading && } Create Election
+
+
+
+
+
+ );
+}
diff --git a/src/components/elections/ElectionTallyCounter.jsx b/src/components/elections/ElectionTallyCounter.jsx
new file mode 100644
index 0000000..0edb04b
--- /dev/null
+++ b/src/components/elections/ElectionTallyCounter.jsx
@@ -0,0 +1,208 @@
+import React, { useState, useEffect, useCallback, useRef } from 'react';
+import { Button } from '@/components/ui/button';
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
+import { Badge } from '@/components/ui/badge';
+import { Progress } from '@/components/ui/progress';
+import { Separator } from '@/components/ui/separator';
+import { Keyboard, RotateCcw, CheckCircle2, Minus, Plus } from 'lucide-react';
+import { useToast } from '@/hooks/use-toast';
+
+const QWERTY_KEYS = ['Q', 'W', 'E', 'R', 'T', 'Y', 'U', 'I', 'O', 'P',
+ 'A', 'S', 'D', 'F', 'G', 'H', 'J', 'K', 'L',
+ 'Z', 'X', 'C', 'V', 'B', 'N', 'M'];
+
+export default function ElectionTallyCounter({ election, positions, candidates, onCertify }) {
+ const { toast } = useToast();
+ const containerRef = useRef(null);
+ const [tallies, setTallies] = useState({});
+ const [keyMap, setKeyMap] = useState({});
+ const [isActive, setIsActive] = useState(false);
+ const [lastAction, setLastAction] = useState(null);
+
+ // Build key assignments on mount
+ useEffect(() => {
+ const map = {};
+ const initialTallies = {};
+ let keyIdx = 0;
+
+ positions.forEach(pos => {
+ const posCandidates = candidates.filter(c => c.position_id === pos.id);
+ posCandidates.forEach(c => {
+ if (keyIdx < QWERTY_KEYS.length) {
+ map[QWERTY_KEYS[keyIdx]] = { candidateId: c.id, candidateName: c.candidate_name, positionId: pos.id, positionTitle: pos.title };
+ keyIdx++;
+ }
+ initialTallies[c.id] = 0;
+ });
+ });
+
+ setKeyMap(map);
+ setTallies(initialTallies);
+ }, [positions, candidates]);
+
+ // Keyboard listener
+ const handleKeyDown = useCallback((e) => {
+ if (!isActive) return;
+ // Ignore if user is typing in an input
+ if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
+
+ const key = e.key.toUpperCase();
+ if (keyMap[key]) {
+ e.preventDefault();
+ const entry = keyMap[key];
+ setTallies(prev => ({ ...prev, [entry.candidateId]: (prev[entry.candidateId] || 0) + 1 }));
+ setLastAction({ key, name: entry.candidateName, time: Date.now() });
+ }
+ }, [isActive, keyMap]);
+
+ useEffect(() => {
+ window.addEventListener('keydown', handleKeyDown);
+ return () => window.removeEventListener('keydown', handleKeyDown);
+ }, [handleKeyDown]);
+
+ const incrementTally = (candidateId) => {
+ setTallies(prev => ({ ...prev, [candidateId]: (prev[candidateId] || 0) + 1 }));
+ };
+
+ const decrementTally = (candidateId) => {
+ setTallies(prev => ({ ...prev, [candidateId]: Math.max(0, (prev[candidateId] || 0) - 1) }));
+ };
+
+ const resetTallies = () => {
+ if (!confirm('Reset all tallies to zero?')) return;
+ const reset = {};
+ Object.keys(tallies).forEach(k => { reset[k] = 0; });
+ setTallies(reset);
+ setLastAction(null);
+ toast({ title: 'Tallies Reset', description: 'All vote counts have been reset to zero.' });
+ };
+
+ const totalVotes = Object.values(tallies).reduce((a, b) => a + b, 0);
+
+ const handleCertify = () => {
+ if (totalVotes === 0) {
+ toast({ variant: 'destructive', title: 'No votes', description: 'You must tally at least one vote before certifying.' });
+ return;
+ }
+ if (!confirm('Are you sure you want to certify this election with the current tallies? This action will lock the results.')) return;
+ onCertify(tallies);
+ };
+
+ // Get key for a candidate
+ const getKeyForCandidate = (candidateId) => {
+ for (const [key, entry] of Object.entries(keyMap)) {
+ if (entry.candidateId === candidateId) return key;
+ }
+ return null;
+ };
+
+ return (
+
+ {/* Controls bar */}
+
+
+ { setIsActive(!isActive); if (!isActive) containerRef.current?.focus(); }}
+ >
+
+ {isActive ? 'Keyboard Active' : 'Enable Keyboard'}
+
+
+ Reset
+
+
+
+
+ Total: {totalVotes} votes
+
+
+ Certify & Submit
+
+
+
+
+ {/* Active indicator */}
+ {isActive && (
+
+
+
+ Keyboard tallying active — press assigned keys to count votes
+
+ {lastAction && (
+
+ Last: {lastAction.key} → {lastAction.name}
+
+ )}
+
+ )}
+
+ {/* Position cards */}
+ {positions.map(pos => {
+ const posCandidates = candidates.filter(c => c.position_id === pos.id);
+ const posTotal = posCandidates.reduce((sum, c) => sum + (tallies[c.id] || 0), 0);
+
+ return (
+
+
+
+
{pos.title}
+
+ {pos.seats_available} seat{pos.seats_available > 1 ? 's' : ''}
+ {posTotal} votes
+
+
+
+
+ {posCandidates.map(c => {
+ const count = tallies[c.id] || 0;
+ const pct = posTotal > 0 ? Math.round((count / posTotal) * 100) : 0;
+ const assignedKey = getKeyForCandidate(c.id);
+
+ return (
+
+ {/* Key badge */}
+ {assignedKey && (
+
+ {assignedKey}
+
+ )}
+
+ {/* Name + progress */}
+
+
+ {c.candidate_name}
+
+ {count} ({pct}%)
+
+
+
+
+
+ {/* Manual +/- buttons */}
+
+
decrementTally(c.id)}>
+
+
+
incrementTally(c.id)}>
+
+
+
+
+ );
+ })}
+
+
+ );
+ })}
+
+ );
+}
\ No newline at end of file
diff --git a/src/components/elections/ElectionVotingPage.jsx b/src/components/elections/ElectionVotingPage.jsx
new file mode 100644
index 0000000..1eed72e
--- /dev/null
+++ b/src/components/elections/ElectionVotingPage.jsx
@@ -0,0 +1,236 @@
+import React, { useState, useEffect, useCallback } from 'react';
+import { useElections } from '@/hooks/useElections';
+import { Button } from '@/components/ui/button';
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
+import { Badge } from '@/components/ui/badge';
+import { Checkbox } from '@/components/ui/checkbox';
+import { Label } from '@/components/ui/label';
+import { ScrollArea } from '@/components/ui/scroll-area';
+import { Vote, CheckCircle2, Clock, Lock, Loader2, AlertTriangle, RefreshCw } from 'lucide-react';
+import { format } from 'date-fns';
+
+export default function ElectionVotingPage({ electionId }) {
+ const {
+ fetchElection, fetchPositions, fetchAllCandidates,
+ getMyVoterRecord, getMyBallot, castBallot, loading
+ } = useElections();
+
+ const [election, setElection] = useState(null);
+ const [positions, setPositions] = useState([]);
+ const [candidates, setCandidates] = useState([]);
+ const [voterRecord, setVoterRecord] = useState(null);
+ const [selections, setSelections] = useState({}); // { positionId: [candidateId, ...] }
+ const [submitted, setSubmitted] = useState(false);
+ const [pageLoading, setPageLoading] = useState(true);
+
+ const loadData = useCallback(async () => {
+ setPageLoading(true);
+ try {
+ const [el, pos, cands, voter] = await Promise.all([
+ fetchElection(electionId),
+ fetchPositions(electionId),
+ fetchAllCandidates(electionId),
+ getMyVoterRecord(electionId),
+ ]);
+ setElection(el);
+ setPositions(pos);
+ setCandidates(cands);
+ setVoterRecord(voter);
+
+ if (voter?.has_voted && voter.vote_token) {
+ const ballot = await getMyBallot(electionId, voter.vote_token);
+ const sels = {};
+ ballot.forEach(b => {
+ if (!sels[b.position_id]) sels[b.position_id] = [];
+ sels[b.position_id].push(b.candidate_id);
+ });
+ setSelections(sels);
+ }
+ } finally {
+ setPageLoading(false);
+ }
+ }, [electionId, fetchElection, fetchPositions, fetchAllCandidates, getMyVoterRecord, getMyBallot]);
+
+ useEffect(() => { loadData(); }, [loadData]);
+
+ const handleToggleCandidate = (positionId, candidateId, seatsAvailable) => {
+ setSelections(prev => {
+ const current = prev[positionId] || [];
+ if (current.includes(candidateId)) {
+ return { ...prev, [positionId]: current.filter(id => id !== candidateId) };
+ }
+ if (current.length >= seatsAvailable) {
+ return { ...prev, [positionId]: [...current.slice(1), candidateId] };
+ }
+ return { ...prev, [positionId]: [...current, candidateId] };
+ });
+ };
+
+ const handleSubmitBallot = async () => {
+ if (!voterRecord?.vote_token) return;
+ const ballotSelections = [];
+ Object.entries(selections).forEach(([posId, candIds]) => {
+ (candIds || []).forEach(candId => {
+ ballotSelections.push({ position_id: posId, candidate_id: candId });
+ });
+ });
+ const success = await castBallot(electionId, voterRecord.vote_token, ballotSelections);
+ if (success) setSubmitted(true);
+ };
+
+ const isVotingOpen = election?.status === 'active' &&
+ (!election.voting_end || new Date(election.voting_end) > new Date());
+ const allPositionsSelected = positions.every(p => (selections[p.id] || []).length > 0);
+
+ if (pageLoading) {
+ return (
+
+ );
+ }
+
+ if (!election) {
+ return (
+
+
+
+ Election Not Found
+
+
+ );
+ }
+
+ if (!voterRecord) {
+ return (
+
+
+
+ Not Eligible
+ You are not listed as an eligible voter.
+
+
+ );
+ }
+
+ if (!voterRecord.has_consent) {
+ return (
+
+
+
+ Electronic Consent Required
+ Please contact your association manager.
+
+
+ );
+ }
+
+ if (submitted) {
+ return (
+
+
+
+ Vote Submitted!
+ Your ballot has been securely recorded.
+
+ {isVotingOpen && 'You may change your vote until the deadline.'}
+
+ {isVotingOpen && (
+ { setSubmitted(false); loadData(); }}>
+ Change My Vote
+
+ )}
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
{election.title}
+
+ {election.description &&
{election.description}
}
+
+ {election.voting_end && (
+
+ Closes: {format(new Date(election.voting_end), 'MMM d, yyyy h:mm a')}
+
+ )}
+ {voterRecord.has_voted && Changing Vote }
+
+
+
+ {!isVotingOpen ? (
+
+
+
+ Voting is currently closed.
+
+
+ ) : (
+ <>
+
+
+ {positions.map(pos => {
+ const posCandidates = candidates.filter(c => c.position_id === pos.id);
+ const selected = selections[pos.id] || [];
+ const seats = pos.seats_available || 1;
+ return (
+
+
+ {pos.title}
+
+ Select up to {seats} candidate{seats > 1 ? 's' : ''} — {selected.length}/{seats} selected
+
+
+
+ {posCandidates.map(c => {
+ const isChecked = selected.includes(c.id);
+ return (
+ handleToggleCandidate(pos.id, c.id, seats)}
+ >
+
handleToggleCandidate(pos.id, c.id, seats)}
+ className="mt-0.5"
+ id={`c-${c.id}`}
+ />
+
+ {c.candidate_name}
+ {c.bio && {c.bio}
}
+
+
+ );
+ })}
+
+
+ );
+ })}
+
+
+
+
+
+ {loading ? : }
+ {voterRecord.has_voted ? 'Update My Vote' : 'Submit Ballot'}
+
+
+ {!allPositionsSelected && (
+
Please select at least one candidate for every position.
+ )}
+ >
+ )}
+
+ );
+}
diff --git a/src/components/email/EmailSignatureManager.tsx b/src/components/email/EmailSignatureManager.tsx
new file mode 100644
index 0000000..8ff6283
--- /dev/null
+++ b/src/components/email/EmailSignatureManager.tsx
@@ -0,0 +1,195 @@
+import { useEffect, useState } from "react";
+import { Plus, Pencil, Trash2, Star, Loader2 } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import {
+ Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter,
+} from "@/components/ui/dialog";
+import { supabase } from "@/integrations/supabase/client";
+import { useAuth } from "@/contexts/AuthContext";
+import { useToast } from "@/hooks/use-toast";
+import RichEmailEditor from "@/components/email/RichEmailEditor";
+
+export interface EmailSignature {
+ id: string;
+ user_id: string;
+ name: string;
+ html: string;
+ is_default: boolean;
+}
+
+interface Props {
+ open: boolean;
+ onOpenChange: (v: boolean) => void;
+ onChanged?: (signatures: EmailSignature[]) => void;
+}
+
+export default function EmailSignatureManager({ open, onOpenChange, onChanged }: Props) {
+ const { user } = useAuth();
+ const { toast } = useToast();
+ const [list, setList] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [editing, setEditing] = useState(null);
+ const [name, setName] = useState("");
+ const [html, setHtml] = useState("");
+ const [isDefault, setIsDefault] = useState(false);
+ const [saving, setSaving] = useState(false);
+
+ const fetchList = async () => {
+ if (!user?.id) return;
+ setLoading(true);
+ const { data, error } = await supabase
+ .from("email_signatures" as any)
+ .select("*")
+ .eq("user_id", user.id)
+ .order("is_default", { ascending: false })
+ .order("name");
+ setLoading(false);
+ if (error) {
+ toast({ variant: "destructive", title: "Failed to load signatures", description: error.message });
+ return;
+ }
+ const rows = (data || []) as unknown as EmailSignature[];
+ setList(rows);
+ onChanged?.(rows);
+ };
+
+ useEffect(() => { if (open) fetchList(); }, [open, user?.id]);
+
+ const startNew = () => {
+ setEditing({ id: "", user_id: user?.id || "", name: "", html: "", is_default: false });
+ setName(""); setHtml(""); setIsDefault(false);
+ };
+
+ const startEdit = (s: EmailSignature) => {
+ setEditing(s);
+ setName(s.name); setHtml(s.html); setIsDefault(s.is_default);
+ };
+
+ const cancelEdit = () => setEditing(null);
+
+ const save = async () => {
+ if (!user?.id) return;
+ if (!name.trim()) {
+ toast({ variant: "destructive", title: "Name required" });
+ return;
+ }
+ setSaving(true);
+ try {
+ if (isDefault) {
+ await supabase.from("email_signatures" as any).update({ is_default: false }).eq("user_id", user.id);
+ }
+ if (editing?.id) {
+ const { error } = await supabase
+ .from("email_signatures" as any)
+ .update({ name, html, is_default: isDefault })
+ .eq("id", editing.id);
+ if (error) throw error;
+ } else {
+ const { error } = await supabase
+ .from("email_signatures" as any)
+ .insert({ user_id: user.id, name, html, is_default: isDefault });
+ if (error) throw error;
+ }
+ toast({ title: "Signature saved" });
+ setEditing(null);
+ await fetchList();
+ } catch (err: any) {
+ toast({ variant: "destructive", title: "Save failed", description: err.message });
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ const remove = async (id: string) => {
+ if (!confirm("Delete this signature?")) return;
+ const { error } = await supabase.from("email_signatures" as any).delete().eq("id", id);
+ if (error) { toast({ variant: "destructive", title: "Delete failed", description: error.message }); return; }
+ toast({ title: "Deleted" });
+ fetchList();
+ };
+
+ const makeDefault = async (id: string) => {
+ if (!user?.id) return;
+ await supabase.from("email_signatures" as any).update({ is_default: false }).eq("user_id", user.id);
+ const { error } = await supabase.from("email_signatures" as any).update({ is_default: true }).eq("id", id);
+ if (error) { toast({ variant: "destructive", title: "Failed", description: error.message }); return; }
+ fetchList();
+ };
+
+ return (
+
+
+
+ Email Signatures
+
+ Create reusable HTML signatures. Pick one in the compose window or mark a default to apply automatically.
+
+
+
+ {!editing ? (
+
+
+ {loading ? (
+
+ ) : list.length === 0 ? (
+
No signatures yet.
+ ) : (
+
+ {list.map((s) => (
+
+
+
+ {s.name}
+ {s.is_default && Default }
+
+
(empty)" }}
+ />
+
+
+ {!s.is_default && (
+
makeDefault(s.id)}>
+
+
+ )}
+
startEdit(s)}>
+
remove(s.id)}>
+
+
+
+
+ ))}
+
+ )}
+
+ ) : (
+
+
+ Name
+ setName(e.target.value)} placeholder="e.g. Friendly, Formal, Board Chair" />
+
+
+ Signature (HTML)
+
+
+
+ setIsDefault(e.target.checked)} />
+ Use as my default signature
+
+
+ Cancel
+
+ {saving && } Save Signature
+
+
+
+ )}
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/components/email/OwnerEmailPicker.tsx b/src/components/email/OwnerEmailPicker.tsx
new file mode 100644
index 0000000..076da01
--- /dev/null
+++ b/src/components/email/OwnerEmailPicker.tsx
@@ -0,0 +1,186 @@
+import { useEffect, useMemo, useState } from "react";
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { ScrollArea } from "@/components/ui/scroll-area";
+import { Checkbox } from "@/components/ui/checkbox";
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
+import { Badge } from "@/components/ui/badge";
+import { Loader2, Search, Users } from "lucide-react";
+import { supabase } from "@/integrations/supabase/client";
+
+interface Props {
+ open: boolean;
+ onOpenChange: (v: boolean) => void;
+ onConfirm: (emails: string[]) => void;
+}
+
+interface OwnerRow {
+ id: string;
+ name: string;
+ email: string;
+ property_address: string | null;
+ association_id: string;
+ association_name?: string;
+}
+
+export default function OwnerEmailPicker({ open, onOpenChange, onConfirm }: Props) {
+ const [loading, setLoading] = useState(false);
+ const [owners, setOwners] = useState
([]);
+ const [associations, setAssociations] = useState<{ id: string; name: string }[]>([]);
+ const [search, setSearch] = useState("");
+ const [associationId, setAssociationId] = useState("all");
+ const [selected, setSelected] = useState>(new Set());
+
+ useEffect(() => {
+ if (!open) return;
+ setSelected(new Set());
+ (async () => {
+ setLoading(true);
+ const [{ data: ownersData }, { data: assocData }] = await Promise.all([
+ supabase
+ .from("owners")
+ .select("id, first_name, last_name, email, property_address, association_id, status")
+ .not("email", "is", null)
+ .neq("email", "")
+ .order("last_name"),
+ supabase.from("associations").select("id, name").order("name"),
+ ]);
+ const assocMap = new Map((assocData || []).map((a: any) => [a.id, a.name]));
+ setAssociations((assocData as any) || []);
+ setOwners(
+ ((ownersData as any[]) || []).map((o) => ({
+ id: o.id,
+ name: `${o.first_name || ""} ${o.last_name || ""}`.trim() || "(no name)",
+ email: o.email,
+ property_address: o.property_address,
+ association_id: o.association_id,
+ association_name: assocMap.get(o.association_id) || "",
+ })),
+ );
+ setLoading(false);
+ })();
+ }, [open]);
+
+ const filtered = useMemo(() => {
+ const q = search.trim().toLowerCase();
+ return owners.filter((o) => {
+ if (associationId !== "all" && o.association_id !== associationId) return false;
+ if (!q) return true;
+ return (
+ o.name.toLowerCase().includes(q) ||
+ o.email.toLowerCase().includes(q) ||
+ (o.property_address || "").toLowerCase().includes(q) ||
+ (o.association_name || "").toLowerCase().includes(q)
+ );
+ });
+ }, [owners, search, associationId]);
+
+ const toggle = (ownerId: string) => {
+ setSelected((prev) => {
+ const next = new Set(prev);
+ if (next.has(ownerId)) next.delete(ownerId);
+ else next.add(ownerId);
+ return next;
+ });
+ };
+
+ const allSelected = filtered.length > 0 && filtered.every((o) => selected.has(o.id));
+ const toggleAll = () => {
+ setSelected((prev) => {
+ const next = new Set(prev);
+ if (allSelected) filtered.forEach((o) => next.delete(o.id));
+ else filtered.forEach((o) => next.add(o.id));
+ return next;
+ });
+ };
+
+ const handleConfirm = () => {
+ const emails = owners.filter((owner) => selected.has(owner.id)).map((owner) => owner.email);
+ onConfirm(Array.from(new Set(emails.map((email) => email.toLowerCase()))));
+ onOpenChange(false);
+ };
+
+ return (
+
+
+
+ Select Homeowners
+ Pick recipients from your homeowner roster.
+
+
+
+
+
+ setSearch(e.target.value)} />
+
+
+
+
+ All Associations
+ {associations.map((a) => (
+ {a.name}
+ ))}
+
+
+
+
+
+ {loading ? (
+
+
+
+ ) : (
+
+
+
+
+ {selected.size} selected · {filtered.length} shown
+
+
+
+ {filtered.map((o) => {
+ const checked = selected.has(o.id);
+ return (
+ toggle(o.id)}
+ >
+ event.stopPropagation()}
+ onCheckedChange={() => toggle(o.id)}
+ />
+
+
+ {o.name}
+ {o.association_name && (
+ {o.association_name}
+ )}
+
+
+ {o.email}{o.property_address ? ` · ${o.property_address}` : ""}
+
+
+
+ );
+ })}
+ {filtered.length === 0 && (
+ No homeowners match your filters.
+ )}
+
+
+ )}
+
+
+
+ onOpenChange(false)}>Cancel
+
+ Add {selected.size > 0 ? `${selected.size} ` : ""}Recipient{selected.size === 1 ? "" : "s"}
+
+
+
+
+ );
+}
diff --git a/src/components/email/RichEmailEditor.tsx b/src/components/email/RichEmailEditor.tsx
new file mode 100644
index 0000000..cd15f3a
--- /dev/null
+++ b/src/components/email/RichEmailEditor.tsx
@@ -0,0 +1,73 @@
+import { useMemo, useRef } from "react";
+import ReactQuill from "react-quill-new";
+import "react-quill-new/dist/quill.snow.css";
+import { supabase } from "@/integrations/supabase/client";
+import { useToast } from "@/hooks/use-toast";
+
+interface Props {
+ value: string;
+ onChange: (val: string) => void;
+ placeholder?: string;
+ minHeight?: number;
+}
+
+export default function RichEmailEditor({ value, onChange, placeholder, minHeight = 280 }: Props) {
+ const quillRef = useRef(null);
+ const { toast } = useToast();
+
+ const imageHandler = () => {
+ const input = document.createElement("input");
+ input.setAttribute("type", "file");
+ input.setAttribute("accept", "image/*");
+ input.click();
+ input.onchange = async () => {
+ const file = input.files?.[0];
+ if (!file) return;
+ try {
+ const ext = file.name.split(".").pop() || "png";
+ const path = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${ext}`;
+ const { error: upErr } = await supabase.storage
+ .from("email-images")
+ .upload(path, file, { contentType: file.type, upsert: false });
+ if (upErr) throw upErr;
+ const { data: { publicUrl } } = supabase.storage.from("email-images").getPublicUrl(path);
+ const editor = quillRef.current?.getEditor?.();
+ const range = editor?.getSelection(true);
+ editor?.insertEmbed(range?.index ?? 0, "image", publicUrl, "user");
+ editor?.setSelection((range?.index ?? 0) + 1, 0);
+ } catch (err: any) {
+ toast({ variant: "destructive", title: "Image upload failed", description: err.message });
+ }
+ };
+ };
+
+ const modules = useMemo(() => ({
+ toolbar: {
+ container: [
+ [{ header: [1, 2, 3, false] }],
+ [{ font: [] }, { size: [] }],
+ ["bold", "italic", "underline", "strike"],
+ [{ color: [] }, { background: [] }],
+ [{ align: [] }],
+ [{ list: "ordered" }, { list: "bullet" }],
+ ["blockquote", "link", "image"],
+ ["clean"],
+ ],
+ handlers: { image: imageHandler },
+ },
+ }), []);
+
+ return (
+
+
+
+ );
+}
diff --git a/src/components/forms/AffidavitOfMailingForm.jsx b/src/components/forms/AffidavitOfMailingForm.jsx
new file mode 100644
index 0000000..df5d2d5
--- /dev/null
+++ b/src/components/forms/AffidavitOfMailingForm.jsx
@@ -0,0 +1,526 @@
+import { useEffect, useState } from "react";
+import { Download, FolderDown } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
+import { Checkbox } from "@/components/ui/checkbox";
+import { supabase } from "@/integrations/supabase/client";
+import { useToast } from "@/hooks/use-toast";
+import jsPDF from "jspdf";
+import autoTable from "jspdf-autotable";
+import { format } from "date-fns";
+import { embedValidationProof } from "@/lib/embedValidationProof";
+import { storeValidatedPdf } from "@/lib/storeValidatedPdf";
+import { SaveToDocumentsDialog } from "@/components/SaveToDocumentsDialog";
+import { buildSecureStorageKey } from "@/lib/secureStorageNaming";
+import { toast as sonnerToast } from "sonner";
+
+const MEETING_TYPES = [
+ "Annual Membership Meeting",
+ "Special Membership Meeting",
+ "Board of Directors Meeting",
+ "Special Board Meeting",
+ "Budget Meeting",
+ "Turnover Meeting",
+ "Election Meeting",
+];
+
+const MAILING_DAYS = [
+ { value: "60", label: "Sixty (60) Days" },
+ { value: "30", label: "Thirty (30) Days" },
+ { value: "14", label: "Fourteen (14) Days" },
+];
+
+const STATUTES = [
+ { value: "720", label: "§720.306(1)(d)(5) — HOA" },
+ { value: "718", label: "§718.112(2)(d)3 — Condo" },
+];
+
+const STATUTE_TEXT = {
+ "720": "§720.306(1)(d)(5)",
+ "718": "§718.112(2)(d)3",
+};
+
+export default function AffidavitOfMailingForm() {
+ const { toast } = useToast();
+ const [associations, setAssociations] = useState([]);
+ const [selectedAssociation, setSelectedAssociation] = useState("");
+ const [association, setAssociation] = useState(null);
+ const [mailingDate, setMailingDate] = useState(format(new Date(), "yyyy-MM-dd"));
+ const [meetingType, setMeetingType] = useState("Annual Membership Meeting");
+ const [mailingDays, setMailingDays] = useState("14");
+ const [statute, setStatute] = useState("720");
+ const [signerName, setSignerName] = useState("Thomas R. Barnhart");
+ const [signerTitle, setSignerTitle] = useState("Manager");
+ const [signerCredentials, setSignerCredentials] = useState("Thomas R. Barnhart, LCAM");
+ const [county, setCounty] = useState("Brevard");
+ const [attachOwnerRoster, setAttachOwnerRoster] = useState(true);
+ const [embedValidation, setEmbedValidation] = useState(true);
+ const [showSaveDocsDialog, setShowSaveDocsDialog] = useState(false);
+ const [owners, setOwners] = useState([]);
+ const [logoUrl, setLogoUrl] = useState("");
+
+ useEffect(() => {
+ supabase
+ .from("associations")
+ .select("id, name")
+ .eq("status", "active")
+ .order("name")
+ .then(({ data }) => { if (data) setAssociations(data); });
+ }, []);
+
+ useEffect(() => {
+ if (!selectedAssociation) {
+ setAssociation(null);
+ setLogoUrl("");
+ setOwners([]);
+ return;
+ }
+ supabase
+ .from("associations")
+ .select("*")
+ .eq("id", selectedAssociation)
+ .single()
+ .then(({ data }) => {
+ if (data) {
+ setAssociation(data);
+ setLogoUrl(data.logo_url || "");
+ }
+ });
+ supabase
+ .from("owners")
+ .select("first_name, last_name, email, mailing_address, property_address, electronic_consent")
+ .eq("association_id", selectedAssociation)
+ .eq("status", "active")
+ .order("property_address")
+ .then(({ data }) => { if (data) setOwners(data); });
+ }, [selectedAssociation]);
+
+ const formatDateWordy = (dateStr) => {
+ const d = new Date(dateStr + "T12:00:00");
+ return format(d, "MMMM d, yyyy");
+ };
+
+ const getOrdinalDay = (dateStr) => {
+ const d = new Date(dateStr + "T12:00:00");
+ const day = d.getDate();
+ const s = ["th", "st", "nd", "rd"];
+ const v = day % 100;
+ return `${day}${s[(v - 20) % 10] || s[v] || s[0]}`;
+ };
+
+ const getMonthName = (dateStr) => {
+ const d = new Date(dateStr + "T12:00:00");
+ return format(d, "MMMM");
+ };
+
+ const getYear = (dateStr) => {
+ const d = new Date(dateStr + "T12:00:00");
+ return format(d, "yyyy");
+ };
+
+ const getDaysWord = () => {
+ const map = { "60": "sixty (60)", "30": "thirty (30)", "14": "fourteen (14)" };
+ return map[mailingDays] || mailingDays;
+ };
+
+ const generatePdf = async () => {
+ if (!association) {
+ toast({ title: "Select an association", variant: "destructive" });
+ return null;
+ }
+
+ const doc = new jsPDF({ orientation: "portrait", unit: "pt", format: "letter" });
+ const pageW = doc.internal.pageSize.getWidth();
+ const margin = 60;
+ const contentW = pageW - margin * 2;
+ const bodySize = 10.5;
+ const bodyLeading = 15;
+ const sectionGap = 22;
+ let y = 60;
+
+ // Logo
+ if (logoUrl) {
+ try {
+ const img = new Image();
+ img.crossOrigin = "anonymous";
+ await new Promise((resolve, reject) => {
+ img.onload = resolve;
+ img.onerror = reject;
+ img.src = logoUrl;
+ });
+ const ratio = img.width / img.height;
+ const logoH = 50;
+ const logoW = logoH * ratio;
+ doc.addImage(img, "PNG", pageW - margin - logoW, y, logoW, logoH);
+ } catch (e) {
+ console.warn("Logo load failed:", e);
+ }
+ }
+
+ // Title
+ doc.setFont("helvetica", "bold");
+ doc.setFontSize(18);
+ doc.setTextColor(0, 0, 0);
+ doc.text("AFFIDAVIT OF MAILING", margin, y + 18);
+
+ y += 34;
+ doc.setFontSize(11);
+ doc.setFont("helvetica", "bolditalic");
+ doc.text(`${getYear(mailingDate)} ${meetingType}`, margin, y);
+
+ // State / County
+ y += sectionGap;
+ doc.setFont("helvetica", "bold");
+ doc.setFontSize(bodySize);
+ doc.text("STATE OF FLORIDA", margin, y);
+ y += bodyLeading;
+ doc.text(`COUNTY OF ${county.toUpperCase()}`, margin, y);
+
+ // Body paragraph
+ y += sectionGap;
+ doc.setFont("helvetica", "normal");
+ doc.setFontSize(bodySize);
+
+ const meetingTypeUpper = meetingType.toUpperCase();
+ const bodyText = `I, ${signerName.toUpperCase()}, on behalf of Secretary of the ${association.name}, being first duly sworn, deposes and says that the notice of the ${meetingTypeUpper} was mailed, hand delivered, or electronically sent to each unit owner at the address last furnished to the Association in accordance with the requirements of Section ${STATUTE_TEXT[statute]} Florida Statutes, at least ${getDaysWord()} days prior to the noticed meeting, or other matter of non-specified date, on ${formatDateWordy(mailingDate)}.`;
+
+ const lines = doc.splitTextToSize(bodyText, contentW);
+ for (let i = 0; i < lines.length; i++) {
+ doc.text(lines[i], margin, y + i * bodyLeading);
+ }
+ y += lines.length * bodyLeading + 6;
+
+ // Dated line
+ doc.text(`Dated this ${getOrdinalDay(mailingDate)} day of ${getMonthName(mailingDate)}, ${getYear(mailingDate)}.`, margin, y);
+ y += sectionGap;
+
+ // BY line
+ doc.setFont("helvetica", "bold");
+ doc.text(`BY: ${signerName}, ${signerTitle}`, margin, y);
+
+ // Signature line
+ y += 44;
+ doc.setDrawColor(0, 0, 0);
+ doc.setLineWidth(0.6);
+ doc.line(margin, y, margin + 250, y);
+ y += 13;
+ doc.setFont("helvetica", "italic");
+ doc.setFontSize(9);
+ doc.text(signerCredentials, margin, y);
+
+ // Notary section
+ y += 40;
+ doc.setFont("helvetica", "bold");
+ doc.setFontSize(bodySize);
+ doc.text("STATE OF FLORIDA", margin, y);
+ y += bodyLeading;
+ doc.text(`COUNTY OF ${county.toUpperCase()}`, margin, y);
+
+ y += sectionGap;
+ doc.setFont("helvetica", "normal");
+ doc.setFontSize(bodySize);
+
+ const notaryText = `The foregoing Affidavit was acknowledged before me this ${getOrdinalDay(mailingDate)} day of ${getMonthName(mailingDate)}, ${getYear(mailingDate)} by ${signerName}, the Community Association Manager for ${association.name}, who is personally known to me or, produced Florida Driver's License No.: N/A.`;
+ const notaryLines = doc.splitTextToSize(notaryText, contentW);
+ for (let i = 0; i < notaryLines.length; i++) {
+ doc.text(notaryLines[i], margin, y + i * bodyLeading);
+ }
+ y += notaryLines.length * bodyLeading + sectionGap;
+
+ // Notary signature line
+ doc.setLineWidth(0.6);
+ doc.line(margin, y, margin + 270, y);
+ y += 13;
+ doc.setFont("helvetica", "normal");
+ doc.setFontSize(10);
+ doc.text("NOTARY PUBLIC", margin, y);
+
+ y += 34;
+ doc.setFont("helvetica", "bold");
+ doc.text("(SEAL)", margin, y);
+
+ // Owner roster pages — split by delivery method
+ if (attachOwnerRoster && owners.length > 0) {
+ const mailedOwners = owners.filter((o) => !o.electronic_consent);
+ const electronicOwners = owners.filter((o) => o.electronic_consent);
+
+ // --- U.S. Mail Recipients ---
+ if (mailedOwners.length > 0) {
+ doc.addPage();
+ doc.setFont("helvetica", "bold");
+ doc.setFontSize(13);
+ doc.setTextColor(0, 0, 0);
+ doc.text("Owner Roster — U.S. Mail Recipients", margin, 50);
+
+ doc.setFont("helvetica", "normal");
+ doc.setFontSize(8.5);
+ doc.setTextColor(80, 80, 80);
+ doc.text(`${association.name} • ${formatDateWordy(mailingDate)} • ${mailedOwners.length} owner(s) via U.S. Mail`, margin, 64);
+
+ const groupedMail = {};
+ for (const o of mailedOwners) {
+ const addr = o.property_address || "—";
+ if (!groupedMail[addr]) groupedMail[addr] = [];
+ groupedMail[addr].push(o);
+ }
+ const sortedMailAddrs = Object.keys(groupedMail).sort((a, b) => a.localeCompare(b));
+ const mailTableBody = sortedMailAddrs.map((addr, i) => {
+ const names = groupedMail[addr].map((o) => `${o.first_name || ""} ${o.last_name || ""}`.trim()).join(", ");
+ const mailing = groupedMail[addr][0].mailing_address || "—";
+ return [i + 1, names, addr, mailing];
+ });
+
+ autoTable(doc, {
+ startY: 76,
+ head: [["#", "Owner Name", "Property Address", "Mailing Address"]],
+ body: mailTableBody,
+ margin: { left: margin, right: margin },
+ styles: { fontSize: 7.5, cellPadding: 3.5, font: "helvetica" },
+ headStyles: { fillColor: [26, 26, 46], textColor: 255, fontSize: 7.5, fontStyle: "bold" },
+ alternateRowStyles: { fillColor: [245, 247, 250] },
+ columnStyles: { 0: { cellWidth: 24 }, 1: { cellWidth: 120 }, 2: { cellWidth: 'auto' }, 3: { cellWidth: 'auto' } },
+ });
+ }
+
+ // --- Electronic Consent Recipients ---
+ if (electronicOwners.length > 0) {
+ doc.addPage();
+ doc.setFont("helvetica", "bold");
+ doc.setFontSize(13);
+ doc.setTextColor(0, 0, 0);
+ doc.text("Owner Roster — Electronic Consent Recipients", margin, 50);
+
+ doc.setFont("helvetica", "normal");
+ doc.setFontSize(8.5);
+ doc.setTextColor(80, 80, 80);
+ doc.text(`${association.name} • ${formatDateWordy(mailingDate)} • ${electronicOwners.length} owner(s) via electronic delivery`, margin, 64);
+
+ const groupedElec = {};
+ for (const o of electronicOwners) {
+ const addr = o.property_address || "—";
+ if (!groupedElec[addr]) groupedElec[addr] = [];
+ groupedElec[addr].push(o);
+ }
+ const sortedElecAddrs = Object.keys(groupedElec).sort((a, b) => a.localeCompare(b));
+ const elecTableBody = sortedElecAddrs.map((addr, i) => {
+ const group = groupedElec[addr];
+ const names = group.map((o) => `${o.first_name || ""} ${o.last_name || ""}`.trim()).join(", ");
+ const emails = [...new Set(group.map((o) => o.email || "—"))].join(", ");
+ return [i + 1, names, addr, emails];
+ });
+
+ autoTable(doc, {
+ startY: 76,
+ head: [["#", "Owner Name", "Property Address", "Email Address"]],
+ body: elecTableBody,
+ margin: { left: margin, right: margin },
+ styles: { fontSize: 7.5, cellPadding: 3.5, font: "helvetica" },
+ headStyles: { fillColor: [10, 80, 60], textColor: 255, fontSize: 7.5, fontStyle: "bold" },
+ alternateRowStyles: { fillColor: [240, 250, 245] },
+ columnStyles: { 0: { cellWidth: 24 }, 1: { cellWidth: 120 }, 2: { cellWidth: 'auto' }, 3: { cellWidth: 'auto' } },
+ });
+ }
+ }
+
+ // Validation proof
+ let proof = null;
+ if (embedValidation) {
+ try {
+ proof = await embedValidationProof(doc, {
+ documentTitle: `Affidavit of Mailing — ${association.name}`,
+ documentType: "affidavit_of_mailing",
+ documentDate: formatDateWordy(mailingDate),
+ associationId: selectedAssociation,
+ metadata: { meetingType, mailingDays, statute: STATUTE_TEXT[statute] },
+ });
+ } catch (e) {
+ console.error("Validation embed error:", e);
+ }
+ }
+
+ return { doc, proof };
+ };
+
+ const handleDownload = async () => {
+ const result = await generatePdf();
+ if (!result) return;
+ const { doc, proof } = result;
+
+ if (proof) {
+ const fileName = `affidavit_of_mailing-${format(new Date(), "yyyy-MM-dd")}.pdf`;
+ await storeValidatedPdf(doc, proof.id, fileName);
+ }
+
+ doc.save(`affidavit_of_mailing-${format(new Date(), "yyyy-MM-dd")}.pdf`);
+ sonnerToast.success("PDF downloaded");
+ };
+
+ const handleSaveToDocs = async (metadata) => {
+ const result = await generatePdf();
+ if (!result) return;
+ const { doc, proof } = result;
+
+ if (proof) {
+ const fileName = `affidavit_of_mailing-${format(new Date(), "yyyy-MM-dd")}.pdf`;
+ await storeValidatedPdf(doc, proof.id, fileName);
+ }
+
+ const blob = doc.output("blob");
+ // Display name kept human-readable; storage key is randomized.
+ const displayFileName = `${(metadata.name || `affidavit_of_mailing-${format(new Date(), "yyyy-MM-dd")}`).replace(/[^a-zA-Z0-9._ -]+/g, "_").trim()}.pdf`;
+ const filePath = buildSecureStorageKey("doc.pdf", `documents/${metadata.clientId}`);
+
+ const { error: uploadErr } = await supabase.storage
+ .from("files")
+ .upload(filePath, blob, { contentType: "application/pdf", upsert: true });
+
+ if (uploadErr) {
+ sonnerToast.error("Upload failed: " + uploadErr.message);
+ return;
+ }
+
+ const { data: { publicUrl } } = supabase.storage.from("files").getPublicUrl(filePath);
+ const { data: { user: userData } } = await supabase.auth.getUser();
+
+ const { error: docError } = await supabase.from("documents").insert({
+ title: metadata.name,
+ file_url: publicUrl,
+ file_name: displayFileName,
+ file_size: blob.size,
+ association_id: metadata.clientId,
+ category: metadata.category || "general",
+ uploaded_by: userData?.id,
+ });
+
+ if (docError) {
+ sonnerToast.error("Save error: " + docError.message);
+ } else {
+ sonnerToast.success("Saved to Documents");
+ }
+ return { blob };
+ };
+
+ return (
+
+
+
+ Affidavit of Mailing
+
+
+ {/* Association */}
+
+
+ Association
+
+
+
+ {associations.map((a) => (
+ {a.name}
+ ))}
+
+
+
+
+
+ Meeting Type
+
+
+
+ {MEETING_TYPES.map((t) => (
+ {t}
+ ))}
+
+
+
+
+
+
+
+ Mailing Date
+ setMailingDate(e.target.value)} />
+
+
+
+ Required Notice Period
+
+
+
+ {MAILING_DAYS.map((d) => (
+ {d.label}
+ ))}
+
+
+
+
+
+ Applicable Statute
+
+
+
+ {STATUTES.map((s) => (
+ {s.label}
+ ))}
+
+
+
+
+
+
+
+
+
+ County
+ setCounty(e.target.value)} />
+
+
+
+ {/* Options */}
+
+
+
+ Attach Owner Roster ({owners.length} owners)
+
+
+
+ Embed Validation Proof QR
+
+
+
+ {/* Actions */}
+
+
+ Download PDF
+
+ setShowSaveDocsDialog(true)} className="gap-2">
+ Save to Docs
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/forms/BallotEnvelopeForm.jsx b/src/components/forms/BallotEnvelopeForm.jsx
new file mode 100644
index 0000000..8afaea9
--- /dev/null
+++ b/src/components/forms/BallotEnvelopeForm.jsx
@@ -0,0 +1,452 @@
+import { useState, useEffect, useRef } from "react";
+import { Mail, Printer, Settings, Users, Plus, Save, Loader2 } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { Label } from "@/components/ui/label";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
+import { Separator } from "@/components/ui/separator";
+import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
+import { Checkbox } from "@/components/ui/checkbox";
+import { ScrollArea } from "@/components/ui/scroll-area";
+import { Badge } from "@/components/ui/badge";
+import { supabase } from "@/integrations/supabase/client";
+import { useAssociation } from "@/contexts/AssociationContext";
+import { useToast } from "@/hooks/use-toast";
+
+const ENVELOPE_SIZES = {
+ "#10": { label: "#10 Standard (4⅛ × 9½ in)", width: "9.5in", height: "4.125in" },
+ "#9": { label: "#9 Reply (3⅞ × 8⅞ in)", width: "8.875in", height: "3.875in" },
+ "a7": { label: "A7 Invitation (5¼ × 7¼ in)", width: "7.25in", height: "5.25in" },
+};
+
+export default function BallotEnvelopeForm() {
+ const { toast } = useToast();
+ const associationCtx = useAssociation() || {};
+ const selectedAssociation = associationCtx.selectedAssociation || "";
+
+ const [envelopeSize, setEnvelopeSize] = useState("#10");
+ const [printMode, setPrintMode] = useState("all-owners");
+ const [associations, setAssociations] = useState([]);
+ const [selectedAssoc, setSelectedAssoc] = useState(selectedAssociation || "");
+ const [owners, setOwners] = useState([]);
+ const [loadingOwners, setLoadingOwners] = useState(false);
+ const [selectedOwnerIds, setSelectedOwnerIds] = useState(new Set());
+
+ // Load associations
+ useEffect(() => {
+ (async () => {
+ const { data } = await supabase
+ .from("associations")
+ .select("id, name, address, city, state, zip, care_of_address, mailing_address")
+ .eq("status", "active")
+ .order("name");
+ setAssociations(data || []);
+ })();
+ }, []);
+
+ // Sync with global association context
+ useEffect(() => {
+ if (selectedAssociation) setSelectedAssoc(selectedAssociation);
+ }, [selectedAssociation]);
+
+ // Load owners when association changes
+ useEffect(() => {
+ if (!selectedAssoc) {
+ setOwners([]);
+ setSelectedOwnerIds(new Set());
+ setLoadingOwners(false);
+ return;
+ }
+
+ let cancelled = false;
+ const loadOwners = async () => {
+ setLoadingOwners(true);
+ try {
+ const { data, error } = await supabase
+ .from("owners")
+ .select("id, first_name, last_name, mailing_address, property_address, street_address, unit_id, units(unit_number, address, city, state, zip)")
+ .eq("association_id", selectedAssoc)
+ .eq("status", "active")
+ .order("last_name");
+
+ if (error) throw error;
+ if (!cancelled) {
+ setOwners(data || []);
+ setSelectedOwnerIds(new Set());
+ }
+ } catch (err) {
+ console.error("Error loading owners:", err);
+ if (!cancelled) setOwners([]);
+ } finally {
+ if (!cancelled) setLoadingOwners(false);
+ }
+ };
+
+ loadOwners();
+ return () => { cancelled = true; };
+ }, [selectedAssoc]);
+
+ const toggleOwner = (id) => {
+ setSelectedOwnerIds((prev) => {
+ const next = new Set(prev);
+ next.has(id) ? next.delete(id) : next.add(id);
+ return next;
+ });
+ };
+ const selectAll = () => setSelectedOwnerIds(new Set(owners.map((o) => o.id)));
+ const deselectAll = () => setSelectedOwnerIds(new Set());
+
+ const getRecipients = () => {
+ if (printMode === "selected") return owners.filter((o) => selectedOwnerIds.has(o.id));
+ return owners;
+ };
+
+ const getAssocAddress = () => {
+ const assoc = associations.find((a) => a.id === selectedAssoc);
+ if (!assoc) return { name: "", line1: "", line2: "" };
+
+ const mailingAddr = assoc.mailing_address || assoc.care_of_address;
+ if (mailingAddr) {
+ const lines = mailingAddr.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
+ return {
+ name: assoc.name,
+ line1: lines[0] || "",
+ line2: lines.slice(1).join(", ") || "",
+ };
+ }
+
+ return {
+ name: assoc.name,
+ line1: assoc.address || "",
+ line2: [assoc.city, assoc.state].filter(Boolean).join(", ") + (assoc.zip ? ` ${assoc.zip}` : ""),
+ };
+ };
+
+ const getOwnerReturnBlock = (owner) => {
+ const unit = Array.isArray(owner.units) ? owner.units[0] : owner.units;
+ const name = `${owner.first_name || ""} ${owner.last_name || ""}`.trim();
+
+ // Resolve property address
+ let propAddr = owner.property_address || owner.street_address || "";
+ if (!propAddr && unit) {
+ propAddr = unit.address || unit.unit_number || "";
+ }
+
+ // Parse the address into lines
+ const rawLines = propAddr.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n").map((l) => l.trim()).filter(Boolean);
+ let addrLine1 = "";
+ let addrLine2 = "";
+
+ if (rawLines.length >= 2) {
+ addrLine1 = rawLines[0];
+ addrLine2 = rawLines.slice(1).join(", ");
+ } else if (rawLines.length === 1) {
+ // Try to split at last comma before a state/zip pattern
+ const single = rawLines[0];
+ const match = single.match(/^(.+),\s*([A-Z]{2}\s+\d{5}(?:-\d{4})?.*)$/i);
+ if (match) {
+ addrLine1 = match[1].trim();
+ addrLine2 = match[2].trim();
+ } else {
+ addrLine1 = single;
+ }
+ }
+
+ return { name, addrLine1, addrLine2 };
+ };
+
+ const handlePrint = () => {
+ const recipients = getRecipients();
+ if (!recipients.length) {
+ toast({ variant: "destructive", title: "No recipients", description: "Select an association and owners to print ballot envelopes." });
+ return;
+ }
+
+ const size = ENVELOPE_SIZES[envelopeSize];
+ const assocAddr = getAssocAddress();
+
+ const printWindow = window.open("", "_blank");
+ if (!printWindow) return;
+
+ printWindow.document.write(`Ballot Envelopes
+
+ `);
+
+ recipients.forEach((owner) => {
+ const ret = getOwnerReturnBlock(owner);
+ printWindow.document.write(`
+
+
Official Ballot
+
+
Designated Voter
+
${ret.name}
+ ${ret.addrLine1 ? `
${ret.addrLine1}
` : ""}
+ ${ret.addrLine2 ? `
${ret.addrLine2}
` : ""}
+
Signature:
+
+
+
${assocAddr.name}
+ ${assocAddr.line1 ? `
${assocAddr.line1}
` : ""}
+ ${assocAddr.line2 ? `
${assocAddr.line2}
` : ""}
+
+
+ `);
+ });
+
+ printWindow.document.write("");
+ printWindow.document.close();
+ printWindow.focus();
+ setTimeout(() => printWindow.print(), 500);
+ };
+
+ // Preview data
+ const previewRecipient = getRecipients()[0];
+ const previewReturn = previewRecipient ? getOwnerReturnBlock(previewRecipient) : null;
+ const assocAddr = getAssocAddress();
+
+ return (
+
+ {/* Header */}
+
+
+
+
+
+
Ballot Envelopes
+
+ Print envelopes addressed to the association with owner return addresses
+
+
+
+
+
+
+ {/* Association Selector */}
+
+ Association (Recipient)
+
+
+
+
+
+ {associations.map((a) => (
+ {a.name}
+ ))}
+
+
+
+
+
+ {/* Settings & Preview */}
+
+
+
+ Envelope Settings
+
+
+
+
+ Envelope Size
+
+
+
+
+
+ {Object.entries(ENVELOPE_SIZES).map(([k, v]) => (
+ {v.label}
+ ))}
+
+
+
+
+
+
+ {/* Preview */}
+
+
Preview
+
+ {/* Ballot label */}
+
+ Official Ballot
+
+
+ {/* Return address (owner) */}
+
+
Designated Voter
+
+ {previewReturn ? previewReturn.name : "Owner Name"}
+
+
{previewReturn ? previewReturn.addrLine1 : "123 Main Street"}
+ {(previewReturn?.addrLine2 || !previewReturn) && (
+
{previewReturn ? previewReturn.addrLine2 : "City, State ZIP"}
+ )}
+
+ Signature:
+
+
+
+ {/* Recipient (association) */}
+
+
+ {assocAddr.name || "Association Name"}
+
+
{assocAddr.line1 || "Association Address"}
+ {(assocAddr.line2) &&
{assocAddr.line2}
}
+
+
+
+
+
+
+ {/* Owners (Voters) */}
+
+
+
+ Voters
+ {owners.length > 0 && (
+
+ {printMode === "selected" ? `${selectedOwnerIds.size} / ${owners.length}` : `${owners.length}`} owner(s)
+
+ )}
+
+
+
+
+
+
+
+
All Owners
+
Print a ballot envelope for every active owner
+
+
+
+
+
+
Selected Owners
+
Choose specific owners below
+
+
+
+
+ {printMode === "selected" && (
+ <>
+
+
+ Select All
+ Deselect All
+
+ {loadingOwners ? (
+
+ Loading owners...
+
+ ) : owners.length === 0 ? (
+ No owners found. Select an association first.
+ ) : (
+
+
+ {owners.map((owner) => {
+ const ret = getOwnerReturnBlock(owner);
+ return (
+
+ toggleOwner(owner.id)}
+ className="mt-0.5"
+ />
+
+
{ret.name}
+
+ {ret.addrLine1}{ret.addrLine2 ? `, ${ret.addrLine2}` : ""}
+
+
+
+ );
+ })}
+
+
+ )}
+ >
+ )}
+
+ {printMode === "all-owners" && !loadingOwners && owners.length > 0 && (
+ <>
+
+
+ Will print {owners.length} ballot envelope(s) for all active owners.
+
+ >
+ )}
+
+
+
+
+ {/* Print Button */}
+
+
+ Print Ballot Envelopes ({getRecipients().length})
+
+
+
+ );
+}
diff --git a/src/components/forms/BallotForm.tsx b/src/components/forms/BallotForm.tsx
new file mode 100644
index 0000000..27272ef
--- /dev/null
+++ b/src/components/forms/BallotForm.tsx
@@ -0,0 +1,455 @@
+import { useState, useRef, useCallback } from "react";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Textarea } from "@/components/ui/textarea";
+import { Separator } from "@/components/ui/separator";
+import { Card, CardContent } from "@/components/ui/card";
+import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
+import { Plus, Trash2, Printer, Eye, X, Save, FolderOpen, GripVertical, ChevronDown } from "lucide-react";
+import { useToast } from "@/components/ui/use-toast";
+import { DragDropContext, Droppable, Draggable, DropResult } from "@hello-pangea/dnd";
+
+interface Candidate {
+ id: string;
+ name: string;
+}
+
+interface SavedBallot {
+ id: string;
+ name: string;
+ savedAt: string;
+ data: {
+ associationName: string;
+ electionDate: string;
+ seatsAvailable: number;
+ termLength: string;
+ instructions: string;
+ candidates: Candidate[];
+ };
+}
+
+const STORAGE_KEY = "saved_ballots";
+
+function getSavedBallots(): SavedBallot[] {
+ try {
+ return JSON.parse(localStorage.getItem(STORAGE_KEY) || "[]");
+ } catch {
+ return [];
+ }
+}
+
+function saveBallots(ballots: SavedBallot[]) {
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(ballots));
+}
+
+// Strict-mode safe droppable wrapper
+function StrictModeDroppable({ children, ...props }: any) {
+ const [enabled, setEnabled] = useState(false);
+ useState(() => {
+ const animation = requestAnimationFrame(() => setEnabled(true));
+ return () => cancelAnimationFrame(animation);
+ });
+ if (!enabled) return null;
+ return {children} ;
+}
+
+export default function BallotForm() {
+ const { toast } = useToast();
+ const [associationName, setAssociationName] = useState("");
+ const [electionDate, setElectionDate] = useState("");
+ const [seatsAvailable, setSeatsAvailable] = useState(1);
+ const [termLength, setTermLength] = useState("1 year");
+ const [instructions, setInstructions] = useState(
+ "After completing the ballot, place your vote in the ballot box."
+ );
+ const [candidates, setCandidates] = useState([
+ { id: crypto.randomUUID(), name: "" },
+ ]);
+ const [showPreview, setShowPreview] = useState(false);
+ const [savedBallots, setSavedBallots] = useState(getSavedBallots);
+ const printRef = useRef(null);
+
+ const addCandidate = () => {
+ setCandidates((prev) => [...prev, { id: crypto.randomUUID(), name: "" }]);
+ };
+
+ const removeCandidate = (id: string) => {
+ setCandidates((prev) => prev.filter((c) => c.id !== id));
+ };
+
+ const updateCandidate = (id: string, name: string) => {
+ setCandidates((prev) => prev.map((c) => (c.id === id ? { ...c, name } : c)));
+ };
+
+ const onDragEnd = useCallback((result: DropResult) => {
+ if (!result.destination) return;
+ setCandidates((prev) => {
+ const items = Array.from(prev);
+ const [moved] = items.splice(result.source.index, 1);
+ items.splice(result.destination!.index, 0, moved);
+ return items;
+ });
+ }, []);
+
+ const filledCandidates = candidates.filter((c) => c.name.trim());
+
+ // Save ballot
+ const handleSave = () => {
+ if (!associationName.trim()) {
+ toast({ variant: "destructive", title: "Missing name", description: "Enter an association name before saving." });
+ return;
+ }
+ const ballotName = `${associationName} — ${electionDate || "No date"}`;
+ const existing = getSavedBallots();
+ const newBallot: SavedBallot = {
+ id: crypto.randomUUID(),
+ name: ballotName,
+ savedAt: new Date().toISOString(),
+ data: { associationName, electionDate, seatsAvailable, termLength, instructions, candidates },
+ };
+ const updated = [newBallot, ...existing].slice(0, 20); // keep max 20
+ saveBallots(updated);
+ setSavedBallots(updated);
+ toast({ title: "Ballot Saved", description: `"${ballotName}" saved successfully.` });
+ };
+
+ // Load ballot
+ const handleLoad = (ballot: SavedBallot) => {
+ const d = ballot.data;
+ setAssociationName(d.associationName);
+ setElectionDate(d.electionDate);
+ setSeatsAvailable(d.seatsAvailable);
+ setTermLength(d.termLength);
+ setInstructions(d.instructions);
+ setCandidates(d.candidates.length > 0 ? d.candidates : [{ id: crypto.randomUUID(), name: "" }]);
+ toast({ title: "Ballot Loaded", description: `"${ballot.name}" loaded.` });
+ };
+
+ // Delete saved ballot
+ const handleDeleteSaved = (id: string, e: React.MouseEvent) => {
+ e.stopPropagation();
+ const updated = getSavedBallots().filter((b) => b.id !== id);
+ saveBallots(updated);
+ setSavedBallots(updated);
+ toast({ title: "Deleted", description: "Saved ballot removed." });
+ };
+
+ const seatsWord = (n: number) => {
+ const words = ["zero","one","two","three","four","five","six","seven","eight","nine","ten"];
+ return words[n] || String(n);
+ };
+
+ const formattedDate = electionDate
+ ? new Date(electionDate + "T00:00:00").toLocaleDateString("en-US", {
+ year: "numeric", month: "long", day: "numeric",
+ })
+ : "";
+
+ const handlePrint = () => {
+ if (!associationName || filledCandidates.length === 0) {
+ toast({ variant: "destructive", title: "Missing information", description: "Please enter an association name and at least one candidate." });
+ return;
+ }
+ setShowPreview(true);
+ setTimeout(() => {
+ const content = printRef.current;
+ if (!content) return;
+ const win = window.open("", "_blank");
+ if (!win) return;
+ win.document.write(`Official Ballot
+ ${content.innerHTML}`);
+ win.document.close();
+ win.focus();
+ win.print();
+ win.close();
+ }, 300);
+ };
+
+ // --- Preview / Print ballot markup ---
+ const BallotPreview = () => (
+
+ {/* OFFICIAL watermark */}
+
+ OFFICIAL
+
+
+
+
+
+
+ {associationName || "Association Name"}
+
+
+ {formattedDate && (
+
+ {formattedDate}
+
+ )}
+
+
+
+
+ The following candidates are for election to the Board of Directors of{" "}
+ {associationName || "the Association"} . There{" "}
+ {seatsAvailable === 1 ? "is" : "are"} currently{" "}
+ {seatsWord(seatsAvailable)} ({seatsAvailable}) {" "}
+ position{seatsAvailable !== 1 ? "s" : ""} open on the Board of Directors for a term of{" "}
+ {termLength} .
+
+
+
+ Place a checkmark (✓) next to the candidate of your choice. Please select{" "}
+ {seatsWord(seatsAvailable).toUpperCase()} candidate
+ {seatsAvailable !== 1 ? "s" : ""}. Any ballot that casts more than{" "}
+ {seatsWord(seatsAvailable)} vote{seatsAvailable !== 1 ? "s" : ""} will be deemed invalid.
+
+
+
+ {filledCandidates.map((c) => (
+
+
+ {c.name}
+
+ ))}
+
+
+ {instructions && (
+
+
+ Instructions
+
+
{instructions}
+
+ )}
+
+
+ );
+
+ return (
+
+
+
+
Ballot Generator
+
+ Create official election ballots with candidates and print-ready output
+
+
+
+
+ Save
+
+
+
+
+
+ Load
+
+
+
+ {savedBallots.length === 0 ? (
+
+ No saved ballots
+
+ ) : (
+ savedBallots.map((b) => (
+ handleLoad(b)} className="flex items-center justify-between text-xs group">
+
+
{b.name}
+
+ {new Date(b.savedAt).toLocaleDateString()}
+
+
+ handleDeleteSaved(b.id, e)}
+ >
+
+
+
+ ))
+ )}
+
+
+
+
{
+ if (!associationName || filledCandidates.length === 0) {
+ toast({ variant: "destructive", title: "Missing info", description: "Enter association name and at least one candidate." });
+ return;
+ }
+ setShowPreview(true);
+ }}
+ >
+ Preview
+
+
+ Print Ballot
+
+
+
+
+
+ {/* Left: Details */}
+
+
+ Election Details
+
+ Association / Community Name
+ setAssociationName(e.target.value)} className="h-9 text-sm" />
+
+
+
+ Term Length
+ setTermLength(e.target.value)} className="h-9 text-sm" />
+
+
+ Voting Instructions
+ setInstructions(e.target.value)} rows={2} className="text-sm" />
+
+
+
+
+ {/* Right: Candidates with drag-and-drop */}
+
+
+
+
Candidates ({candidates.length})
+
+ Add Candidate
+
+
+
+
+
+ {(provided: any) => (
+
+ {candidates.map((c, idx) => (
+
+ {(provided: any, snapshot: any) => (
+
+
+
+
+
+ {idx + 1}.
+
+
updateCandidate(c.id, e.target.value)}
+ className="h-9 text-sm flex-1"
+ />
+
removeCandidate(c.id)}
+ disabled={candidates.length <= 1}
+ >
+
+
+
+ )}
+
+ ))}
+ {provided.placeholder}
+
+ )}
+
+
+
+
+
+
+ {/* Live mini preview */}
+
+
+
+ Live Preview
+
+
+
+
+
+ {/* Full-screen preview modal */}
+ {showPreview && (
+
+
+
+
Ballot Preview
+
+
+ Print
+
+
setShowPreview(false)}>
+
+
+
+
+
+
+
+
+
+ )}
+
+ );
+}
diff --git a/src/components/forms/BatchAssessmentForm.jsx b/src/components/forms/BatchAssessmentForm.jsx
new file mode 100644
index 0000000..f4a198e
--- /dev/null
+++ b/src/components/forms/BatchAssessmentForm.jsx
@@ -0,0 +1,800 @@
+import React, { useState, useEffect } from 'react';
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import { Button } from '@/components/ui/button';
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
+import { useToast } from '@/hooks/use-toast';
+import { FileDown, Loader2, Trash2, Settings, Database, AlertTriangle, FolderDown } from 'lucide-react';
+import { jsPDF } from 'jspdf';
+import 'jspdf-autotable';
+import { supabase } from '@/integrations/supabase/client';
+import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
+import { Badge } from '@/components/ui/badge';
+import { getNoticeContent } from '@/lib/noticeContentUtils';
+import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
+import { loadNotoSymbolsFont, registerNotoSymbolsFont } from '@/lib/fontManager';
+import { SaveToDocumentsDialog } from '@/components/SaveToDocumentsDialog';
+import PdfStylingControls from '@/components/forms/PdfStylingControls';
+import { buildLedgerAccountBreakdown } from '@/lib/unitLedgerAccountBreakdown';
+import { formatMailingAddress } from '@/lib/formatMailingAddress';
+import { combineOwnerNames } from '@/lib/ownerAddressUtils';
+import { Checkbox } from '@/components/ui/checkbox';
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from '@/components/ui/dialog';
+import { ScrollArea } from '@/components/ui/scroll-area';
+
+export default function BatchAssessmentForm() {
+ const { toast } = useToast();
+ const [associations, setAssociations] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [selectedAssociationId, setSelectedAssociationId] = useState('');
+ const [showSaveDocsDialog, setShowSaveDocsDialog] = useState(false);
+
+ const [noticeType, setNoticeType] = useState('reminder');
+ const [pdfConfig, setPdfConfig] = useState({
+ font: 'helvetica',
+ fontSize: '9',
+ textAlign: 'left'
+ });
+
+ // Selection dialog state
+ const [showSelectionDialog, setShowSelectionDialog] = useState(false);
+ const [candidateItems, setCandidateItems] = useState([]);
+ const [selectedCandidateIds, setSelectedCandidateIds] = useState(new Set());
+
+ const getDefaultDueDate = () => {
+ const d = new Date();
+ d.setDate(d.getDate() + 30);
+ return d.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' });
+ };
+
+ const [globalSettings, setGlobalSettings] = useState({
+ date: new Date().toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' }),
+ dueDate: getDefaultDueDate(),
+ interestRate: '12.00',
+ managementCompany: 'Avria Community Management, LLC',
+ managementEmail: '',
+ managementPhone: '(321) 405-2449',
+ managementWebsite: '',
+ associationName: '',
+ paymentAddressLine1: 'P.O. Box 560060',
+ paymentAddressLine2: 'Rockledge, Florida 32956',
+ noticeLogo: '',
+ footerLogo: ''
+ });
+
+ const [batchData, setBatchData] = useState([]);
+
+ useEffect(() => {
+ fetchAssociations();
+ }, []);
+
+ const fetchAssociations = async () => {
+ const { data, error } = await supabase.from('associations').select('id, name, email, phone, website, logo_url');
+ if (!error) setAssociations(data || []);
+ };
+
+ const handleAssociationChange = (associationId) => {
+ setSelectedAssociationId(associationId);
+ setBatchData([]);
+ const assoc = associations.find(c => c.id === associationId);
+ if (assoc) {
+ setGlobalSettings(prev => ({
+ ...prev,
+ associationName: assoc.name,
+ managementEmail: assoc.email || prev.managementEmail,
+ managementPhone: assoc.phone || prev.managementPhone,
+ managementWebsite: assoc.website || prev.managementWebsite,
+ noticeLogo: assoc.logo_url || ''
+ }));
+ }
+ };
+
+ const handleGlobalChange = (e) => {
+ const { name, value } = e.target;
+ setGlobalSettings(prev => ({ ...prev, [name]: value }));
+ };
+
+ const removeItem = (id) => {
+ setBatchData(prev => prev.filter(item => item.id !== id));
+ };
+
+ const clearBatch = () => {
+ if(window.confirm('Are you sure you want to clear the entire batch?')) {
+ setBatchData([]);
+ }
+ };
+
+ const buildBatchItems = (allOwners, allLedger) => {
+ const ledgerByOwner = {};
+ allLedger.forEach(e => {
+ if (!ledgerByOwner[e.owner_id]) ledgerByOwner[e.owner_id] = [];
+ ledgerByOwner[e.owner_id].push(e);
+ });
+
+ const unitGroups = new Map();
+ let noUnitIdx = 0;
+ allOwners.forEach(owner => {
+ const key = owner.unit_id || `__no_unit_${noUnitIdx++}`;
+ if (!unitGroups.has(key)) unitGroups.set(key, []);
+ unitGroups.get(key).push(owner);
+ });
+
+ const items = [];
+ let itemIdx = 0;
+ for (const [, groupOwners] of unitGroups) {
+ let combinedEntries = [];
+ groupOwners.forEach(owner => {
+ const entries = ledgerByOwner[owner.id] || [];
+ combinedEntries = combinedEntries.concat(entries);
+ });
+ if (combinedEntries.length === 0) continue;
+
+ const { rows: breakdownRows, totals } = buildLedgerAccountBreakdown(combinedEntries);
+ if (totals.openBalance <= 0) continue;
+
+ const assessments = breakdownRows.find(r => r.type === 'assessment')?.openBalance || 0;
+ const lateFees = breakdownRows.find(r => r.type === 'late_fee')?.openBalance || 0;
+ const interest = breakdownRows.find(r => r.type === 'interest')?.openBalance || 0;
+ const otherCharges = breakdownRows
+ .filter(r => !['assessment', 'late_fee', 'interest'].includes(r.type))
+ .reduce((sum, r) => sum + (r.openBalance > 0 ? r.openBalance : 0), 0);
+
+ const ownerName = combineOwnerNames(groupOwners);
+ const primaryOwner = groupOwners[0];
+ const { full: formattedAddress } = formatMailingAddress(primaryOwner, ownerName);
+ const primaryAddress = primaryOwner.mailing_address || primaryOwner.property_address || '';
+
+ const alternates = [];
+ const seenAddr = new Set([primaryAddress.trim().toLowerCase()]);
+ groupOwners.forEach(owner => {
+ if (owner.alternate_address_1?.trim() && !seenAddr.has(owner.alternate_address_1.trim().toLowerCase())) {
+ seenAddr.add(owner.alternate_address_1.trim().toLowerCase());
+ alternates.push(owner.alternate_address_1.trim());
+ }
+ if (owner.alternate_address_2?.trim() && !seenAddr.has(owner.alternate_address_2.trim().toLowerCase())) {
+ seenAddr.add(owner.alternate_address_2.trim().toLowerCase());
+ alternates.push(owner.alternate_address_2.trim());
+ }
+ });
+
+ const lineItems = [
+ { description: 'Assessments', amount: assessments > 0 ? assessments.toFixed(2) : '0.00' },
+ { description: 'Late Fees', amount: lateFees > 0 ? lateFees.toFixed(2) : '0.00' },
+ { description: 'Interest', amount: interest > 0 ? interest.toFixed(2) : '0.00' }
+ ];
+ if (otherCharges > 0) {
+ lineItems.push({ description: 'Other Charges', amount: otherCharges.toFixed(2) });
+ }
+
+ items.push({
+ id: Date.now() + itemIdx++ + Math.random(),
+ ownerName,
+ propertyAddress: primaryOwner.property_address || '',
+ accountNo: primaryOwner.units?.account_number || primaryOwner.account_number || '',
+ primaryAddress,
+ alternateAddresses: alternates,
+ items: lineItems,
+ hiddenItems: [],
+ totalDue: totals.openBalance
+ });
+ }
+
+ return items;
+ };
+
+ const loadFromDatabase = async () => {
+ if (!selectedAssociationId) {
+ toast({ variant: "destructive", title: "Selection Required", description: "Please select an association first." });
+ return;
+ }
+
+ setLoading(true);
+ try {
+ // Fetch ALL active owners for the association
+ const CHUNK = 100;
+ const PAGE = 1000;
+
+ let allOwners = [];
+ let from = 0;
+ let keepGoing = true;
+ while (keepGoing) {
+ const { data, error } = await supabase
+ .from('owners')
+ .select('*')
+ .eq('association_id', selectedAssociationId)
+ .neq('status', 'archived')
+ .range(from, from + PAGE - 1);
+ if (error) throw error;
+ allOwners = allOwners.concat(data || []);
+ keepGoing = (data || []).length === PAGE;
+ from += PAGE;
+ }
+
+ if (allOwners.length === 0) {
+ toast({ title: "No Owners Found", description: "No active owners found for this association." });
+ setLoading(false);
+ return;
+ }
+
+ const ownerIds = allOwners.map(o => o.id);
+
+ // Fetch all ledger entries (paginated + chunked)
+ let allLedger = [];
+ for (let ci = 0; ci < ownerIds.length; ci += CHUNK) {
+ const chunk = ownerIds.slice(ci, ci + CHUNK);
+ let lFrom = 0;
+ let lKeep = true;
+ while (lKeep) {
+ const { data: batch, error: lErr } = await supabase
+ .from('owner_ledger_entries')
+ .select('*')
+ .in('owner_id', chunk)
+ .range(lFrom, lFrom + PAGE - 1);
+ if (lErr) throw lErr;
+ allLedger = allLedger.concat(batch || []);
+ lKeep = (batch || []).length === PAGE;
+ lFrom += PAGE;
+ }
+ }
+
+ const candidates = buildBatchItems(allOwners, allLedger);
+
+ if (candidates.length === 0) {
+ toast({ title: "No Outstanding Balances", description: "No accounts with outstanding balances found." });
+ setLoading(false);
+ return;
+ }
+
+ // Sort by total due descending
+ candidates.sort((a, b) => b.totalDue - a.totalDue);
+
+ // Pre-select all
+ setCandidateItems(candidates);
+ setSelectedCandidateIds(new Set(candidates.map(c => c.id)));
+ setShowSelectionDialog(true);
+
+ } catch (err) {
+ console.error(err);
+ toast({ variant: "destructive", title: "Error", description: "Failed to load accounts." });
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleConfirmSelection = () => {
+ const selected = candidateItems.filter(c => selectedCandidateIds.has(c.id));
+ if (selected.length === 0) {
+ toast({ variant: "destructive", title: "No Selection", description: "Please select at least one account." });
+ return;
+ }
+ setBatchData(prev => [...prev, ...selected]);
+ setShowSelectionDialog(false);
+ setCandidateItems([]);
+ setSelectedCandidateIds(new Set());
+ toast({ title: "Loaded", description: `Added ${selected.length} accounts to batch.` });
+ };
+
+ const toggleCandidate = (id) => {
+ setSelectedCandidateIds(prev => {
+ const next = new Set(prev);
+ if (next.has(id)) next.delete(id);
+ else next.add(id);
+ return next;
+ });
+ };
+
+ const toggleAllCandidates = () => {
+ if (selectedCandidateIds.size === candidateItems.length) {
+ setSelectedCandidateIds(new Set());
+ } else {
+ setSelectedCandidateIds(new Set(candidateItems.map(c => c.id)));
+ }
+ };
+
+ const loadImage = (url) => {
+ return new Promise((resolve) => {
+ const img = new Image();
+ img.crossOrigin = 'Anonymous';
+ img.onload = () => {
+ const canvas = document.createElement('canvas');
+ canvas.width = img.width;
+ canvas.height = img.height;
+ const ctx = canvas.getContext('2d');
+ ctx.drawImage(img, 0, 0);
+ try {
+ resolve({ dataURL: canvas.toDataURL('image/png'), width: img.width, height: img.height });
+ } catch (e) { resolve(null); }
+ };
+ img.onerror = () => resolve(null);
+ img.src = url;
+ });
+ };
+
+ const getTotal = (items, hiddenItems = []) => {
+ const visibleTotal = items.reduce((acc, item) => acc + (parseFloat(item.amount.replace(/,/g, '')) || 0), 0);
+ const hiddenTotal = hiddenItems.reduce((acc, item) => acc + (parseFloat(item.amount.replace(/,/g, '')) || 0), 0);
+ return (visibleTotal + hiddenTotal).toFixed(2);
+ };
+
+ const calculateTotalLetters = () => {
+ return batchData.reduce((acc, item) => acc + 1 + (item.alternateAddresses?.length || 0), 0);
+ };
+
+ const generateBatchPDF = async (action = 'download') => {
+ if (batchData.length === 0) {
+ toast({ variant: "destructive", title: "Empty Batch", description: "No records to generate." });
+ return null;
+ }
+
+ setLoading(true);
+ try {
+ await loadNotoSymbolsFont();
+ const doc = new jsPDF({ orientation: 'portrait', unit: 'pt', format: 'letter' });
+ registerNotoSymbolsFont(doc);
+
+ const pageWidth = doc.internal.pageSize.getWidth();
+ const pageHeight = doc.internal.pageSize.getHeight();
+ const margin = 54;
+ const returnAddressX = 38;
+ const returnAddressY = 60;
+ const baseFontSize = parseInt(pdfConfig.fontSize) || 9;
+ const baseFont = pdfConfig.font || "helvetica";
+ const textAlign = pdfConfig.textAlign || 'left';
+
+ let headerLogoData = null;
+ if (globalSettings.noticeLogo) headerLogoData = await loadImage(globalSettings.noticeLogo);
+ let footerLogoData = null;
+ if (globalSettings.footerLogo) footerLogoData = await loadImage(globalSettings.footerLogo);
+
+ const alignRight = (text, y, fontSize = baseFontSize, font = baseFont, style = "normal") => {
+ doc.setFont(font, style);
+ doc.setFontSize(fontSize);
+ const w = doc.getTextWidth(text);
+ doc.text(text, pageWidth - margin - w, y);
+ };
+
+ const paragraphGap = baseFontSize * 0.8;
+
+ const writeTextBlock = (text, startY, isBold = false) => {
+ doc.setFont(baseFont, isBold ? "bold" : "normal");
+ doc.setFontSize(baseFontSize);
+ const maxLineWidth = pageWidth - (margin * 2);
+ const lineHeight = baseFontSize * 1.3;
+ const lines = doc.splitTextToSize(text, maxLineWidth);
+ let y = startY;
+ if (y + (lines.length * lineHeight) > pageHeight - 120) {
+ doc.addPage();
+ y = margin + 30;
+ }
+ doc.text(text, margin, y, { maxWidth: maxLineWidth, align: textAlign, lineHeightFactor: 1.3 });
+ return y + (lines.length * lineHeight) + 12 + paragraphGap;
+ };
+
+ // Expand batch: Primary + Alternates
+ const printQueue = [];
+ batchData.forEach(item => {
+ printQueue.push({ ...item, recipientName: item.ownerName, recipientAddress: item.primaryAddress, isAlternate: false });
+ if (item.alternateAddresses && item.alternateAddresses.length > 0) {
+ item.alternateAddresses.forEach(altAddr => {
+ printQueue.push({ ...item, recipientName: globalSettings.associationName, recipientAddress: altAddr, isAlternate: true });
+ });
+ }
+ });
+
+ const content = getNoticeContent(noticeType, globalSettings);
+
+ for (let i = 0; i < printQueue.length; i++) {
+ if (i > 0) doc.addPage();
+ const startPage = doc.internal.getNumberOfPages();
+ const letterData = printQueue[i];
+
+ doc.setFont(baseFont, "normal");
+ doc.setFontSize(baseFontSize);
+ doc.setTextColor(0);
+
+ // --- 1. HEADER ---
+ let currentY = returnAddressY;
+ [globalSettings.associationName, "c/o " + globalSettings.managementCompany, globalSettings.paymentAddressLine1, globalSettings.paymentAddressLine2].forEach(line => {
+ if (line) { doc.text(line, returnAddressX, currentY); currentY += 12; }
+ });
+
+ if (headerLogoData) {
+ const { dataURL, width, height } = headerLogoData;
+ const maxLogoW = 200; const maxLogoH = 50;
+ let logoW = maxLogoW; let logoH = logoW * (width / height);
+ if (logoH > maxLogoH) { logoH = maxLogoH; logoW = logoH * (width / height); }
+ doc.addImage(dataURL, 'PNG', pageWidth - margin - logoW, margin - 15, logoW, logoH);
+ }
+
+ // --- 2. RECIPIENT ---
+ let addressBlockY = 175;
+ alignRight(globalSettings.date, addressBlockY, baseFontSize, baseFont, "bold");
+
+ let addrY = addressBlockY;
+ doc.setFont(baseFont, "normal");
+ doc.setFontSize(baseFontSize);
+
+ if (letterData.recipientName) { doc.text(letterData.recipientName, margin, addrY); addrY += 12; }
+ if (letterData.recipientAddress) {
+ letterData.recipientAddress.split('\n').map(l => l.trim()).filter(l => l).forEach(line => {
+ doc.text(line, margin, addrY); addrY += 12;
+ });
+ }
+
+ currentY = Math.max(addrY + 30, 240);
+
+ // --- 3. BODY ---
+ doc.setFont(baseFont, "bold");
+ doc.setFontSize(11);
+ doc.text(content.title, pageWidth / 2, currentY, { align: 'center' });
+ const titleW = doc.getTextWidth(content.title);
+ doc.setLineWidth(1);
+ doc.line((pageWidth - titleW)/2, currentY + 2, (pageWidth + titleW)/2, currentY + 2);
+ currentY += 24;
+
+ doc.setFont(baseFont, "normal");
+ doc.setFontSize(baseFontSize);
+ doc.text(`Dear ${letterData.ownerName}:`, margin, currentY);
+ currentY += 24;
+
+ currentY = writeTextBlock(content.intro, currentY, false, true);
+ currentY += 12;
+
+ const totalDue = getTotal(letterData.items, letterData.hiddenItems);
+
+ // --- FINANCIAL BREAKDOWN TABLE ---
+ const tableIndent = margin + 40;
+ const dollarSignX = pageWidth - margin - 120;
+ const amountRightEdge = pageWidth - margin - 60;
+
+ doc.setFont(baseFont, "normal");
+ doc.setFontSize(baseFontSize);
+
+ letterData.items.forEach(item => {
+ const amt = parseFloat(item.amount.replace(/,/g, '') || '0');
+ if (amt > 0) {
+ doc.setFont(baseFont, "normal");
+ doc.setFontSize(baseFontSize);
+ doc.text(`${item.description}:`, tableIndent, currentY);
+ doc.text("$", dollarSignX, currentY);
+ doc.text(amt.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }), amountRightEdge, currentY, { align: 'right' });
+ currentY += 15;
+ }
+ });
+
+ currentY += 5;
+ doc.setLineWidth(0.5);
+ doc.line(tableIndent, currentY, amountRightEdge + 10, currentY);
+ currentY += 15;
+
+ doc.setFont(baseFont, "bold");
+ doc.setFontSize(baseFontSize);
+ doc.text("TOTAL AMOUNT DUE:", tableIndent, currentY);
+ doc.text("$", dollarSignX, currentY);
+ const fmtTotal = parseFloat(totalDue.replace(/,/g, '') || '0').toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
+ doc.text(fmtTotal, amountRightEdge, currentY, { align: 'right' });
+ doc.setFont(baseFont, "normal");
+ currentY += 24;
+
+ currentY = writeTextBlock(content.consequences, currentY, false, true);
+ currentY += 12;
+ currentY = writeTextBlock(content.contact, currentY, false, true);
+ currentY += 12;
+ currentY = writeTextBlock(content.instructions, currentY, false, true);
+ currentY += 24;
+
+ if (currentY + 60 > pageHeight - 120) { doc.addPage(); currentY = margin + 30; }
+ doc.text("Sincerely,", margin, currentY); currentY += 25;
+ doc.text(globalSettings.managementCompany, margin, currentY); currentY += 11;
+ doc.text(`Managing Agent for ${globalSettings.associationName}`, margin, currentY);
+
+ // --- FOOTER ---
+ const endPage = doc.internal.getNumberOfPages();
+ for (let j = startPage; j <= endPage; j++) {
+ doc.setPage(j);
+ const footerLineY = pageHeight - 50;
+
+ if (footerLogoData) {
+ const { dataURL, width, height } = footerLogoData;
+ const fLogoH = 25; const fLogoW = fLogoH * (width / height);
+ doc.addImage(dataURL, 'PNG', (pageWidth - fLogoW) / 2, footerLineY - fLogoH - 5, fLogoW, fLogoH);
+ } else {
+ doc.setFont("helvetica", "bold");
+ doc.setFontSize(9);
+ const txt = "ACM";
+ doc.text(txt, (pageWidth - doc.getTextWidth(txt))/2, footerLineY - 15);
+ }
+
+ doc.setDrawColor(0);
+ doc.setLineWidth(0.5);
+ doc.line(margin, footerLineY, pageWidth - margin, footerLineY);
+
+ const infoY = footerLineY + 15;
+ doc.setFont("helvetica", "normal");
+ doc.setFontSize(8);
+ doc.text(`Phone: ${globalSettings.managementPhone}`, margin, infoY);
+ doc.text(globalSettings.managementEmail, (pageWidth - doc.getTextWidth(globalSettings.managementEmail)) / 2, infoY);
+ const webW = doc.getTextWidth(globalSettings.managementWebsite);
+ doc.text(globalSettings.managementWebsite, pageWidth - margin - webW, infoY);
+ doc.text(`${j - startPage + 1}`, (pageWidth - doc.getTextWidth(`${j - startPage + 1}`)) / 2, pageHeight - 20);
+ }
+ }
+
+ if (action === 'blob') {
+ return doc.output('blob');
+ } else {
+ const fileName = `Batch_${noticeType === 'final' ? 'Final' : 'Reminder'}_Notices_${new Date().toISOString().split('T')[0]}.pdf`;
+ doc.save(fileName);
+ toast({ title: "Success", description: `Generated PDF with ${printQueue.length} letters.` });
+ }
+
+ } catch (err) {
+ console.error(err);
+ toast({ variant: "destructive", title: "Error", description: "Failed to generate PDF" });
+ return null;
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleSaveToDocuments = async (metadata) => {
+ try {
+ const blob = await generateBatchPDF('blob');
+ if (!blob) throw new Error("Failed to generate PDF blob.");
+
+ const { data: userData } = await supabase.auth.getUser();
+ if (!userData?.user) throw new Error("Not authenticated");
+
+ const safeName = metadata.name.replace(/[^a-z0-9]/gi, '_').toLowerCase();
+ const fileName = `${Date.now()}-${safeName}.pdf`;
+ const filePath = `${userData.user.id}/${fileName}`;
+
+ const { error: uploadError } = await supabase.storage
+ .from('files')
+ .upload(filePath, blob, { contentType: 'application/pdf' });
+ if (uploadError) throw uploadError;
+
+ toast({ title: 'Success', description: 'Document saved successfully.' });
+ return { blob };
+ } catch (err) {
+ console.error(err);
+ toast({ variant: 'destructive', title: 'Error saving document', description: err.message });
+ throw err;
+ }
+ };
+
+ return (
+
+
+ {/* Configuration Card */}
+
+
+
+
+
+ Batch Configuration
+
+
+ {loading ? : }
+ Load from DB
+
+
+
+
+
+
+ Select Association
+
+
+
+
+
+ {associations.map((assoc) => (
+ {assoc.name}
+ ))}
+
+
+
+
+
+
+
+
Notice Type
+
+
+
+ Reminder Notice
+
+
+
+
+ Final Notice
+ {noticeType === 'final' && }
+
+
+
+
+ {noticeType === 'final'
+ ? 'Includes firm language regarding collections, legal action, and credit reporting.'
+ : 'Standard friendly reminder about past due balance.'}
+
+
+
+ {/* PDF Styling */}
+
+
+
+
+
+ Advanced Settings (Contact & Logos)
+
+
+
+
+
+
+
+
+
+ {/* Batch Data Table */}
+
+
+
+
+ Batch Queue ({batchData.length})
+ {batchData.length > 0 && (
+ <>
+
+ Total Letters: {calculateTotalLetters()}
+
+
+ Clear All
+
+ >
+ )}
+
+
+ setShowSaveDocsDialog(true)} disabled={loading || batchData.length === 0}>
+ Save to Docs
+
+ generateBatchPDF('download')}
+ disabled={loading || batchData.length === 0}
+ variant={noticeType === 'final' ? "destructive" : "default"}
+ >
+ {loading ? : }
+ {noticeType === 'final' ? 'Generate FINAL Notices' : 'Generate Reminder Notices'}
+
+
+
+
+
+ {batchData.length === 0 ? (
+
+
No records in batch. Use "Load from DB" to load owners with outstanding balances.
+
+ ) : (
+
+
+
+
+ Owner Name
+ Property Address
+ Letters Generated
+ Total Due
+
+
+
+
+ {batchData.map((item) => (
+
+ {item.ownerName}
+ {item.propertyAddress}
+
+
+ Primary
+ {item.alternateAddresses && item.alternateAddresses.map((alt, idx) => (
+ + Alternate {idx + 1}
+ ))}
+
+
+ ${getTotal(item.items, item.hiddenItems)}
+
+ removeItem(item.id)}>
+
+
+
+
+ ))}
+
+
+
+ )}
+
+
+
+
+
+ {/* Account Selection Dialog */}
+
+
+
+ Select Accounts for Batch
+
+ {candidateItems.length} accounts with outstanding balances found. Select which to include.
+
+
+
+
+ 0}
+ onCheckedChange={toggleAllCandidates}
+ />
+ Select All
+
+
+ {selectedCandidateIds.size} of {candidateItems.length} selected
+
+
+
+
+ {candidateItems.map(item => (
+
toggleCandidate(item.id)}
+ >
+
toggleCandidate(item.id)}
+ />
+
+
{item.ownerName}
+
{item.propertyAddress || 'No address'}
+
+
+
+ ${item.totalDue.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
+
+
{item.accountNo || ''}
+
+
+ ))}
+
+
+
+ setShowSelectionDialog(false)}>Cancel
+
+ Add {selectedCandidateIds.size} to Batch
+
+
+
+
+
+ );
+}
diff --git a/src/components/forms/CreditLineDialog.jsx b/src/components/forms/CreditLineDialog.jsx
new file mode 100644
index 0000000..214e1d6
--- /dev/null
+++ b/src/components/forms/CreditLineDialog.jsx
@@ -0,0 +1,96 @@
+import React, { useState } from 'react';
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
+import { Textarea } from "@/components/ui/textarea";
+
+const CREDIT_CATEGORIES = [
+ { value: "payment", label: "AR Payment (General)" },
+ { value: "assessment", label: "Assessment Write-Off" },
+ { value: "lateFee", label: "Late Fee Write-Off" },
+ { value: "adminFee", label: "Admin Fee Write-Off" },
+ { value: "legalFee", label: "Legal Fee Write-Off" },
+ { value: "violation", label: "Violation Write-Off" },
+ { value: "interest", label: "Interest Write-Off" },
+];
+
+export function CreditLineDialog({ open, onOpenChange, onAddCredit }) {
+ const [amount, setAmount] = useState('');
+ const [category, setCategory] = useState('payment');
+ const [description, setDescription] = useState('');
+
+ const handleSubmit = (e) => {
+ e.preventDefault();
+ if (!amount || parseFloat(amount) <= 0) return;
+
+ onAddCredit({
+ entryType: 'credit',
+ amount: parseFloat(amount),
+ category,
+ description
+ });
+
+ setAmount('');
+ setCategory('payment');
+ setDescription('');
+ onOpenChange(false);
+ };
+
+ return (
+
+
+
+ Add Payment / Credit
+
+
+
+
+
Payment or Write-Off Credit
+
General payments follow the payment priority waterfall. Category credits write off the selected balance bucket.
+
+
+
+
+ Credit Amount ($)
+ setAmount(e.target.value)}
+ className="font-mono"
+ />
+
+
+
+ Category
+
+
+
+ {CREDIT_CATEGORIES.map(c => (
+ {c.label}
+ ))}
+
+
+
+
+
+ Description
+ setDescription(e.target.value)}
+ />
+
+
+
+ onOpenChange(false)}>Cancel
+ Add Credit
+
+
+
+
+ );
+}
diff --git a/src/components/forms/CustomFormBuilder.jsx b/src/components/forms/CustomFormBuilder.jsx
new file mode 100644
index 0000000..be7f62d
--- /dev/null
+++ b/src/components/forms/CustomFormBuilder.jsx
@@ -0,0 +1,1788 @@
+import React, {
+ useState, useEffect, useCallback, useMemo, useRef,
+ forwardRef, useImperativeHandle,
+} from 'react';
+import { supabase } from '@/integrations/supabase/client';
+import { combineOwnerNames } from '@/lib/ownerAddressUtils';
+import {
+ Loader2, Save, AlertTriangle, Settings,
+ Check, ChevronsUpDown, Trash2,
+} from 'lucide-react';
+import { CustomFormPreview } from './CustomFormPreview';
+import { CustomFormPdfExport } from './CustomFormPdfExport';
+import { CustomFormPdfExportButton } from './CustomFormPdfExportButton';
+import { useSavedFormTemplates } from '@/hooks/useSavedFormTemplates';
+import SaveToDocumentsDialog from '@/components/documents/SaveToDocumentsDialog';
+import { sanitizeRichTextHtml } from '@/lib/customFormHtmlSanitizer';
+import { normalizePastedRichHtml, plainTextToRichHtml } from '@/lib/customFormPasteUtils';
+import { convertDocxFileToHtml } from '@/lib/customFormDocxImport';
+
+// ─── CONSTANTS ────────────────────────────────────────────────────────────────
+const PAGE_W_PX = 816;
+const PAGE_H_PX = 1056;
+const MARGIN_PX = 72;
+
+const FONT_SIZES = [8, 9, 10, 11, 12, 14, 16, 18, 20, 22, 24, 28, 32, 36, 48, 72];
+const EDITOR_FONTS = [
+ { label: 'Default', value: 'inherit' },
+ { label: 'Helvetica', value: 'Helvetica, Arial, sans-serif' },
+ { label: 'Times New Roman', value: '"Times New Roman", Times, serif' },
+ { label: 'Georgia', value: 'Georgia, serif' },
+ { label: 'Courier New', value: '"Courier New", monospace' },
+ { label: 'Arial', value: 'Arial, sans-serif' },
+ { label: 'Garamond', value: 'Garamond, serif' },
+ { label: 'Verdana', value: 'Verdana, sans-serif' },
+ { label: 'Trebuchet MS', value: '"Trebuchet MS", sans-serif' },
+];
+const BLOCK_FORMATS = [
+ { label: 'Normal', value: 'p' },
+ { label: 'Heading 1', value: 'h1' },
+ { label: 'Heading 2', value: 'h2' },
+ { label: 'Heading 3', value: 'h3' },
+ { label: 'Heading 4', value: 'h4' },
+ { label: 'Preformatted',value: 'pre' },
+];
+const FORMAT_TARGET_SELECTOR = 'li,p,div,h1,h2,h3,h4,blockquote,pre,td,th';
+
+// ─── INLINE SVG ICONS ─────────────────────────────────────────────────────────
+const Icon = ({ d, size = 14, className = '' }) => (
+
+ {Array.isArray(d) ? d.map((p, i) => ) : }
+
+);
+const BoldIcon = () => ;
+const ItalicIcon = () => ;
+const UnderlineIcon = () => ;
+const StrikeIcon = () => ;
+const AlignLIcon = () => ;
+const AlignCIcon = () => ;
+const AlignRIcon = () => ;
+const AlignJIcon = () => ;
+const ListIcon = () => ;
+const OListIcon = () => ;
+const IndentIcon = () => ;
+const OutdentIcon = () => ;
+const LinkIcon = () => ;
+const TableIcon = () => ;
+const UndoIcon = () => ;
+const RedoIcon = () => ;
+const PaletteIcon = () => ;
+const HighlightIcon = () => ;
+const RemoveIcon = () => ;
+const PrinterIcon = () => ;
+const EyeIcon = () => ;
+const EyeOffIcon = () => ;
+const ChevronDownIcon = () => ;
+const XIcon = () => ;
+const PageBreakIcon = () => ;
+
+// ─── UTILITIES ────────────────────────────────────────────────────────────────
+function cls(...args) { return args.filter(Boolean).join(' '); }
+
+function getClosestFormatTarget(node, root) {
+ if (!node || !root) return null;
+
+ const element = node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement;
+ if (!element) return null;
+
+ const target = element.closest(FORMAT_TARGET_SELECTOR);
+ return target && root.contains(target) ? target : root;
+}
+
+function getSelectionFormatTargets(root) {
+ if (!root) return [];
+
+ const selection = window.getSelection();
+ if (!selection || selection.rangeCount === 0) return [];
+
+ const range = selection.getRangeAt(0);
+ const targets = new Set();
+
+ const commonAncestor = range.commonAncestorContainer.nodeType === Node.ELEMENT_NODE
+ ? range.commonAncestorContainer
+ : range.commonAncestorContainer.parentElement;
+
+ if (commonAncestor && root.contains(commonAncestor)) {
+ const walker = document.createTreeWalker(commonAncestor, NodeFilter.SHOW_ELEMENT, {
+ acceptNode(node) {
+ if (!(node instanceof Element) || !root.contains(node)) return NodeFilter.FILTER_REJECT;
+ if (!node.matches(FORMAT_TARGET_SELECTOR)) return NodeFilter.FILTER_SKIP;
+ return range.intersectsNode(node) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP;
+ },
+ });
+
+ while (walker.nextNode()) {
+ targets.add(walker.currentNode);
+ }
+ }
+
+ if (targets.size === 0) {
+ [selection.anchorNode, selection.focusNode].forEach((node) => {
+ const target = getClosestFormatTarget(node, root);
+ if (target) targets.add(target);
+ });
+ }
+
+ return [...targets];
+}
+
+function getOrderedListItems(list) {
+ return [...(list?.children || [])].filter((child) => child.tagName === 'LI');
+}
+
+function getOrderedListStart(list) {
+ const start = Number.parseInt(list?.getAttribute?.('start') || '1', 10);
+ return Number.isFinite(start) && start > 0 ? start : 1;
+}
+
+function getOrderedListNextValue(list) {
+ const items = getOrderedListItems(list);
+ let value = getOrderedListStart(list) - 1;
+
+ items.forEach((item) => {
+ const explicitValue = Number.parseInt(item.getAttribute('value') || '', 10);
+ value = Number.isFinite(explicitValue) && explicitValue > 0 ? explicitValue : value + 1;
+ });
+
+ return Math.max(1, value + 1);
+}
+
+function getPreviousOrderedList(root, referenceNode) {
+ if (!root || !referenceNode) return null;
+
+ let previous = null;
+ [...root.querySelectorAll('ol')].forEach((list) => {
+ if (list === referenceNode || list.contains(referenceNode)) return;
+
+ const relation = list.compareDocumentPosition(referenceNode);
+ if (relation & Node.DOCUMENT_POSITION_FOLLOWING) {
+ previous = list;
+ }
+ });
+
+ return previous;
+}
+
+function setOrderedListStart(list, start) {
+ if (!list) return;
+ const normalizedStart = Math.max(1, Number(start) || 1);
+
+ if (normalizedStart === 1) list.removeAttribute('start');
+ else list.setAttribute('start', String(normalizedStart));
+}
+
+function convertLegacyFontTag(fontElement) {
+ if (!fontElement?.parentNode) return;
+
+ const span = document.createElement('span');
+ const size = fontElement.getAttribute('size');
+ const face = fontElement.getAttribute('face');
+ const color = fontElement.getAttribute('color');
+
+ const sizeMap = {
+ 1: '8pt',
+ 2: '10pt',
+ 3: '12pt',
+ 4: '14pt',
+ 5: '18pt',
+ 6: '24pt',
+ 7: '32pt',
+ };
+
+ if (size && sizeMap[size]) span.style.fontSize = sizeMap[size];
+ if (face) span.style.fontFamily = face;
+ if (color) span.style.color = color;
+
+ while (fontElement.firstChild) {
+ span.appendChild(fontElement.firstChild);
+ }
+
+ fontElement.replaceWith(span);
+}
+
+// ─── Inline Toast ─────────────────────────────────────────────────────────────
+function useToast() {
+ const [toasts, setToasts] = useState([]);
+ const toast = useCallback(({ variant, title, description }) => {
+ const id = Date.now();
+ setToasts(t => [...t, { id, variant, title, description }]);
+ setTimeout(() => setToasts(t => t.filter(x => x.id !== id)), 4000);
+ }, []);
+ const ToastContainer = () => (
+
+ {toasts.map(t => (
+
+
{t.title}
+ {t.description &&
{t.description}
}
+
+ ))}
+
+ );
+ return { toast, ToastContainer };
+}
+
+// ─── Simple Homeowner Combobox ────────────────────────────────────────────────
+function HomeownerCombobox({ homeowners, selectedId, onSelect }) {
+ const [open, setOpen] = useState(false);
+ const [query, setQuery] = useState('');
+ const ref = useRef(null);
+ const selected = homeowners.find(h => h.id === selectedId);
+ const filtered = homeowners.filter(h =>
+ h.owner_name?.toLowerCase().includes(query.toLowerCase()) ||
+ h.property_address?.toLowerCase().includes(query.toLowerCase())
+ );
+ useEffect(() => {
+ const h = e => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); };
+ document.addEventListener('mousedown', h);
+ return () => document.removeEventListener('mousedown', h);
+ }, []);
+ return (
+
+
setOpen(o => !o)}
+ className="w-full flex items-center justify-between h-8 px-3 rounded-md border border-slate-200 bg-white text-xs text-slate-700 hover:border-blue-400">
+ {selected?.owner_name || 'Select Homeowner (Optional)'}
+
+
+ {open && (
+
+
setQuery(e.target.value)}
+ className="w-full h-8 px-3 text-xs border-b border-slate-200 focus:outline-none bg-white" />
+
+ {filtered.length === 0
+ ?
No homeowner found.
+ : filtered.map(owner => (
+
{ onSelect(owner.id === selectedId ? '' : owner.id); setOpen(false); setQuery(''); }}
+ className="flex items-center gap-2 w-full px-3 py-2 text-xs text-left hover:bg-blue-50">
+
+
+
{owner.owner_name}
+
{owner.property_address}
+
+
+ ))}
+
+
+ )}
+
+ );
+}
+
+// ─── TOOLBAR PRIMITIVES ───────────────────────────────────────────────────────
+function ToolBtn({ onClick, active, title, children }) {
+ return (
+ { e.preventDefault(); onClick(); }}
+ className={cls(
+ 'flex items-center justify-center w-7 h-7 rounded text-sm transition-all select-none cursor-pointer flex-shrink-0',
+ active
+ ? 'bg-blue-100 text-blue-700 shadow-inner'
+ : 'text-slate-600 hover:bg-slate-100 hover:text-slate-900'
+ )}>
+ {children}
+
+ );
+}
+
+function Sep() {
+ return
;
+}
+
+function Picker({ label, items, onSelect, renderItem, minWidth = 60 }) {
+ const [open, setOpen] = useState(false);
+ const ref = useRef(null);
+ useEffect(() => {
+ const h = e => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); };
+ document.addEventListener('mousedown', h);
+ return () => document.removeEventListener('mousedown', h);
+ }, []);
+ return (
+
+
{ e.preventDefault(); setOpen(o => !o); }}
+ className="flex items-center gap-1 h-7 px-2 rounded border border-slate-200 bg-white text-xs text-slate-700 hover:border-blue-400 transition-colors"
+ style={{ minWidth }}>
+ {label}
+
+
+ {open && (
+
+ {items.map((item, i) => (
+ { e.preventDefault(); onSelect(item); setOpen(false); }}
+ className="w-full text-left px-3 py-1.5 text-xs hover:bg-blue-50 text-slate-700 whitespace-nowrap">
+ {renderItem ? renderItem(item) : String(item)}
+
+ ))}
+
+ )}
+
+ );
+}
+
+function ColorBtn({ icon: IconComp, title, onPick, currentColor }) {
+ const inputRef = useRef(null);
+ return (
+ { e.preventDefault(); inputRef.current?.click(); }}>
+
+ {currentColor && (
+
+ )}
+ onPick(e.target.value)} />
+
+ );
+}
+
+// ─── RICH TEXT EDITOR ─────────────────────────────────────────────────────────
+const RichTextEditor = forwardRef(function RichTextEditor(
+ { value = '', onChange, placeholder = 'Start typing...', minHeight = 400 }, fwdRef
+) {
+ const divRef = useRef(null);
+ const savedRange = useRef(null);
+ const skipSync = useRef(false);
+ const [fontSize, setFontSize] = useState(11);
+ const [fontLabel, setFontLabel] = useState('Default');
+ const [blockLabel, setBlockLabel] = useState('Normal');
+ const [fgColor, setFgColor] = useState('#111111');
+ const [bgColor, setBgColor] = useState('#ffff00');
+ const [es, setEs] = useState({
+ bold: false, italic: false, underline: false, strike: false,
+ alignL: true, alignC: false, alignR: false, alignJ: false,
+ ol: false, ul: false,
+ });
+
+ useEffect(() => {
+ if (skipSync.current || !divRef.current) return;
+ if (divRef.current.innerHTML !== value) divRef.current.innerHTML = value || '';
+ }, [value]);
+
+ const refresh = useCallback(() => {
+ try {
+ setEs({
+ bold: document.queryCommandState('bold'),
+ italic: document.queryCommandState('italic'),
+ underline: document.queryCommandState('underline'),
+ strike: document.queryCommandState('strikeThrough'),
+ alignL: document.queryCommandState('justifyLeft'),
+ alignC: document.queryCommandState('justifyCenter'),
+ alignR: document.queryCommandState('justifyRight'),
+ alignJ: document.queryCommandState('justifyFull'),
+ ol: document.queryCommandState('insertOrderedList'),
+ ul: document.queryCommandState('insertUnorderedList'),
+ });
+ } catch (_) {}
+ }, []);
+
+ const saveRange = useCallback(() => {
+ const sel = window.getSelection();
+ if (sel && sel.rangeCount > 0) savedRange.current = sel.getRangeAt(0).cloneRange();
+ }, []);
+
+ const restoreRange = useCallback(() => {
+ if (!savedRange.current) { divRef.current?.focus(); return; }
+ const sel = window.getSelection();
+ if (sel) { sel.removeAllRanges(); sel.addRange(savedRange.current); }
+ }, []);
+
+ useImperativeHandle(fwdRef, () => ({
+ insertAtCursor(html) {
+ divRef.current?.focus();
+ restoreRange();
+ document.execCommand('insertHTML', false, html);
+ const sel = window.getSelection();
+ if (sel && sel.rangeCount > 0) savedRange.current = sel.getRangeAt(0).cloneRange();
+ refresh();
+ if (onChange) onChange(divRef.current?.innerHTML || '');
+ },
+ getContent() { return divRef.current?.innerHTML || ''; },
+ focus() { divRef.current?.focus(); },
+ }), [restoreRange, refresh, onChange]);
+
+ const exec = useCallback((cmd, val = null) => {
+ restoreRange(); divRef.current?.focus();
+ document.execCommand(cmd, false, val);
+ saveRange(); refresh();
+ if (onChange) onChange(divRef.current?.innerHTML || '');
+ }, [restoreRange, saveRange, refresh, onChange]);
+
+ const normalizeLegacyFontTags = useCallback(() => {
+ if (!divRef.current) return;
+ [...divRef.current.querySelectorAll('font')].forEach(convertLegacyFontTag);
+ }, []);
+
+ const getActiveOrderedList = useCallback(() => {
+ const selection = window.getSelection();
+ if (!selection || selection.rangeCount === 0 || !divRef.current) return null;
+
+ const node = selection.anchorNode?.nodeType === Node.ELEMENT_NODE
+ ? selection.anchorNode
+ : selection.anchorNode?.parentElement;
+ const list = node?.closest?.('ol');
+
+ return list && divRef.current.contains(list) ? list : null;
+ }, []);
+
+ const applyInlineStyle = useCallback((styles) => {
+ restoreRange();
+ divRef.current?.focus();
+
+ const selection = window.getSelection();
+ if (!selection || selection.rangeCount === 0 || !divRef.current) return;
+
+ const range = selection.getRangeAt(0);
+
+ if (range.collapsed) {
+ const target = getClosestFormatTarget(selection.anchorNode, divRef.current);
+ if (target) Object.assign(target.style, styles);
+ } else {
+ const wrapper = document.createElement('span');
+ Object.assign(wrapper.style, styles);
+ wrapper.appendChild(range.extractContents());
+ range.insertNode(wrapper);
+
+ const updatedRange = document.createRange();
+ updatedRange.selectNodeContents(wrapper);
+ selection.removeAllRanges();
+ selection.addRange(updatedRange);
+ }
+
+ saveRange();
+ refresh();
+ if (onChange) onChange(divRef.current.innerHTML || '');
+ }, [restoreRange, saveRange, refresh, onChange]);
+
+ const applyAlignment = useCallback((alignment) => {
+ restoreRange();
+ divRef.current?.focus();
+
+ if (!divRef.current) return;
+
+ const targets = getSelectionFormatTargets(divRef.current);
+ if (targets.length === 0) return;
+
+ targets.forEach((target) => {
+ const effectiveAlignment = target.tagName === 'LI' && alignment === 'justify' ? 'left' : alignment;
+
+ if (effectiveAlignment === 'left') target.style.removeProperty('text-align');
+ else target.style.textAlign = effectiveAlignment;
+
+ if (target.tagName === 'LI') {
+ target.querySelectorAll('ul, ol').forEach((list) => list.style.textAlign = 'left');
+ }
+ });
+
+ saveRange();
+ refresh();
+ if (onChange) onChange(divRef.current.innerHTML || '');
+ }, [restoreRange, saveRange, refresh, onChange]);
+
+ const applyOrderedListStart = useCallback((mode) => {
+ restoreRange();
+ divRef.current?.focus();
+
+ if (!divRef.current) return;
+
+ const selection = window.getSelection();
+ const referenceTarget = getClosestFormatTarget(selection?.anchorNode, divRef.current) || divRef.current;
+ const previousList = mode === 'continue'
+ ? getPreviousOrderedList(divRef.current, referenceTarget)
+ : null;
+
+ document.execCommand('insertOrderedList', false, null);
+
+ const activeList = getActiveOrderedList();
+ setOrderedListStart(activeList, previousList ? getOrderedListNextValue(previousList) : 1);
+
+ saveRange();
+ refresh();
+ if (onChange) onChange(divRef.current.innerHTML || '');
+ }, [restoreRange, saveRange, refresh, onChange, getActiveOrderedList]);
+
+ const applyFontSize = useCallback(pt => {
+ setFontSize(pt);
+ restoreRange();
+ divRef.current?.focus();
+ document.execCommand('styleWithCSS', false, true);
+ document.execCommand('fontSize', false, '7');
+ normalizeLegacyFontTags();
+
+ const selection = window.getSelection();
+ if (selection && selection.rangeCount > 0 && divRef.current?.contains(selection.anchorNode)) {
+ const targets = getSelectionFormatTargets(divRef.current);
+ targets.forEach((target) => {
+ if (target.tagName !== 'LI') target.style.fontSize = `${pt}pt`;
+ });
+ }
+
+ const legacySized = divRef.current?.querySelectorAll('span[style*="font-size: 32pt"]') || [];
+ legacySized.forEach((element) => {
+ element.style.fontSize = `${pt}pt`;
+ });
+
+ saveRange();
+ refresh();
+ if (onChange) onChange(divRef.current?.innerHTML || '');
+ }, [normalizeLegacyFontTags, restoreRange, saveRange, refresh, onChange]);
+
+ const applyFont = useCallback(f => {
+ setFontLabel(f.label);
+ restoreRange();
+ divRef.current?.focus();
+ document.execCommand('styleWithCSS', false, true);
+ document.execCommand('fontName', false, f.label);
+ normalizeLegacyFontTags();
+
+ const selection = window.getSelection();
+ if (selection && selection.rangeCount > 0 && divRef.current?.contains(selection.anchorNode)) {
+ const targets = getSelectionFormatTargets(divRef.current);
+ targets.forEach((target) => {
+ target.style.fontFamily = f.value;
+ });
+ }
+
+ saveRange();
+ refresh();
+ if (onChange) onChange(divRef.current?.innerHTML || '');
+ }, [normalizeLegacyFontTags, restoreRange, saveRange, refresh, onChange]);
+ const applyBlock = useCallback(f => {
+ setBlockLabel(f.label); restoreRange(); divRef.current?.focus();
+ document.execCommand('formatBlock', false, f.value);
+ saveRange(); refresh();
+ if (onChange) onChange(divRef.current?.innerHTML || '');
+ }, [restoreRange, saveRange, refresh, onChange]);
+
+ const applyFgColor = useCallback(color => { setFgColor(color); exec('foreColor', color); }, [exec]);
+ const applyBgColor = useCallback(color => { setBgColor(color); exec('hiliteColor', color); }, [exec]);
+
+ const insertTable = useCallback(() => {
+ restoreRange(); divRef.current?.focus();
+ document.execCommand('insertHTML', false,
+ '' +
+ Array(2).fill('' + Array(3).fill(' ').join('') + ' ').join('') +
+ '
');
+ saveRange(); refresh();
+ if (onChange) onChange(divRef.current?.innerHTML || '');
+ }, [restoreRange, saveRange, refresh, onChange]);
+
+ const insertLink = useCallback(() => {
+ restoreRange();
+ const url = prompt('Enter URL:', 'https://');
+ if (url) exec('createLink', url);
+ }, [restoreRange, exec]);
+
+ const handlePaste = useCallback(e => {
+ e.preventDefault();
+ restoreRange();
+ divRef.current?.focus();
+
+ const html = e.clipboardData?.getData('text/html');
+ const text = e.clipboardData?.getData('text/plain');
+
+ if (html) {
+ const clean = normalizePastedRichHtml(html, text || '');
+ document.execCommand('insertHTML', false, clean || plainTextToRichHtml(text || ''));
+ } else if (text) {
+ document.execCommand('insertHTML', false, plainTextToRichHtml(text));
+ }
+
+ saveRange(); refresh();
+ if (onChange) onChange(divRef.current?.innerHTML || '');
+ }, [restoreRange, saveRange, refresh, onChange]);
+
+ const handleInput = useCallback(() => {
+ skipSync.current = true; refresh();
+ if (onChange) onChange(divRef.current?.innerHTML || '');
+ setTimeout(() => { skipSync.current = false; }, 0);
+ }, [refresh, onChange]);
+
+ const currentFont = EDITOR_FONTS.find(f => f.label === fontLabel) || EDITOR_FONTS[0];
+
+ return (
+
+ {/* Toolbar */}
+
+
exec('undo')}>
+
exec('redo')}>
+
+
(
+
+ {f.label}
+
+ )}
+ />
+
+ {fontLabel}} items={EDITOR_FONTS} onSelect={applyFont} minWidth={130}
+ renderItem={f => {f.label} }
+ />
+
+ String(s)}
+ />
+
+ exec('bold')}>
+ exec('italic')}>
+ exec('underline')}>
+ exec('strikeThrough')}>
+
+
+
+ exec('removeFormat')}>
+
+ applyAlignment('left')}>
+ applyAlignment('center')}>
+ applyAlignment('right')}>
+ applyAlignment('justify')}>
+
+ exec('insertUnorderedList')}>
+ applyOrderedListStart('restart')}>
+ applyOrderedListStart('continue')}>↺
+ exec('indent')}>
+ exec('outdent')}>
+
+
+
+
+ {
+ restoreRange();
+ document.execCommand('insertHTML', false, '\u2014 PAGE BREAK \u2014
');
+ handleInput();
+ }}>
+
+
+ {/* Editable area */}
+
{ saveRange(); refresh(); }}
+ onMouseUp={() => { saveRange(); refresh(); }}
+ onPaste={handlePaste}
+ data-placeholder={placeholder}
+ className="flex-1 outline-none text-slate-800 leading-relaxed"
+ style={{
+ minHeight,
+ padding: '16px 20px',
+ fontFamily: 'Helvetica, Arial, sans-serif',
+ fontSize: '11pt',
+ lineHeight: 1.65,
+ wordBreak: 'break-word',
+ overflowWrap: 'break-word',
+ }}
+ />
+
+
+ );
+});
+
+// ─── PAGE CALCULATOR ─────────────────────────────────────────────────────────
+function usePageCount({ richContent, headerConfig, footerConfig }) {
+ const [pageCount, setPageCount] = useState(1);
+ const timerRef = useRef(null);
+
+ useEffect(() => {
+ clearTimeout(timerRef.current);
+ timerRef.current = setTimeout(() => {
+ const iframe = document.createElement('iframe');
+ iframe.style.cssText = `position:fixed;left:-9999px;top:0;width:${PAGE_W_PX}px;height:${PAGE_H_PX}px;border:none;visibility:hidden;`;
+ document.body.appendChild(iframe);
+ const doc = iframe.contentDocument;
+ const headerH = headerConfig?.enabled !== false ? (headerConfig?.styles?.logoHeight || 60) + 60 : 0;
+ const footerH = footerConfig?.enabled !== false ? 24 : 0;
+ const usableH = PAGE_H_PX - MARGIN_PX * 2 - headerH - footerH;
+
+ doc.open();
+ doc.write(`
${richContent || ''}
`);
+ doc.close();
+
+ const contentH = doc.body.scrollHeight;
+ const pages = Math.max(1, Math.ceil(contentH / usableH));
+ setPageCount(pages);
+ document.body.removeChild(iframe);
+ }, 300);
+ return () => clearTimeout(timerRef.current);
+ }, [richContent, headerConfig, footerConfig]);
+
+ return pageCount;
+}
+
+// ─── CANVAS HEADER ────────────────────────────────────────────────────────────
+function CanvasHeader({ config, clientLogo }) {
+ if (!config?.enabled) return null;
+ const st = config.styles || {};
+ const align = st.alignment || 'left';
+ const justifyMap = { left: 'flex-start', center: 'center', right: 'flex-end' };
+ const logo = config.logoUrl || clientLogo;
+
+ return (
+
+
+ {logo && (
+
{ e.target.style.display = 'none'; }}
+ style={{
+ height: `${st.logoHeight || 60}px`,
+ width: st.logoWidth ? `${st.logoWidth}px` : 'auto',
+ objectFit: 'contain',
+ }}
+ />
+ )}
+
+ {config.title && (
+
f.label === st.fontFamily)?.value || 'inherit' : 'inherit',
+ lineHeight: 1.2,
+ }}>
+ {config.title}
+
+ )}
+ {config.description && (
+
f.label === st.fontFamily)?.value || 'inherit' : 'inherit',
+ }}>
+ {config.description}
+
+ )}
+
+
+
+
+ );
+}
+
+// ─── CANVAS FOOTER ────────────────────────────────────────────────────────────
+function CanvasFooter({ config, logoUrl, pageCount, companyName }) {
+ if (!config?.enabled) return null;
+ const today = new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });
+ const st = config.styles || {};
+ const fSize = Math.max(6, st.fontSize || 8);
+
+ const left = config.showLogo !== false
+ ? (logoUrl ?
e.target.style.display='none'} />
+ :
{config.companyName || companyName || ''} )
+ : (config.companyName || companyName || '');
+
+ const centerParts = [
+ config.content ? config.content.replace(/<[^>]+>/g, '') : '',
+ config.showDate ? today : '',
+ ].filter(Boolean);
+
+ return (
+
+ {left}
+ {centerParts.join(' · ')}
+
+ {config.showPageNumbers !== false && (
+
+ Page 1 of {pageCount}
+
+ )}
+
+
+ );
+}
+
+// ─── HEADER CONFIG PANEL ──────────────────────────────────────────────────────
+function HeaderPanel({ config, onChange, clientLogo }) {
+ const set = (k, v) => onChange(prev => ({ ...prev, [k]: v }));
+ const setSt = (k, v) => onChange(prev => ({ ...prev, styles: { ...prev.styles, [k]: v } }));
+ const st = config.styles || {};
+
+ return (
+
+
+
Header
+
+ Show
+ set('enabled', !config.enabled)}
+ className={cls('w-8 h-4 rounded-full transition-colors cursor-pointer relative', config.enabled ? 'bg-blue-500' : 'bg-slate-300')}>
+
+
+
+
+
+ {config.enabled && (
+
+
+ Title
+ set('title', e.target.value)}
+ className="w-full border border-slate-200 rounded px-2 py-1.5 text-xs focus:outline-none focus:ring-1 focus:ring-blue-400"
+ placeholder="Document Title" />
+
+
+ Subtitle
+ set('description', e.target.value)}
+ className="w-full border border-slate-200 rounded px-2 py-1.5 text-xs focus:outline-none focus:ring-1 focus:ring-blue-400 resize-none h-14"
+ placeholder="Description or subtitle..." />
+
+
+
Logo URL
+
+ set('logoUrl', e.target.value)}
+ className="flex-1 border border-slate-200 rounded px-2 py-1.5 text-xs focus:outline-none focus:ring-1 focus:ring-blue-400"
+ placeholder="https://..." />
+ {config.logoUrl && (
+ set('logoUrl', '')} className="text-slate-400 hover:text-red-500 px-1">
+ )}
+
+ {(config.logoUrl || clientLogo) && (
+
+
e.target.style.display='none'} />
+
+ )}
+
+
+
+
+ Styling
+
+
+
+
+ Alignment
+ setSt('alignment', e.target.value)}
+ className="w-full border border-slate-200 rounded px-1.5 py-1 text-xs">
+ Left
+ Center
+ Right
+
+
+
+ Font
+ setSt('fontFamily', e.target.value)}
+ className="w-full border border-slate-200 rounded px-1.5 py-1 text-xs">
+ {EDITOR_FONTS.map(f => {f.label} )}
+
+
+
+
+
+
+
+
+ )}
+
+ );
+}
+
+// ─── FOOTER CONFIG PANEL ──────────────────────────────────────────────────────
+function FooterPanel({ config, onChange }) {
+ const set = (k, v) => onChange(prev => ({ ...prev, [k]: v }));
+ const setSt = (k, v) => onChange(prev => ({ ...prev, styles: { ...prev.styles, [k]: v } }));
+ const st = config.styles || {};
+
+ return (
+
+
+
Footer
+
+ Show
+ set('enabled', !config.enabled)}
+ className={cls('w-8 h-4 rounded-full transition-colors cursor-pointer relative', config.enabled ? 'bg-blue-500' : 'bg-slate-300')}>
+
+
+
+
+
+ {config.enabled && (
+
+
+ Center Text
+ set('content', e.target.value)}
+ className="w-full border border-slate-200 rounded px-2 py-1.5 text-xs focus:outline-none focus:ring-1 focus:ring-blue-400"
+ placeholder="Confidential · Internal Use Only" />
+
+
+ {[
+ { key: 'showDate', label: 'Date' },
+ { key: 'showLogo', label: 'Logo' },
+ { key: 'showPageNumbers', label: 'Page #s' },
+ ].map(({ key, label }) => (
+
+ set(key, !config[key])}
+ className={cls('w-7 h-3.5 rounded-full transition-colors cursor-pointer relative flex-shrink-0', config[key] !== false ? 'bg-blue-500' : 'bg-slate-300')}>
+
+
+ {label}
+
+ ))}
+
+
+
+ Font Size
+ setSt('fontSize', +e.target.value)}
+ className="w-full border border-slate-200 rounded px-1.5 py-1 text-xs">
+ {[8, 9, 10, 11, 12].map(s => {s}pt )}
+
+
+
+ Margin Top
+ setSt('marginTop', +e.target.value)}
+ className="w-full border border-slate-200 rounded px-1.5 py-1 text-xs">
+ {[8, 12, 16, 24, 32].map(s => {s}px )}
+
+
+
+
+ )}
+
+ );
+}
+
+// ─── BUILD PRINT HTML ─────────────────────────────────────────────────────────
+async function toDataUrl(url) {
+ if (!url) return '';
+ if (url.startsWith('data:')) return url;
+ try {
+ const resp = await fetch(url, { mode: 'cors' });
+ const blob = await resp.blob();
+ return new Promise(resolve => {
+ const reader = new FileReader();
+ reader.onloadend = () => resolve(reader.result || '');
+ reader.onerror = () => resolve('');
+ reader.readAsDataURL(blob);
+ });
+ } catch { return ''; }
+}
+
+async function buildPrintHtml({ richContent, headerConfig, footerConfig, sampleData, documentName, borderConfig, clientLogo, pageCount }) {
+ let body = (richContent || '')
+ .replace(/
]*>\s*(\{\{[\w.]+\}\})\s*<\/span>/g, '$1')
+ .replace(/\{\{([\w.]+)\}\}/g, (match, k) => {
+ const v = sampleData?.[k];
+ return (v != null && v !== '') ? String(v) : match;
+ })
+ .replace(/]*class="var-pill"[^>]*>([\s\S]*?)<\/span>/g, '$1');
+
+ const hst = headerConfig?.styles || {};
+ const fst = footerConfig?.styles || {};
+ const hOn = headerConfig?.enabled !== false;
+ const fOn = footerConfig?.enabled !== false;
+ const rawLogo = headerConfig?.logoUrl || clientLogo || '';
+ const logo = await toDataUrl(rawLogo);
+ const hAlign = hst.alignment || 'left';
+ const fSize = Math.max(6, fst.fontSize || 9);
+ const today = new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });
+ const centerTxt = footerConfig?.content ? footerConfig.content.replace(/<[^>]+>/g, '') : '';
+ const companyName = sampleData?.clientName || footerConfig?.companyName || '';
+ const FOOTER_H = fOn ? '0.4in' : '0';
+
+ const headerHtml = hOn ? `
+` : '';
+
+ const fLeft = footerConfig?.showLogo !== false
+ ? (logo ? ` ` : `${companyName} `)
+ : companyName;
+ const fCenter = [centerTxt, footerConfig?.showDate ? today : ''].filter(Boolean).join(' · ');
+ const fRight = footerConfig?.showPageNumbers !== false ? ` ` : '';
+
+ const footerHtml = fOn ? `
+` : '';
+
+ return `
+
+
+
+${(documentName||'Document').replace(/
+
+
+
+${headerHtml}
+${body}
+${footerHtml}
+
+`;
+}
+
+function triggerPrint(htmlString) {
+ const w = window.open('', '_blank', 'width=960,height=720,scrollbars=yes');
+ if (!w) { alert('Pop-up blocked — please allow pop-ups and try again.'); return; }
+ w.document.open();
+ w.document.write(htmlString);
+ w.document.close();
+ const doPrint = () => { w.focus(); w.print(); };
+ w.addEventListener('load', () => setTimeout(doPrint, 350));
+ setTimeout(() => { if (w.document.readyState === 'complete') doPrint(); }, 1400);
+}
+
+// =============================================================================
+// MAIN EXPORT
+// =============================================================================
+export function CustomFormBuilder() {
+ const { toast, ToastContainer } = useToast();
+ const exportRef = useRef(null);
+ const editorRef = useRef(null);
+ const docxInputRef = useRef(null);
+
+ const [preview, setPreview] = useState(false);
+ const [sidePanel, setSidePanel] = useState('content');
+ const [isLoading, setIsLoading] = useState(false);
+ const [isExporting, setIsExporting] = useState(false);
+ const [isImportingDocx, setIsImportingDocx] = useState(false);
+ const [showSaveToDocsDialog, setShowSaveToDocsDialog] = useState(false);
+
+ // ── Saved forms ──
+ const {
+ savedTemplates, loading: templatesLoading, currentTemplateId,
+ currentTemplateName, setCurrentTemplateName,
+ saveTemplate, loadTemplate, deleteTemplate, newTemplate,
+ } = useSavedFormTemplates('custom_form');
+ const [clients, setClients] = useState([]);
+ const [selectedClient, setSelectedClient] = useState('');
+ const [formMetadata, setFormMetadata] = useState({ name: '', description: '' });
+ const [homeowners, setHomeowners] = useState([]);
+ const [selectedHomeownerId, setSelectedHomeownerId] = useState('');
+ const [customVariables, setCustomVariables] = useState([]);
+
+ // ── Config state ──
+ const [headerConfig, setHeaderConfig] = useState({
+ enabled: true, title: 'Official Notice', logoUrl: '', description: '',
+ styles: { alignment: 'left', logoHeight: 60, textColor: '#000000', backgroundColor: '#ffffff', fontFamily: 'Default', fontSize: 22, fontWeight: 'bold' },
+ });
+ const [footerConfig, setFooterConfig] = useState({
+ enabled: true, content: '', showPageNumbers: true, showDate: true, showLogo: true, companyName: '',
+ styles: { fontSize: 9, marginTop: 16 },
+ });
+ const [borderConfig, setBorderConfig] = useState({ enabled: false, color: '#000000' });
+ const [globalSettings, setGlobalSettings] = useState({ lineWidth: 100, fontFamily: 'Helvetica', attachValidationProof: false });
+ const [richContent, setRichContent] = useState('Start typing here...
');
+ const [docName, setDocName] = useState('My Document');
+ const [sampleData, setSampleData] = useState({
+ propertyAddress: '123 Palm Tree Lane, Miami FL 33101',
+ ownerName: 'John & Jane Doe',
+ currentDate: new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }),
+ pageNumber: '1',
+ accountNumber: 'ACC-99823',
+ currentBalance: '$450.00',
+ clientName: 'Association Name',
+ });
+
+ const sanitizedRichContent = useMemo(() => sanitizeRichTextHtml(richContent), [richContent]);
+ const pageCount = usePageCount({ richContent: sanitizedRichContent, headerConfig, footerConfig });
+
+ // ── Fetch associations ──
+ useEffect(() => {
+ const fetchClients = async () => {
+ const { data, error } = await supabase.from('associations').select('id, name, logo_url').eq('status', 'active').order('name');
+ if (!error && data) {
+ setClients(data.map(a => ({ id: a.id, name: a.name, notice_logo: a.logo_url || '' })));
+ if (data.length > 0 && !selectedClient) setSelectedClient(data[0].id);
+ }
+ };
+ fetchClients();
+ }, [selectedClient]);
+
+ useEffect(() => {
+ const fetchClientContext = async () => {
+ if (!selectedClient) { setHomeowners([]); setSelectedHomeownerId(''); return; }
+
+ const [{ data: association }, { data: owners }, { data: boardMembers }] = await Promise.all([
+ supabase.from('associations').select('id, name').eq('id', selectedClient).single(),
+ supabase.from('owners')
+ .select('id, first_name, last_name, property_address, mailing_address, alternate_address_1, alternate_address_2, balance, unit_id, units(unit_number, address, account_number)')
+ .eq('association_id', selectedClient).eq('status', 'active').order('last_name'),
+ supabase.from('board_members').select('member_name, role').eq('association_id', selectedClient).ilike('role', '%president%').limit(1),
+ ]);
+
+ const formatCurrency = (value) => new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(Number(value || 0));
+
+ const ownersByUnit = {};
+ for (const owner of (owners || [])) {
+ const key = owner.unit_id || owner.id;
+ if (!ownersByUnit[key]) ownersByUnit[key] = [];
+ ownersByUnit[key].push(owner);
+ }
+
+ const mappedHomeowners = Object.values(ownersByUnit).map(group => {
+ const primary = group[0];
+ const combinedName = combineOwnerNames(group);
+ const propertyAddress = primary.property_address || primary.units?.address || '';
+ const allAddresses = [
+ primary.mailing_address || propertyAddress,
+ ...group.flatMap(o => [o.alternate_address_1, o.alternate_address_2].filter(Boolean)),
+ ].filter((v, i, a) => v && a.indexOf(v) === i);
+
+ return {
+ id: primary.id,
+ owner_name: combinedName,
+ property_address: propertyAddress,
+ recipient_address: primary.mailing_address || propertyAddress,
+ alternate_addresses: allAddresses.slice(1),
+ unit_number: primary.units?.unit_number || primary.units?.account_number || '',
+ account_number: primary.units?.account_number || '',
+ formatted_balance: formatCurrency(primary.balance),
+ };
+ });
+
+ setHomeowners(mappedHomeowners);
+ setSelectedHomeownerId(current => mappedHomeowners.some(owner => owner.id === current) ? current : '');
+ setSampleData(prev => ({
+ ...prev,
+ clientName: association?.name || prev.clientName,
+ boardPresident: boardMembers?.[0]?.member_name || prev.boardPresident || '',
+ }));
+ };
+ fetchClientContext();
+ }, [selectedClient]);
+
+ // ── Fetch custom variables ──
+ useEffect(() => {
+ if (!selectedClient) { setCustomVariables([]); return; }
+ supabase.from('custom_variables')
+ .select('variable_name, display_label, default_value, category')
+ .eq('association_id', selectedClient).order('category').order('variable_name')
+ .then(({ data }) => {
+ setCustomVariables(data || []);
+ if (data?.length) {
+ setSampleData(prev => {
+ const merged = { ...prev };
+ data.forEach(v => { if (v.default_value && !merged[v.variable_name]) merged[v.variable_name] = v.default_value; });
+ return merged;
+ });
+ }
+ });
+ }, [selectedClient]);
+
+ useEffect(() => {
+ const selectedClientRecord = clients.find(client => client.id === selectedClient);
+ const selectedHomeowner = homeowners.find(owner => owner.id === selectedHomeownerId);
+ setSampleData(prev => ({
+ ...prev,
+ clientName: selectedClientRecord?.name || prev.clientName,
+ ownerName: selectedHomeowner?.owner_name || 'John & Jane Doe',
+ ownersNames: selectedHomeowner?.owner_name || 'John & Jane Doe',
+ propertyAddress: selectedHomeowner?.property_address || '123 Palm Tree Lane, Miami FL 33101',
+ recipientAddress: selectedHomeowner?.recipient_address || selectedHomeowner?.property_address || '123 Palm Tree Lane, Miami FL 33101',
+ unitId: selectedHomeowner?.unit_number || selectedHomeowner?.account_number || 'ACC-99823',
+ accountNumber: selectedHomeowner?.account_number || selectedHomeowner?.unit_number || 'ACC-99823',
+ balance: selectedHomeowner?.formatted_balance || '$450.00',
+ }));
+ }, [clients, homeowners, selectedClient, selectedHomeownerId]);
+
+ const clientLogo = clients.find(c => c.id === selectedClient)?.notice_logo || '';
+ const companyName = sampleData?.clientName || '';
+ const systemVariables = [
+ { key: 'clientName', label: 'Association name' },
+ { key: 'ownersNames', label: 'Recipient full name(s)' },
+ { key: 'ownerName', label: 'Recipient name' },
+ { key: 'propertyAddress', label: 'Property address' },
+ { key: 'recipientAddress', label: 'Mailing address' },
+ { key: 'currentDate', label: 'Current date' },
+ { key: 'unitId', label: 'Unit / account number' },
+ { key: 'accountNumber', label: 'Account number' },
+ { key: 'balance', label: 'Current balance' },
+ { key: 'boardPresident', label: 'Board president' },
+ ];
+ const variableOptions = [
+ ...systemVariables,
+ ...customVariables.map(v => ({ key: v.variable_name, label: v.display_label || v.variable_name, isCustom: true })),
+ ];
+
+ const previewFormConfig = useMemo(() => ({
+ header: { ...headerConfig, logoUrl: headerConfig.logoUrl || clientLogo },
+ footer: footerConfig,
+ richContent,
+ borderSettings: borderConfig,
+ globalSettings,
+ styles: {},
+ blocks: richContent
+ ? [{ id: 'rich-content-preview', content: richContent, styles: { fontFamily: globalSettings.fontFamily || 'Helvetica, Arial, sans-serif' } }]
+ : [],
+ }), [headerConfig, footerConfig, richContent, borderConfig, globalSettings, clientLogo]);
+
+ const resetForm = useCallback(() => {
+ newTemplate();
+ setFormMetadata({ name: '', description: '' });
+ setRichContent('Start typing here...
');
+ setBorderConfig({ enabled: false, color: '#000000' });
+ setGlobalSettings({ lineWidth: 100, fontFamily: 'Helvetica', attachValidationProof: false });
+ setDocName('My Document');
+ }, [newTemplate]);
+
+ const handleLoadForm = useCallback(async (formId) => {
+ if (formId === 'new') { resetForm(); return; }
+ const data = await loadTemplate(formId);
+ if (!data) return;
+ const cfg = data.form_data || {};
+ setFormMetadata({ name: data.name, description: '' });
+ setDocName(data.name || 'My Document');
+ if (cfg.header) setHeaderConfig(cfg.header);
+ if (cfg.footer) setFooterConfig(cfg.footer);
+ if (cfg.borderSettings) setBorderConfig(cfg.borderSettings);
+ if (cfg.globalSettings) setGlobalSettings(cfg.globalSettings);
+ setRichContent(cfg.richContent || (cfg.blocks?.length ? cfg.blocks.map(b => `${b.content || ''}
`).join('') : '
'));
+ }, [loadTemplate, resetForm]);
+
+ const handleSaveForm = useCallback(async () => {
+ const name = formMetadata.name || docName;
+ if (!name) { toast({ variant: 'destructive', title: 'Error', description: 'Please enter a template name.' }); return; }
+ setIsLoading(true);
+ try {
+ const formData = { header: headerConfig, footer: footerConfig, richContent, borderSettings: borderConfig, globalSettings, styles: {} };
+ const result = await saveTemplate(name, formData, selectedClient || null);
+ if (result) toast({ title: 'Saved', description: `Template "${name}" saved successfully.` });
+ } finally { setIsLoading(false); }
+ }, [formMetadata, docName, headerConfig, footerConfig, richContent, borderConfig, globalSettings, selectedClient, saveTemplate, toast]);
+
+ const insertVariable = useCallback(variableName => {
+ const token = `{{${variableName.replace(/[{}]/g, '')}}}`;
+ if (editorRef.current?.insertAtCursor) {
+ editorRef.current.insertAtCursor(`${token} `);
+ } else {
+ setRichContent(p => p + `${token}
`);
+ }
+ toast({ title: 'Variable Inserted', description: `${token} inserted at cursor.` });
+ }, [toast]);
+
+ const handlePrint = useCallback(async () => {
+ setIsExporting(true);
+ try {
+ if (!exportRef.current) throw new Error('PDF generator unavailable.');
+
+ const blob = await exportRef.current.generatePdf(formMetadata.name || docName || 'Document', true, true);
+ if (!blob) throw new Error('Could not generate the PDF preview.');
+
+ const url = URL.createObjectURL(blob);
+ const win = window.open(url, '_blank');
+
+ if (win) {
+ win.opener = null;
+ setTimeout(() => URL.revokeObjectURL(url), 60000);
+ } else {
+ URL.revokeObjectURL(url);
+ throw new Error('Pop-up blocked — please allow pop-ups and try again.');
+ }
+ } catch (e) {
+ toast({ variant: 'destructive', title: 'Print Error', description: e.message || 'Print preview failed.' });
+ } finally { setIsExporting(false); }
+ }, [formMetadata.name, docName, toast]);
+
+ const handleExportPdf = useCallback(async () => {
+ setIsExporting(true);
+ try {
+ if (exportRef.current) await exportRef.current.generatePdf(formMetadata.name || docName || 'Form');
+ } catch (e) {
+ toast({ variant: 'destructive', title: 'Export Error', description: e.message || 'PDF generation failed.' });
+ } finally { setIsExporting(false); }
+ }, [formMetadata.name, docName, toast]);
+
+ const handleImportDocx = useCallback(async (event) => {
+ const file = event.target.files?.[0];
+ event.target.value = '';
+ if (!file) return;
+
+ setIsImportingDocx(true);
+ try {
+ const { html, messages } = await convertDocxFileToHtml(file);
+ setRichContent(html);
+ editorRef.current?.focus?.();
+
+ const warningCount = messages.filter((message) => message.type === 'warning').length;
+ toast({
+ title: 'DOCX imported',
+ description: warningCount > 0
+ ? `Imported ${file.name} with ${warningCount} formatting note${warningCount === 1 ? '' : 's'}.`
+ : `Imported ${file.name}.`,
+ });
+ } catch (error) {
+ toast({
+ variant: 'destructive',
+ title: 'Import failed',
+ description: error?.message || 'Could not import the DOCX file.',
+ });
+ } finally {
+ setIsImportingDocx(false);
+ }
+ }, [toast]);
+
+ const contentAreaStyle = {
+ padding: `${MARGIN_PX}px`,
+ paddingBottom: footerConfig?.enabled !== false ? MARGIN_PX + 52 : MARGIN_PX,
+ flex: 1,
+ minHeight: 0,
+ };
+
+ // ── Render ──
+ return (
+
+
+
+ {/* Header — matches other form pages */}
+
+
+
+
Custom Form Builder
+
Create and manage custom document templates.
+
+
+
+
+ {/* Template & doc name row */}
+
+
+ { setDocName(e.target.value); setFormMetadata(p => ({ ...p, name: e.target.value })); }}
+ className="w-full h-8 px-3 rounded-md border border-border bg-card text-[13px] text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring"
+ placeholder="Document Name..." />
+
+
+
handleLoadForm(e.target.value)}
+ className="h-8 px-2 rounded-md border border-border bg-card text-[12px] text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring">
+ + New Template
+ {savedTemplates.map(f => {f.name} )}
+
+
+
+ {isLoading ? : }
+ Save
+
+
+ {currentTemplateId && (
+
{ if (!confirm('Delete this template?')) return; await deleteTemplate(currentTemplateId); resetForm(); toast({ title: 'Deleted', description: 'Template removed.' }); }}
+ className="inline-flex items-center gap-1.5 h-8 px-2.5 rounded-md border border-border text-destructive hover:bg-destructive/10 transition-colors">
+
+
+ )}
+
+
+
+ {/* Main Content */}
+
+
+ {/* Left Config Panel */}
+ {!preview && (
+
+ {/* Panel Tabs */}
+
+ {['content', 'header', 'footer', 'global'].map(tab => (
+ setSidePanel(tab)}
+ className={`flex-1 py-2.5 px-1 text-[10px] font-medium capitalize tracking-wide border-b-2 transition-colors ${
+ sidePanel === tab
+ ? 'bg-card text-primary border-primary'
+ : 'text-muted-foreground border-transparent hover:text-foreground'
+ }`}>
+ {tab}
+
+ ))}
+
+
+ {/* Panel Content */}
+
+ {sidePanel === 'content' && (
+
+ {/* Association Selector */}
+
+
Association / Client
+
setSelectedClient(e.target.value)}
+ className="w-full h-8 text-xs rounded-md border border-border bg-card px-2 text-foreground focus:outline-none focus:ring-1 focus:ring-ring">
+ Select Association
+ {clients.map(c => {c.name} )}
+
+ {!selectedClient && (
+
+
Select a client to resolve variables
+
+ )}
+
+
+ {/* Homeowner Selector */}
+ {selectedClient && homeowners.length > 0 && (
+
+ )}
+
+ {/* Variables */}
+
+
Variables
+
Click a variable to insert it at your cursor position.
+
+ {variableOptions.map(variable => (
+
insertVariable(variable.key)}
+ className="w-full rounded-md border border-border bg-muted/50 px-2.5 py-2 text-left transition-colors hover:border-primary/30 hover:bg-primary/5">
+
+ {`{{${variable.key}}}`}
+ {variable.isCustom && Custom }
+
+ {variable.label}
+
+ ))}
+
+
+
+ {/* Border toggle */}
+
+
+
Page Border
+
setBorderConfig(p => ({ ...p, enabled: !p.enabled }))}
+ className={cls('w-8 h-4 rounded-full transition-colors cursor-pointer relative', borderConfig.enabled ? 'bg-primary' : 'bg-muted-foreground/30')}>
+
+
+
+ {borderConfig.enabled && (
+
+ Color
+ setBorderConfig(p => ({ ...p, color: e.target.value }))} className="w-7 h-7 rounded border border-border p-0.5 cursor-pointer" />
+
+ )}
+
+
+ )}
+ {sidePanel === 'header' &&
}
+ {sidePanel === 'footer' &&
}
+ {sidePanel === 'global' && (
+
+
+ Security & Validation
+
+
+
+
Attach Validation Proof
+
Append a certificate to the PDF.
+
+
setGlobalSettings(p => ({ ...p, attachValidationProof: !p.attachValidationProof }))}
+ className={cls('w-8 h-4 rounded-full transition-colors cursor-pointer relative', globalSettings.attachValidationProof ? 'bg-primary' : 'bg-muted-foreground/30')}>
+
+
+
+
+ )}
+
+
+ )}
+
+ {/* Document Canvas Area */}
+
+ {preview ? (
+
+
+
+ ) : (
+
+
+ {/* Content area */}
+
+
+ {!selectedClient && (
+
+
+
+
No Client Selected
+
Select a client from the sidebar to resolve variables.
+
+
+ )}
+
+
+
+ {/* Sticky footer strip */}
+ {footerConfig?.enabled !== false && (
+
+
+
+ )}
+
+ {/* Page-overflow indicators */}
+ {Array.from({ length: pageCount - 1 }, (_, i) => (
+
+ ))}
+ {Array.from({ length: pageCount - 1 }, (_, i) => (
+
+ Page {i + 2}
+
+ ))}
+
+
+ )}
+
+
+
+ {/* Hidden PDF engine */}
+
+
+
{
+ if (exportRef.current) return await exportRef.current.generatePdf(formMetadata.name || docName || 'Form', true, true);
+ return null;
+ }}
+ defaultTitle={formMetadata.name || docName || "Custom Form"}
+ associationId={selectedClient}
+ associations={clients}
+ />
+
+ );
+}
+
+export default CustomFormBuilder;
diff --git a/src/components/forms/CustomFormFontPanel.jsx b/src/components/forms/CustomFormFontPanel.jsx
new file mode 100644
index 0000000..662c288
--- /dev/null
+++ b/src/components/forms/CustomFormFontPanel.jsx
@@ -0,0 +1,138 @@
+import React, { useEffect } from 'react';
+import { Bold, Italic } from 'lucide-react';
+
+// ---------------------------------------------------------------------------
+// Font list — replace with your own AVAILABLE_FONTS if needed, or import from
+// wherever your project stores them. Kept as a local constant to remove the
+// dependency on @/lib/googleFontsManager for Lovable.dev compatibility.
+// ---------------------------------------------------------------------------
+const AVAILABLE_FONTS = [
+ { name: 'Inter', family: 'Inter, sans-serif' },
+ { name: 'Helvetica', family: 'Helvetica, Arial, sans-serif' },
+ { name: 'Times New Roman', family: '"Times New Roman", Times, serif' },
+ { name: 'Georgia', family: 'Georgia, serif' },
+ { name: 'Courier New', family: '"Courier New", Courier, monospace' },
+ { name: 'Arial', family: 'Arial, sans-serif' },
+ { name: 'Verdana', family: 'Verdana, sans-serif' },
+ { name: 'Trebuchet MS', family: '"Trebuchet MS", sans-serif' },
+ { name: 'Garamond', family: 'Garamond, serif' },
+];
+
+function loadGoogleFont(family) {
+ const id = `gf-${family.replace(/\s/g, '-')}`;
+ if (document.getElementById(id)) return;
+ const link = document.createElement('link');
+ link.id = id;
+ link.rel = 'stylesheet';
+ link.href = `https://fonts.googleapis.com/css2?family=${encodeURIComponent(family)}&display=swap`;
+ document.head.appendChild(link);
+}
+
+export function CustomFormFontPanel({ settings, onChange, label = 'Font Settings' }) {
+ const current = {
+ family: 'Inter',
+ size: '12',
+ weight: 'normal',
+ style: 'normal',
+ ...settings,
+ };
+
+ useEffect(() => {
+ if (current.family) loadGoogleFont(current.family);
+ }, [current.family]);
+
+ const set = (key, value) => onChange({ ...current, [key]: value });
+
+ const currentFont = AVAILABLE_FONTS.find(f => f.name === current.family);
+
+ return (
+
+ {/* Section label */}
+
{label}
+
+
+
+ {/* Font family */}
+
+ Font Family
+ set('family', e.target.value)}
+ className="w-full h-8 text-xs rounded-md border border-slate-200 bg-white px-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
+ >
+ {AVAILABLE_FONTS.map(f => (
+
+ {f.name}
+
+ ))}
+
+
+
+
+ {/* Size slider */}
+
+ Size ({current.size}px)
+ set('size', e.target.value)}
+ className="w-full h-1.5 accent-blue-600 mt-2"
+ />
+
+
+ {/* Weight & Style toggles */}
+
+
Weight & Style
+
+ {/* Bold */}
+ set('weight', current.weight === 'bold' ? 'normal' : 'bold')}
+ aria-label="Toggle bold"
+ className={`w-7 h-7 rounded flex items-center justify-center border text-xs transition-colors ${
+ current.weight === 'bold'
+ ? 'bg-slate-200 border-slate-400 text-slate-900'
+ : 'bg-white border-slate-200 text-slate-500 hover:bg-slate-50'
+ }`}
+ >
+
+
+ {/* Italic */}
+ set('style', current.style === 'italic' ? 'normal' : 'italic')}
+ aria-label="Toggle italic"
+ className={`w-7 h-7 rounded flex items-center justify-center border text-xs transition-colors ${
+ current.style === 'italic'
+ ? 'bg-slate-200 border-slate-400 text-slate-900'
+ : 'bg-white border-slate-200 text-slate-500 hover:bg-slate-50'
+ }`}
+ >
+
+
+
+
+
+
+ {/* Preview */}
+
+
Preview
+
+ The quick brown fox jumps over the lazy dog.
+
+
+
+
+ );
+}
diff --git a/src/components/forms/CustomFormFooter.jsx b/src/components/forms/CustomFormFooter.jsx
new file mode 100644
index 0000000..26951c9
--- /dev/null
+++ b/src/components/forms/CustomFormFooter.jsx
@@ -0,0 +1,140 @@
+import React from 'react';
+
+// Plain Tailwind toggle switch — replaces shadcn Switch
+function Toggle({ checked, onChange, id, scale = false }) {
+ return (
+ onChange(!checked)}
+ className={`relative inline-flex items-center rounded-full transition-colors focus:outline-none ${
+ scale ? 'h-4 w-7' : 'h-5 w-9'
+ } ${checked ? 'bg-blue-600' : 'bg-slate-200'}`}
+ >
+
+
+ );
+}
+
+const CustomFormFooter = ({ footerConfig, onChange }) => {
+ const handleChange = (key, value) =>
+ onChange(prev => ({ ...prev, [key]: value }));
+
+ const handleStyleChange = (key, value) =>
+ onChange(prev => ({ ...prev, styles: { ...prev.styles, [key]: value } }));
+
+ return (
+
+ {/* Section header */}
+
+
Footer Configuration
+
+
+ Show Footer
+
+
+
+
+ {footerConfig.enabled && (
+
+ {/* Left/Right content toggles */}
+
+
+
Left Content (Date)
+
+ Show Date
+ handleChange('showDate', v)}
+ scale
+ />
+
+
+
+
Right Content (Page #)
+
+ Page Numbers
+ handleChange('showPageNumbers', v)}
+ scale
+ />
+
+
+
+
+ {/* Center text */}
+
+
Center Footer Text
+
handleChange('content', e.target.value)}
+ placeholder="e.g. Confidential - For Internal Use Only"
+ className="w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-xs focus:outline-none focus:ring-2 focus:ring-blue-500"
+ />
+
Supports variables like {'{{client_name}}'}.
+
+
+ {/* Style selects */}
+
+ {/* Alignment */}
+
+ Alignment
+ handleStyleChange('alignment', e.target.value)}
+ className="w-full h-8 text-xs rounded-md border border-slate-200 bg-white px-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
+ >
+ {['left', 'center', 'right'].map(v => (
+ {v.charAt(0).toUpperCase() + v.slice(1)}
+ ))}
+
+
+
+ {/* Font size */}
+
+ Font Size
+ handleStyleChange('fontSize', parseInt(e.target.value))}
+ className="w-full h-8 text-xs rounded-md border border-slate-200 bg-white px-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
+ >
+ {[8, 9, 10, 11, 12].map(s => (
+ {s}pt
+ ))}
+
+
+
+ {/* Top margin */}
+
+ Top Margin
+ handleStyleChange('marginTop', parseInt(e.target.value))}
+ className="w-full h-8 text-xs rounded-md border border-slate-200 bg-white px-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
+ >
+ {[10, 20, 30, 40].map(s => (
+ {s}px
+ ))}
+
+
+
+
+ )}
+
+ );
+};
+
+export default CustomFormFooter;
diff --git a/src/components/forms/CustomFormHeader.jsx b/src/components/forms/CustomFormHeader.jsx
new file mode 100644
index 0000000..fd671a1
--- /dev/null
+++ b/src/components/forms/CustomFormHeader.jsx
@@ -0,0 +1,307 @@
+import React, { useCallback, useState } from 'react';
+import { FileImage as ImageIcon, X, Bold, Italic, Underline, ChevronDown } from 'lucide-react';
+
+// ---------------------------------------------------------------------------
+// Standard fonts — inline to avoid the @/lib/customFormStyleUtils dependency
+// ---------------------------------------------------------------------------
+const STANDARD_FONTS = {
+ 'Helvetica': 'Helvetica, Arial, sans-serif',
+ 'Times New Roman': '"Times New Roman", Times, serif',
+ 'Georgia': 'Georgia, serif',
+ 'Courier New': '"Courier New", Courier, monospace',
+ 'Arial': 'Arial, sans-serif',
+ 'Verdana': 'Verdana, sans-serif',
+ 'Trebuchet MS': '"Trebuchet MS", sans-serif',
+ 'Garamond': 'Garamond, serif',
+};
+
+// Plain toggle switch
+function Toggle({ checked, onChange }) {
+ return (
+ onChange(!checked)}
+ className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors focus:outline-none ${
+ checked ? 'bg-blue-600' : 'bg-slate-200'
+ }`}
+ >
+
+
+ );
+}
+
+// Plain range slider with label
+function SliderField({ label, value, min, max, step, onChange }) {
+ return (
+
+
+ {label}
+ {value}
+
+
onChange(Number(e.target.value))}
+ className="w-full h-1.5 accent-blue-600"
+ />
+
+ );
+}
+
+// Style toggle button (B / I / U)
+function StyleBtn({ active, onClick, children }) {
+ return (
+
+ {children}
+
+ );
+}
+
+export function CustomFormHeader({ headerConfig, onChange, clientLogo }) {
+ const [advancedOpen, setAdvancedOpen] = useState(false);
+
+ const handleChange = useCallback((key, value) => {
+ if (headerConfig[key] === value) return;
+ onChange(prev => ({ ...prev, [key]: value }));
+ }, [headerConfig, onChange]);
+
+ const handleStyleChange = useCallback((key, value) => {
+ if (headerConfig.styles?.[key] === value) return;
+ onChange(prev => ({ ...prev, styles: { ...prev.styles, [key]: value } }));
+ }, [headerConfig, onChange]);
+
+ const toggleStyle = useCallback((styleKey) => {
+ onChange(prev => ({ ...prev, styles: { ...prev.styles, [styleKey]: !prev.styles?.[styleKey] } }));
+ }, [onChange]);
+
+ const st = headerConfig.styles || {};
+ const fontKeys = Object.keys(STANDARD_FONTS);
+ const currentFontFamily = STANDARD_FONTS[st.fontFamily] || 'inherit';
+
+ return (
+
+ {/* Header toggle row */}
+
+
+
+
+
+ Header Configuration
+
+
+ Show Header
+ handleChange('enabled', v)} />
+
+
+
+ {headerConfig.enabled && (
+
+
+
+ {/* Left column: Content */}
+
+
+ Left Side: Content
+
+
+
+ Form Title
+ handleChange('title', e.target.value)}
+ placeholder="e.g. Official Notice"
+ className="w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
+ style={{
+ fontFamily: currentFontFamily,
+ fontWeight: st.bold ? 'bold' : 'normal',
+ fontStyle: st.italic ? 'italic' : 'normal',
+ textDecoration: st.underline ? 'underline' : 'none',
+ }}
+ />
+
+
+
+ Description / Subtitle
+ handleChange('description', e.target.value)}
+ placeholder="Brief description appearing under the title..."
+ rows={3}
+ className="w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm resize-none focus:outline-none focus:ring-2 focus:ring-blue-500"
+ style={{ fontFamily: currentFontFamily }}
+ />
+
+
+
+ {/* Right column: Logo */}
+
+
+ Right Side: Logo
+
+
+
+
Logo Source URL
+
+
+ handleChange('logoUrl', e.target.value)}
+ placeholder="https://..."
+ className="w-full rounded-md border border-slate-200 bg-white px-3 py-2 pr-8 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
+ />
+ {headerConfig.logoUrl && (
+ handleChange('logoUrl', '')}
+ className="absolute right-2 top-2.5 text-slate-400 hover:text-red-500"
+ >
+
+
+ )}
+
+ {clientLogo && (
+
handleChange('logoUrl', clientLogo)}
+ title="Fetch Client Logo"
+ className="h-9 px-3 rounded-md border border-slate-200 text-xs text-slate-700 hover:bg-slate-50 transition-colors"
+ >
+ Fetch
+
+ )}
+
+
+
+ {headerConfig.logoUrl && (
+
+
+
+ Preview
+
+
+ )}
+
+
+
+ {/* Advanced styling — collapsible */}
+
+
setAdvancedOpen(o => !o)}
+ className="flex items-center gap-1 text-xs text-slate-500 hover:text-blue-600 transition-colors py-1"
+ >
+
+ Advanced Styling & Dimensions
+
+
+ {advancedOpen && (
+
+
+ {/* Font family + style */}
+
+
+ Font Family
+ handleStyleChange('fontFamily', e.target.value)}
+ className="w-full h-9 text-xs rounded-md border border-slate-200 bg-white px-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
+ >
+ {fontKeys.map(key => (
+ {key}
+ ))}
+
+
+
+
+
Font Style
+
+ toggleStyle('bold')}>
+
+
+ toggleStyle('italic')}>
+
+
+ toggleStyle('underline')}>
+
+
+
+
+
+
+ {/* Dimension sliders */}
+
+ handleStyleChange('fontSize', v)}
+ />
+ handleStyleChange('logoHeight', v)}
+ />
+ handleStyleChange('logoWidth', v)}
+ />
+
+
+ {/* Text color */}
+
+
+ )}
+
+
+ )}
+
+ );
+}
+
+export default CustomFormHeader;
diff --git a/src/components/forms/CustomFormPdfExport.jsx b/src/components/forms/CustomFormPdfExport.jsx
new file mode 100644
index 0000000..1e4a602
--- /dev/null
+++ b/src/components/forms/CustomFormPdfExport.jsx
@@ -0,0 +1,961 @@
+import React, { forwardRef, useImperativeHandle } from 'react';
+import { jsPDF } from 'jspdf';
+import autoTable from 'jspdf-autotable';
+import { sanitizeRichTextHtml } from '@/lib/customFormHtmlSanitizer';
+
+const PAGE_SIZE = 'letter';
+const PAGE_UNIT = 'pt';
+const PAGE_MARGIN = 54;
+const FOOTER_HEIGHT = 34;
+const BODY_FONT_SIZE = 11;
+const LINE_HEIGHT_RATIO = 1.45;
+const MIN_LINE_HEIGHT_RATIO = 1.1;
+const MAX_LINE_HEIGHT_RATIO = 1.9;
+const DEFAULT_BLOCK_SPACING = 8;
+const MAX_BLOCK_SPACING = 24;
+const CONTAINER_TAGS = new Set(['div', 'section', 'article', 'aside']);
+const NESTED_BLOCK_TAGS = new Set([
+ 'p',
+ 'div',
+ 'section',
+ 'article',
+ 'aside',
+ 'blockquote',
+ 'pre',
+ 'h1',
+ 'h2',
+ 'h3',
+ 'h4',
+ 'h5',
+ 'h6',
+ 'ul',
+ 'ol',
+ 'table',
+ 'hr',
+ 'img',
+]);
+const SKIPPABLE_TAGS = new Set(['style', 'script', 'meta', 'link', 'title', 'xml', 'head']);
+
+function resolveContent(html = '', data = {}) {
+ if (!html) return '';
+
+ let out = html.replace(/]*>\s*(\{\{[\w.]+\}\})\s*<\/span>/g, '$1');
+
+ if (data && typeof data === 'object') {
+ out = out.replace(/\{\{([\w.]+)\}\}/g, (match, key) => {
+ const value = data[key];
+ return value != null && value !== '' ? String(value) : match;
+ });
+ }
+
+ return out.replace(/]*class="var-pill"[^>]*>([\s\S]*?)<\/span>/g, '$1');
+}
+
+function stripHtml(html = '') {
+ return html.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
+}
+
+function cssSizeToPt(value) {
+ if (!value) return null;
+ const match = String(value).trim().match(/^([\d.]+)(px|pt|em|rem)?$/i);
+ if (!match) return null;
+
+ const amount = parseFloat(match[1]);
+ const unit = (match[2] || 'px').toLowerCase();
+
+ if (unit === 'pt') return amount;
+ if (unit === 'em' || unit === 'rem') return amount * 12;
+ return amount * 0.75;
+}
+
+function clamp(value, min, max) {
+ return Math.min(Math.max(value, min), max);
+}
+
+function getSpacingInPt(value) {
+ const size = cssSizeToPt(value);
+ if (size == null) return null;
+ return clamp(size, 0, MAX_BLOCK_SPACING);
+}
+
+function getLineHeightRatio(value, fontSize = BODY_FONT_SIZE) {
+ if (!value) return null;
+
+ const raw = String(value).trim().toLowerCase();
+ if (!raw || raw === 'normal') return null;
+
+ if (/^[\d.]+$/.test(raw)) {
+ return clamp(Number(raw), MIN_LINE_HEIGHT_RATIO, MAX_LINE_HEIGHT_RATIO);
+ }
+
+ if (raw.endsWith('%')) {
+ const amount = Number(raw.replace('%', ''));
+ if (!Number.isNaN(amount)) {
+ return clamp(amount / 100, MIN_LINE_HEIGHT_RATIO, MAX_LINE_HEIGHT_RATIO);
+ }
+ }
+
+ const size = cssSizeToPt(raw);
+ if (size != null && fontSize > 0) {
+ return clamp(size / fontSize, MIN_LINE_HEIGHT_RATIO, MAX_LINE_HEIGHT_RATIO);
+ }
+
+ return null;
+}
+
+function parseColor(value) {
+ if (!value) return null;
+ const color = String(value).trim();
+
+ const hex = color.match(/^#([\da-f]{3}|[\da-f]{6})$/i);
+ if (hex) {
+ const raw = hex[1];
+ const full = raw.length === 3 ? raw.split('').map((c) => c + c).join('') : raw;
+ return [
+ parseInt(full.slice(0, 2), 16),
+ parseInt(full.slice(2, 4), 16),
+ parseInt(full.slice(4, 6), 16),
+ ];
+ }
+
+ const rgb = color.match(/^rgba?\((\d+),\s*(\d+),\s*(\d+)/i);
+ if (rgb) {
+ return [Number(rgb[1]), Number(rgb[2]), Number(rgb[3])];
+ }
+
+ return null;
+}
+
+function mapFontToJsPdf(fontFamily) {
+ if (!fontFamily) return 'helvetica';
+ const normalized = String(fontFamily).toLowerCase();
+ if (normalized.includes('times') || normalized.includes('georgia') || normalized.includes('garamond')) return 'times';
+ if (normalized.includes('courier') || normalized.includes('mono')) return 'courier';
+ return 'helvetica';
+}
+
+function getExplicitBlockAlignment(element) {
+ if (!element) return null;
+ const cls = element.classList?.toString?.() || '';
+ if (cls.includes('ql-align-center')) return 'center';
+ if (cls.includes('ql-align-right')) return 'right';
+ if (cls.includes('ql-align-justify')) return 'justify';
+ const textAlign = element.style?.textAlign || element.getAttribute?.('align');
+ return textAlign || null;
+}
+
+function getBlockAlignment(element, fallback = 'left') {
+ return getExplicitBlockAlignment(element) || fallback;
+}
+
+function getBlockIndent(element) {
+ if (!element) return 0;
+
+ const cls = element.classList?.toString?.() || '';
+ const match = cls.match(/ql-indent-(\d+)/);
+ const classIndent = (match ? Number(match[1]) || 0 : 0) * 18;
+ const marginIndent = getSpacingInPt(element.style?.marginLeft) || 0;
+ const paddingIndent = getSpacingInPt(element.style?.paddingLeft) || 0;
+ const textIndent = getSpacingInPt(element.style?.textIndent) || 0;
+
+ return classIndent + marginIndent + paddingIndent + textIndent;
+}
+
+function getBlockSpacing(tag, element) {
+ const marginTop = getSpacingInPt(element?.style?.marginTop) || 0;
+ const marginBottom = getSpacingInPt(element?.style?.marginBottom);
+ const isHeading = /^h[1-6]$/i.test(tag);
+
+ return {
+ spacingBefore: marginTop,
+ spacingAfter: marginBottom ?? (isHeading ? 10 : DEFAULT_BLOCK_SPACING),
+ lineHeight: element?.style?.lineHeight || null,
+ };
+}
+
+function isPageBreakElement(element) {
+ return Boolean(element?.classList?.contains('page-break-marker') || element?.dataset?.pageBreak === 'true');
+}
+
+async function toDataUrl(url) {
+ if (!url) return null;
+ if (url.startsWith('data:')) return url;
+
+ try {
+ const response = await fetch(url, { mode: 'cors' });
+ const blob = await response.blob();
+
+ return await new Promise((resolve) => {
+ const reader = new FileReader();
+ reader.onloadend = () => resolve(reader.result);
+ reader.onerror = () => resolve(null);
+ reader.readAsDataURL(blob);
+ });
+ } catch {
+ return null;
+ }
+}
+
+function getImageFormat(dataUrl = '') {
+ if (dataUrl.startsWith('data:image/png')) return 'PNG';
+ if (dataUrl.startsWith('data:image/webp')) return 'WEBP';
+ return 'JPEG';
+}
+
+function createBaseStyle(defaultFont) {
+ return {
+ font: defaultFont,
+ size: BODY_FONT_SIZE,
+ bold: false,
+ italic: false,
+ underline: false,
+ strikethrough: false,
+ color: null,
+ };
+}
+
+function getOrderedListPrefix(index, markerStyle = 'decimal') {
+ if (markerStyle === 'lower-alpha' || markerStyle === 'upper-alpha') {
+ let value = Math.max(1, index);
+ let output = '';
+
+ while (value > 0) {
+ value -= 1;
+ output = String.fromCharCode(97 + (value % 26)) + output;
+ value = Math.floor(value / 26);
+ }
+
+ return `${markerStyle === 'upper-alpha' ? output.toUpperCase() : output}. `;
+ }
+
+ if (markerStyle === 'lower-roman' || markerStyle === 'upper-roman') {
+ const numerals = [
+ ['M', 1000], ['CM', 900], ['D', 500], ['CD', 400], ['C', 100], ['XC', 90],
+ ['L', 50], ['XL', 40], ['X', 10], ['IX', 9], ['V', 5], ['IV', 4], ['I', 1],
+ ];
+ let value = Math.max(1, index);
+ let output = '';
+
+ numerals.forEach(([symbol, amount]) => {
+ while (value >= amount) {
+ output += symbol;
+ value -= amount;
+ }
+ });
+
+ const normalized = markerStyle === 'lower-roman' ? output.toLowerCase() : output;
+ return `${normalized}. `;
+ }
+
+ return `${index}. `;
+}
+
+function getListMarkerStyle(tag, depth = 0) {
+ if (tag === 'ul') {
+ return ['disc', 'circle', 'square'][depth % 3] || 'disc';
+ }
+
+ return ['decimal', 'lower-alpha', 'lower-roman'][depth % 3] || 'decimal';
+}
+
+function getOrderedListStartValue(element) {
+ const start = Number.parseInt(element?.getAttribute?.('start') || '1', 10);
+ return Number.isFinite(start) && start > 0 ? start : 1;
+}
+
+function extendSegmentStyle(element, inheritedStyle, defaultFont) {
+ const style = { ...inheritedStyle };
+ style.font = style.font || defaultFont;
+
+ for (const cls of element.classList) {
+ if (cls.startsWith('ql-font-')) {
+ style.font = mapFontToJsPdf(cls.replace('ql-font-', ''));
+ }
+ if (cls.startsWith('ql-size-')) {
+ const size = cssSizeToPt(cls.replace('ql-size-', ''));
+ if (size) style.size = size;
+ }
+ }
+
+ if (element.style.fontFamily) style.font = mapFontToJsPdf(element.style.fontFamily);
+ if (element.style.fontSize) {
+ const size = cssSizeToPt(element.style.fontSize);
+ if (size) style.size = size;
+ }
+ if (element.style.color) {
+ const color = parseColor(element.style.color);
+ if (color) style.color = color;
+ }
+
+ const fontWeight = String(element.style.fontWeight || '').toLowerCase();
+ if (fontWeight === 'bold' || (Number.isFinite(Number(fontWeight)) && Number(fontWeight) >= 600)) {
+ style.bold = true;
+ }
+
+ const fontStyle = String(element.style.fontStyle || '').toLowerCase();
+ if (fontStyle === 'italic' || fontStyle === 'oblique') {
+ style.italic = true;
+ }
+
+ const textDecoration = String(element.style.textDecoration || element.style.textDecorationLine || '').toLowerCase();
+ if (textDecoration.includes('underline')) style.underline = true;
+ if (textDecoration.includes('line-through')) style.strikethrough = true;
+
+ const tag = element.tagName.toLowerCase();
+ if (tag === 'strong' || tag === 'b') style.bold = true;
+ if (tag === 'em' || tag === 'i') style.italic = true;
+ if (tag === 'u') style.underline = true;
+ if (tag === 's' || tag === 'strike' || tag === 'del') style.strikethrough = true;
+ if (tag === 'a') {
+ style.underline = true;
+ style.color = style.color || [37, 99, 235];
+ }
+ if (tag === 'h1') { style.size = 24; style.bold = true; }
+ if (tag === 'h2') { style.size = 18; style.bold = true; }
+ if (tag === 'h3') { style.size = 14; style.bold = true; }
+ if (tag === 'h4') { style.size = 12; style.bold = true; }
+
+ return style;
+}
+
+function parseInlineSegments(node, inheritedStyle, defaultFont) {
+ if (node.nodeType === Node.TEXT_NODE) {
+ const normalizedText = String(node.textContent || '').replace(/[\t\r\n]+/g, ' ');
+ return normalizedText ? [{ text: normalizedText, ...inheritedStyle }] : [];
+ }
+
+ if (node.nodeType !== Node.ELEMENT_NODE) return [];
+
+ const element = node;
+ const tag = element.tagName.toLowerCase();
+
+ if (SKIPPABLE_TAGS.has(tag)) {
+ return [];
+ }
+
+ if (tag === 'br') {
+ return [{ text: '\n', ...inheritedStyle }];
+ }
+
+ if (isPageBreakElement(element)) {
+ return [{ pageBreak: true }];
+ }
+
+ const style = extendSegmentStyle(element, inheritedStyle, defaultFont);
+
+ const segments = [];
+ for (const child of element.childNodes) {
+ segments.push(...parseInlineSegments(child, style, style.font || defaultFont));
+ }
+ return segments;
+}
+
+function parseTable(element) {
+ const rows = [...element.querySelectorAll('tr')].map((row) =>
+ [...row.children].map((cell) => stripHtml(cell.innerHTML || cell.textContent || '')),
+ );
+
+ const headerRows = rows.filter((_, index) => index === 0 && element.querySelector('th'));
+ const bodyRows = rows.slice(headerRows.length);
+
+ return { type: 'table', headerRows, bodyRows };
+}
+
+function createBlockContext(defaultFont) {
+ return {
+ align: 'left',
+ indent: 0,
+ listDepth: 0,
+ lineHeight: null,
+ baseStyle: createBaseStyle(defaultFont),
+ };
+}
+
+function extendBlockContext(context, element, defaultFont) {
+ return {
+ align: getBlockAlignment(element, context.align || 'left'),
+ indent: (context.indent || 0) + getBlockIndent(element),
+ lineHeight: element.style?.lineHeight || context.lineHeight || null,
+ baseStyle: extendSegmentStyle(element, context.baseStyle, defaultFont),
+ };
+}
+
+function hasNestedBlockChildren(element) {
+ return [...element.children].some((child) => {
+ if (isPageBreakElement(child)) return true;
+ return NESTED_BLOCK_TAGS.has(child.tagName.toLowerCase());
+ });
+}
+
+function pushTextNodeBlock(blocks, text, context) {
+ const value = String(text || '').replace(/\s+/g, ' ').trim();
+ if (!value) return;
+
+ blocks.push({
+ type: 'text',
+ segments: [{ ...context.baseStyle, text: value }],
+ align: context.align || 'left',
+ indent: context.indent || 0,
+ spacingBefore: 0,
+ spacingAfter: DEFAULT_BLOCK_SPACING,
+ lineHeight: context.lineHeight || null,
+ });
+}
+
+function appendBlocksFromNode(blocks, node, defaultFont, context) {
+ if (node.nodeType === Node.TEXT_NODE) {
+ pushTextNodeBlock(blocks, node.textContent, context);
+ return;
+ }
+
+ if (node.nodeType !== Node.ELEMENT_NODE) return;
+
+ const element = node;
+ const tag = element.tagName.toLowerCase();
+
+ if (SKIPPABLE_TAGS.has(tag)) return;
+
+ if (isPageBreakElement(element)) {
+ blocks.push({ type: 'pageBreak' });
+ return;
+ }
+
+ if (tag === 'table') {
+ blocks.push(parseTable(element));
+ return;
+ }
+
+ if (tag === 'hr') {
+ blocks.push({ type: 'divider' });
+ return;
+ }
+
+ if (tag === 'img') {
+ blocks.push({ type: 'image', src: element.getAttribute('src'), alt: element.getAttribute('alt') || '' });
+ return;
+ }
+
+ const nextContext = extendBlockContext(context, element, defaultFont);
+
+ if (tag === 'ol' || tag === 'ul') {
+ const listSpacing = getBlockSpacing(tag, element);
+ const listItems = [...element.children].filter((li) => li.tagName.toLowerCase() === 'li');
+ const listDepth = context.listDepth || 0;
+ const listMarkerStyle = getListMarkerStyle(tag, listDepth);
+ let index = tag === 'ol' ? getOrderedListStartValue(element) - 1 : 0;
+
+ for (const li of listItems) {
+ const explicitValue = tag === 'ol' ? Number.parseInt(li.getAttribute('value') || '', 10) : Number.NaN;
+ index = Number.isFinite(explicitValue) && explicitValue > 0 ? explicitValue : index + 1;
+ const itemContext = extendBlockContext(nextContext, li, defaultFont);
+ const itemSpacing = getBlockSpacing('li', li);
+ const itemAlign = itemContext.align === 'justify' ? 'left' : (itemContext.align || 'left');
+ const segments = li.childNodes.length
+ ? [...li.childNodes].flatMap((child) => {
+ if (child.nodeType === Node.ELEMENT_NODE) {
+ const childTag = child.tagName.toLowerCase();
+ if (childTag === 'ol' || childTag === 'ul') return [];
+ }
+
+ return parseInlineSegments(child, itemContext.baseStyle, itemContext.baseStyle.font || defaultFont);
+ })
+ : parseInlineSegments(li, itemContext.baseStyle, itemContext.baseStyle.font || defaultFont);
+
+ const textSegments = segments.filter((segment) => !segment.pageBreak);
+
+ if (textSegments.length > 0 || li.childNodes.length === 0) {
+ blocks.push({
+ type: 'text',
+ segments: textSegments.length ? textSegments : [{ ...itemContext.baseStyle, text: '' }],
+ align: itemAlign,
+ indent: itemContext.indent,
+ spacingBefore: index === 1 ? Math.max(listSpacing.spacingBefore, itemSpacing.spacingBefore) : 2,
+ spacingAfter: 4,
+ lineHeight: itemSpacing.lineHeight || listSpacing.lineHeight || itemContext.lineHeight,
+ listType: tag === 'ol' ? 'ordered' : 'bullet',
+ listMarkerStyle,
+ listIndex: index,
+ });
+ }
+
+ const nestedListContext = { ...itemContext, align: itemAlign, indent: itemContext.indent + 18, listDepth: listDepth + 1 };
+ const nestedLists = [...li.children].filter((child) => {
+ const childTag = child.tagName.toLowerCase();
+ return childTag === 'ol' || childTag === 'ul';
+ });
+
+ nestedLists.forEach((child, nestedIndex) => {
+ appendBlocksFromNode(blocks, child, defaultFont, nestedListContext);
+
+ if (nestedIndex === nestedLists.length - 1) {
+ const lastBlock = blocks[blocks.length - 1];
+ if (lastBlock?.type === 'text') {
+ lastBlock.spacingAfter = Math.max(lastBlock.spacingAfter ?? 0, index === listItems.length ? Math.max(listSpacing.spacingAfter, itemSpacing.spacingAfter) : 4);
+ }
+ }
+ });
+ }
+
+ return;
+ }
+
+ if (CONTAINER_TAGS.has(tag) && hasNestedBlockChildren(element)) {
+ for (const child of element.childNodes) {
+ appendBlocksFromNode(blocks, child, defaultFont, nextContext);
+ }
+ return;
+ }
+
+ const segments = parseInlineSegments(element, nextContext.baseStyle, nextContext.baseStyle.font || defaultFont);
+ const pageBreaks = segments.some((segment) => segment.pageBreak);
+ const textSegments = segments.filter((segment) => !segment.pageBreak);
+ const spacing = getBlockSpacing(tag, element);
+
+ if (pageBreaks) blocks.push({ type: 'pageBreak' });
+
+ blocks.push({
+ type: 'text',
+ segments: textSegments.length ? textSegments : [{ ...nextContext.baseStyle, text: '' }],
+ align: nextContext.align || 'left',
+ indent: nextContext.indent,
+ spacingBefore: spacing.spacingBefore,
+ spacingAfter: spacing.spacingAfter,
+ lineHeight: spacing.lineHeight || nextContext.lineHeight || null,
+ isHeading: /^h[1-6]$/i.test(tag),
+ });
+}
+
+function parseHtmlToBlocks(html, defaultFont) {
+ const parser = new DOMParser();
+ const doc = parser.parseFromString(`${html || ''}
`, 'text/html');
+ const root = doc.body.firstChild;
+ const blocks = [];
+
+ if (!root) return blocks;
+
+ const baseContext = createBlockContext(defaultFont);
+
+ for (const child of root.childNodes) {
+ appendBlocksFromNode(blocks, child, defaultFont, baseContext);
+ }
+
+ return blocks;
+}
+
+function setSegmentStyle(doc, segment, fallbackFont) {
+ const fontName = segment.font || fallbackFont || 'helvetica';
+ let fontStyle = 'normal';
+
+ if (segment.bold && segment.italic) fontStyle = 'bolditalic';
+ else if (segment.bold) fontStyle = 'bold';
+ else if (segment.italic) fontStyle = 'italic';
+
+ doc.setFont(fontName, fontStyle);
+ doc.setFontSize(segment.size || BODY_FONT_SIZE);
+
+ if (segment.color) {
+ doc.setTextColor(segment.color[0], segment.color[1], segment.color[2]);
+ } else {
+ doc.setTextColor(17, 17, 17);
+ }
+}
+
+function measureText(doc, text, style, fallbackFont) {
+ setSegmentStyle(doc, style, fallbackFont);
+ return doc.getTextWidth(text);
+}
+
+function trimLineTokens(tokens) {
+ const next = [...tokens];
+ while (next.length && /^\s+$/.test(next[next.length - 1].text)) next.pop();
+ return next;
+}
+
+function splitLongToken(doc, token, maxWidth, fallbackFont) {
+ const chars = [...token.text];
+ const chunks = [];
+ let current = '';
+
+ chars.forEach((char) => {
+ const test = current + char;
+ const width = measureText(doc, test, token, fallbackFont);
+ if (current && width > maxWidth) {
+ chunks.push({ ...token, text: current });
+ current = char;
+ } else {
+ current = test;
+ }
+ });
+
+ if (current) chunks.push({ ...token, text: current });
+ return chunks;
+}
+
+function tokenizeSegments(doc, segments, maxWidth, fallbackFont) {
+ const tokens = [];
+
+ segments.forEach((segment) => {
+ const pieces = String(segment.text || '').split(/(\n)/);
+ pieces.forEach((piece) => {
+ if (piece === '\n') {
+ tokens.push({ type: 'break' });
+ return;
+ }
+
+ const parts = piece.split(/(\s+)/).filter(Boolean);
+ parts.forEach((part) => {
+ const token = { ...segment, text: part };
+ const width = measureText(doc, part, token, fallbackFont);
+ if (!/^\s+$/.test(part) && width > maxWidth) {
+ splitLongToken(doc, token, maxWidth, fallbackFont).forEach((chunk) => tokens.push(chunk));
+ } else {
+ tokens.push(token);
+ }
+ });
+ });
+ });
+
+ return tokens;
+}
+
+function buildLines(doc, segments, maxWidth, fallbackFont) {
+ const tokens = tokenizeSegments(doc, segments, maxWidth, fallbackFont);
+ const lines = [];
+ let current = [];
+ let currentWidth = 0;
+
+ const pushLine = (endsWithBreak = false) => {
+ lines.push({ tokens: trimLineTokens(current), endsWithBreak });
+ current = [];
+ currentWidth = 0;
+ };
+
+ tokens.forEach((token) => {
+ if (token.type === 'break') {
+ pushLine(true);
+ return;
+ }
+
+ if (/^\s+$/.test(token.text) && current.length === 0) return;
+
+ const width = measureText(doc, token.text, token, fallbackFont);
+
+ if (currentWidth + width > maxWidth && current.length > 0 && !/^\s+$/.test(token.text)) {
+ pushLine();
+ }
+
+ if (/^\s+$/.test(token.text) && current.length === 0) return;
+
+ current.push(token);
+ currentWidth += width;
+ });
+
+ if (current.length || lines.length === 0) pushLine();
+ return lines;
+}
+
+function measureLine(doc, line, fallbackFont) {
+ return line.reduce((total, token) => total + measureText(doc, token.text, token, fallbackFont), 0);
+}
+
+function renderLine(doc, line, x, y, maxWidth, align, fallbackFont, justify) {
+ const lineWidth = measureLine(doc, line, fallbackFont);
+ let cursorX = x;
+
+ if (align === 'center') cursorX = x + (maxWidth - lineWidth) / 2;
+ if (align === 'right') cursorX = x + maxWidth - lineWidth;
+
+ const whitespaceTokens = justify ? line.filter((token) => /^\s+$/.test(token.text)).length : 0;
+ const extraGap = justify && whitespaceTokens > 0 ? (maxWidth - lineWidth) / whitespaceTokens : 0;
+
+ line.forEach((token) => {
+ const width = measureText(doc, token.text, token, fallbackFont);
+ if (!/^\s+$/.test(token.text)) {
+ setSegmentStyle(doc, token, fallbackFont);
+ doc.text(token.text, cursorX, y);
+
+ if (token.underline) {
+ doc.setLineWidth(0.5);
+ doc.line(cursorX, y + 1.5, cursorX + width, y + 1.5);
+ }
+
+ if (token.strikethrough) {
+ doc.setLineWidth(0.5);
+ doc.line(cursorX, y - (token.size || BODY_FONT_SIZE) * 0.28, cursorX + width, y - (token.size || BODY_FONT_SIZE) * 0.28);
+ }
+ }
+
+ cursorX += width + (/^\s+$/.test(token.text) ? extraGap : 0);
+ });
+}
+
+function drawBorder(doc, pageWidth, pageHeight, borderConfig) {
+ if (!borderConfig?.enabled) return;
+ doc.setDrawColor(borderConfig.color || '#000000');
+ doc.setLineWidth(1);
+ doc.rect(22, 22, pageWidth - 44, pageHeight - 44);
+}
+
+function drawFooter(doc, pageWidth, pageHeight, footerConfig, footerStyles, totalPages, pageNum, sampleData) {
+ if (footerConfig?.enabled === false) return;
+
+ const footerY = pageHeight - 24;
+ const today = new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });
+ const centerText = stripHtml(footerConfig?.content || '');
+ const fontName = mapFontToJsPdf(footerStyles?.fontFamily);
+ const fontSize = Math.max(7, Number(footerStyles?.fontSize) || 8);
+
+ doc.setDrawColor(203, 213, 225);
+ doc.setLineWidth(0.5);
+ doc.line(PAGE_MARGIN, footerY - 10, pageWidth - PAGE_MARGIN, footerY - 10);
+
+ doc.setFont(fontName, 'normal');
+ doc.setFontSize(fontSize);
+ doc.setTextColor(100, 116, 139);
+
+ if (footerConfig?.showDate) {
+ doc.text(today, PAGE_MARGIN, footerY);
+ }
+
+ if (centerText) {
+ doc.text(centerText, pageWidth / 2, footerY, { align: 'center' });
+ }
+
+ if (footerConfig?.showPageNumbers !== false) {
+ doc.text(`Page ${pageNum} of ${totalPages}`, pageWidth - PAGE_MARGIN, footerY, { align: 'right' });
+ }
+
+ doc.setTextColor(17, 17, 17);
+ if (sampleData?.clientName && !centerText) {
+ doc.text(String(sampleData.clientName), pageWidth / 2, footerY, { align: 'center' });
+ }
+}
+
+async function drawHeader(doc, pageWidth, headerConfig, sampleData) {
+ if (headerConfig?.enabled === false) return PAGE_MARGIN;
+
+ const styles = headerConfig?.styles || {};
+ const align = styles.alignment || 'left';
+ const titleFont = mapFontToJsPdf(styles.fontFamily);
+ const titleSize = Number(styles.fontSize) || 24;
+ const textColor = parseColor(styles.textColor) || [0, 0, 0];
+ const logoUrl = await toDataUrl(headerConfig?.logoUrl || '');
+
+ let y = PAGE_MARGIN;
+ let leftX = PAGE_MARGIN;
+ let rightX = pageWidth - PAGE_MARGIN;
+
+ if (logoUrl) {
+ try {
+ const props = doc.getImageProperties(logoUrl);
+ const logoHeight = Number(styles.logoHeight) || 60;
+ const logoWidth = Number(styles.logoWidth) || (logoHeight * props.width) / props.height;
+ const drawX = align === 'right' ? rightX - logoWidth : PAGE_MARGIN;
+ doc.addImage(logoUrl, getImageFormat(logoUrl), drawX, y, logoWidth, logoHeight);
+ if (align !== 'right') leftX += logoWidth + 14;
+ if (align === 'right') rightX -= logoWidth + 14;
+ } catch {
+ // ignore logo rendering failure
+ }
+ }
+
+ const textX = align === 'center' ? pageWidth / 2 : align === 'right' ? rightX : leftX;
+ const textOptions = align === 'center' ? { align: 'center' } : align === 'right' ? { align: 'right' } : undefined;
+
+ doc.setTextColor(textColor[0], textColor[1], textColor[2]);
+ doc.setFont(titleFont, styles.fontWeight === 'normal' ? 'normal' : 'bold');
+ doc.setFontSize(titleSize);
+
+ if (headerConfig?.title) {
+ const titleLines = doc.splitTextToSize(stripHtml(resolveContent(headerConfig.title, sampleData || {})), pageWidth - PAGE_MARGIN * 2 - 90);
+ doc.text(titleLines, textX, y + titleSize * 0.85, textOptions);
+ y += titleLines.length * (titleSize * 1.1);
+ }
+
+ if (headerConfig?.description) {
+ const descriptionSize = Math.max(10, Math.round(titleSize * 0.45));
+ doc.setFont(titleFont, 'normal');
+ doc.setFontSize(descriptionSize);
+ const descriptionLines = doc.splitTextToSize(stripHtml(resolveContent(headerConfig.description, sampleData || {})), pageWidth - PAGE_MARGIN * 2 - 90);
+ doc.text(descriptionLines, textX, y + 8, textOptions);
+ y += descriptionLines.length * (descriptionSize * 1.2) + 10;
+ } else {
+ y += 10;
+ }
+
+ doc.setDrawColor(226, 232, 240);
+ doc.setLineWidth(1);
+ doc.line(PAGE_MARGIN, y, pageWidth - PAGE_MARGIN, y);
+ doc.setTextColor(17, 17, 17);
+
+ return y + 16;
+}
+
+export const CustomFormPdfExport = forwardRef(function CustomFormPdfExport(
+ { formConfig, sampleData, borderSettings },
+ ref,
+) {
+ useImperativeHandle(ref, () => ({
+ async generatePdf(name = 'Document', silent = false, returnBlob = false) {
+ const headerConfig = formConfig?.header || {};
+ const footerConfig = formConfig?.footer || {};
+ const globalSettings = formConfig?.globalSettings || {};
+ const effectiveBorder = borderSettings || formConfig?.borderSettings || {};
+ const defaultFont = mapFontToJsPdf(globalSettings.fontFamily);
+ const richContent = sanitizeRichTextHtml(resolveContent(formConfig?.richContent || '', sampleData || {}));
+
+ const doc = new jsPDF({ orientation: 'portrait', unit: PAGE_UNIT, format: PAGE_SIZE });
+ const pageWidth = doc.internal.pageSize.getWidth();
+ const pageHeight = doc.internal.pageSize.getHeight();
+ const contentWidth = pageWidth - PAGE_MARGIN * 2;
+ const contentBottom = pageHeight - PAGE_MARGIN - (footerConfig?.enabled === false ? 0 : FOOTER_HEIGHT);
+
+ const blocks = parseHtmlToBlocks(richContent, defaultFont);
+ let y = await drawHeader(doc, pageWidth, headerConfig, sampleData || {});
+
+ const ensureSpace = (neededHeight = 16) => {
+ if (y + neededHeight <= contentBottom) return;
+ doc.addPage();
+ y = PAGE_MARGIN;
+ };
+
+ for (const block of blocks) {
+ if (block.type === 'pageBreak') {
+ doc.addPage();
+ y = PAGE_MARGIN;
+ continue;
+ }
+
+ if (block.type === 'divider') {
+ ensureSpace(20);
+ doc.setDrawColor(226, 232, 240);
+ doc.setLineWidth(1);
+ doc.line(PAGE_MARGIN, y, pageWidth - PAGE_MARGIN, y);
+ y += 16;
+ continue;
+ }
+
+ if (block.type === 'image' && block.src) {
+ const imageData = await toDataUrl(block.src);
+ if (imageData) {
+ try {
+ const props = doc.getImageProperties(imageData);
+ const maxWidth = Math.min(contentWidth, 320);
+ const imageWidth = Math.min(maxWidth, props.width);
+ const imageHeight = (imageWidth * props.height) / props.width;
+ ensureSpace(imageHeight + 12);
+ doc.addImage(imageData, getImageFormat(imageData), PAGE_MARGIN, y, imageWidth, imageHeight);
+ y += imageHeight + 12;
+ } catch {
+ // ignore image block failures
+ }
+ }
+ continue;
+ }
+
+ if (block.type === 'table') {
+ ensureSpace(30);
+
+ autoTable(doc, {
+ startY: y,
+ head: block.headerRows?.length ? block.headerRows : undefined,
+ body: block.bodyRows || [],
+ margin: { left: PAGE_MARGIN, right: PAGE_MARGIN, top: PAGE_MARGIN, bottom: PAGE_MARGIN + FOOTER_HEIGHT },
+ theme: 'grid',
+ styles: {
+ font: defaultFont,
+ fontSize: 9,
+ cellPadding: 4,
+ lineColor: [203, 213, 225],
+ lineWidth: 0.5,
+ textColor: [17, 17, 17],
+ overflow: 'linebreak',
+ },
+ headStyles: {
+ fillColor: [241, 245, 249],
+ textColor: [17, 17, 17],
+ fontStyle: 'bold',
+ },
+ });
+
+ y = (doc.lastAutoTable?.finalY || y) + 14;
+ continue;
+ }
+
+ const segments = block.segments || [];
+ const maxFontSize = Math.max(...segments.map((segment) => segment.size || BODY_FONT_SIZE), BODY_FONT_SIZE);
+ const lineHeight = maxFontSize * (getLineHeightRatio(block.lineHeight, maxFontSize) || LINE_HEIGHT_RATIO);
+ const indentWidth = block.indent || 0;
+ const prefix = block.listType
+ ? (block.listType === 'ordered'
+ ? getOrderedListPrefix(block.listIndex, block.listMarkerStyle)
+ : (block.listMarkerStyle === 'square' ? '▪ ' : block.listMarkerStyle === 'circle' ? '◦ ' : '• '))
+ : '';
+ const prefixWidth = prefix ? measureText(doc, prefix, { font: defaultFont, size: maxFontSize }, defaultFont) : 0;
+ const availableWidth = Math.max(contentWidth - indentWidth - prefixWidth, 80);
+ const lines = buildLines(doc, segments, availableWidth, defaultFont);
+ const spacingBefore = Math.min(block.spacingBefore || 0, MAX_BLOCK_SPACING);
+ const spacingAfter = Math.min(block.spacingAfter ?? (block.isHeading ? 6 : DEFAULT_BLOCK_SPACING), MAX_BLOCK_SPACING);
+
+ if (spacingBefore > 0) {
+ ensureSpace(spacingBefore);
+ y += spacingBefore;
+ }
+
+ if (lines.length === 0 || (lines.length === 1 && lines[0].tokens.length === 0)) {
+ ensureSpace(Math.max(lineHeight * 0.65, spacingAfter));
+ y += Math.max(lineHeight * 0.65, spacingAfter);
+ continue;
+ }
+
+ lines.forEach((line, lineIndex) => {
+ ensureSpace(lineHeight + 2);
+
+ const lineX = PAGE_MARGIN + indentWidth + prefixWidth;
+ if (lineIndex === 0 && prefix) {
+ setSegmentStyle(doc, { font: defaultFont, size: maxFontSize }, defaultFont);
+ doc.text(prefix, PAGE_MARGIN + indentWidth, y);
+ }
+
+ renderLine(
+ doc,
+ line.tokens,
+ lineX,
+ y,
+ availableWidth,
+ block.align || 'left',
+ defaultFont,
+ block.align === 'justify' && !block.listType && !line.endsWithBreak && lineIndex < lines.length - 1,
+ );
+
+ y += lineHeight;
+ });
+
+ y += spacingAfter;
+ }
+
+ const totalPages = doc.getNumberOfPages();
+ const footerStyles = footerConfig?.styles || {};
+
+ for (let pageNum = 1; pageNum <= totalPages; pageNum += 1) {
+ doc.setPage(pageNum);
+ drawBorder(doc, pageWidth, pageHeight, effectiveBorder);
+ drawFooter(doc, pageWidth, pageHeight, footerConfig, footerStyles, totalPages, pageNum, sampleData || {});
+ }
+
+ if (returnBlob) {
+ return doc.output('blob');
+ }
+
+ if (!silent) {
+ doc.save(`${(name || 'Document').replace(/[^a-z0-9]/gi, '_').toLowerCase()}.pdf`);
+ }
+
+ return undefined;
+ },
+ }));
+
+ return null;
+});
+
+export default CustomFormPdfExport;
\ No newline at end of file
diff --git a/src/components/forms/CustomFormPdfExportButton.jsx b/src/components/forms/CustomFormPdfExportButton.jsx
new file mode 100644
index 0000000..6fbe6d6
--- /dev/null
+++ b/src/components/forms/CustomFormPdfExportButton.jsx
@@ -0,0 +1,53 @@
+import React, { useState } from 'react';
+import { Loader2, FileDown } from 'lucide-react';
+import { format } from 'date-fns';
+
+export function CustomFormPdfExportButton({ onClick, isLoading, formName, className = '' }) {
+ const [isHovered, setIsHovered] = useState(false);
+
+ const getFilename = () => {
+ const safeName = (formName || 'form').replace(/[^a-z0-9]/gi, '_').toLowerCase();
+ const date = format(new Date(), 'yyyy-MM-dd');
+ return `${safeName}-${date}`;
+ };
+
+ const handleClick = async () => {
+ if (!onClick) return;
+ try {
+ await onClick(getFilename());
+ } catch (error) {
+ console.error('ExportButton: Export failed', error);
+ }
+ };
+
+ return (
+ setIsHovered(true)}
+ onMouseLeave={() => setIsHovered(false)}
+ title="Generate and download PDF report"
+ className={[
+ 'inline-flex items-center justify-center gap-2 h-8 px-4 text-xs font-medium rounded-lg',
+ 'border border-slate-200 bg-white hover:bg-slate-50 text-slate-700',
+ 'transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed',
+ 'min-w-[140px]',
+ className,
+ ].join(' ')}
+ >
+ {isLoading ? (
+ <>
+
+ Exporting...
+ >
+ ) : (
+ <>
+
+ Export PDF
+ >
+ )}
+
+ );
+}
+
+export default CustomFormPdfExportButton;
diff --git a/src/components/forms/CustomFormPreview.jsx b/src/components/forms/CustomFormPreview.jsx
new file mode 100644
index 0000000..7e9f29c
--- /dev/null
+++ b/src/components/forms/CustomFormPreview.jsx
@@ -0,0 +1,368 @@
+import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { Loader2, Printer, Download, AlertCircle, ChevronDown } from 'lucide-react';
+import { CustomFormPdfExport } from './CustomFormPdfExport';
+import { sanitizeRichTextHtml } from '@/lib/customFormHtmlSanitizer';
+
+// ---------------------------------------------------------------------------
+// Inline stub utilities — replace these with your real implementations if
+// you have them in your Lovable project, or keep as-is for basic behavior.
+// ---------------------------------------------------------------------------
+const replaceVariables = (text, data) => {
+ if (!text || !data) return text || '';
+ return text.replace(/\{\{([\w.]+)\}\}/g, (_, key) => {
+ const val = data[key];
+ return val != null ? String(val) : `{{${key}}}`;
+ });
+};
+const decodeHtmlEntities = (str) => {
+ if (!str) return '';
+ const el = document.createElement('div');
+ el.innerHTML = str;
+ return el.innerText;
+};
+const sanitizeHtml = (html) => sanitizeRichTextHtml(html || '');
+
+const toPdfFileName = (fileName = 'form-export') => `${String(fileName).replace(/[^a-z0-9]/gi, '_').toLowerCase() || 'form_export'}.pdf`;
+
+async function downloadPdfBlob(blob, fileName) {
+ const { saveFile } = await import('@/lib/saveFile');
+ await saveFile(blob, { suggestedName: toPdfFileName(fileName), mimeType: 'application/pdf', description: 'PDF Document' });
+}
+
+function openPdfBlob(blob, fileName) {
+ const url = URL.createObjectURL(blob);
+ const win = window.open(url, '_blank');
+
+ if (win) {
+ win.opener = null;
+ setTimeout(() => URL.revokeObjectURL(url), 60000);
+ return true;
+ }
+
+ downloadPdfBlob(blob, fileName);
+ setTimeout(() => URL.revokeObjectURL(url), 60000);
+ return false;
+}
+
+// Simple dropdown menu (replaces shadcn DropdownMenu)
+function PrintMenu({ onVisualCapture, onIframePrint, onNativePrint, isCapturing }) {
+ const [open, setOpen] = useState(false);
+ const ref = useRef(null);
+
+ useEffect(() => {
+ const handler = e => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); };
+ document.addEventListener('mousedown', handler);
+ return () => document.removeEventListener('mousedown', handler);
+ }, []);
+
+ return (
+
+
setOpen(o => !o)}
+ disabled={isCapturing}
+ className="inline-flex items-center gap-2 h-8 px-3 rounded-md border border-slate-200 bg-white text-sm text-slate-700 hover:bg-slate-50 shadow-sm disabled:opacity-50 transition-colors"
+ >
+ {isCapturing
+ ?
+ : }
+ Print / Save
+
+
+
+ {open && (
+
+
+ Print Options
+
+
{ setOpen(false); onVisualCapture(); }}
+ disabled={isCapturing}
+ className="flex items-center gap-2 w-full px-3 py-2.5 text-sm text-slate-700 hover:bg-slate-50 transition-colors"
+ >
+ Save as PDF (Visual)
+
+
{ setOpen(false); onIframePrint(); }}
+ className="flex items-center gap-2 w-full px-3 py-2.5 text-sm text-slate-700 hover:bg-slate-50 transition-colors"
+ >
+ Print (Standard)
+
+
{ setOpen(false); onNativePrint(); }}
+ className="flex items-center gap-2 w-full px-3 py-2.5 text-sm text-slate-700 hover:bg-slate-50 transition-colors"
+ >
+ Browser Print (Fallback)
+
+
+ )}
+
+ );
+}
+
+export function CustomFormPreview({ formConfig, clientId, sampleData, mode = 'preview' }) {
+ const { header, footer, blocks = [], borderSettings, globalSettings } = formConfig;
+ const [resolvedData, setResolvedData] = useState(null);
+ const [isCapturing, setIsCapturing] = useState(false);
+ const exportRef = useRef(null);
+
+ const effectiveSampleData = useMemo(() => sampleData || {}, [sampleData]);
+ const pdfFileName = useMemo(() => formConfig?.name || 'form-export', [formConfig?.name]);
+
+ // Resolve sample data (no Supabase — uses passed-in sampleData directly)
+ useEffect(() => {
+ const base = {
+ propertyAddress: '123 Palm Tree Lane, Miami FL 33101',
+ ownerName: 'John & Jane Doe',
+ currentDate: new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }),
+ pageNumber: '1',
+ totalPages: '1',
+ accountNumber: 'ACC-99823',
+ currentBalance: '$450.00',
+ clientName: 'Association Name',
+ ...effectiveSampleData,
+ };
+ setResolvedData(base);
+ }, [effectiveSampleData]);
+
+ const processText = (text) => {
+ if (!text || !resolvedData) return text || '';
+ return sanitizeHtml(decodeHtmlEntities(replaceVariables(text, resolvedData)));
+ };
+
+ const generatePdfBlob = useCallback(async () => {
+ if (!exportRef.current) throw new Error('PDF generator unavailable');
+ const blob = await exportRef.current.generatePdf(pdfFileName, true, true);
+ if (!blob) throw new Error('Could not generate PDF');
+ return blob;
+ }, [pdfFileName]);
+
+ const handleVisualCapture = useCallback(async () => {
+ try {
+ setIsCapturing(true);
+ downloadPdfBlob(await generatePdfBlob(), pdfFileName);
+ } catch (err) {
+ console.error('PDF Export Error:', err);
+ } finally {
+ setIsCapturing(false);
+ }
+ }, [generatePdfBlob, pdfFileName]);
+
+ const handleIframePrint = useCallback(async () => {
+ try {
+ setIsCapturing(true);
+ openPdfBlob(await generatePdfBlob(), pdfFileName);
+ } catch (err) {
+ console.error('Print Preview Error:', err);
+ } finally {
+ setIsCapturing(false);
+ }
+ }, [generatePdfBlob, pdfFileName]);
+
+ const handleNativePrint = useCallback(() => {
+ handleIframePrint();
+ }, [handleIframePrint]);
+
+ if (!resolvedData) {
+ return (
+
+ Generating Preview…
+
+ );
+ }
+
+ const headerStyle = header?.styles || {};
+ const footerStyle = footer?.styles || {};
+ const certifiedMailNumber = resolvedData.certifiedMailNumber || '';
+
+ const renderSafeFooterContent = (htmlContent) => {
+ if (!htmlContent) return null;
+ return
;
+ };
+
+ return (
+
+
+
+ {/* Print/Save menu */}
+ {mode === 'preview' && (
+
+ )}
+
+ {/* Page simulation */}
+
+ {/* Header */}
+ {header?.enabled && (
+
+
+ {header.title && (
+
+ )}
+ {header.description && (
+
+ )}
+
+ {header.logoUrl && (
+
+
+
+ )}
+
+ )}
+
+ {/* Certified mail number */}
+ {certifiedMailNumber && (
+
+
Sent via U.S. Certified Mail No.:
+
{certifiedMailNumber}
+
+ )}
+
+ {/* Body content */}
+
+ {blocks.length === 0 ? (
+
+ Start adding sections to see preview…
+
+ ) : (
+ blocks.map((block, i) => (
+
+ ))
+ )}
+
+
+ {/* Footer */}
+ {footer?.enabled && (
+
+
+ {renderSafeFooterContent(footer.content)}
+
+
+ {footer.showDate ? {resolvedData.currentDate} : }
+ {resolvedData?.clientName || ''}
+ Confidential Information
+ {footer.showPageNumbers ? Page 1 : }
+
+
+ )}
+
+
+
+
+
+
+ );
+}
+
+export default CustomFormPreview;
diff --git a/src/components/forms/CustomFormPrintPreview.jsx b/src/components/forms/CustomFormPrintPreview.jsx
new file mode 100644
index 0000000..65ea8b1
--- /dev/null
+++ b/src/components/forms/CustomFormPrintPreview.jsx
@@ -0,0 +1,110 @@
+import React, { useState, useEffect, useRef } from 'react';
+import { Loader2, X, Printer } from 'lucide-react';
+import { CustomFormPdfExport } from './CustomFormPdfExport';
+
+// Tiny inline toast — no shadcn dependency
+function useToast() {
+ const [message, setMessage] = useState(null);
+ const show = ({ variant, title, description }) => {
+ setMessage({ variant, title, description });
+ setTimeout(() => setMessage(null), 4000);
+ };
+ const ToastEl = message ? (
+
+
{message.title}
+ {message.description &&
{message.description}
}
+
+ ) : null;
+ return { toast: show, ToastEl };
+}
+
+export function CustomFormPrintPreview({
+ formConfig, sampleData, onClose, formName, autoPrint = false, onPrinted,
+}) {
+ const { toast, ToastEl } = useToast();
+ const [pdfUrl, setPdfUrl] = useState(null);
+ const [isLoading, setIsLoading] = useState(true);
+ const exportRef = useRef(null);
+
+ useEffect(() => {
+ let mounted = true;
+
+ const generate = async () => {
+ if (!exportRef.current) return;
+ try {
+ const blob = await exportRef.current.generatePdf(formName, true, true);
+ if (mounted && blob) {
+ setPdfUrl(URL.createObjectURL(blob));
+ setIsLoading(false);
+ }
+ } catch (e) {
+ console.error('Preview Generation Error', e);
+ if (mounted) {
+ setIsLoading(false);
+ toast({ variant: 'destructive', title: 'Preview Failed', description: 'Could not generate PDF preview.' });
+ }
+ }
+ };
+
+ const timer = setTimeout(generate, 500);
+ return () => {
+ mounted = false;
+ clearTimeout(timer);
+ };
+ }, [formName, formConfig, sampleData]);
+
+ return (
+
+ {ToastEl}
+
+ {/* Header bar */}
+
+
+
+
+
Print Preview
+
PDF Rendering
+
+
+
+ Close
+
+
+
+ {/* Content area */}
+
+ {isLoading ? (
+
+
+
Generating PDF Preview…
+
+ ) : pdfUrl ? (
+
+ ) : (
+
Failed to load preview.
+ )}
+
+
+ {/* Hidden export engine */}
+
+
+
+
+ );
+}
+
+export default CustomFormPrintPreview;
diff --git a/src/components/forms/CustomFormStylePanel.jsx b/src/components/forms/CustomFormStylePanel.jsx
new file mode 100644
index 0000000..5813dce
--- /dev/null
+++ b/src/components/forms/CustomFormStylePanel.jsx
@@ -0,0 +1,27 @@
+import React from 'react';
+import { Info } from 'lucide-react';
+
+export function CustomFormStylePanel() {
+ return (
+
+ {/* Info alert — plain Tailwind, no shadcn Alert */}
+
+
+
+
Block-Level Styling Only
+
+ Global styling has been disabled to provide more granular control.
+ Please select individual blocks (Title, Body, Footer, etc.) to customize
+ their appearance independently.
+
+
+
+
+
+ Select a block in the editor to view its specific styling options.
+
+
+ );
+}
+
+export default CustomFormStylePanel;
diff --git a/src/components/forms/CustomFormTemplateDialog.jsx b/src/components/forms/CustomFormTemplateDialog.jsx
new file mode 100644
index 0000000..a2d0439
--- /dev/null
+++ b/src/components/forms/CustomFormTemplateDialog.jsx
@@ -0,0 +1,6 @@
+import React from 'react';
+
+const CustomFormTemplateDialog = () => null;
+
+export { CustomFormTemplateDialog };
+export default CustomFormTemplateDialog;
diff --git a/src/components/forms/CustomLedgerForm.jsx b/src/components/forms/CustomLedgerForm.jsx
new file mode 100644
index 0000000..b8ec66f
--- /dev/null
+++ b/src/components/forms/CustomLedgerForm.jsx
@@ -0,0 +1,1088 @@
+
+import React, { useState, useEffect, useMemo, useRef } from 'react';
+import { supabase } from '@/integrations/supabase/client';
+import { combineOwnerNames } from '@/lib/ownerAddressUtils';
+import {
+ Plus, Trash2, Save, Upload, Calculator,
+ Loader2, GripVertical, FileUp, RotateCcw,
+ FileDown, History, Database
+} from 'lucide-react';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
+import { useToast } from '@/components/ui/use-toast';
+import { cn } from '@/lib/utils';
+import { Combobox } from '@/components/Combobox';
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog";
+import { Badge } from "@/components/ui/badge";
+import { DragDropContext, Droppable as OriginalDroppable, Draggable } from '@hello-pangea/dnd';
+
+// Fix for React 18 strict mode — Droppable doesn't render on first mount
+function Droppable({ children, ...props }) {
+ const [enabled, setEnabled] = React.useState(false);
+ React.useEffect(() => {
+ const animation = requestAnimationFrame(() => setEnabled(true));
+ return () => {
+ cancelAnimationFrame(animation);
+ setEnabled(false);
+ };
+ }, []);
+ if (!enabled) return {/* placeholder while droppable mounts */} ;
+ return {children} ;
+}
+import { HtmlPreviewModal } from './HtmlPreviewModal';
+import { CreditLineDialog } from './CreditLineDialog';
+import { BankFeeDialog } from '@/components/BankFeeDialog';
+import { calculateLedger } from '@/lib/ledgerCalculationUtils';
+import ChartOfAccountsDropdown from '@/components/ChartOfAccountsDropdown';
+import { jsPDF } from 'jspdf';
+import autoTable from 'jspdf-autotable';
+import { Alert, AlertDescription } from '@/components/ui/alert';
+
+const DEFAULT_ROW = {
+ id: null,
+ entry_type: 'debit',
+ date: new Date().toISOString().split('T')[0],
+ description: '',
+ chart_of_account_id: '',
+ bankFee: 0,
+ assessment: 0,
+ lateFee: 0,
+ adminFee: 0,
+ legalFee: 0,
+ violation: 0,
+ interest: 0,
+ payment: 0,
+ isAutoInterest: false,
+};
+
+export default function CustomLedgerForm() {
+ const { toast } = useToast();
+ const fileInputRef = useRef(null);
+
+ // Metadata State
+ const [ledgerName, setLedgerName] = useState('');
+ const [selectedClient, setSelectedClient] = useState('');
+ const [selectedProperty, setSelectedProperty] = useState('');
+ const [clients, setClients] = useState([]);
+ const [properties, setProperties] = useState([]);
+ const [savedLedgers, setSavedLedgers] = useState([]);
+ const [isLoading, setIsLoading] = useState(false);
+ const [loadDialogOpen, setLoadDialogOpen] = useState(false);
+ const [currentLedgerId, setCurrentLedgerId] = useState(null);
+
+ // Dialog State
+ const [creditDialogOpen, setCreditDialogOpen] = useState(false);
+ const [bankFeeDialogOpen, setBankFeeDialogOpen] = useState(false);
+
+ // Preview State
+ const [previewOpen, setPreviewOpen] = useState(false);
+ const [previewHtml, setPreviewHtml] = useState('');
+
+ // Ledger State
+ const [rows, setRows] = useState([{ ...DEFAULT_ROW, id: crypto.randomUUID() }]);
+ const [interestRate, setInterestRate] = useState("0.015"); // 1.5% default (18% annual)
+
+ // Fetch initial data - associations as clients
+ useEffect(() => {
+ const fetchData = async () => {
+ const { data: assocData } = await supabase.from('associations').select('id, name').order('name');
+ setClients(assocData || []);
+ fetchSavedLedgers();
+ };
+ fetchData();
+ }, []);
+
+ // Fetch owners when association changes
+ useEffect(() => {
+ if (selectedClient) {
+ const fetchProps = async () => {
+ const { data } = await supabase
+ .from('owners')
+ .select('id, first_name, last_name, property_address, mailing_address, alternate_address_1, alternate_address_2, unit_id, units(unit_number, account_number)')
+ .eq('association_id', selectedClient)
+ .eq('status', 'active')
+ .order('last_name');
+
+ // Group by unit to combine co-owner names
+ const ownersByUnit = {};
+ for (const owner of (data || [])) {
+ const key = owner.unit_id || owner.id;
+ if (!ownersByUnit[key]) ownersByUnit[key] = [];
+ ownersByUnit[key].push(owner);
+ }
+
+ setProperties(Object.values(ownersByUnit).map(group => {
+ const primary = group[0];
+ return {
+ id: primary.id,
+ owner_name: combineOwnerNames(group),
+ property_address: primary.property_address || primary.units?.unit_number || '',
+ account_number: primary.units?.account_number || '',
+ mailing_address: primary.mailing_address || '',
+ alternate_address_1: group.flatMap(o => [o.alternate_address_1]).filter(Boolean)[0] || '',
+ alternate_address_2: group.flatMap(o => [o.alternate_address_2]).filter(Boolean)[0] || '',
+ };
+ }));
+ };
+ fetchProps();
+ } else {
+ setProperties([]);
+ }
+ }, [selectedClient]);
+
+ const fetchSavedLedgers = async () => {
+ const { data } = await supabase.from('custom_ledgers').select('id, name, created_at').order('created_at', { ascending: false });
+ setSavedLedgers(data || []);
+ };
+
+ // --- Core Calculation Logic ---
+ const calculatedData = useMemo(() => {
+ const numericRate = parseFloat(interestRate);
+ return calculateLedger(rows, isNaN(numericRate) ? 0 : numericRate);
+ }, [rows, interestRate]);
+
+ // Handle Drag & Drop
+ const handleOnDragEnd = (result) => {
+ if (!result.destination) return;
+ const items = Array.from(rows);
+ const [reorderedItem] = items.splice(result.source.index, 1);
+ items.splice(result.destination.index, 0, reorderedItem);
+ setRows(items);
+ };
+
+ const handleImportAutomations = () => {
+ const simulatedEntries = [
+ { ...DEFAULT_ROW, id: crypto.randomUUID(), entry_type: 'late_fee', description: '[AUTO] Late Fee applied', lateFee: 25.00 },
+ { ...DEFAULT_ROW, id: crypto.randomUUID(), entry_type: 'interest', description: '[AUTO] Interest applied', interest: 12.50 },
+ ];
+ setRows([...rows, ...simulatedEntries]);
+ toast({ title: 'Audit Trail Imported', description: 'Fetched automated ledger entries for this property.' });
+ };
+
+ // Handle CSV Upload
+ const handleCsvUpload = (e) => {
+ const file = e.target.files[0];
+ if (!file) return;
+
+ const reader = new FileReader();
+ reader.onload = (event) => {
+ try {
+ const text = event.target.result;
+ const lines = text.split(/\r?\n/);
+
+ const newRows = [];
+ const parseCSVLine = (str) => {
+ const result = [];
+ let current = '';
+ let inQuote = false;
+ for (let i = 0; i < str.length; i++) {
+ const char = str[i];
+ if (char === '"') {
+ if (inQuote && str[i + 1] === '"') { current += '"'; i++; } else { inQuote = !inQuote; }
+ } else if (char === ',' && !inQuote) { result.push(current); current = ''; } else { current += char; }
+ }
+ result.push(current);
+ return result;
+ };
+
+ lines.forEach((line, index) => {
+ if (!line.trim()) return;
+ if (line.includes('TOTALS') || line.startsWith(',,TOTALS')) return;
+ const cols = parseCSVLine(line).map(c => c.trim());
+ if (index === 0 && cols.join(',').toLowerCase().includes('date') && cols.join(',').toLowerCase().includes('description')) return;
+
+ if (cols.length >= 10) {
+ const row = { ...DEFAULT_ROW, id: crypto.randomUUID() };
+ const dateVal = new Date(cols[0]);
+ if (!isNaN(dateVal.getTime())) row.date = dateVal.toISOString().split('T')[0];
+ row.description = cols[1] ? cols[1].replace(/^"|"$/g, '') : '';
+
+ const parseVal = (v) => { const num = parseFloat(v); return isNaN(num) ? 0 : num; };
+
+ row.assessment = parseVal(cols[2]);
+ row.lateFee = parseVal(cols[3]);
+ row.adminFee = parseVal(cols[4]);
+ row.legalFee = parseVal(cols[5]);
+ row.violation = parseVal(cols[6]);
+ row.interest = parseVal(cols[7]);
+ row.bankFee = parseVal(cols[8]);
+ row.payment = Math.abs(parseVal(cols[9])); // Ensure payments are positive
+
+ if (row.bankFee > 0) row.entry_type = 'bank_fee';
+ if (row.description.toLowerCase().includes('credit') || row.payment > 0) row.entry_type = 'credit';
+
+ newRows.push(row);
+ }
+ });
+
+ if (newRows.length > 0) {
+ setRows(prev => [...prev, ...newRows]);
+ toast({ title: 'Success', description: `Imported ${newRows.length} rows.` });
+ } else {
+ toast({ variant: "destructive", title: 'Import Failed', description: 'Could not parse CSV data.' });
+ }
+ } catch (err) {
+ toast({ variant: "destructive", title: 'Error', description: 'Failed to parse CSV file.' });
+ }
+ if (fileInputRef.current) fileInputRef.current.value = '';
+ };
+ reader.readAsText(file);
+ };
+
+ const formatCurrency = (val) => new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(val || 0);
+ const formatBalance = (val) => {
+ const amount = Number(val || 0);
+ return amount < 0 ? `(${formatCurrency(Math.abs(amount))})` : formatCurrency(amount);
+ };
+
+ const handleDownloadPDF = () => {
+ try {
+ const doc = new jsPDF('landscape', 'pt', 'letter');
+ const pageWidth = doc.internal.pageSize.getWidth();
+ const margin = 40;
+
+ // Data Gathering
+ const clientName = clients.find(c => c.id === selectedClient)?.name || 'N/A';
+ let prop = properties.find(p => p.id === selectedProperty) || {};
+
+ const ownerName = prop.owner_name || 'N/A';
+ const propertyAddress = prop.property_address || 'N/A';
+ const accountNumber = prop.account_number || 'N/A';
+ const mailingAddress = prop.mailing_address || '';
+ const statementDate = new Date().toLocaleDateString('en-US');
+
+ // --- 1. Header Section ---
+ doc.setFont('helvetica', 'bold');
+ doc.setFontSize(22);
+ doc.setTextColor(0, 0, 0);
+ doc.text("ACCOUNT STATEMENT", pageWidth - margin, margin + 15, { align: 'right' });
+
+ doc.setFontSize(10);
+ doc.setFont('helvetica', 'normal');
+ doc.text(`Statement Date: ${statementDate}`, pageWidth - margin, margin + 30, { align: 'right' });
+ doc.text(`Association: ${clientName}`, pageWidth - margin, margin + 45, { align: 'right' });
+
+ // Owner & Property Info Block
+ let currentY = margin + 15;
+
+ doc.setFont('helvetica', 'bold');
+ doc.setFontSize(10);
+ doc.text("ACCOUNT HOLDER:", margin, currentY);
+ currentY += 14;
+
+ doc.setFont('helvetica', 'normal');
+ doc.text(ownerName, margin, currentY);
+ currentY += 14;
+
+ if (mailingAddress) {
+ mailingAddress.split('\n').filter(Boolean).forEach(line => {
+ doc.text(line, margin, currentY);
+ currentY += 14;
+ });
+ }
+
+ currentY += 10;
+ doc.setFont('helvetica', 'bold');
+ doc.text("PROPERTY ADDRESS:", margin, currentY);
+ currentY += 14;
+ doc.setFont('helvetica', 'normal');
+ doc.text(propertyAddress, margin, currentY);
+
+ // --- 2. Summary Tables Section ---
+ const summaryYStart = Math.max(currentY + 30, margin + 70);
+
+ // Left: Amounts Due Breakdown
+ autoTable(doc, {
+ startY: summaryYStart,
+ margin: { left: margin },
+ tableWidth: 280,
+ head: [['Amounts Due Breakdown', 'Amount']],
+ body: [
+ ['Assessments', formatCurrency(calculatedData.buckets.assessment)],
+ ['Late Fees', formatCurrency(calculatedData.buckets.late)],
+ ['Admin Fees', formatCurrency(calculatedData.buckets.admin)],
+ ['Legal Fees', formatCurrency(calculatedData.buckets.legal)],
+ ['Violations', formatCurrency(calculatedData.buckets.violation)],
+ [`Interest @ ${(parseFloat(interestRate) * 100).toFixed(2)}%`, formatCurrency(calculatedData.buckets.interest)],
+ ['Bank Fees', formatCurrency(calculatedData.buckets.bankFee)],
+ ],
+ theme: 'grid',
+ headStyles: { fillColor: [0, 0, 0], textColor: 255, halign: 'left', fontStyle: 'bold' },
+ columnStyles: {
+ 0: { halign: 'left', cellPadding: 4 },
+ 1: { halign: 'right', cellPadding: 4 }
+ },
+ styles: { fontSize: 9, lineColor: [200, 200, 200] },
+ });
+ const breakdownBottomY = doc.lastAutoTable.finalY;
+
+ // Right: Account Summary
+ autoTable(doc, {
+ startY: summaryYStart,
+ margin: { left: pageWidth - margin - 280 },
+ tableWidth: 280,
+ head: [['Account Number', 'Due Date', 'Total Due']],
+ body: [[
+ accountNumber,
+ 'Upon Receipt',
+ formatBalance(calculatedData.totals.totalDue)
+ ]],
+ theme: 'grid',
+ headStyles: { fillColor: [0, 0, 0], textColor: 255, halign: 'center', fontStyle: 'bold' },
+ bodyStyles: { halign: 'center', fontStyle: 'bold', fontSize: 11, cellPadding: 6 },
+ styles: { lineColor: [200, 200, 200] }
+ });
+ const accountInfoBottomY = doc.lastAutoTable.finalY;
+
+ // --- 3. Main Ledger Table ---
+ const tableColumn = ["Date", "Description", "Assess", "Late", "Admin", "Legal", "Viol", "Int", "Bank", "Pay (AR)", "Balance"];
+ const tableRows = [];
+
+ const getCellData = (row, field) => {
+ const allocationVal = row.allocations?.[field];
+ const showAllocation = field !== 'payment' && allocationVal > 0 && (!row[field] || parseFloat(row[field]) === 0);
+ if (showAllocation) {
+ return { content: formatCurrency(-Math.abs(allocationVal)), styles: { textColor: [100, 116, 139], fontStyle: 'italic' } };
+ }
+ return formatCurrency(row[field] || 0);
+ };
+
+ calculatedData.rows.forEach(row => {
+ const rowData = [
+ row.date,
+ row.description || '-',
+ getCellData(row, 'assessment'),
+ getCellData(row, 'lateFee'),
+ getCellData(row, 'adminFee'),
+ getCellData(row, 'legalFee'),
+ getCellData(row, 'violation'),
+ getCellData(row, 'interest'),
+ getCellData(row, 'bankFee'),
+ parseFloat(row.payment) > 0
+ ? { content: formatCurrency(row.payment), styles: { textColor: [5, 150, 105], fontStyle: 'bold' } }
+ : formatCurrency(row.payment || 0),
+ formatBalance(row.balance)
+ ];
+ tableRows.push(rowData);
+ });
+
+ // Totals Row for Main Table
+ const totalsRow = [
+ "TOTALS",
+ "",
+ formatCurrency(calculatedData.totals.assessment),
+ formatCurrency(calculatedData.totals.lateFee),
+ formatCurrency(calculatedData.totals.adminFee),
+ formatCurrency(calculatedData.totals.legalFee),
+ formatCurrency(calculatedData.totals.violation),
+ formatCurrency(calculatedData.totals.interest),
+ formatCurrency(calculatedData.totals.bankFee),
+ formatCurrency(calculatedData.totals.payment),
+ formatBalance(calculatedData.totals.totalDue)
+ ];
+ tableRows.push(totalsRow);
+
+ const tableStartY = Math.max(breakdownBottomY, accountInfoBottomY) + 30;
+
+ autoTable(doc, {
+ head: [tableColumn],
+ body: tableRows,
+ startY: tableStartY,
+ margin: { left: margin, right: margin },
+ theme: 'grid',
+ styles: { fontSize: 8, cellPadding: 4, lineColor: [220, 220, 220] },
+ headStyles: { fillColor: [0, 0, 0], textColor: 255, fontStyle: 'bold', halign: 'center' },
+ columnStyles: {
+ 0: { cellWidth: 50, halign: 'center' },
+ 1: { cellWidth: 'auto' },
+ 2: { halign: 'right', cellWidth: 55 },
+ 3: { halign: 'right', cellWidth: 50 },
+ 4: { halign: 'right', cellWidth: 50 },
+ 5: { halign: 'right', cellWidth: 50 },
+ 6: { halign: 'right', cellWidth: 50 },
+ 7: { halign: 'right', cellWidth: 50 },
+ 8: { halign: 'right', cellWidth: 50 },
+ 9: { halign: 'right', cellWidth: 55 },
+ 10: { halign: 'right', cellWidth: 65, fontStyle: 'bold', fillColor: [248, 248, 248] }
+ },
+ didParseCell: function(data) {
+ if (data.row.index === tableRows.length - 1 && data.section === 'body') {
+ data.cell.styles.fontStyle = 'bold';
+ data.cell.styles.fillColor = [240, 240, 240];
+ data.cell.styles.textColor = [0, 0, 0];
+ }
+ }
+ });
+
+ // --- 4. Footer ---
+ const pageCount = doc.internal.getNumberOfPages();
+ for (let i = 1; i <= pageCount; i++) {
+ doc.setPage(i);
+ doc.setFontSize(8);
+ doc.setTextColor(150, 150, 150);
+ doc.text(
+ `Generated by Avria Community Management • Page ${i} of ${pageCount} • Printed on ${new Date().toLocaleString()}`,
+ pageWidth / 2,
+ doc.internal.pageSize.getHeight() - 20,
+ { align: 'center' }
+ );
+ }
+
+ // Save
+ const fileNameDate = new Date().toISOString().split('T')[0];
+ const safePropName = (prop.owner_name || 'ledger').replace(/[^a-z0-9]/gi, '_').toLowerCase();
+ doc.save(`account-statement-${safePropName}-${fileNameDate}.pdf`);
+
+ toast({ title: "Success", description: "Account statement PDF downloaded." });
+ } catch (error) {
+ console.error("PDF generation failed", error);
+ toast({ variant: "destructive", title: "Error", description: "Failed to generate professional PDF." });
+ }
+ };
+
+ const updateRow = (id, field, value) => {
+ setRows(prev => prev.map(r => {
+ if (r.id === id) {
+ const updated = { ...r, [field]: value };
+ if (field === 'interest') updated.isAutoInterest = false;
+
+ if (field === 'payment' && parseFloat(value) > 0) {
+ updated.entry_type = 'credit';
+ }
+
+ return updated;
+ }
+ return r;
+ }));
+ };
+
+ const addRow = () => setRows([...rows, { ...DEFAULT_ROW, id: crypto.randomUUID() }]);
+
+ const addInterestRow = () => {
+ setRows([...rows, {
+ ...DEFAULT_ROW,
+ id: crypto.randomUUID(),
+ description: `Interest @ ${(parseFloat(interestRate) * 100).toFixed(2)}%`,
+ isAutoInterest: true
+ }]);
+ };
+
+ const handleAddCredit = ({ entryType, amount, category, description }) => {
+ const absAmount = Math.abs(amount);
+ const newRow = {
+ ...DEFAULT_ROW, id: crypto.randomUUID(), entry_type: 'credit', description: description || 'Credit Adjustment / AR Payment', credit_amount: absAmount, payment: absAmount
+ };
+ if (category && category !== 'payment') newRow[category] = -absAmount;
+ setRows([...rows, newRow]);
+ };
+
+ const handleAddBankFee = ({ amount, description }) => {
+ const newRow = { ...DEFAULT_ROW, id: crypto.randomUUID(), entry_type: 'bank_fee', description: description || 'Bank Fee', bankFee: amount, payment: 0 };
+ setRows([...rows, newRow]);
+ };
+
+ const removeRow = (id) => {
+ if (rows.length > 1) setRows(rows.filter(r => r.id !== id));
+ };
+
+ const handleReset = () => {
+ if (confirm("Are you sure? This will clear the current form.")) {
+ setCurrentLedgerId(null);
+ setLedgerName('');
+ setSelectedClient('');
+ setSelectedProperty('');
+ setRows([{ ...DEFAULT_ROW, id: crypto.randomUUID() }]);
+ setInterestRate("0.015");
+ toast({ title: "Reset", description: "Form cleared for new ledger." });
+ }
+ };
+
+ const handleSave = async () => {
+ if (!ledgerName) return toast({ variant: 'destructive', title: 'Error', description: 'Please enter a ledger name.' });
+ if (!selectedClient) return toast({ variant: 'destructive', title: 'Error', description: 'Please select a client.' });
+
+ const hasNegativePayments = rows.some(r => parseFloat(r.payment) < 0);
+ if (hasNegativePayments) {
+ return toast({ variant: 'destructive', title: 'Validation Error', description: 'Payment amounts must be positive numbers.' });
+ }
+
+ setIsLoading(true);
+ try {
+ const { data: { user } } = await supabase.auth.getUser();
+
+ const cleanedRows = rows.map(r => ({
+ ...r,
+ entry_type: parseFloat(r.payment) > 0 ? 'credit' : r.entry_type
+ }));
+
+ const payload = {
+ name: ledgerName,
+ association_id: selectedClient,
+ owner_id: selectedProperty || null,
+ rows: cleanedRows,
+ settings: { interestRate },
+ updated_at: new Date().toISOString()
+ };
+
+ if (currentLedgerId) {
+ const { error } = await supabase.from('custom_ledgers').update(payload).eq('id', currentLedgerId);
+ if (error) throw error;
+ toast({ title: 'Success', description: 'Ledger updated successfully.' });
+ } else {
+ const insertPayload = { ...payload, created_by: user?.id };
+ const { data, error } = await supabase.from('custom_ledgers').insert([insertPayload]).select('id').single();
+ if (error) throw error;
+ if (data) setCurrentLedgerId(data.id);
+ toast({ title: 'Success', description: 'Ledger created successfully.' });
+ }
+
+ // Auto-sync to unit ledger
+ try {
+ await syncToUnitLedger();
+ } catch (syncErr) {
+ console.warn('Unit ledger sync warning:', syncErr);
+ }
+
+ fetchSavedLedgers();
+ } catch (err) {
+ toast({ variant: 'destructive', title: 'Error', description: 'Failed to save ledger.' });
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ const handleLoad = async (ledger) => {
+ setIsLoading(true);
+ try {
+ const { data, error } = await supabase.from('custom_ledgers').select('*').eq('id', ledger.id).single();
+ if (error) throw error;
+
+ setCurrentLedgerId(data.id);
+ setLedgerName(data.name);
+ setSelectedClient(data.association_id || '');
+ setSelectedProperty(data.owner_id || '');
+
+ const loadedRows = (data.rows || []).map(r => {
+ if (r.entry_type === 'bank_fee' && !r.bankFee && r.payment < 0) {
+ return { ...r, bankFee: Math.abs(r.payment), payment: 0 };
+ }
+ return {
+ ...r,
+ chart_of_account_id: r.chart_of_account_id || ''
+ };
+ });
+
+ setRows(loadedRows);
+ if (data.settings?.interestRate) setInterestRate(data.settings.interestRate);
+
+ setLoadDialogOpen(false);
+ toast({ title: 'Loaded', description: `Ledger "${data.name}" loaded.` });
+ } catch (err) {
+ toast({ variant: 'destructive', title: 'Error', description: 'Failed to load ledger.' });
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ // Load from Unit Ledger (owner_ledger_entries)
+ // If sinceLastZero is true, only load entries AFTER the most recent $0.00 running balance.
+ const handleLoadFromUnitLedger = async (sinceLastZero = false) => {
+ if (!selectedClient) return toast({ variant: 'destructive', title: 'Error', description: 'Please select a client first.' });
+ if (!selectedProperty) return toast({ variant: 'destructive', title: 'Error', description: 'Please select a property/owner first.' });
+
+ setIsLoading(true);
+ try {
+ // Find the owner's unit_id
+ const { data: ownerData } = await supabase
+ .from('owners')
+ .select('id, unit_id')
+ .eq('id', selectedProperty)
+ .single();
+
+ if (!ownerData?.unit_id) {
+ toast({ variant: 'destructive', title: 'No Unit', description: 'This owner has no unit linked. Cannot load ledger.' });
+ setIsLoading(false);
+ return;
+ }
+
+ // Fetch all ledger entries for this unit (paginated)
+ const allEntries = [];
+ let from = 0;
+ const pageSize = 1000;
+ while (true) {
+ const { data: page, error } = await supabase
+ .from('owner_ledger_entries')
+ .select('*')
+ .eq('unit_id', ownerData.unit_id)
+ .order('date', { ascending: true })
+ .range(from, from + pageSize - 1);
+ if (error) throw error;
+ allEntries.push(...(page || []));
+ if (!page || page.length < pageSize) break;
+ from += pageSize;
+ }
+
+ if (allEntries.length === 0) {
+ toast({ title: 'No Entries', description: 'No ledger entries found for this unit.' });
+ setIsLoading(false);
+ return;
+ }
+
+ let workingEntries = allEntries;
+ if (sinceLastZero) {
+ // Walk entries to find the most recent point where running balance hit $0.00
+ let bal = 0;
+ let lastZeroIdx = -1;
+ allEntries.forEach((e, i) => {
+ bal += Number(e.debit || 0) - Number(e.credit || 0);
+ if (Math.abs(bal) < 0.01) lastZeroIdx = i;
+ });
+ if (lastZeroIdx >= 0) {
+ workingEntries = allEntries.slice(lastZeroIdx + 1);
+ }
+ if (workingEntries.length === 0) {
+ toast({ title: 'Account at $0', description: 'No entries since the last zero balance — account is currently at $0.' });
+ setIsLoading(false);
+ return;
+ }
+ }
+
+ // Map ledger entries to custom ledger row format
+ const mapTypeToColumn = (entry) => {
+ const row = { ...DEFAULT_ROW, id: crypto.randomUUID() };
+ row.date = entry.date || new Date().toISOString().split('T')[0];
+ row.description = entry.description || '';
+ row._ledgerEntryId = entry.id; // Track source for sync
+
+ const type = (entry.transaction_type || '').toLowerCase();
+ const debit = Number(entry.debit || 0);
+ const credit = Number(entry.credit || 0);
+
+ if (type === 'payment' || credit > 0) {
+ row.entry_type = 'credit';
+ row.payment = credit || Math.abs(debit);
+ } else if (type === 'late_fee') {
+ row.entry_type = 'late_fee';
+ row.lateFee = debit;
+ } else if (type === 'interest') {
+ row.entry_type = 'interest';
+ row.interest = debit;
+ } else if (type === 'legal_fee') {
+ row.entry_type = 'debit';
+ row.legalFee = debit;
+ } else if (type === 'admin_fee') {
+ row.entry_type = 'debit';
+ row.adminFee = debit;
+ } else if (type === 'violation') {
+ row.entry_type = 'debit';
+ row.violation = debit;
+ } else if (type === 'bank_fee') {
+ row.entry_type = 'bank_fee';
+ row.bankFee = debit;
+ } else if (type === 'special_assessment') {
+ row.entry_type = 'debit';
+ row.assessment = debit;
+ } else {
+ // Default: assessment
+ row.entry_type = 'debit';
+ row.assessment = debit;
+ }
+
+ return row;
+ };
+
+ const mappedRows = workingEntries.map(mapTypeToColumn);
+ setRows(mappedRows);
+ if (!ledgerName) {
+ const ownerLabel = properties.find(p => p.id === selectedProperty)?.owner_name || 'Unknown';
+ setLedgerName(sinceLastZero ? `Ledger since last $0 — ${ownerLabel}` : `Unit Ledger — ${ownerLabel}`);
+ }
+ toast({
+ title: 'Loaded',
+ description: sinceLastZero
+ ? `Loaded ${mappedRows.length} entries since the last $0 balance.`
+ : `Loaded ${mappedRows.length} entries from unit ledger.`,
+ });
+ } catch (err) {
+ console.error('Load from unit ledger error:', err);
+ toast({ variant: 'destructive', title: 'Error', description: 'Failed to load unit ledger entries.' });
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ // Sync rows back to owner_ledger_entries on save
+ const syncToUnitLedger = async () => {
+ if (!selectedClient || !selectedProperty) return;
+
+ const { data: ownerData } = await supabase
+ .from('owners')
+ .select('id, unit_id')
+ .eq('id', selectedProperty)
+ .single();
+
+ if (!ownerData?.unit_id) return;
+
+ const { data: { user } } = await supabase.auth.getUser();
+
+ for (const row of rows) {
+ // Determine transaction_type and debit/credit from columns
+ let transaction_type = 'assessment';
+ let debit = 0;
+ let credit = 0;
+
+ const payment = parseFloat(row.payment) || 0;
+ if (payment > 0) {
+ transaction_type = 'payment';
+ credit = payment;
+ } else if (parseFloat(row.bankFee) > 0) {
+ transaction_type = 'bank_fee';
+ debit = parseFloat(row.bankFee);
+ } else if (parseFloat(row.interest) > 0) {
+ transaction_type = 'interest';
+ debit = parseFloat(row.interest);
+ } else if (parseFloat(row.lateFee) > 0) {
+ transaction_type = 'late_fee';
+ debit = parseFloat(row.lateFee);
+ } else if (parseFloat(row.legalFee) > 0) {
+ transaction_type = 'legal_fee';
+ debit = parseFloat(row.legalFee);
+ } else if (parseFloat(row.adminFee) > 0) {
+ transaction_type = 'admin_fee';
+ debit = parseFloat(row.adminFee);
+ } else if (parseFloat(row.violation) > 0) {
+ transaction_type = 'violation';
+ debit = parseFloat(row.violation);
+ } else if (parseFloat(row.assessment) > 0) {
+ transaction_type = 'assessment';
+ debit = parseFloat(row.assessment);
+ }
+
+ if (debit === 0 && credit === 0) continue; // skip empty rows
+
+ if (row._ledgerEntryId) {
+ // Update existing entry
+ await supabase.from('owner_ledger_entries').update({
+ date: row.date,
+ description: row.description,
+ transaction_type,
+ debit,
+ credit,
+ }).eq('id', row._ledgerEntryId);
+ } else {
+ // Insert new entry
+ const { data: inserted } = await supabase.from('owner_ledger_entries').insert({
+ association_id: selectedClient,
+ owner_id: selectedProperty,
+ unit_id: ownerData.unit_id,
+ date: row.date,
+ description: row.description,
+ transaction_type,
+ debit,
+ credit,
+ created_by: user?.id || null,
+ }).select('id').single();
+
+ if (inserted) row._ledgerEntryId = inserted.id;
+ }
+ }
+ };
+
+ const handleDeleteSaved = async (id, e) => {
+ e.stopPropagation();
+ if (!confirm('Are you sure you want to delete this template?')) return;
+ await supabase.from('custom_ledgers').delete().eq('id', id);
+ if (currentLedgerId === id) setCurrentLedgerId(null);
+ fetchSavedLedgers();
+ toast({ title: 'Deleted', description: 'Ledger template deleted.' });
+ };
+
+
+ return (
+
+
+
+ Accounting Rules Enforced: All Income Charges are treated as Debits (increases balance). Payments should be entered as positive numbers and are automatically treated as Credits against Accounts Receivable (AR).
+
+
+
+
+
+
+
+ Ledger Name
+ setLedgerName(e.target.value)} />
+
+
+ Client Association
+
+
+ {clients.map(c => {c.name} )}
+
+
+
+ Property / Owner
+ ({ value: p.id, label: `${p.owner_name} - ${p.property_address}` }))} value={selectedProperty} onChange={setSelectedProperty} placeholder="Select Property..." disabled={!selectedClient} />
+
+
+
+
+
+ Export Statement
+
+
+ Load
+
+ Load Saved Ledger
+
+ {savedLedgers.length === 0 ?
No saved ledgers found.
:
+ savedLedgers.map(l => (
+
handleLoad(l)}>
+
{l.name}
{new Date(l.created_at).toLocaleDateString()}
+
handleDeleteSaved(l.id, e)} className="text-destructive hover:text-destructive hover:bg-destructive/10">
+
+ ))
+ }
+
+
+
+
handleLoadFromUnitLedger(false)} disabled={isLoading || !selectedProperty} title="Load entries from the unit's actual ledger">
+ Load from Unit Ledger
+
+
handleLoadFromUnitLedger(true)} disabled={isLoading || !selectedProperty} title="Load only entries since the most recent $0 balance">
+ From Last $0 Balance
+
+
+
+
+
+ {isLoading ? : }
+ {currentLedgerId ? 'Update Ledger' : 'Create Ledger'}
+
+
+
+
+
+
+
+
Interest Rate:
+
+
+
+ 12% / Year (1% Monthly)
+ 18% / Year (1.5% Monthly)
+ Custom Override...
+
+
+ {!["0.01", "0.015"].includes(interestRate) || interestRate === "custom" ? null : null}
+ {(interestRate === "custom" || (interestRate !== "0.01" && interestRate !== "0.015")) && (
+
+ {
+ const pct = parseFloat(e.target.value);
+ if (isNaN(pct)) {
+ setInterestRate("custom");
+ } else {
+ setInterestRate((pct / 100).toString());
+ }
+ }}
+ />
+ % / month
+
+ )}
+
+
* Interest is calculated on the Assessment Balance.
+
+
+
+
+ Sync Automated History
+
+
+ fileInputRef.current?.click()}> Import CSV
+
+
+
+
+
+
+ {calculatedData.totals.totalDue < 0 ? 'Credit Balance' : 'Total Due'}
+ {formatBalance(calculatedData.totals.totalDue)}
Outstanding Balance
+
+
+
+ Amount Due Breakdown (In Priority Order)
+
+
+
1. Bank Fees
+
{formatCurrency(calculatedData.buckets.bankFee)}
+
+ 2. Interest
{formatCurrency(calculatedData.buckets.interest)}
+ 3. Late Fees
{formatCurrency(calculatedData.buckets.late)}
+ 4. Legal Fees
{formatCurrency(calculatedData.buckets.legal)}
+ 5. Admin Fees
{formatCurrency(calculatedData.buckets.admin)}
+ 6. Violations
{formatCurrency(calculatedData.buckets.violation)}
+ 7. Assessments
{formatCurrency(calculatedData.buckets.assessment)}
+
+
+
+
+
+
+
+
+
+
+ Date
+ Description
+ Account
+ Assess ($)
+ Late ($)
+ Admin ($)
+ Legal ($)
+ Viol ($)
+ Int ($)
+ Bank ($)
+ Pay (AR) ($)
+ Balance
+
+
+
+
+ {(provided) => (
+
+ {calculatedData.rows.map((row, index) => {
+ const isCredit = row.entry_type === 'credit';
+ const isBankFee = row.entry_type === 'bank_fee';
+ const isAuto = row.entry_type === 'late_fee' || row.entry_type === 'interest' || (row.description || '').includes('[AUTO]');
+ const fields = ['assessment', 'lateFee', 'adminFee', 'legalFee', 'violation', 'interest', 'bankFee', 'payment'];
+
+ return (
+
+ {(provided) => (
+
+
+
+
+
+ updateRow(row.id, 'date', e.target.value)} className="h-8 text-xs border-transparent focus:border-border bg-transparent" />
+
+
+
+ {isCredit && AR PAY }
+ {isBankFee && !isCredit && BANK FEE }
+ {isAuto && AUTO }
+ updateRow(row.id, 'description', e.target.value)} className={cn("h-8 text-xs border-transparent focus:border-border bg-transparent flex-1", isCredit && "text-emerald-900 dark:text-emerald-300", isBankFee && !isCredit && "text-red-900 dark:text-red-300")} />
+
+
+
+ updateRow(row.id, 'chart_of_account_id', v)}
+ className="h-8 py-0 px-2 text-xs border-transparent bg-transparent"
+ placeholder="Account"
+ associationId={selectedClient}
+ />
+
+ {fields.map(field => {
+ const allocationVal = row.allocations?.[field];
+ const showAllocation = field !== 'payment' && allocationVal > 0 && (!row[field] || parseFloat(row[field]) === 0);
+ const hasValue = row[field] && parseFloat(row[field]) !== 0;
+
+ return (
+
+ {showAllocation ? (
+
+ {formatCurrency(-Math.abs(allocationVal))}
+
+ ) : (
+
+ updateRow(row.id, field, e.target.value)}
+ className={cn(
+ "h-8 text-xs text-right border-transparent focus:border-border bg-transparent",
+ row.isAutoInterest && field === 'interest' && "text-muted-foreground italic bg-muted cursor-not-allowed",
+ !hasValue && "text-muted-foreground italic",
+ hasValue && field === 'payment' && parseFloat(row[field]) > 0 && "text-emerald-600 dark:text-emerald-400 font-bold",
+ hasValue && field !== 'payment' && parseFloat(row[field]) < 0 && "text-muted-foreground font-bold"
+ )}
+ placeholder="0.00"
+ />
+
+ )}
+
+ );
+ })}
+ {formatBalance(row.balance)}
+
+ removeRow(row.id)}>
+
+
+ )}
+
+ );})}
+ {provided.placeholder}
+
+ Totals:
+ {formatCurrency(calculatedData.totals.assessment)}
+ {formatCurrency(calculatedData.totals.lateFee)}
+ {formatCurrency(calculatedData.totals.adminFee)}
+ {formatCurrency(calculatedData.totals.legalFee)}
+ {formatCurrency(calculatedData.totals.violation)}
+ {formatCurrency(calculatedData.totals.interest)}
+ {formatCurrency(calculatedData.totals.bankFee)}
+ {formatCurrency(calculatedData.totals.payment)}
+ {formatBalance(calculatedData.totals.totalDue)}
+
+
+
+ )}
+
+
+
+
+
+
Add Charge (Debit)
+
setCreditDialogOpen(true)} className="text-emerald-600 hover:text-emerald-700 hover:bg-emerald-50 border-emerald-200 dark:text-emerald-400 dark:border-emerald-800 dark:hover:bg-emerald-950"> Add Payment (AR Credit)
+
setBankFeeDialogOpen(true)} className="text-red-600 hover:text-red-700 hover:bg-red-50 border-red-200 dark:text-red-400 dark:border-red-800 dark:hover:bg-red-950"> Add Bank Fee
+
Calculate Interest Row
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/forms/EnvelopePrintingForm.jsx b/src/components/forms/EnvelopePrintingForm.jsx
new file mode 100644
index 0000000..706cc5a
--- /dev/null
+++ b/src/components/forms/EnvelopePrintingForm.jsx
@@ -0,0 +1,704 @@
+import { useState, useEffect, useRef } from "react";
+import { Mail, Printer, Settings, Users, Plus, Save, Loader2, Eye, Stamp, Trash2, Image, Type } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
+import { Separator } from "@/components/ui/separator";
+import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
+import { Checkbox } from "@/components/ui/checkbox";
+import { ScrollArea } from "@/components/ui/scroll-area";
+import { Badge } from "@/components/ui/badge";
+import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
+import { Textarea } from "@/components/ui/textarea";
+import { useSavedFormTemplates } from "@/hooks/useSavedFormTemplates";
+import LoadTemplateDialog from "@/components/LoadTemplateDialog";
+import { supabase } from "@/integrations/supabase/client";
+import { useAssociation } from "@/contexts/AssociationContext";
+import { useToast } from "@/hooks/use-toast";
+
+const ENVELOPE_SIZES = {
+ "#10": { label: "#10 Standard (4⅛ × 9½ in)", width: "9.5in", height: "4.125in" },
+ "#9": { label: "#9 Reply (3⅞ × 8⅞ in)", width: "8.875in", height: "3.875in" },
+ "#6": { label: "#6¾ (3⅝ × 6½ in)", width: "6.5in", height: "3.625in" },
+ "a7": { label: "A7 Invitation (5¼ × 7¼ in)", width: "7.25in", height: "5.25in" },
+};
+
+const STAMP_POSITIONS = {
+ "top-right": { label: "Top Right", style: { top: "0.35in", right: "0.5in" } },
+ "top-center": { label: "Top Center", style: { top: "0.35in", left: "50%", transform: "translateX(-50%)" } },
+ "bottom-right": { label: "Bottom Right", style: { bottom: "0.35in", right: "0.5in" } },
+ "bottom-left": { label: "Bottom Left", style: { bottom: "0.35in", left: "0.5in" } },
+ "top-left-below": { label: "Below Return Address", style: { top: "1.6in", left: "0.5in" } },
+};
+
+const PRESET_STAMPS = [
+ { id: "certified", label: "CERTIFIED MAIL", text: "CERTIFIED MAIL", style: { border: "2px solid #c00", color: "#c00", fontWeight: "bold", padding: "4px 12px", fontSize: "11px", letterSpacing: "2px", textTransform: "uppercase" } },
+ { id: "priority", label: "PRIORITY", text: "PRIORITY", style: { background: "#1a56db", color: "#fff", fontWeight: "bold", padding: "4px 14px", fontSize: "11px", letterSpacing: "1px", borderRadius: "3px" } },
+ { id: "confidential", label: "CONFIDENTIAL", text: "CONFIDENTIAL", style: { border: "1.5px solid #333", color: "#333", fontWeight: "bold", padding: "3px 10px", fontSize: "10px", letterSpacing: "2px" } },
+ { id: "do-not-forward", label: "DO NOT FORWARD", text: "DO NOT FORWARD", style: { border: "2px solid #c00", color: "#c00", fontWeight: "bold", padding: "3px 10px", fontSize: "10px", letterSpacing: "1px" } },
+ { id: "first-class", label: "FIRST CLASS MAIL", text: "FIRST CLASS MAIL", style: { background: "#166534", color: "#fff", fontWeight: "bold", padding: "4px 12px", fontSize: "10px", letterSpacing: "1px", borderRadius: "2px" } },
+ { id: "return-service", label: "RETURN SERVICE REQUESTED", text: "RETURN SERVICE REQUESTED", style: { color: "#333", fontWeight: "bold", fontSize: "9px", letterSpacing: "1px", textTransform: "uppercase" } },
+ { id: "address-service", label: "ADDRESS SERVICE REQUESTED", text: "ADDRESS SERVICE REQUESTED", style: { color: "#333", fontWeight: "bold", fontSize: "9px", letterSpacing: "1px", textTransform: "uppercase" } },
+ { id: "official", label: "OFFICIAL BUSINESS", text: "OFFICIAL BUSINESS", style: { border: "1.5px solid #1e40af", color: "#1e40af", fontWeight: "bold", padding: "3px 10px", fontSize: "10px", letterSpacing: "1px" } },
+];
+
+export default function EnvelopePrintingForm() {
+ const { toast } = useToast();
+ const associationCtx = useAssociation() || {};
+ const selectedAssociation = associationCtx.selectedAssociation || "";
+ const [envelopeSize, setEnvelopeSize] = useState("#10");
+ const [returnAddress, setReturnAddress] = useState("");
+ const [printMode, setPrintMode] = useState("all-owners");
+ const [customRecipientName, setCustomRecipientName] = useState("");
+ const [customRecipientAddress, setCustomRecipientAddress] = useState("");
+ const [includeBarcode, setIncludeBarcode] = useState(false);
+ const [stamps, setStamps] = useState([]);
+
+ const [associations, setAssociations] = useState([]);
+ const [selectedAssoc, setSelectedAssoc] = useState(selectedAssociation || "");
+ const [owners, setOwners] = useState([]);
+ const [loadingOwners, setLoadingOwners] = useState(false);
+ const [selectedOwnerIds, setSelectedOwnerIds] = useState(new Set());
+ const [showPreview, setShowPreview] = useState(false);
+
+ const templates = useSavedFormTemplates("envelope");
+ const printRef = useRef(null);
+
+ // Load associations
+ useEffect(() => {
+ (async () => {
+ const { data } = await supabase.from("associations").select("id, name, address, city, state, zip, care_of_address, mailing_address").eq("status", "active").order("name");
+ setAssociations(data || []);
+ })();
+ }, []);
+
+ // Set return address from selected association
+ useEffect(() => {
+ if (!selectedAssoc || !associations.length) return;
+ const assoc = associations.find(a => a.id === selectedAssoc);
+ if (assoc) {
+ const mailingAddr = assoc.mailing_address || assoc.care_of_address;
+ const lines = [assoc.name];
+ if (mailingAddr) lines.push(mailingAddr);
+ else {
+ if (assoc.address) lines.push(assoc.address);
+ if (assoc.city || assoc.state || assoc.zip) {
+ lines.push([assoc.city, assoc.state].filter(Boolean).join(", ") + (assoc.zip ? ` ${assoc.zip}` : ""));
+ }
+ }
+ setReturnAddress(lines.join("\n"));
+ }
+ }, [selectedAssoc, associations]);
+
+ // Load owners when association changes
+ useEffect(() => {
+ if (!selectedAssoc) {
+ setOwners([]);
+ setSelectedOwnerIds(new Set());
+ setLoadingOwners(false);
+ return;
+ }
+
+ let cancelled = false;
+ const loadOwners = async () => {
+ setLoadingOwners(true);
+ try {
+ const { data, error } = await supabase
+ .from("owners")
+ .select("id, first_name, last_name, mailing_address, property_address, alternate_address_1, alternate_address_2, street_address, unit_id, units(unit_number, address, city, state, zip)")
+ .eq("association_id", selectedAssoc)
+ .eq("status", "active")
+ .order("last_name");
+
+ if (error) throw error;
+
+ if (!cancelled) {
+ setOwners(data || []);
+ setSelectedOwnerIds(new Set());
+ }
+ } catch (error) {
+ console.error("Error loading owners:", error);
+ if (!cancelled) {
+ setOwners([]);
+ setSelectedOwnerIds(new Set());
+ }
+ } finally {
+ if (!cancelled) setLoadingOwners(false);
+ }
+ };
+
+ loadOwners();
+ return () => {
+ cancelled = true;
+ };
+ }, [selectedAssoc]);
+
+ // Sync with global association context
+ useEffect(() => {
+ if (selectedAssociation) setSelectedAssoc(selectedAssociation);
+ }, [selectedAssociation]);
+
+
+ const addStamp = (type = "text") => {
+ setStamps(prev => [...prev, {
+ id: Date.now(), type, text: type === "text" ? "CERTIFIED MAIL" : "", presetId: "", imageUrl: "",
+ position: "top-right", customStyle: { fontSize: "11", color: "#cc0000", fontWeight: "bold", border: true, background: "" },
+ }]);
+ };
+ const addPresetStamp = (presetId) => {
+ const preset = PRESET_STAMPS.find(p => p.id === presetId);
+ if (!preset) return;
+ setStamps(prev => [...prev, { id: Date.now(), type: "preset", presetId, text: preset.text, imageUrl: "", position: "top-right", customStyle: {} }]);
+ };
+ const updateStamp = (id, field, value) => setStamps(prev => prev.map(s => s.id === id ? { ...s, [field]: value } : s));
+ const updateStampStyle = (id, field, value) => setStamps(prev => prev.map(s => s.id === id ? { ...s, customStyle: { ...s.customStyle, [field]: value } } : s));
+ const removeStamp = (id) => setStamps(prev => prev.filter(s => s.id !== id));
+
+ const getStampInlineStyle = (stamp) => {
+ if (stamp.type === "preset") return PRESET_STAMPS.find(p => p.id === stamp.presetId)?.style || {};
+ if (stamp.type === "image") return {};
+ const s = stamp.customStyle;
+ return {
+ fontSize: `${s.fontSize || 11}px`, color: s.color || "#000", fontWeight: s.fontWeight || "normal",
+ ...(s.border ? { border: `1.5px solid ${s.color || "#000"}`, padding: "3px 10px" } : {}),
+ ...(s.background ? { background: s.background, color: "#fff", padding: "4px 12px", borderRadius: "2px" } : {}),
+ letterSpacing: "1px", fontFamily: "Arial, sans-serif", lineHeight: "1.4", whiteSpace: "pre-line",
+ };
+ };
+ const renderStampHtml = (stamp) => {
+ if (stamp.type === "image" && stamp.imageUrl) return ` `;
+ const style = getStampInlineStyle(stamp);
+ const styleStr = Object.entries(style).map(([k, v]) => `${k.replace(/([A-Z])/g, '-$1').toLowerCase()}:${v}`).join(";");
+ return `${(stamp.text || "").replace(/\n/g, " ")} `;
+ };
+
+ const getFormData = () => ({ envelopeSize, returnAddress, printMode, includeBarcode, selectedAssoc, stamps });
+ const applyFormData = (d) => {
+ if (d.envelopeSize) setEnvelopeSize(d.envelopeSize);
+ if (d.returnAddress !== undefined) setReturnAddress(d.returnAddress);
+ if (d.printMode) setPrintMode(d.printMode);
+ if (d.includeBarcode !== undefined) setIncludeBarcode(d.includeBarcode);
+ if (d.selectedAssoc) setSelectedAssoc(d.selectedAssoc);
+ if (d.stamps) setStamps(d.stamps);
+ };
+ const handleSave = () => templates.saveTemplate(`Envelope - ${envelopeSize}`, getFormData());
+ const handleLoad = async (id) => { const r = await templates.loadTemplate(id); if (r?.form_data) applyFormData(r.form_data); };
+ const handleNew = () => { templates.newTemplate(); setEnvelopeSize("#10"); setReturnAddress(""); setPrintMode("all-owners"); setIncludeBarcode(false); };
+
+ const toggleOwner = (id) => {
+ setSelectedOwnerIds(prev => {
+ const next = new Set(prev);
+ next.has(id) ? next.delete(id) : next.add(id);
+ return next;
+ });
+ };
+
+ const selectAll = () => setSelectedOwnerIds(new Set(owners.map(o => o.id)));
+ const deselectAll = () => setSelectedOwnerIds(new Set());
+
+ /**
+ * Group owners by unit_id so co-owners on the same unit share one envelope.
+ * Returns array of { names: string, owner: firstOwner } where names is combined.
+ */
+ const getGroupedRecipients = () => {
+ if (printMode === "custom") {
+ if (!customRecipientName.trim() && !customRecipientAddress.trim()) return [];
+ return [{ combinedName: customRecipientName.trim() || "Recipient", owner: null, customAddress: customRecipientAddress.trim() }];
+ }
+ const pool = printMode === "selected" ? owners.filter(o => selectedOwnerIds.has(o.id)) : owners;
+ const groups = new Map();
+ let noUnitCounter = 0;
+
+ for (const owner of pool) {
+ const key = owner.unit_id || `__no_unit_${noUnitCounter++}`;
+ const group = groups.get(key) || [];
+ group.push(owner);
+ groups.set(key, group);
+ }
+
+ return Array.from(groups.values()).map(group => {
+ const names = [...new Set(group.map(o => `${o.first_name || ""} ${o.last_name || ""}`.trim()).filter(Boolean))];
+ let combinedName = "Unknown Owner";
+ if (names.length === 1) combinedName = names[0];
+ else if (names.length === 2) combinedName = `${names[0]} & ${names[1]}`;
+ else if (names.length > 2) combinedName = names.slice(0, -1).join(", ") + " & " + names[names.length - 1];
+
+ return { combinedName, owner: group[0] };
+ });
+ };
+
+ const formatAddressLines = (value) => {
+ const lines = String(value || "")
+ .replace(/\r\n/g, "\n")
+ .replace(/\r/g, "\n")
+ .split("\n")
+ .map(line => line.trim())
+ .filter(Boolean);
+
+ if (lines.length > 1) {
+ return {
+ line1: lines[0],
+ line2: lines.slice(1).join(", "),
+ };
+ }
+
+ const singleLine = lines[0] || "";
+ const commaParts = singleLine.split(",").map(part => part.trim()).filter(Boolean);
+
+ if (commaParts.length === 2) {
+ return { line1: commaParts[0], line2: commaParts[1] };
+ }
+
+ if (commaParts.length === 3) {
+ return { line1: commaParts[0], line2: commaParts.slice(1).join(", ") };
+ }
+
+ return { line1: singleLine, line2: "" };
+ };
+
+ const extractZip = (text) => {
+ const m = (text || "").match(/\b(\d{5}(?:-\d{4})?)\s*$/);
+ return m ? m[1] : "";
+ };
+
+ const getOwnerAddress = (owner) => {
+ const unit = Array.isArray(owner.units) ? owner.units[0] : owner.units;
+ const unitAddress = unit?.address || [
+ unit?.unit_number,
+ [unit?.city, unit?.state].filter(Boolean).join(", ") + (unit?.zip ? ` ${unit.zip}` : ""),
+ ].filter(Boolean).join("\n");
+ const alternateAddress = [owner.alternate_address_1, owner.alternate_address_2].filter(Boolean).join("\n");
+ const resolvedAddress = owner.mailing_address || alternateAddress || owner.property_address || owner.street_address || unitAddress || unit?.unit_number || "";
+ const { line1, line2 } = formatAddressLines(resolvedAddress);
+ const zip = unit?.zip || extractZip(line2) || extractZip(line1);
+
+ return {
+ name: `${owner.first_name || ""} ${owner.last_name || ""}`.trim(),
+ line1,
+ line2,
+ zip,
+ };
+ };
+
+ const handlePrint = () => {
+ const grouped = getGroupedRecipients();
+ if (!grouped.length) {
+ toast({ variant: "destructive", title: "No recipients", description: printMode === "custom" ? "Enter a recipient name and address." : "Select an association and owners to print envelopes." });
+ return;
+ }
+ setShowPreview(true);
+ setTimeout(() => {
+ if (printRef.current) {
+ const printWindow = window.open("", "_blank");
+ if (!printWindow) return;
+ const size = ENVELOPE_SIZES[envelopeSize];
+ const barcodeScript = includeBarcode ? `
+