Add ACMCC app source, Supabase backend, and project config
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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?
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<!-- TODO: Set the document title to the name of your application -->
|
||||||
|
<title>Community Cloud | Avria Community Management</title>
|
||||||
|
<meta name="description" content="A community cloud platform for managing and sharing resources.">
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Open+Sans:wght@400;600;700;800&display=swap" rel="stylesheet">
|
||||||
|
<meta name="author" content="Lovable" />
|
||||||
|
|
||||||
|
<!-- TODO: Update og:title to match your application name -->
|
||||||
|
|
||||||
|
|
||||||
|
<meta property="og:type" content="website" />
|
||||||
|
<meta property="og:image" content="https://pub-bb2e103a32db4e198524a2e9ed8f35b4.r2.dev/ffa7f4a3-1ea8-4fc2-8d67-4091d4bf86c3/id-preview-74469638--4ec621fe-9fba-4d11-bb8e-67ea3df47c50.lovable.app-1773709737460.png">
|
||||||
|
|
||||||
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
|
<meta name="twitter:site" content="@Lovable" />
|
||||||
|
<meta name="twitter:image" content="https://pub-bb2e103a32db4e198524a2e9ed8f35b4.r2.dev/ffa7f4a3-1ea8-4fc2-8d67-4091d4bf86c3/id-preview-74469638--4ec621fe-9fba-4d11-bb8e-67ea3df47c50.lovable.app-1773709737460.png">
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<meta property="og:title" content="Community Cloud | Avria Community Management">
|
||||||
|
<meta name="twitter:title" content="Community Cloud | Avria Community Management">
|
||||||
|
<meta property="og:description" content="A community cloud platform for managing and sharing resources.">
|
||||||
|
<meta name="twitter:description" content="A community cloud platform for managing and sharing resources.">
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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.
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
@@ -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',
|
||||||
|
// },
|
||||||
|
});
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
After Width: | Height: | Size: 16 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="1200" fill="none"><rect width="1200" height="1200" fill="#EAEAEA" rx="3"/><g opacity=".5"><g opacity=".5"><path fill="#FAFAFA" d="M600.709 736.5c-75.454 0-136.621-61.167-136.621-136.62 0-75.454 61.167-136.621 136.621-136.621 75.453 0 136.62 61.167 136.62 136.621 0 75.453-61.167 136.62-136.62 136.62Z"/><path stroke="#C9C9C9" stroke-width="2.418" d="M600.709 736.5c-75.454 0-136.621-61.167-136.621-136.62 0-75.454 61.167-136.621 136.621-136.621 75.453 0 136.62 61.167 136.62 136.621 0 75.453-61.167 136.62-136.62 136.62Z"/></g><path stroke="url(#a)" stroke-width="2.418" d="M0-1.209h553.581" transform="scale(1 -1) rotate(45 1163.11 91.165)"/><path stroke="url(#b)" stroke-width="2.418" d="M404.846 598.671h391.726"/><path stroke="url(#c)" stroke-width="2.418" d="M599.5 795.742V404.017"/><path stroke="url(#d)" stroke-width="2.418" d="m795.717 796.597-391.441-391.44"/><path fill="#fff" d="M600.709 656.704c-31.384 0-56.825-25.441-56.825-56.824 0-31.384 25.441-56.825 56.825-56.825 31.383 0 56.824 25.441 56.824 56.825 0 31.383-25.441 56.824-56.824 56.824Z"/><g clip-path="url(#e)"><path fill="#666" fill-rule="evenodd" d="M616.426 586.58h-31.434v16.176l3.553-3.554.531-.531h9.068l.074-.074 8.463-8.463h2.565l7.18 7.181V586.58Zm-15.715 14.654 3.698 3.699 1.283 1.282-2.565 2.565-1.282-1.283-5.2-5.199h-6.066l-5.514 5.514-.073.073v2.876a2.418 2.418 0 0 0 2.418 2.418h26.598a2.418 2.418 0 0 0 2.418-2.418v-8.317l-8.463-8.463-7.181 7.181-.071.072Zm-19.347 5.442v4.085a6.045 6.045 0 0 0 6.046 6.045h26.598a6.044 6.044 0 0 0 6.045-6.045v-7.108l1.356-1.355-1.282-1.283-.074-.073v-17.989h-38.689v23.43l-.146.146.146.147Z" clip-rule="evenodd"/></g><path stroke="#C9C9C9" stroke-width="2.418" d="M600.709 656.704c-31.384 0-56.825-25.441-56.825-56.824 0-31.384 25.441-56.825 56.825-56.825 31.383 0 56.824 25.441 56.824 56.825 0 31.383-25.441 56.824-56.824 56.824Z"/></g><defs><linearGradient id="a" x1="554.061" x2="-.48" y1=".083" y2=".087" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><linearGradient id="b" x1="796.912" x2="404.507" y1="599.963" y2="599.965" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><linearGradient id="c" x1="600.792" x2="600.794" y1="403.677" y2="796.082" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><linearGradient id="d" x1="404.85" x2="796.972" y1="403.903" y2="796.02" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><clipPath id="e"><path fill="#fff" d="M581.364 580.535h38.689v38.689h-38.689z"/></clipPath></defs></svg>
|
||||||
|
After Width: | Height: | Size: 3.2 KiB |
@@ -0,0 +1,14 @@
|
|||||||
|
User-agent: Googlebot
|
||||||
|
Allow: /
|
||||||
|
|
||||||
|
User-agent: Bingbot
|
||||||
|
Allow: /
|
||||||
|
|
||||||
|
User-agent: Twitterbot
|
||||||
|
Allow: /
|
||||||
|
|
||||||
|
User-agent: facebookexternalhit
|
||||||
|
Allow: /
|
||||||
|
|
||||||
|
User-agent: *
|
||||||
|
Allow: /
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 = () => (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<AuthProvider>
|
||||||
|
<TooltipProvider>
|
||||||
|
<Toaster />
|
||||||
|
<Sonner />
|
||||||
|
<BrowserRouter>
|
||||||
|
<ViewAsBanner />
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<Index />} />
|
||||||
|
<Route path="/auth" element={<Auth />} />
|
||||||
|
<Route path="/reset-password" element={<ResetPasswordPage />} />
|
||||||
|
<Route path="/violation/:id" element={<ViolationResponsePage />} />
|
||||||
|
<Route path="/shared/:token" element={<SharedAccessPage />} />
|
||||||
|
<Route path="/verify/:proofId" element={<VerifyDocumentPage />} />
|
||||||
|
<Route path="/public-form/:slug" element={<PublicFormSubmitPage />} />
|
||||||
|
<Route path="/privacy" element={<PrivacyPolicyPage />} />
|
||||||
|
<Route path="/terms" element={<TermsOfServicePage />} />
|
||||||
|
<Route path="/community/:slug" element={<CommunityPage />} />
|
||||||
|
<Route path="/community/:slug/amenities/:amenityId" element={<CommunityAmenityPage />} />
|
||||||
|
<Route path="/booking/:bookingId" element={<BookingConfirmationPage />} />
|
||||||
|
<Route path="/vote/:electionId" element={<ElectionVotePage />} />
|
||||||
|
<Route path="/board-vote/:voteId" element={<BoardVotePublicPage />} />
|
||||||
|
<Route path="/bill-approve/:billId" element={<BillApprovePublicPage />} />
|
||||||
|
<Route path="/sign/:token" element={<PublicSignPage />} />
|
||||||
|
<Route path="/vendor-insurance/:token" element={<VendorInsuranceSubmitPage />} />
|
||||||
|
<Route path="/vendor-profile/:token" element={<VendorProfileSubmitPage />} />
|
||||||
|
<Route path="/tenant-info/:token" element={<TenantInfoSubmitPage />} />
|
||||||
|
<Route path="/rv-boat-waitlist/:associationId" element={<PublicRVBoatWaitlistPage />} />
|
||||||
|
<Route path="/rv-portal" element={<RVRenterPortalPage />} />
|
||||||
|
<Route path="/register/:code" element={<CodeRegistrationPage />} />
|
||||||
|
<Route path="/unsubscribe" element={<UnsubscribePage />} />
|
||||||
|
|
||||||
|
{/* ─── Admin / Manager Portal ─── */}
|
||||||
|
<Route path="/dashboard" element={<DashboardLayout />}>
|
||||||
|
<Route index element={<Dashboard />} />
|
||||||
|
<Route path="projects" element={<ProjectsPage />} />
|
||||||
|
<Route path="projects/:id" element={<ProjectDetailPage />} />
|
||||||
|
<Route path="billable-expenses" element={<BillableExpensesPage />} />
|
||||||
|
<Route path="invoice-clients" element={<InvoiceClientsPage />} />
|
||||||
|
<Route path="calendar" element={<CalendarPage />} />
|
||||||
|
<Route path="call-log" element={<CallLogPage />} />
|
||||||
|
<Route path="documents" element={<DocumentsPage />} />
|
||||||
|
<Route path="forms-letters" element={<FormsLettersPage />} />
|
||||||
|
<Route path="owner-updates" element={<OwnerUpdatesPage />} />
|
||||||
|
<Route path="reminders" element={<RemindersPage />} />
|
||||||
|
<Route path="status-updates" element={<StatusUpdatesPage />} />
|
||||||
|
<Route path="tasks" element={<TasksPage />} />
|
||||||
|
<Route path="inspections" element={<InspectionsPage />} />
|
||||||
|
<Route path="ai-invoice-parser" element={<AIInvoiceParserPage />} />
|
||||||
|
<Route path="blocked-dates" element={<BlockedDatesPage />} />
|
||||||
|
<Route path="checklists" element={<ChecklistsPage />} />
|
||||||
|
<Route path="invoice-tracking" element={<InvoiceTrackingPage />} />
|
||||||
|
<Route path="client-invoices" element={<ClientInvoicesPage />} />
|
||||||
|
<Route path="payables" element={<PayablesPage />} />
|
||||||
|
<Route path="payments" element={<PaymentsPage />} />
|
||||||
|
<Route path="user-management" element={<UserManagementPage />} />
|
||||||
|
<Route path="signup-codes" element={<SignupCodesPage />} />
|
||||||
|
<Route path="rv-boat-lots" element={<RVBoatLotsPage />} />
|
||||||
|
<Route path="arc-applications" element={<ARCApplicationsPage />} />
|
||||||
|
<Route path="arc-inbound" element={<ARCInboundEmailsPage />} />
|
||||||
|
<Route path="associations" element={<AssociationsPage />} />
|
||||||
|
<Route path="associations/:id" element={<AssociationDetailPage />} />
|
||||||
|
<Route path="compliance-checklists" element={<ComplianceChecklistsHubPage />} />
|
||||||
|
<Route path="associations/:id/compliance" element={<ComplianceChecklistPage />} />
|
||||||
|
<Route path="bids-quotes" element={<BidsQuotesPage />} />
|
||||||
|
<Route path="bill-approvals" element={<BillApprovalsHubPage />} />
|
||||||
|
<Route path="bill-approvals-list" element={<BillApprovalsPage />} />
|
||||||
|
<Route path="bill-approvals/:id" element={<BillDetailPage />} />
|
||||||
|
<Route path="directory" element={<DirectoryPage />} />
|
||||||
|
<Route path="committees" element={<CommitteesPage />} />
|
||||||
|
<Route path="board-members" element={<BoardMembersPage />} />
|
||||||
|
<Route path="board-resources" element={<ManageBoardResourcesPage />} />
|
||||||
|
<Route path="board-votes" element={<BoardVotesPage />} />
|
||||||
|
<Route path="elections" element={<ElectionsPage />} />
|
||||||
|
|
||||||
|
<Route path="form-inbox" element={<FormInboxPage />} />
|
||||||
|
|
||||||
|
<Route path="client-requests" element={<ClientRequestsPage />} />
|
||||||
|
<Route path="collections" element={<CollectionsPage />} />
|
||||||
|
<Route path="estoppels" element={<EstoppelsPage />} />
|
||||||
|
<Route path="homeowner-requests" element={<HomeownerRequestsPage />} />
|
||||||
|
<Route path="legal-matters" element={<LegalMattersPage />} />
|
||||||
|
<Route path="bulk-updates" element={<BulkUpdatesPage />} />
|
||||||
|
<Route path="bulk-owner-updates" element={<BulkUpdatesPage />} />
|
||||||
|
<Route path="bulk-unit-updates" element={<BulkUpdatesPage />} />
|
||||||
|
<Route path="owner-roster" element={<OwnersPage />} />
|
||||||
|
<Route path="owner-roster/:id" element={<OwnerProfilePage />} />
|
||||||
|
<Route path="parking" element={<ParkingPage />} />
|
||||||
|
<Route path="payment-plans" element={<PaymentPlansPage />} />
|
||||||
|
<Route path="reports" element={<ReportGeneratorPage />} />
|
||||||
|
<Route path="unit-directory" element={<UnitsPage />} />
|
||||||
|
<Route path="units/:id" element={<UnitProfilePage />} />
|
||||||
|
<Route path="violations" element={<ViolationsPage />} />
|
||||||
|
<Route path="announcements" element={<AnnouncementsPage />} />
|
||||||
|
<Route path="media" element={<MediaLibraryPage />} />
|
||||||
|
<Route path="budget-management" element={<BudgetManagementPage />} />
|
||||||
|
<Route path="bank-accounts" element={<BankAccountsHubPage />} />
|
||||||
|
<Route path="bank-accounts-list" element={<BankAccountsPage />} />
|
||||||
|
<Route path="bank-register" element={<BankRegisterPage />} />
|
||||||
|
|
||||||
|
<Route path="reconciliations" element={<ReconciliationsPage />} />
|
||||||
|
<Route path="general-ledger" element={<GeneralLedgerPage />} />
|
||||||
|
<Route path="import-transactions" element={<ImportTransactionsPage />} />
|
||||||
|
<Route path="write-checks" element={<WriteChecksPage />} />
|
||||||
|
<Route path="print-checks" element={<PrintChecksPage />} />
|
||||||
|
<Route path="company-ledger" element={<CompanyLedgerPage />} />
|
||||||
|
<Route path="company-bank-accounts" element={<CompanyBankAccountsHubPage />} />
|
||||||
|
<Route path="company-bank-register" element={<CompanyBankRegisterPage />} />
|
||||||
|
<Route path="company-checks" element={<CompanyChecksPage />} />
|
||||||
|
<Route path="accounting-reports" element={<AccountingReportsPage />} />
|
||||||
|
<Route path="financial-reports" element={<ZohoFinancialReportsPage />} />
|
||||||
|
<Route path="financial-overview" element={<FinancialOverviewPage />} />
|
||||||
|
<Route path="accounting" element={<AccountingLayout />}>
|
||||||
|
<Route index element={<AccountingDashboardPage />} />
|
||||||
|
<Route path="chart-of-accounts" element={<AccountingChartOfAccountsPage />} />
|
||||||
|
<Route path="journal-entries" element={<AccountingJournalEntriesPage />} />
|
||||||
|
<Route path="invoices" element={<AccountingInvoicesPage />} />
|
||||||
|
<Route path="bills" element={<AccountingBillsPage />} />
|
||||||
|
<Route path="customers" element={<AccountingCustomersPage />} />
|
||||||
|
<Route path="customers/:id" element={<AccountingCustomerDetailPage />} />
|
||||||
|
<Route path="vendors" element={<AccountingVendorsPage />} />
|
||||||
|
<Route path="expenses" element={<AccountingExpensesPage />} />
|
||||||
|
<Route path="estimates" element={<AccountingEstimatesPage />} />
|
||||||
|
<Route path="deposits" element={<AccountingDepositsPage />} />
|
||||||
|
<Route path="receive-payments" element={<AccountingReceivePaymentsPage />} />
|
||||||
|
<Route path="banking" element={<AccountingBankingPage />} />
|
||||||
|
<Route path="reconciliation" element={<AccountingReconciliationPage />} />
|
||||||
|
<Route path="reconciliation/:accountId" element={<AccountingReconcileDetailPage />} />
|
||||||
|
<Route path="budgets" element={<AccountingBudgetsPage />} />
|
||||||
|
<Route path="budgets/:id" element={<AccountingBudgetDetailPage />} />
|
||||||
|
<Route path="assessments" element={<AccountingAssessmentsPage />} />
|
||||||
|
<Route path="work-orders" element={<AccountingWorkOrdersPage />} />
|
||||||
|
<Route path="opening-balances" element={<AccountingOpeningBalancesPage />} />
|
||||||
|
<Route path="reports" element={<PlatformAccountingReportsPage />} />
|
||||||
|
<Route path="settings" element={<AccountingSettingsLayout />}>
|
||||||
|
<Route index element={<AccountingGeneralSettingsPage />} />
|
||||||
|
<Route path="check-setup" element={<AccountingCheckSetupPage />} />
|
||||||
|
<Route path="integrations" element={<AccountingIntegrationsPage />} />
|
||||||
|
</Route>
|
||||||
|
</Route>
|
||||||
|
<Route path="recent-ledger-updates" element={<RecentLedgerUpdatesPage />} />
|
||||||
|
<Route path="outstanding-balances" element={<OutstandingBalancesPage />} />
|
||||||
|
<Route path="bulk-charges" element={<BulkChargesPage />} />
|
||||||
|
<Route path="ledger-charges-report" element={<LedgerChargesReportPage />} />
|
||||||
|
<Route path="compose-email" element={<ComposeEmailPage />} />
|
||||||
|
<Route path="email-history" element={<EmailHistoryPage />} />
|
||||||
|
<Route path="email-routing" element={<EmailRoutingPage />} />
|
||||||
|
<Route path="email-senders" element={<EmailSendersPage />} />
|
||||||
|
|
||||||
|
<Route path="email-templates" element={<EmailTemplatesPage />} />
|
||||||
|
<Route path="notify-board" element={<NotifyBoardPage />} />
|
||||||
|
<Route path="notify-owners" element={<NotifyOwnersPage />} />
|
||||||
|
<Route path="mailchimp" element={<MailchimpPage />} />
|
||||||
|
<Route path="messages" element={<MessagesPage />} />
|
||||||
|
{/* Financial module */}
|
||||||
|
<Route path="vendors" element={<VendorsPage />} />
|
||||||
|
<Route path="vendors/:id" element={<VendorDetailPage />} />
|
||||||
|
<Route path="bills" element={<BillsPage />} />
|
||||||
|
<Route path="inbound-bills" element={<InboundBillsPage />} />
|
||||||
|
<Route path="chart-of-accounts" element={<ChartOfAccountsPage />} />
|
||||||
|
<Route path="owner-ledger" element={<OwnerLedgerPage />} />
|
||||||
|
<Route path="record-payment" element={<RecordOwnerPaymentPage />} />
|
||||||
|
<Route path="deposit-batches" element={<DepositBatchesPage />} />
|
||||||
|
<Route path="transfers" element={<TransfersPage />} />
|
||||||
|
<Route path="data-migration" element={<DataMigration />} />
|
||||||
|
<Route path="migration-fields" element={<MigrationFieldsPage />} />
|
||||||
|
<Route path="time-tracking" element={<TimeTrackingPage />} />
|
||||||
|
<Route path="docusign" element={<DocuSignEnvelopesPage />} />
|
||||||
|
<Route path="avria-sign" element={<AvriaSignEnvelopesPage />} />
|
||||||
|
<Route path="collaborative-docs" element={<CollaborativeDocumentsPageAdmin />} />
|
||||||
|
<Route path="settings" element={<SettingsPage />}>
|
||||||
|
<Route index element={<GeneralSettingsPage />} />
|
||||||
|
<Route path="general" element={<GeneralSettingsPage />} />
|
||||||
|
<Route path="branding" element={<BrandingSettingsPage />} />
|
||||||
|
<Route path="zoho-books" element={<ZohoBooksSettingsPage />} />
|
||||||
|
<Route path="stripe-accounts" element={<AdminStripeAccountsPage />} />
|
||||||
|
<Route path="role-permissions" element={<RolePermissionsPage />} />
|
||||||
|
<Route path="portal-visibility" element={<PortalFunctionVisibilityPage />} />
|
||||||
|
<Route path="buildium" element={<BuildiumSettingsPage />} />
|
||||||
|
<Route path="buildium/review" element={<BuildiumImportReviewPage />} />
|
||||||
|
<Route path="recurring-rules" element={<RecurringRulesPage />} />
|
||||||
|
<Route path="profile" element={<MyProfilePage />} />
|
||||||
|
<Route path="*" element={<GeneralSettingsPage />} />
|
||||||
|
</Route>
|
||||||
|
</Route>
|
||||||
|
|
||||||
|
{/* ─── Client Portal ─── */}
|
||||||
|
<Route path="/client" element={<ClientLayout />}>
|
||||||
|
<Route index element={<ClientHomePage />} />
|
||||||
|
<Route path="documents" element={<ClientDocumentsPage />} />
|
||||||
|
<Route path="tasks" element={<ClientTasksPage />} />
|
||||||
|
<Route path="violations" element={<ClientViolationsPage />} />
|
||||||
|
<Route path="violation-reports" element={<ClientViolationReportsPage />} />
|
||||||
|
<Route path="calendar" element={<ClientCalendarPage />} />
|
||||||
|
<Route path="personal-calendar" element={<ClientPersonalCalendarPage />} />
|
||||||
|
<Route path="projects" element={<ClientProjectsPage />} />
|
||||||
|
<Route path="projects/:id" element={<ProjectDetailPage />} />
|
||||||
|
<Route path="collections" element={<ClientCollectionsPage />} />
|
||||||
|
<Route path="estoppels" element={<ClientEstoppelsPage />} />
|
||||||
|
<Route path="status-updates" element={<ClientStatusUpdatesPage />} />
|
||||||
|
<Route path="owner-updates" element={<ClientOwnerUpdatesPage />} />
|
||||||
|
<Route path="homeowner-requests" element={<ClientHomeownerRequestsPage />} />
|
||||||
|
<Route path="directory" element={<ClientDirectoryPage />} />
|
||||||
|
<Route path="parking" element={<ClientParkingPage />} />
|
||||||
|
<Route path="board-votes" element={<ClientBoardVotesPage />} />
|
||||||
|
<Route path="payment-plans" element={<ClientPaymentPlansPage />} />
|
||||||
|
<Route path="bids-quotes" element={<ClientBidsQuotesPage />} />
|
||||||
|
<Route path="call-logs" element={<ClientCallLogsPage />} />
|
||||||
|
</Route>
|
||||||
|
|
||||||
|
{/* ─── Homeowner Portal ─── */}
|
||||||
|
<Route path="/homeowner" element={<HomeownerLayout />}>
|
||||||
|
<Route index element={<HomeownerHomePage />} />
|
||||||
|
<Route path="profile" element={<HomeownerProfilePage />} />
|
||||||
|
<Route path="ledger" element={<HomeownerLedgerPage />} />
|
||||||
|
<Route path="documents" element={<HomeownerDocumentsPage />} />
|
||||||
|
<Route path="arc" element={<HomeownerARCPage />} />
|
||||||
|
<Route path="statements" element={<HomeownerStatementsPage />} />
|
||||||
|
<Route path="payments" element={<HomeownerPaymentsPage />} />
|
||||||
|
<Route path="tickets" element={<HomeownerTicketsPage />} />
|
||||||
|
<Route path="amenity-calendar" element={<HomeownerAmenityCalendarPage />} />
|
||||||
|
<Route path="elections" element={<HomeownerElectionsPage />} />
|
||||||
|
<Route path="violations" element={<HomeownerViolationsPage />} />
|
||||||
|
<Route path="messages" element={<HomeownerMessagesPage />} />
|
||||||
|
<Route path="directory" element={<HomeownerDirectoryPage />} />
|
||||||
|
{/* Board Member Routes */}
|
||||||
|
<Route path="board/projects" element={<BoardProjectsPage />} />
|
||||||
|
<Route path="board/projects/:id" element={<ProjectDetailPage />} />
|
||||||
|
<Route path="board/calendar" element={<BoardCalendarPage />} />
|
||||||
|
<Route path="board/documents" element={<BoardDocumentsPage />} />
|
||||||
|
<Route path="board/status-updates" element={<BoardStatusUpdatesPage />} />
|
||||||
|
<Route path="board/tasks" element={<BoardTasksPage />} />
|
||||||
|
<Route path="board/arc-applications" element={<BoardARCPage />} />
|
||||||
|
<Route path="board/bids-quotes" element={<BoardBidsQuotesPage />} />
|
||||||
|
<Route path="board/bill-approvals" element={<BoardBillApprovalsPage />} />
|
||||||
|
<Route path="board/bill-approvals/:id" element={<BoardBillDetailPage />} />
|
||||||
|
<Route path="board/submit-invoice" element={<BoardSubmitInvoicePage />} />
|
||||||
|
<Route path="board/board-votes" element={<BoardBoardVotesPage />} />
|
||||||
|
<Route path="board/elections" element={<BoardElectionsPage />} />
|
||||||
|
<Route path="board/client-requests" element={<BoardClientRequestsPage />} />
|
||||||
|
<Route path="board/homeowner-requests" element={<BoardHomeownerRequestsPage />} />
|
||||||
|
<Route path="board/owner-roster" element={<BoardOwnerRosterPage />} />
|
||||||
|
<Route path="board/parking" element={<BoardParkingPage />} />
|
||||||
|
<Route path="board/violations" element={<BoardViolationsPage />} />
|
||||||
|
<Route path="board/announcements" element={<BoardAnnouncementsPage />} />
|
||||||
|
<Route path="board/reports" element={<BoardReportsPage />} />
|
||||||
|
<Route path="board/financial-reports" element={<BoardFinancialReportsPage />} />
|
||||||
|
<Route path="board/financial-overview" element={<BoardFinancialOverviewPage />} />
|
||||||
|
<Route path="board/messages" element={<BoardMessagesPage />} />
|
||||||
|
<Route path="board/collaborative-docs" element={<BoardCollaborativeDocsPage />} />
|
||||||
|
<Route path="board/resources" element={<BoardResourcesPage />} />
|
||||||
|
<Route path="board/estoppels" element={<BoardEstoppelsPage />} />
|
||||||
|
</Route>
|
||||||
|
|
||||||
|
{/* ─── Legal Portal ─── */}
|
||||||
|
<Route path="/legal" element={<LegalLayout />}>
|
||||||
|
<Route index element={<LegalCasesPage />} />
|
||||||
|
<Route path="case/:id" element={<LegalCaseDetailPage />} />
|
||||||
|
</Route>
|
||||||
|
|
||||||
|
{/* ─── ARC Committee Portal ─── */}
|
||||||
|
<Route path="/arc" element={<ArcLayout />}>
|
||||||
|
<Route index element={<ArcCommitteePage />} />
|
||||||
|
</Route>
|
||||||
|
|
||||||
|
{/* ─── Master Board Portal ─── */}
|
||||||
|
<Route path="/master-board" element={<MasterBoardLayout />}>
|
||||||
|
<Route index element={<MasterBoardDashboardPage />} />
|
||||||
|
<Route path="projects" element={<BoardProjectsPage />} />
|
||||||
|
<Route path="projects/:id" element={<ProjectDetailPage />} />
|
||||||
|
<Route path="calendar" element={<BoardCalendarPage />} />
|
||||||
|
<Route path="documents" element={<BoardDocumentsPage />} />
|
||||||
|
<Route path="status-updates" element={<BoardStatusUpdatesPage />} />
|
||||||
|
<Route path="tasks" element={<BoardTasksPage />} />
|
||||||
|
<Route path="arc-applications" element={<BoardARCPage />} />
|
||||||
|
<Route path="bids-quotes" element={<BoardBidsQuotesPage />} />
|
||||||
|
<Route path="bill-approvals" element={<BoardBillApprovalsPage />} />
|
||||||
|
<Route path="bill-approvals/:id" element={<BoardBillDetailPage />} />
|
||||||
|
<Route path="submit-invoice" element={<BoardSubmitInvoicePage />} />
|
||||||
|
<Route path="board-votes" element={<BoardBoardVotesPage />} />
|
||||||
|
<Route path="elections" element={<BoardElectionsPage />} />
|
||||||
|
<Route path="client-requests" element={<BoardClientRequestsPage />} />
|
||||||
|
<Route path="homeowner-requests" element={<BoardHomeownerRequestsPage />} />
|
||||||
|
<Route path="owner-roster" element={<BoardOwnerRosterPage />} />
|
||||||
|
<Route path="parking" element={<BoardParkingPage />} />
|
||||||
|
<Route path="violations" element={<BoardViolationsPage />} />
|
||||||
|
<Route path="announcements" element={<BoardAnnouncementsPage />} />
|
||||||
|
<Route path="reports" element={<BoardReportsPage />} />
|
||||||
|
<Route path="financial-reports" element={<BoardFinancialReportsPage />} />
|
||||||
|
<Route path="financial-overview" element={<BoardFinancialOverviewPage />} />
|
||||||
|
<Route path="messages" element={<BoardMessagesPage />} />
|
||||||
|
<Route path="collaborative-docs" element={<BoardCollaborativeDocsPage />} />
|
||||||
|
<Route path="resources" element={<BoardResourcesPage />} />
|
||||||
|
</Route>
|
||||||
|
|
||||||
|
<Route path="*" element={<NotFound />} />
|
||||||
|
</Routes>
|
||||||
|
</BrowserRouter>
|
||||||
|
</TooltipProvider>
|
||||||
|
</AuthProvider>
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default App;
|
||||||
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 182 KiB |
|
After Width: | Height: | Size: 9.9 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 302 B |
@@ -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 (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-[600px] max-h-[90vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{application ? 'Edit ARC Application' : 'New ARC Application'}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="title">Project Title *</Label>
|
||||||
|
<Input
|
||||||
|
id="title"
|
||||||
|
value={formData.title}
|
||||||
|
onChange={e => setFormData({...formData, title: e.target.value})}
|
||||||
|
required
|
||||||
|
placeholder="e.g. Fence Installation"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="association_id">Association *</Label>
|
||||||
|
<select
|
||||||
|
id="association_id"
|
||||||
|
value={formData.association_id}
|
||||||
|
onChange={e => setFormData({...formData, association_id: e.target.value})}
|
||||||
|
required
|
||||||
|
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
|
||||||
|
>
|
||||||
|
<option value="">Select Association</option>
|
||||||
|
{clients.map(c => (
|
||||||
|
<option key={c.id} value={c.id}>{c.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label className="mb-1 block">Homeowner</Label>
|
||||||
|
{!formData.association_id ? (
|
||||||
|
<p className="text-xs text-muted-foreground">Select an association first to choose an owner.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{selectedOwner ? (
|
||||||
|
<div className="flex items-center justify-between rounded-md border bg-muted/40 p-2 text-sm">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="font-medium truncate">
|
||||||
|
{(selectedOwner.first_name || '') + ' ' + (selectedOwner.last_name || '')}
|
||||||
|
</div>
|
||||||
|
{selectedOwner.property_address && (
|
||||||
|
<div className="text-xs text-muted-foreground truncate">{selectedOwner.property_address}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setFormData(f => ({ ...f, owner_id: '', unit_id: '' }))}
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Input
|
||||||
|
placeholder={ownersLoading ? 'Loading owners...' : 'Search owners by name or address...'}
|
||||||
|
value={ownerSearch}
|
||||||
|
onChange={(e) => setOwnerSearch(e.target.value)}
|
||||||
|
disabled={ownersLoading}
|
||||||
|
/>
|
||||||
|
{ownerSearch.trim() && (
|
||||||
|
<div className="max-h-48 overflow-y-auto rounded-md border divide-y">
|
||||||
|
{filteredOwners.length === 0 ? (
|
||||||
|
<div className="p-2 text-xs text-muted-foreground">No owners match.</div>
|
||||||
|
) : (
|
||||||
|
filteredOwners.map(o => (
|
||||||
|
<button
|
||||||
|
key={o.id}
|
||||||
|
type="button"
|
||||||
|
className="w-full text-left p-2 text-sm hover:bg-accent"
|
||||||
|
onClick={() => {
|
||||||
|
setFormData(f => ({ ...f, owner_id: o.id, unit_id: o.unit_id || '' }));
|
||||||
|
setOwnerSearch('');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="font-medium">{(o.first_name || '') + ' ' + (o.last_name || '')}</div>
|
||||||
|
{o.property_address && (
|
||||||
|
<div className="text-xs text-muted-foreground truncate">{o.property_address}</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="description">Description</Label>
|
||||||
|
<Textarea
|
||||||
|
id="description"
|
||||||
|
value={formData.description}
|
||||||
|
onChange={e => setFormData({...formData, description: e.target.value})}
|
||||||
|
rows={4}
|
||||||
|
placeholder="Describe the proposed changes, materials, dimensions, etc..."
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label className="mb-2 block">Attachments</Label>
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<label htmlFor="file-upload" className="cursor-pointer inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors border border-input hover:bg-accent hover:text-accent-foreground h-10 px-4 py-2 w-full border-dashed border-2">
|
||||||
|
<Upload className="w-4 h-4 mr-2" />
|
||||||
|
Click to Select Files (Images, PDF)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="file-upload"
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
onChange={handleFileChange}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2 max-h-[150px] overflow-y-auto">
|
||||||
|
{newFiles.map((file, idx) => (
|
||||||
|
<div key={idx} className="flex items-center justify-between p-2 bg-muted/50 rounded border text-sm">
|
||||||
|
<div className="flex items-center gap-2 overflow-hidden">
|
||||||
|
<Upload className="w-4 h-4 text-primary flex-shrink-0" />
|
||||||
|
<span className="truncate">{file.name}</span>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 w-6 p-0"
|
||||||
|
onClick={() => removeNewFile(idx)}
|
||||||
|
>
|
||||||
|
<X className="w-3 h-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end pt-4 space-x-2">
|
||||||
|
<Button variant="outline" type="button" onClick={() => onOpenChange(false)}>Cancel</Button>
|
||||||
|
<Button type="submit" disabled={loading}>
|
||||||
|
{loading && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
|
||||||
|
Save Application
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ARCApplicationDialog;
|
||||||
@@ -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 <Badge className="bg-green-600">Approved</Badge>;
|
||||||
|
case 'rejected': return <Badge className="bg-red-600">Rejected</Badge>;
|
||||||
|
default: return <Badge variant="outline" className="text-yellow-600 border-yellow-600 bg-yellow-50">Pending</Badge>;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!application) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-[900px] h-[90vh] flex flex-col p-0 overflow-hidden">
|
||||||
|
<DialogHeader className="px-6 py-4 border-b">
|
||||||
|
<div className="flex flex-col sm:flex-row justify-between items-start gap-4">
|
||||||
|
<div>
|
||||||
|
<DialogTitle className="text-xl font-bold">{application.title}</DialogTitle>
|
||||||
|
<div className="flex items-center gap-2 mt-2">
|
||||||
|
{getStatusBadge(application.status)}
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{application.associations?.name}
|
||||||
|
</span>
|
||||||
|
{!countLoading && (
|
||||||
|
<Badge variant="outline" className="ml-2 font-normal">
|
||||||
|
<MessageSquare className="w-3 h-3 mr-1" />
|
||||||
|
Comments ({commentCount})
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-hidden flex flex-col md:flex-row">
|
||||||
|
{/* Left Side: Details */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-6 space-y-6">
|
||||||
|
|
||||||
|
{/* Admin Status Controls */}
|
||||||
|
{isAdmin && (
|
||||||
|
<div className="p-4 rounded-lg border border-primary/20 shadow-sm">
|
||||||
|
<h3 className="font-semibold mb-2 flex items-center text-sm uppercase tracking-wide">
|
||||||
|
<ShieldAlert className="w-4 h-4 mr-2" /> Admin Actions
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
|
Review the application and cast the final decision.
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="bg-green-600 hover:bg-green-700 text-white"
|
||||||
|
onClick={() => handleStatusUpdate('approved')}
|
||||||
|
disabled={application.status === 'approved' || statusUpdating}
|
||||||
|
>
|
||||||
|
<CheckCircle className="w-4 h-4 mr-2" />
|
||||||
|
Approve Application
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => handleStatusUpdate('rejected')}
|
||||||
|
disabled={application.status === 'rejected' || statusUpdating}
|
||||||
|
>
|
||||||
|
<XCircle className="w-4 h-4 mr-2" />
|
||||||
|
Reject Application
|
||||||
|
</Button>
|
||||||
|
{application.status !== 'submitted' && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => handleStatusUpdate('submitted')}
|
||||||
|
disabled={statusUpdating}
|
||||||
|
>
|
||||||
|
Reset to Pending
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="p-4 rounded-lg border shadow-sm">
|
||||||
|
<h3 className="font-semibold mb-2">Description</h3>
|
||||||
|
<p className="text-muted-foreground whitespace-pre-wrap">{application.description}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Attachments Section */}
|
||||||
|
<div className="p-4 rounded-lg border shadow-sm">
|
||||||
|
<h3 className="font-semibold mb-3 flex items-center">
|
||||||
|
<Paperclip className="w-4 h-4 mr-2" />
|
||||||
|
Attachments ({arcFiles.length})
|
||||||
|
</h3>
|
||||||
|
{arcFiles.length > 0 ? (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||||
|
{arcFiles.map(file => (
|
||||||
|
<a
|
||||||
|
key={file.id}
|
||||||
|
href={file.file_url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-center p-3 rounded border hover:bg-muted transition-colors group"
|
||||||
|
>
|
||||||
|
<FileText className="w-5 h-5 text-primary mr-3" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium truncate group-hover:text-primary">
|
||||||
|
{file.file_name}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{format(new Date(file.created_at), 'MMM d, yyyy')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Download className="w-4 h-4 text-muted-foreground group-hover:text-primary" />
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-4 bg-muted/30 rounded border border-dashed">
|
||||||
|
<p className="text-sm text-muted-foreground">No attachments uploaded.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Side: Comments */}
|
||||||
|
<div className="w-full md:w-[350px] flex flex-col border-l">
|
||||||
|
<div className="p-4 border-b flex justify-between items-center">
|
||||||
|
<h3 className="font-semibold flex items-center">
|
||||||
|
<MessageSquare className="w-4 h-4 mr-2" /> Discussion
|
||||||
|
</h3>
|
||||||
|
{!countLoading && (
|
||||||
|
<Badge variant="secondary" className="text-xs font-normal">
|
||||||
|
{commentCount}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto p-4 space-y-4" ref={scrollRef}>
|
||||||
|
{comments.length === 0 ? (
|
||||||
|
<div className="text-center py-8 text-muted-foreground text-sm">
|
||||||
|
No comments yet. Start the discussion!
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
comments.map((comment) => (
|
||||||
|
<div key={comment.id} className={`flex flex-col ${comment.user_id === user?.id ? 'items-end' : 'items-start'}`}>
|
||||||
|
<div className={`max-w-[85%] rounded-lg p-3 text-sm ${
|
||||||
|
comment.user_id === user?.id
|
||||||
|
? 'bg-primary text-primary-foreground'
|
||||||
|
: 'bg-muted border'
|
||||||
|
}`}>
|
||||||
|
<p>{comment.content}</p>
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] text-muted-foreground mt-1 px-1">
|
||||||
|
{format(new Date(comment.created_at), 'MMM d, h:mm a')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 border-t">
|
||||||
|
<form onSubmit={handlePostComment} className="flex gap-2">
|
||||||
|
<Textarea
|
||||||
|
value={newComment}
|
||||||
|
onChange={(e) => 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);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button type="submit" size="icon" disabled={commentLoading || !newComment.trim()}>
|
||||||
|
<Send className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ARCDetailsDialog;
|
||||||
@@ -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 (
|
||||||
|
<Select
|
||||||
|
value={safeValue}
|
||||||
|
onValueChange={(v) => onChange?.(v)}
|
||||||
|
disabled={disabled || loading}
|
||||||
|
required={required}
|
||||||
|
>
|
||||||
|
<SelectTrigger className={cn("w-full", className)}>
|
||||||
|
<div className="flex items-center gap-2 truncate">
|
||||||
|
{loading && <Loader2 className="w-3.5 h-3.5 animate-spin text-muted-foreground shrink-0" />}
|
||||||
|
{error && <AlertCircle className="w-3.5 h-3.5 text-destructive shrink-0" />}
|
||||||
|
<SelectValue
|
||||||
|
placeholder={
|
||||||
|
error ? "Error loading accounts" : loading ? "Loading..." : placeholder
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SelectTrigger>
|
||||||
|
|
||||||
|
<SelectContent className="max-h-[300px] z-[9999]">
|
||||||
|
{accounts.map((acc) => (
|
||||||
|
<SelectItem key={acc.id} value={String(acc.id)} className="cursor-pointer">
|
||||||
|
<div className="flex items-center w-full">
|
||||||
|
{acc.account_number && (
|
||||||
|
<span className="font-mono text-muted-foreground mr-2 shrink-0 text-xs">
|
||||||
|
{acc.account_number}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="truncate">{acc.account_name}</span>
|
||||||
|
<span className="ml-auto text-xs text-muted-foreground pl-2 shrink-0">
|
||||||
|
{acc.account_type}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-[425px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Add New Subcategory</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Create a custom subcategory for fee schedules.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="name"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Subcategory Name</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="e.g. Special Project" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||||
|
{form.formState.isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-[700px] max-h-[90vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{announcement ? 'Edit Announcement' : 'Create Announcement'}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4 mt-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="title">Title</Label>
|
||||||
|
<Input
|
||||||
|
id="title"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
placeholder="Announcement Title"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="content">Content</Label>
|
||||||
|
<Textarea
|
||||||
|
id="content"
|
||||||
|
value={content}
|
||||||
|
onChange={(e) => setContent(e.target.value)}
|
||||||
|
placeholder="Write your announcement content here..."
|
||||||
|
rows={10}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<DialogFooter className="mt-8">
|
||||||
|
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={loading}>
|
||||||
|
{loading && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
|
||||||
|
{announcement ? 'Update' : 'Post Announcement'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AnnouncementDialog;
|
||||||
@@ -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<Announcement[]>([]);
|
||||||
|
const [associations, setAssociations] = useState<Association[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
|
const [formData, setFormData] = useState({ title: "", content: "", visibility: "staff_board", association_id: "", pinned: false, expires_at: "" });
|
||||||
|
const { toast } = useToast();
|
||||||
|
const quillRef = useRef<any>(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 <input type="datetime-local">
|
||||||
|
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 (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Megaphone className="w-8 h-8 text-primary" />
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-foreground">Announcements</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">Post and manage team announcements visible across the platform.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="ghost" size="icon" className="h-9 w-9" onClick={fetchAnnouncements}>
|
||||||
|
<RefreshCw className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
{canManage && (
|
||||||
|
<Button size="sm" onClick={() => openModal()}>
|
||||||
|
<Plus className="w-4 h-4 mr-1.5" /> Post Announcement
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table Card */}
|
||||||
|
<div className="bg-card border rounded-lg">
|
||||||
|
<div className="p-5 border-b">
|
||||||
|
<h2 className="text-lg font-semibold text-foreground">Active Announcements</h2>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
All active announcements are displayed on the dashboard and visible to relevant staff.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-5">
|
||||||
|
{loading ? (
|
||||||
|
<div className="text-center py-8 text-muted-foreground">Loading announcements...</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="text-center py-8 text-destructive">
|
||||||
|
<AlertCircle className="w-6 h-6 mx-auto mb-2" />
|
||||||
|
<p className="text-sm font-medium">{error}</p>
|
||||||
|
<Button variant="outline" size="sm" onClick={fetchAnnouncements} className="mt-3">Retry</Button>
|
||||||
|
</div>
|
||||||
|
) : announcements.length === 0 ? (
|
||||||
|
<div className="text-center py-8 text-muted-foreground">No active announcements.</div>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Title</TableHead>
|
||||||
|
<TableHead>Content</TableHead>
|
||||||
|
<TableHead className="w-[140px]">Visibility</TableHead>
|
||||||
|
<TableHead className="w-[160px]">Posted</TableHead>
|
||||||
|
<TableHead className="w-[160px]">Expires</TableHead>
|
||||||
|
{canManage && <TableHead className="w-[150px] text-right">Actions</TableHead>}
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{announcements.map((ann) => (
|
||||||
|
<TableRow key={ann.id}>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
{ann.pinned && <Pin className="w-3.5 h-3.5 text-primary fill-primary" />}
|
||||||
|
<span className="font-semibold text-foreground">{ann.title}</span>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<p className="text-sm text-muted-foreground line-clamp-2 max-w-[400px]">
|
||||||
|
{htmlToPlainText(ann.content)}
|
||||||
|
</p>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant="outline" className="text-[10px] font-medium">
|
||||||
|
{visibilityLabel(ann.visibility)}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm text-muted-foreground whitespace-nowrap">
|
||||||
|
{formatDateTimeShortEST(ann.created_at)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm text-muted-foreground whitespace-nowrap">
|
||||||
|
{ann.expires_at ? formatDateTimeShortEST(ann.expires_at) : <span className="italic">Never</span>}
|
||||||
|
</TableCell>
|
||||||
|
{canManage && (
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<div className="flex justify-end gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className={`h-8 w-8 ${ann.pinned ? "text-primary hover:text-primary" : "text-muted-foreground hover:text-primary"}`}
|
||||||
|
title={ann.pinned ? "Unpin" : "Pin to top"}
|
||||||
|
onClick={() => handleTogglePin(ann.id, !!ann.pinned)}
|
||||||
|
>
|
||||||
|
{ann.pinned ? <PinOff className="w-4 h-4" /> : <Pin className="w-4 h-4" />}
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="icon" className="h-8 w-8 text-primary hover:text-primary" onClick={() => openModal(ann)}>
|
||||||
|
<Edit2 className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="icon" className="h-8 w-8 text-amber-600 hover:text-amber-600" title="Archive" onClick={() => handleArchive(ann.id)}>
|
||||||
|
<Archive className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="icon" className="h-8 w-8 text-destructive hover:text-destructive" title="Delete" onClick={() => handleDelete(ann.id)}>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
)}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modal */}
|
||||||
|
<Dialog open={modalOpen} onOpenChange={setModalOpen}>
|
||||||
|
<DialogContent className="sm:max-w-[700px] max-h-[90vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{editingId ? "Edit Announcement" : "Post Announcement"}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="grid gap-4 py-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Title</Label>
|
||||||
|
<Input
|
||||||
|
value={formData.title}
|
||||||
|
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
|
||||||
|
placeholder="Announcement title..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer select-none">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={formData.pinned}
|
||||||
|
onChange={(e) => setFormData({ ...formData, pinned: e.target.checked })}
|
||||||
|
className="h-4 w-4 rounded border-input accent-primary"
|
||||||
|
/>
|
||||||
|
<Pin className="w-3.5 h-3.5 text-primary" />
|
||||||
|
<span className="font-medium text-foreground">Pin to top</span>
|
||||||
|
<span className="text-xs text-muted-foreground">— Pinned announcements appear above all others.</span>
|
||||||
|
</label>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Content</Label>
|
||||||
|
<div className="rounded-md border bg-background">
|
||||||
|
<ReactQuill
|
||||||
|
ref={quillRef}
|
||||||
|
theme="snow"
|
||||||
|
value={formData.content}
|
||||||
|
onChange={(val) => setFormData({ ...formData, content: val })}
|
||||||
|
modules={quillModules}
|
||||||
|
placeholder="Write your announcement... use the image button to embed pictures."
|
||||||
|
className="announcement-quill"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">Click the image icon in the toolbar to upload and embed an image.</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Visibility</Label>
|
||||||
|
<Select value={formData.visibility} onValueChange={(val) => setFormData({ ...formData, visibility: val })}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="staff_board">Staff & Board Members</SelectItem>
|
||||||
|
<SelectItem value="all">Staff, Board Members & Homeowners</SelectItem>
|
||||||
|
<SelectItem value="public">Public (Everyone incl. Community Page)</SelectItem>
|
||||||
|
<SelectItem value="public_only">Public Only (Community Page only)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{formData.visibility === "public" && (
|
||||||
|
<p className="text-xs text-amber-600">Public announcements appear on the community page when the Announcements module is enabled.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Association</Label>
|
||||||
|
<Select
|
||||||
|
value={formData.association_id || ALL_ASSOCIATIONS_VALUE}
|
||||||
|
onValueChange={(val) => setFormData({ ...formData, association_id: val === ALL_ASSOCIATIONS_VALUE ? "" : val })}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select association (optional)" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value={ALL_ASSOCIATIONS_VALUE}>All / No Association</SelectItem>
|
||||||
|
{associations.map(a => (
|
||||||
|
<SelectItem key={a.id} value={a.id}>{a.name}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-xs text-muted-foreground">Required for Public visibility to appear on a community page.</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Expiration date & time (optional)</Label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
type="datetime-local"
|
||||||
|
value={formData.expires_at}
|
||||||
|
onChange={(e) => setFormData({ ...formData, expires_at: e.target.value })}
|
||||||
|
/>
|
||||||
|
{formData.expires_at && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setFormData({ ...formData, expires_at: "" })}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">After this date and time the announcement will no longer be shown. Leave blank to keep it active indefinitely.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setModalOpen(false)}>Cancel</Button>
|
||||||
|
<Button onClick={handleSave} disabled={saving}>{saving ? "Saving..." : editingId ? "Save Changes" : "Post Announcement"}</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-[500px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Manage Board Members</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Select users to assign as board members for <strong>{client?.name}</strong>.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4 py-2">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="Search users by name or email..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="pl-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border rounded-md">
|
||||||
|
<div className="bg-muted px-4 py-2 border-b text-xs font-medium text-muted-foreground uppercase flex justify-between">
|
||||||
|
<span>User</span>
|
||||||
|
<span>{selectedUserIds.length} Selected</span>
|
||||||
|
</div>
|
||||||
|
<ScrollArea className="h-[300px]">
|
||||||
|
{initLoading ? (
|
||||||
|
<div className="flex flex-col items-center justify-center h-full gap-2 text-muted-foreground">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin" />
|
||||||
|
<span className="text-xs">Loading users...</span>
|
||||||
|
</div>
|
||||||
|
) : filteredUsers.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center h-full p-4 text-center text-muted-foreground">
|
||||||
|
<p className="text-sm">No users found.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y">
|
||||||
|
{filteredUsers.map(user => {
|
||||||
|
const isSelected = selectedUserIds.includes(user.id);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={user.id}
|
||||||
|
className={`flex items-center gap-3 p-3 hover:bg-muted/50 transition-colors cursor-pointer ${isSelected ? 'bg-primary/5' : ''}`}
|
||||||
|
onClick={() => handleToggle(user.id)}
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={isSelected}
|
||||||
|
onCheckedChange={() => handleToggle(user.id)}
|
||||||
|
id={`user-${user.id}`}
|
||||||
|
/>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<label
|
||||||
|
htmlFor={`user-${user.id}`}
|
||||||
|
className="text-sm font-medium cursor-pointer block truncate"
|
||||||
|
>
|
||||||
|
{user.full_name || 'Unknown Name'}
|
||||||
|
</label>
|
||||||
|
<p className="text-xs text-muted-foreground truncate">{user.email}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={loading}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSave} disabled={loading || initLoading}>
|
||||||
|
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
Save Changes
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<>
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-[550px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Edit Association Details</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Update basic information for <strong>{association?.name}</strong>.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="grid gap-6 py-4">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="name" className="flex items-center gap-2">
|
||||||
|
<Building className="w-3.5 h-3.5 text-muted-foreground" />
|
||||||
|
Association Name
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
value={formData.name}
|
||||||
|
onChange={(e) => handleChange('name', e.target.value)}
|
||||||
|
placeholder="e.g. Sunset Valley HOA"
|
||||||
|
className="h-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="email" className="flex items-center gap-2">
|
||||||
|
<Mail className="w-3.5 h-3.5 text-muted-foreground" />
|
||||||
|
Email Address
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
value={formData.email}
|
||||||
|
onChange={(e) => handleChange('email', e.target.value)}
|
||||||
|
placeholder="contact@example.com"
|
||||||
|
className="h-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="phone" className="flex items-center gap-2">
|
||||||
|
<Phone className="w-3.5 h-3.5 text-muted-foreground" />
|
||||||
|
Phone Number
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="phone"
|
||||||
|
value={formData.phone}
|
||||||
|
onChange={(e) => handleChange('phone', e.target.value)}
|
||||||
|
placeholder="(555) 123-4567"
|
||||||
|
className="h-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="address">Address</Label>
|
||||||
|
<Input
|
||||||
|
id="address"
|
||||||
|
value={formData.address}
|
||||||
|
onChange={(e) => handleChange('address', e.target.value)}
|
||||||
|
placeholder="123 Main St"
|
||||||
|
className="h-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="city">City</Label>
|
||||||
|
<Input id="city" value={formData.city} onChange={(e) => handleChange('city', e.target.value)} className="h-10" />
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="state">State</Label>
|
||||||
|
<Input id="state" value={formData.state} onChange={(e) => handleChange('state', e.target.value)} className="h-10" />
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="zip">Zip</Label>
|
||||||
|
<Input id="zip" value={formData.zip} onChange={(e) => handleChange('zip', e.target.value)} className="h-10" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="flex flex-col-reverse sm:flex-row sm:justify-between items-center gap-2 sm:gap-0">
|
||||||
|
<div className="flex-1 flex justify-start w-full sm:w-auto mt-2 sm:mt-0">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setShowDeleteAlert(true)}
|
||||||
|
type="button"
|
||||||
|
className="text-destructive hover:text-destructive w-full sm:w-auto"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4 mr-2" />
|
||||||
|
Delete Association
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 w-full sm:w-auto justify-end">
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={loading}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSave} disabled={loading}>
|
||||||
|
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
{!loading && <Save className="w-4 h-4 mr-2" />}
|
||||||
|
Save Changes
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<AlertDialog open={showDeleteAlert} onOpenChange={setShowDeleteAlert}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
This action cannot be undone. This will permanently delete the association
|
||||||
|
<span className="font-semibold"> {association?.name}</span> and remove all associated data.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel disabled={isDeleting}>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
handleDelete();
|
||||||
|
}}
|
||||||
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
|
disabled={isDeleting}
|
||||||
|
>
|
||||||
|
{isDeleting ? <Loader2 className="w-4 h-4 animate-spin mr-2" /> : <Trash2 className="w-4 h-4 mr-2" />}
|
||||||
|
Delete Association
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<any[]>([]);
|
||||||
|
const [associationId, setAssociationId] = useState(initialAssocId || "");
|
||||||
|
const [documentName, setDocumentName] = useState(initialDocName || "");
|
||||||
|
const [emailSubject, setEmailSubject] = useState("");
|
||||||
|
const [emailBody, setEmailBody] = useState("");
|
||||||
|
const [recipients, setRecipients] = useState<Recipient[]>([{ name: "", email: "" }]);
|
||||||
|
const [file, setFile] = useState<File | null>(null);
|
||||||
|
const [sourceMode, setSourceMode] = useState<"upload" | "library">("upload");
|
||||||
|
const [libraryDocs, setLibraryDocs] = useState<any[]>([]);
|
||||||
|
const [selectedDocUrl, setSelectedDocUrl] = useState<string>(documentUrl || "");
|
||||||
|
const [fields, setFields] = useState<PlacedField[]>([]);
|
||||||
|
|
||||||
|
// Local preview URL (created from File for the placer)
|
||||||
|
const [localFileUrl, setLocalFileUrl] = useState<string>("");
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-3xl max-h-[90vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<FileSignature className="h-5 w-5 text-primary" />
|
||||||
|
{step === 1 ? "Send for Signature" : "Place Signature Fields"}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{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."}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{step === 1 ? (
|
||||||
|
<div className="space-y-4 py-2">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>Association (optional)</Label>
|
||||||
|
<Select value={associationId} onValueChange={setAssociationId}>
|
||||||
|
<SelectTrigger><SelectValue placeholder="Select association" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{associations.map(a => <SelectItem key={a.id} value={a.id}>{a.name}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>Document Source</Label>
|
||||||
|
<Tabs value={sourceMode} onValueChange={(v) => setSourceMode(v as any)}>
|
||||||
|
<TabsList className="grid grid-cols-2 w-full">
|
||||||
|
<TabsTrigger value="upload"><Upload className="h-3.5 w-3.5 mr-1" /> Upload</TabsTrigger>
|
||||||
|
<TabsTrigger value="library"><FolderOpen className="h-3.5 w-3.5 mr-1" /> Library</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
<TabsContent value="upload" className="pt-2">
|
||||||
|
<Input type="file" accept=".pdf" onChange={e => {
|
||||||
|
const f = e.target.files?.[0] || null; setFile(f); setSelectedDocUrl("");
|
||||||
|
if (f && !documentName) setDocumentName(f.name);
|
||||||
|
}} />
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="library" className="pt-2 max-h-48 overflow-y-auto border rounded-md">
|
||||||
|
{libraryDocs.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground p-3">No documents in library.</p>
|
||||||
|
) : libraryDocs.map(d => (
|
||||||
|
<button key={d.id} type="button" onClick={() => { 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}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>Document Name</Label>
|
||||||
|
<Input value={documentName} onChange={e => setDocumentName(e.target.value)} placeholder="e.g., Estoppel Certificate" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>Email Subject (optional)</Label>
|
||||||
|
<Input value={emailSubject} onChange={e => setEmailSubject(e.target.value)} placeholder={`Please sign: ${documentName || "Document"}`} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>Message (optional)</Label>
|
||||||
|
<Textarea value={emailBody} onChange={e => setEmailBody(e.target.value)} rows={2} placeholder="Please review and sign." />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label>Recipients</Label>
|
||||||
|
<Button type="button" variant="ghost" size="sm" onClick={addRecipient} className="gap-1 h-7 text-xs">
|
||||||
|
<UserPlus className="h-3 w-3" /> Add Signer
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{recipients.map((r, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-2">
|
||||||
|
<Input placeholder="Full name" value={r.name} onChange={e => updateRecipient(i, "name", e.target.value)} className="flex-1" />
|
||||||
|
<Input placeholder="Email" type="email" value={r.email} onChange={e => updateRecipient(i, "email", e.target.value)} className="flex-1" />
|
||||||
|
{recipients.length > 1 && (
|
||||||
|
<Button type="button" variant="ghost" size="icon" className="h-8 w-8 shrink-0" onClick={() => removeRecipient(i)}>
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="py-2">
|
||||||
|
{previewUrl ? (
|
||||||
|
<SignatureFieldPlacer
|
||||||
|
fileUrl={previewUrl}
|
||||||
|
recipients={validRecipients}
|
||||||
|
fields={fields}
|
||||||
|
onChange={setFields}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="border rounded p-6 text-sm text-muted-foreground text-center">
|
||||||
|
Document preview unavailable. You can still send without placed fields — signers will use the bottom of the document.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-muted-foreground mt-2">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DialogFooter className="gap-2">
|
||||||
|
{step === 2 && (
|
||||||
|
<Button variant="outline" onClick={() => setStep(1)} disabled={sending} className="gap-1 mr-auto">
|
||||||
|
<ChevronLeft className="h-4 w-4" /> Back
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={sending}>Cancel</Button>
|
||||||
|
{step === 1 ? (
|
||||||
|
<Button onClick={goToStep2} className="gap-1">
|
||||||
|
Next: Place Fields <ChevronRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button onClick={handleSend} disabled={sending} className="gap-2">
|
||||||
|
{sending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
|
||||||
|
{sending ? "Sending..." : "Send for Signature"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-[600px] overflow-y-auto max-h-[90vh]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2 text-xl">
|
||||||
|
<Landmark className="w-5 h-5 text-primary" />
|
||||||
|
{account ? 'Edit Bank Account' : 'Add New Bank Account'}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{account ? 'Update the details for this bank account.' : "Enter the details for this association's new bank account."}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="pt-2">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<Dialog open={isOpen} onOpenChange={(val) => !loading && onClose(val)}>
|
||||||
|
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<DollarSign className="w-5 h-5 text-green-600" />
|
||||||
|
Record Bank Deposit
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Record a new deposit for {bankAccount?.account_name} ({bankAccount?.bank_name}).
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<form id="deposit-form" onSubmit={handleSubmit} className="space-y-6 mt-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className={errors.deposit_date ? "text-destructive" : ""}>Deposit Date *</Label>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={formData.deposit_date}
|
||||||
|
onChange={e => setFormData({...formData, deposit_date: e.target.value})}
|
||||||
|
className={errors.deposit_date ? "border-destructive" : ""}
|
||||||
|
/>
|
||||||
|
{errors.deposit_date && <p className="text-xs text-destructive">{errors.deposit_date}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className={errors.deposit_type ? "text-destructive" : ""}>Deposit Type *</Label>
|
||||||
|
<Select value={formData.deposit_type} onValueChange={v => setFormData({...formData, deposit_type: v})}>
|
||||||
|
<SelectTrigger className={errors.deposit_type ? "border-destructive" : ""}>
|
||||||
|
<SelectValue placeholder="Select type..." />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="Manual Payment">Manual Payment</SelectItem>
|
||||||
|
<SelectItem value="Transfer In">Transfer In</SelectItem>
|
||||||
|
<SelectItem value="Other">Other</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{errors.deposit_type && <p className="text-xs text-destructive">{errors.deposit_type}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className={errors.amount ? "text-destructive" : ""}>Amount *</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0.01"
|
||||||
|
placeholder="0.00"
|
||||||
|
value={formData.amount}
|
||||||
|
onChange={e => setFormData({...formData, amount: e.target.value})}
|
||||||
|
className={errors.amount ? "border-destructive" : ""}
|
||||||
|
/>
|
||||||
|
{errors.amount && <p className="text-xs text-destructive">{errors.amount}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Reference #</Label>
|
||||||
|
<Input
|
||||||
|
placeholder="Check #, Transaction ID..."
|
||||||
|
value={formData.reference}
|
||||||
|
onChange={e => setFormData({...formData, reference: e.target.value})}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Description</Label>
|
||||||
|
<Input
|
||||||
|
placeholder="Brief description of the deposit"
|
||||||
|
value={formData.description}
|
||||||
|
onChange={e => setFormData({...formData, description: e.target.value})}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Notes</Label>
|
||||||
|
<Textarea
|
||||||
|
placeholder="Additional internal notes..."
|
||||||
|
value={formData.notes}
|
||||||
|
onChange={e => setFormData({...formData, notes: e.target.value})}
|
||||||
|
className="resize-none h-20"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="outline" onClick={onClose} disabled={loading}>Cancel</Button>
|
||||||
|
<Button type="submit" disabled={loading}>
|
||||||
|
{loading && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
|
||||||
|
Save Deposit
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-[425px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Add Bank Fee</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div className="bg-amber-50 p-4 rounded-md border border-amber-100 flex gap-3">
|
||||||
|
<AlertCircle className="w-5 h-5 text-amber-600 shrink-0 mt-0.5" />
|
||||||
|
<div className="text-sm text-amber-800">
|
||||||
|
<p className="font-semibold mb-1">Priority Deduction</p>
|
||||||
|
<p>Bank fees are deducted <strong>FIRST</strong> from any payments received, before Assessments, Interest, or other fees.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid w-full gap-2">
|
||||||
|
<Label>Fee Amount ($)</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
placeholder="0.00"
|
||||||
|
value={amount}
|
||||||
|
onChange={(e) => setAmount(e.target.value)}
|
||||||
|
className="font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid w-full gap-2">
|
||||||
|
<Label>Description</Label>
|
||||||
|
<Textarea
|
||||||
|
placeholder="e.g. Returned Check Fee, Wire Transfer Fee"
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>Cancel</Button>
|
||||||
|
<Button type="submit" variant="destructive">Add Bank Fee</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<>
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-4xl max-h-[90vh] flex flex-col p-0 gap-0 overflow-hidden">
|
||||||
|
<div className="p-6 pb-2 border-b">
|
||||||
|
<DialogHeader>
|
||||||
|
<div className="flex justify-between items-start gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<DialogTitle className="text-xl">{bid.title || bid.vendor_name}</DialogTitle>
|
||||||
|
<div className="flex items-center gap-3 text-sm text-muted-foreground">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Calendar className="w-3 h-3" />
|
||||||
|
{format(new Date(bid.created_at), 'MMM d, yyyy')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{isAdmin && (
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setDeleteDialogOpen(true)}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4 mr-2" />
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogHeader>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ScrollArea className="flex-1 p-6">
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="prose max-w-none text-sm">
|
||||||
|
<h3 className="text-lg font-semibold mb-2">Description</h3>
|
||||||
|
<div className="whitespace-pre-wrap">{bid.description || "No description provided."}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 border rounded-lg">
|
||||||
|
<h3 className="font-semibold mb-2">Details</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||||
|
<div><span className="text-muted-foreground">Vendor:</span> {bid.vendor_name}</div>
|
||||||
|
<div><span className="text-muted-foreground">Amount:</span> ${bid.amount?.toFixed(2)}</div>
|
||||||
|
<div><span className="text-muted-foreground">Status:</span> {bid.status}</div>
|
||||||
|
{bid.received_date && <div><span className="text-muted-foreground">Received:</span> {format(new Date(bid.received_date), 'MMM d, yyyy')}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
This action cannot be undone. This will permanently delete this bid/quote.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={handleDelete} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
|
||||||
|
Delete Bid/Quote
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-2xl max-h-[90vh] flex flex-col">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Create New Bid / Quote</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Create a new bid or quote for review by association members.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4 flex-1 overflow-y-auto pr-2">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="vendor_name"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Vendor Name</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="e.g. ABC Landscaping" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="amount"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Amount</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="number" step="0.01" placeholder="0.00" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="description"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Description</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Textarea
|
||||||
|
placeholder="Enter details about this bid..."
|
||||||
|
className="min-h-[100px]"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<FormLabel>Assign to Associations</FormLabel>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-auto p-0 text-xs text-primary"
|
||||||
|
onClick={selectAll}
|
||||||
|
>
|
||||||
|
{selectedAssociations.length === associations.length ? 'Deselect All' : 'Select All'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<ScrollArea className="h-[200px] border rounded-md p-4">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
|
{associations.map((assoc) => (
|
||||||
|
<div key={assoc.id} className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id={`assoc-${assoc.id}`}
|
||||||
|
checked={selectedAssociations.includes(assoc.id)}
|
||||||
|
onCheckedChange={() => toggleAssociation(assoc.id)}
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor={`assoc-${assoc.id}`}
|
||||||
|
className="text-sm leading-none cursor-pointer"
|
||||||
|
>
|
||||||
|
{assoc.name}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Selected: {selectedAssociations.length} associations
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="pt-4">
|
||||||
|
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={uploading}>
|
||||||
|
{uploading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Creating...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Create Bid'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 = () => (
|
||||||
|
<div className="py-6 flex flex-col items-center justify-center space-y-6">
|
||||||
|
<div className="w-16 h-16 bg-emerald-100 rounded-full flex items-center justify-center">
|
||||||
|
<CheckCircle2 className="w-10 h-10 text-emerald-600" />
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<h3 className="text-xl font-semibold">Bill Created Successfully</h3>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">Bill is now pending approval.</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={() => onOpenChange(false)} className="w-full">Close</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-[650px] max-h-[90vh] overflow-y-auto">
|
||||||
|
{!submittedData && (
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-xl">Create Bill & Request Approval</DialogTitle>
|
||||||
|
<DialogDescription>Review invoice data and select board members for approval.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{submittedData ? renderSuccessView() : (
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5 py-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="font-semibold">Association <span className="text-destructive">*</span></Label>
|
||||||
|
<Controller control={control} name="association_id" render={({ field }) => (
|
||||||
|
<Select onValueChange={field.onChange} value={field.value}>
|
||||||
|
<SelectTrigger className={errors.association_id ? 'border-destructive' : ''}><SelectValue placeholder="Select Association" /></SelectTrigger>
|
||||||
|
<SelectContent>{associations.map(c => <SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>)}</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="font-semibold">Invoice Number <span className="text-destructive">*</span></Label>
|
||||||
|
<Input {...register('invoice_number')} className={errors.invoice_number ? 'border-destructive' : ''} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="font-semibold">Vendor Name <span className="text-destructive">*</span></Label>
|
||||||
|
<Input {...register('vendor_name')} className={errors.vendor_name ? 'border-destructive' : ''} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="font-semibold">Amount <span className="text-destructive">*</span></Label>
|
||||||
|
<div className="relative">
|
||||||
|
<DollarSign className="absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input type="number" step="0.01" className={`pl-9 ${errors.amount ? 'border-destructive' : ''}`} placeholder="0.00" {...register('amount')} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="font-semibold">Invoice Date <span className="text-destructive">*</span></Label>
|
||||||
|
<Input type="date" {...register('invoice_date')} className={errors.invoice_date ? 'border-destructive' : ''} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="font-semibold">Due Date <span className="text-destructive">*</span></Label>
|
||||||
|
<Input type="date" {...register('due_date')} className={errors.due_date ? 'border-destructive' : ''} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="font-semibold flex items-center"><Users className="w-4 h-4 mr-2 text-primary"/> Required Approvers</Label>
|
||||||
|
<div className="border rounded-md p-3 max-h-[150px] overflow-y-auto bg-muted/30 space-y-2">
|
||||||
|
{!selectedAssociationId ? (
|
||||||
|
<p className="text-sm text-muted-foreground italic">Select an association to view approvers.</p>
|
||||||
|
) : boardMembers.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground italic">No board members with approval authority found.</p>
|
||||||
|
) : (
|
||||||
|
boardMembers.map(bm => (
|
||||||
|
<div key={bm.id} className="flex items-center space-x-2 bg-background p-2 rounded border">
|
||||||
|
<Checkbox
|
||||||
|
id={`bm-${bm.id}`}
|
||||||
|
checked={selectedBoardMembers.includes(bm.id)}
|
||||||
|
onCheckedChange={() => toggleBoardMember(bm.id)}
|
||||||
|
/>
|
||||||
|
<Label htmlFor={`bm-${bm.id}`} className="cursor-pointer flex-1">
|
||||||
|
<span className="font-medium">{bm.member_name}</span>
|
||||||
|
<span className="text-xs text-muted-foreground block">{bm.member_email}</span>
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="font-semibold">Notes</Label>
|
||||||
|
<Textarea placeholder="Optional notes..." {...register('description')} className="min-h-[60px]" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-4 border-t flex justify-end gap-3">
|
||||||
|
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={isSubmitting}>Cancel</Button>
|
||||||
|
<Button type="submit" disabled={isSubmitting || !isValid}>
|
||||||
|
{isSubmitting ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : 'Create & Request'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||||
|
<DialogContent className="sm:max-w-[500px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Edit Bill Details</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Modify the details of this bill. Paid bills cannot be altered.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{isPaid && (
|
||||||
|
<Alert className="bg-amber-50 border-amber-200 text-amber-800">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<AlertTitle>Read Only</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
This bill has been marked as paid and can no longer be edited.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Amount</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
disabled={isPaid || isSaving}
|
||||||
|
{...register('amount', { required: true, min: 0.01 })}
|
||||||
|
/>
|
||||||
|
{errors.amount && <span className="text-xs text-destructive">Valid amount required</span>}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Due Date</Label>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
disabled={isPaid || isSaving}
|
||||||
|
{...register('due_date', { required: true })}
|
||||||
|
/>
|
||||||
|
{errors.due_date && <span className="text-xs text-destructive">Date required</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Invoice Number</Label>
|
||||||
|
<Input
|
||||||
|
disabled={isPaid || isSaving}
|
||||||
|
{...register('invoice_number')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Status</Label>
|
||||||
|
<select
|
||||||
|
disabled={isPaid || isSaving}
|
||||||
|
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
{...register('status', { required: true })}
|
||||||
|
>
|
||||||
|
<option value="pending">Pending</option>
|
||||||
|
<option value="approved">Approved</option>
|
||||||
|
<option value="denied">Denied</option>
|
||||||
|
<option value="paid">Paid</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Description</Label>
|
||||||
|
<Textarea
|
||||||
|
disabled={isPaid || isSaving}
|
||||||
|
{...register('description')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="outline" onClick={onClose} disabled={isSaving}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={isPaid || isSaving}>
|
||||||
|
{isSaving && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
|
||||||
|
Save Changes
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Request Approval</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Select board members with approval authority to review this bill.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4 py-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Board Members</Label>
|
||||||
|
<div className="border rounded-md p-3 max-h-[200px] overflow-y-auto bg-muted/30 space-y-2">
|
||||||
|
{loadingMembers ? (
|
||||||
|
<p className="text-sm text-muted-foreground italic">Loading...</p>
|
||||||
|
) : boardMembers.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground italic">No board members found for this association.</p>
|
||||||
|
) : (
|
||||||
|
boardMembers.map(bm => (
|
||||||
|
<div key={bm.id} className="flex items-center space-x-2 bg-background p-2 rounded border">
|
||||||
|
<Checkbox
|
||||||
|
id={`req-bm-${bm.id}`}
|
||||||
|
checked={selectedMembers.includes(bm.id)}
|
||||||
|
onCheckedChange={() => toggleMember(bm.id)}
|
||||||
|
/>
|
||||||
|
<Label htmlFor={`req-bm-${bm.id}`} className="cursor-pointer flex-1">
|
||||||
|
<span className="font-medium">{bm.member_name}</span>
|
||||||
|
{bm.approval_authority && (
|
||||||
|
<span className="ml-2 text-xs px-1.5 py-0.5 rounded bg-primary/10 text-primary">Approver</span>
|
||||||
|
)}
|
||||||
|
{bm.member_email && <span className="text-xs text-muted-foreground block">{bm.member_email}</span>}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{selectedMembers.length > 0 && (
|
||||||
|
<p className="text-xs text-muted-foreground">{selectedMembers.length} member(s) selected</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Comments (Optional)</Label>
|
||||||
|
<Textarea
|
||||||
|
value={comment}
|
||||||
|
onChange={(e) => setComment(e.target.value)}
|
||||||
|
placeholder="Add any notes for the approvers..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isSubmitting}>Cancel</Button>
|
||||||
|
<Button onClick={handleSubmit} disabled={isSubmitting || selectedMembers.length === 0}>
|
||||||
|
{isSubmitting && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
|
||||||
|
Send to {selectedMembers.length || ''} Approver{selectedMembers.length !== 1 ? 's' : ''}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<Dialog open={open} onOpenChange={(val) => !isProcessing && onOpenChange(val)}>
|
||||||
|
<DialogContent className="sm:max-w-[700px] h-[90vh] sm:h-auto flex flex-col">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<FileText className="h-5 w-5 text-primary" /> Review Extracted Data
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Please verify the details extracted from the invoice below.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<ScrollArea className="flex-1 pr-4 -mr-4">
|
||||||
|
<div className="space-y-6 py-2 p-1">
|
||||||
|
{Object.keys(errors).length > 0 && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertTriangle className="h-4 w-4" />
|
||||||
|
<AlertTitle>Validation Errors</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
Please correct the highlighted fields before proceeding.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="vendor_name">Vendor Name <span className="text-destructive">*</span></Label>
|
||||||
|
<Input
|
||||||
|
id="vendor_name"
|
||||||
|
value={data.vendor_name || ''}
|
||||||
|
onChange={(e) => handleChange('vendor_name', e.target.value)}
|
||||||
|
className={errors.vendor_name ? "border-destructive" : ""}
|
||||||
|
/>
|
||||||
|
{errors.vendor_name && <p className="text-xs text-destructive">{errors.vendor_name}</p>}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="invoice_number">Invoice Number</Label>
|
||||||
|
<Input
|
||||||
|
id="invoice_number"
|
||||||
|
value={data.invoice_number || ''}
|
||||||
|
onChange={(e) => handleChange('invoice_number', e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="amount">Total Amount <span className="text-destructive">*</span></Label>
|
||||||
|
<div className="relative">
|
||||||
|
<span className="absolute left-3 top-2.5 text-muted-foreground">$</span>
|
||||||
|
<Input
|
||||||
|
id="amount"
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
className={`pl-7 ${errors.amount ? "border-destructive" : ""}`}
|
||||||
|
value={data.amount || ''}
|
||||||
|
onChange={(e) => handleChange('amount', e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{errors.amount && <p className="text-xs text-destructive">{errors.amount}</p>}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="bill_date">Invoice Date <span className="text-destructive">*</span></Label>
|
||||||
|
<Input
|
||||||
|
id="bill_date"
|
||||||
|
type="date"
|
||||||
|
value={data.bill_date || ''}
|
||||||
|
onChange={(e) => handleChange('bill_date', e.target.value)}
|
||||||
|
className={errors.bill_date ? "border-destructive" : ""}
|
||||||
|
/>
|
||||||
|
{errors.bill_date && <p className="text-xs text-destructive">{errors.bill_date}</p>}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="due_date">Due Date</Label>
|
||||||
|
<Input
|
||||||
|
id="due_date"
|
||||||
|
type="date"
|
||||||
|
value={data.due_date || ''}
|
||||||
|
onChange={(e) => handleChange('due_date', e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="description">Description/Notes</Label>
|
||||||
|
<Textarea
|
||||||
|
id="description"
|
||||||
|
value={data.description || ''}
|
||||||
|
onChange={(e) => handleChange('description', e.target.value)}
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
|
||||||
|
<DialogFooter className="mt-4 pt-4 border-t">
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isProcessing}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleConfirm} disabled={isProcessing} className="gap-2">
|
||||||
|
{isProcessing ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" /> Processing...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<CheckCircle className="h-4 w-4" /> Confirm & Create Bill
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Upload Bill PDF</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Upload a PDF invoice to automatically extract details.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4 py-4">
|
||||||
|
{error && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<AlertTitle>Error</AlertTitle>
|
||||||
|
<AlertDescription>{error}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
{...getRootProps()}
|
||||||
|
className={cn(
|
||||||
|
"border-2 border-dashed rounded-lg p-10 text-center cursor-pointer transition-colors relative overflow-hidden",
|
||||||
|
isDragActive ? "border-primary bg-primary/5" : "border-muted-foreground/25 hover:bg-muted/50",
|
||||||
|
loading && "pointer-events-none opacity-60"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<input {...getInputProps()} />
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex flex-col items-center justify-center space-y-3">
|
||||||
|
<Loader2 className="h-10 w-10 text-primary animate-spin" />
|
||||||
|
<p className="text-sm font-medium">Analyzing PDF...</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center justify-center space-y-3">
|
||||||
|
<div className="bg-muted p-3 rounded-full">
|
||||||
|
<Upload className="h-6 w-6 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-sm font-medium">Click to upload or drag and drop</p>
|
||||||
|
<p className="text-xs text-muted-foreground">PDF files only (max 10MB)</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => onOpenChange(false)} disabled={loading}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<Dialog open={open} onOpenChange={(val) => !loading && onOpenChange(val)}>
|
||||||
|
<DialogContent className="sm:max-w-[500px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Create New Board Vote</DialogTitle>
|
||||||
|
<DialogDescription>Setup a new voting item for board members.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4 py-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Association <span className="text-destructive">*</span></Label>
|
||||||
|
<Select onValueChange={(val) => reset(prev => ({ ...prev, association_id: val }))} required>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select Association" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{associations.map(assoc => (
|
||||||
|
<SelectItem key={assoc.id} value={assoc.id}>{assoc.name}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Title <span className="text-destructive">*</span></Label>
|
||||||
|
<Input {...register('title', { required: true })} placeholder="e.g. Approve 2024 Budget" />
|
||||||
|
{errors.title && <span className="text-xs text-destructive">Required</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Description</Label>
|
||||||
|
<Textarea {...register('description')} placeholder="Additional details..." rows={3} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label>Vote Options</Label>
|
||||||
|
<Button type="button" variant="ghost" size="sm" onClick={() => append({ value: '' })} className="h-6 text-xs">
|
||||||
|
<Plus className="w-3 h-3 mr-1" /> Add Option
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2 max-h-[150px] overflow-y-auto pr-1">
|
||||||
|
{fields.map((field, index) => (
|
||||||
|
<div key={field.id} className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
{...register(`vote_options.${index}.value`, { required: true })}
|
||||||
|
placeholder={`Option ${index + 1}`}
|
||||||
|
/>
|
||||||
|
{fields.length > 2 && (
|
||||||
|
<Button type="button" variant="ghost" size="icon" onClick={() => remove(index)}>
|
||||||
|
<Trash2 className="w-4 h-4 text-muted-foreground hover:text-destructive" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="pt-4">
|
||||||
|
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>Cancel</Button>
|
||||||
|
<Button type="submit" disabled={loading}>
|
||||||
|
{loading && <Loader2 className="w-4 h-4 mr-2 animate-spin" />} Create Vote
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label>Vote Options</Label>
|
||||||
|
<Button type="button" variant="ghost" size="sm" onClick={addOption} className="h-6 text-xs">
|
||||||
|
<Plus className="w-3 h-3 mr-1" /> Add Option
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2 max-h-[150px] overflow-y-auto pr-1">
|
||||||
|
{options.map((opt, i) => (
|
||||||
|
<div key={i} className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
value={opt}
|
||||||
|
onChange={(e) => updateOption(i, e.target.value)}
|
||||||
|
placeholder={`Option ${i + 1}`}
|
||||||
|
/>
|
||||||
|
{options.length > 2 && (
|
||||||
|
<Button type="button" variant="ghost" size="icon" onClick={() => removeOption(i)}>
|
||||||
|
<Trash2 className="w-4 h-4 text-muted-foreground hover:text-destructive" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">Minimum 2 options required. Board members can select only one.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<any[]>([]);
|
||||||
|
const [selectedOption, setSelectedOption] = useState("");
|
||||||
|
const [myVote, setMyVote] = useState<string | null>(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 <div className="flex justify-center py-4"><Loader2 className="h-5 w-5 animate-spin text-muted-foreground" /></div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="space-y-4 border rounded-lg p-4 bg-muted/20">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h4 className="font-semibold text-sm">Vote Options</h4>
|
||||||
|
<Badge variant={isOpen ? "default" : "secondary"}>{total} vote{total !== 1 ? "s" : ""} cast</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isOpen && winner && total > 0 && (
|
||||||
|
<div className="rounded-md border border-primary/30 bg-primary/5 p-3 text-sm">
|
||||||
|
<span className="font-semibold">Final Result: </span>
|
||||||
|
{isTie ? (
|
||||||
|
<span>Tie ({winner.count} vote{winner.count !== 1 ? "s" : ""} each)</span>
|
||||||
|
) : (
|
||||||
|
<span>{winner.option} wins with {winner.count} vote{winner.count !== 1 ? "s" : ""} ({winner.percentage}%)</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Results view - always shown */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
{results.map(r => (
|
||||||
|
<div key={r.option} className="space-y-1">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
{r.option}
|
||||||
|
{myVote === r.option && <CheckCircle className="h-3.5 w-3.5 text-primary" />}
|
||||||
|
</span>
|
||||||
|
<span className="text-muted-foreground">{r.count} ({r.percentage}%)</span>
|
||||||
|
</div>
|
||||||
|
<Progress value={r.percentage} className="h-2" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Voter list - shown when closed */}
|
||||||
|
{!isOpen && responses.length > 0 && (
|
||||||
|
<div className="border-t pt-4 space-y-2">
|
||||||
|
<h5 className="font-semibold text-sm">Voter List</h5>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{(voteOptions || []).map(opt => {
|
||||||
|
const voters = responses.filter((r: any) => r.vote_option === opt);
|
||||||
|
if (voters.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<div key={opt} className="text-sm">
|
||||||
|
<span className="font-medium">{opt}:</span>{" "}
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{voters.map((v: any) => v.profile?.full_name || v.profile?.email || "Unknown").join(", ")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Voting form - only if open and not yet voted */}
|
||||||
|
{isOpen && !hasVoted && (
|
||||||
|
<div className="border-t pt-4 space-y-3">
|
||||||
|
<p className="text-sm text-muted-foreground">Select one option to cast your vote:</p>
|
||||||
|
<RadioGroup value={selectedOption} onValueChange={setSelectedOption} className="gap-2">
|
||||||
|
{(voteOptions || []).map(opt => (
|
||||||
|
<div key={opt} className="flex items-center space-x-2 border rounded-md p-3 hover:bg-muted/50 cursor-pointer">
|
||||||
|
<RadioGroupItem value={opt} id={`vote-opt-${opt}`} />
|
||||||
|
<Label htmlFor={`vote-opt-${opt}`} className="flex-1 cursor-pointer text-sm">{opt}</Label>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</RadioGroup>
|
||||||
|
<Button size="sm" onClick={handleSubmit} disabled={loading || !selectedOption}>
|
||||||
|
{loading && <Loader2 className="h-4 w-4 mr-2 animate-spin" />} Submit Vote
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasVoted && isOpen && (
|
||||||
|
<p className="text-sm text-muted-foreground italic">You voted: <span className="font-medium text-foreground">{myVote}</span></p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<string, any>();
|
||||||
|
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<string, typeof allResponses>();
|
||||||
|
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 (
|
||||||
|
<Button variant="outline" size="sm" onClick={handleExport} disabled={generating} className="gap-2">
|
||||||
|
{generating ? <Loader2 className="h-4 w-4 animate-spin" /> : <FileDown className="h-4 w-4" />}
|
||||||
|
{generating ? "Generating..." : "Export PDF"}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<Dialog open={open} onOpenChange={(val) => !loading && onOpenChange(val)}>
|
||||||
|
<DialogContent className="sm:max-w-[400px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Record Vote</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Recording vote on behalf of <span className="font-semibold">{targetUser.full_name || targetUser.email}</span>
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="py-4 space-y-4">
|
||||||
|
<div>
|
||||||
|
<h4 className="font-medium text-sm mb-1">{vote.title}</h4>
|
||||||
|
<p className="text-xs text-muted-foreground">{vote.description}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<RadioGroup value={selectedOption} onValueChange={setSelectedOption} className="gap-3">
|
||||||
|
{(vote.vote_options || []).map((option) => (
|
||||||
|
<div key={option} className="flex items-center space-x-2 border rounded p-3 hover:bg-muted/50 cursor-pointer">
|
||||||
|
<RadioGroupItem value={option} id={`opt-${option}`} />
|
||||||
|
<Label htmlFor={`opt-${option}`} className="flex-1 cursor-pointer">{option}</Label>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</RadioGroup>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)}>Cancel</Button>
|
||||||
|
<Button onClick={handleSubmit} disabled={loading || !selectedOption}>
|
||||||
|
{loading && <Loader2 className="w-4 h-4 mr-2 animate-spin" />} Submit Vote
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-[425px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<UploadCloud className="w-5 h-5 text-primary" />
|
||||||
|
Import Budget (CSV)
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Upload a CSV file containing budget data. Make sure it includes account numbers and amounts.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="grid gap-4 py-4">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<label className="text-sm font-medium">Target Association</label>
|
||||||
|
<Select value={associationId} onValueChange={setAssociationId}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select Association" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="global">Global (All Associations)</SelectItem>
|
||||||
|
{associations.filter(c => c && c.id).map(c => (
|
||||||
|
<SelectItem key={c.id} value={String(c.id)}>
|
||||||
|
{c.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<label className="text-sm font-medium">CSV File</label>
|
||||||
|
<div
|
||||||
|
className="border-2 border-dashed rounded-lg p-6 flex flex-col items-center justify-center text-center hover:bg-muted/50 transition-colors cursor-pointer"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
>
|
||||||
|
<UploadCloud className="w-8 h-8 text-muted-foreground mb-2" />
|
||||||
|
{file ? (
|
||||||
|
<span className="text-sm font-medium">{file.name}</span>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span className="text-sm font-medium">Click to browse</span>
|
||||||
|
<span className="text-xs text-muted-foreground mt-1">Supports .csv files</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept=".csv"
|
||||||
|
className="hidden"
|
||||||
|
ref={fileInputRef}
|
||||||
|
onChange={handleFileChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-blue-50 text-blue-800 p-3 rounded-md flex gap-2 text-sm">
|
||||||
|
<AlertCircle className="w-5 h-5 shrink-0" />
|
||||||
|
<p>Ensure columns include <strong>Account Number</strong> and <strong>Annual Amount</strong>.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={loading}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="button" onClick={processImport} disabled={loading || !file}>
|
||||||
|
{loading ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : null}
|
||||||
|
Import Data
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<string, keyof Omit<ParsedAccount, "accountLabel" | "accountNumber" | "ownerNames" | "balance">> = {
|
||||||
|
"assessment": "assessments",
|
||||||
|
"legal": "legalFees",
|
||||||
|
"violation": "violations",
|
||||||
|
"administrative": "adminFees",
|
||||||
|
"admin": "adminFees",
|
||||||
|
"interest": "interest",
|
||||||
|
"late": "lateFees",
|
||||||
|
};
|
||||||
|
|
||||||
|
function classifyFeeType(label: string): keyof Omit<ParsedAccount, "accountLabel" | "accountNumber" | "ownerNames" | "balance"> | 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<ParsedAccount, "accountLabel" | "accountNumber" | "ownerNames" | "balance">][] = [
|
||||||
|
[/^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<ParsedAccount[]>([]);
|
||||||
|
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 (
|
||||||
|
<Dialog open={open} onOpenChange={handleClose}>
|
||||||
|
<DialogContent className="max-w-4xl max-h-[85vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Import Buildium AR Aging</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Paste the Buildium Outstanding Balances / AR Aging data directly from the Buildium page. The system will parse account rows and fee category breakdowns.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<Label>Association</Label>
|
||||||
|
<Select value={selectedAssociation} onValueChange={setSelectedAssociation}>
|
||||||
|
<SelectTrigger><SelectValue placeholder="Select association..." /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{associations.map(a => (
|
||||||
|
<SelectItem key={a.id} value={a.id}>{a.name}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label>Paste Buildium Data</Label>
|
||||||
|
<Textarea
|
||||||
|
placeholder="Paste the Buildium AR aging table content here..."
|
||||||
|
value={csvText}
|
||||||
|
onChange={(e) => { setCsvText(e.target.value); setParsed([]); setImported(false); }}
|
||||||
|
rows={8}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button onClick={handleParse} className="gap-2" disabled={!csvText.trim()}>
|
||||||
|
<Upload className="h-4 w-4" /> Parse Data
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{parsed.length > 0 && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-sm font-medium">{parsed.length} account(s) found:</p>
|
||||||
|
<div className="overflow-x-auto border rounded-md">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Account / Owner</TableHead>
|
||||||
|
<TableHead className="text-right">Assessments</TableHead>
|
||||||
|
<TableHead className="text-right">Late Fees</TableHead>
|
||||||
|
<TableHead className="text-right">Interest</TableHead>
|
||||||
|
<TableHead className="text-right">Legal</TableHead>
|
||||||
|
<TableHead className="text-right">Admin</TableHead>
|
||||||
|
<TableHead className="text-right">Violations</TableHead>
|
||||||
|
<TableHead className="text-right">Balance</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{parsed.map((a, i) => (
|
||||||
|
<TableRow key={i}>
|
||||||
|
<TableCell>
|
||||||
|
<div className="font-medium text-sm">{a.ownerNames || a.accountLabel}</div>
|
||||||
|
{a.accountNumber && <div className="text-xs text-muted-foreground">Acct: {a.accountNumber}</div>}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right tabular-nums">{fmt(a.assessments)}</TableCell>
|
||||||
|
<TableCell className="text-right tabular-nums">{fmt(a.lateFees)}</TableCell>
|
||||||
|
<TableCell className="text-right tabular-nums">{fmt(a.interest)}</TableCell>
|
||||||
|
<TableCell className="text-right tabular-nums">{fmt(a.legalFees)}</TableCell>
|
||||||
|
<TableCell className="text-right tabular-nums">{fmt(a.adminFees)}</TableCell>
|
||||||
|
<TableCell className="text-right tabular-nums">{fmt(a.violations)}</TableCell>
|
||||||
|
<TableCell className="text-right tabular-nums font-semibold">{fmt(a.balance)}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{imported ? (
|
||||||
|
<div className="flex items-center gap-2 text-emerald-600">
|
||||||
|
<CheckCircle2 className="h-5 w-5" /> Successfully imported!
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Button onClick={handleImport} disabled={importing || !selectedAssociation} className="gap-2">
|
||||||
|
{importing ? <Loader2 className="h-4 w-4 animate-spin" /> : <Upload className="h-4 w-4" />}
|
||||||
|
{importing ? "Importing..." : "Import All"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-[500px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Bulk Update Due Date</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Set a new deadline for <span className="font-semibold">{selectedCollections.length}</span> selected collections.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4 py-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="deadline">New Deadline</Label>
|
||||||
|
<Input
|
||||||
|
id="deadline"
|
||||||
|
type="date"
|
||||||
|
value={newDeadline}
|
||||||
|
onChange={(e) => setNewDeadline(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs text-muted-foreground">Selected Items</Label>
|
||||||
|
<ScrollArea className="h-[150px] w-full border rounded-md p-2 bg-muted/50">
|
||||||
|
{selectedCollections.length === 0 ? (
|
||||||
|
<div className="text-xs text-muted-foreground p-2">No items selected</div>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{selectedCollections.map(c => (
|
||||||
|
<li key={c.id} className="text-xs flex items-center gap-2 py-1 border-b last:border-0">
|
||||||
|
<CheckCircle2 className="w-3 h-3 text-emerald-500" />
|
||||||
|
<span className="font-medium truncate max-w-[200px]">
|
||||||
|
{c.address || c.id}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={loading}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleBulkUpdate}
|
||||||
|
disabled={!newDeadline || loading || selectedCollections.length === 0}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" /> Updating...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Update Date'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-[600px] max-h-[90vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Bulk Edit Collections</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Update multiple fields for <span className="font-semibold text-primary">{selectedCollections.length}</span> selected collections.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-6 py-4">
|
||||||
|
<div className="space-y-4 border rounded-md p-4 bg-muted/30">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="pt-3">
|
||||||
|
<input type="checkbox" id="check_status" checked={selectedFields.status} onChange={() => handleFieldSelect('status')} className="h-4 w-4 rounded border-input" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 space-y-2">
|
||||||
|
<Label htmlFor="check_status" className="cursor-pointer">Update Status</Label>
|
||||||
|
<Select value={values.status} onValueChange={(val) => handleValueChange('status', val)} disabled={!selectedFields.status}>
|
||||||
|
<SelectTrigger><SelectValue placeholder="Select Status..." /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{statusOptions.map(status => (
|
||||||
|
<SelectItem key={status} value={status}>{status}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="pt-3">
|
||||||
|
<input type="checkbox" id="check_deadline" checked={selectedFields.deadline} onChange={() => handleFieldSelect('deadline')} className="h-4 w-4 rounded border-input" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 space-y-2">
|
||||||
|
<Label htmlFor="check_deadline" className="cursor-pointer">Update Deadline</Label>
|
||||||
|
<Input type="date" value={values.deadline} onChange={(e) => handleValueChange('deadline', e.target.value)} disabled={!selectedFields.deadline} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="pt-3">
|
||||||
|
<input type="checkbox" id="check_amount" checked={selectedFields.amount_due} onChange={() => handleFieldSelect('amount_due')} className="h-4 w-4 rounded border-input" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 space-y-2">
|
||||||
|
<Label htmlFor="check_amount" className="cursor-pointer">Update Total Amount Due</Label>
|
||||||
|
<Input type="number" min="0" step="0.01" value={values.amount_due} onChange={(e) => handleValueChange('amount_due', e.target.value)} disabled={!selectedFields.amount_due} placeholder="0.00" />
|
||||||
|
<p className="text-[10px] text-muted-foreground">Note: This overrides the calculated total from individual fees.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs text-muted-foreground">Selected Items Preview</Label>
|
||||||
|
<ScrollArea className="h-[120px] w-full border rounded-md p-2">
|
||||||
|
{selectedCollections.length === 0 ? (
|
||||||
|
<div className="text-xs text-muted-foreground p-2">No items selected</div>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{selectedCollections.map(c => (
|
||||||
|
<li key={c.id} className="text-xs flex items-center gap-2 py-1 border-b last:border-0">
|
||||||
|
<CheckCircle2 className="w-3 h-3 text-emerald-500" />
|
||||||
|
<span className="font-medium truncate max-w-[200px]">{c.address || c.id}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-amber-50 border border-amber-200 rounded-md p-3 flex gap-3 items-start">
|
||||||
|
<AlertTriangle className="w-4 h-4 text-amber-600 shrink-0 mt-0.5" />
|
||||||
|
<div className="text-xs text-amber-800">
|
||||||
|
Are you sure? This action will overwrite data for all selected records and cannot be undone easily.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={loading}>Cancel</Button>
|
||||||
|
<Button onClick={handleBulkUpdate} disabled={loading || selectedCollections.length === 0 || (!selectedFields.status && !selectedFields.deadline && !selectedFields.amount_due)}>
|
||||||
|
{loading ? (<><Loader2 className="w-4 h-4 mr-2 animate-spin" /> Updating...</>) : 'Apply Changes'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-[500px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Bulk Update Status</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Update the status for <span className="font-semibold">{selectedCollections.length}</span> selected collections.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4 py-4">
|
||||||
|
<div className="bg-amber-50 border border-amber-200 rounded-md p-3 flex gap-3 items-start">
|
||||||
|
<AlertTriangle className="w-5 h-5 text-amber-600 shrink-0 mt-0.5" />
|
||||||
|
<div className="text-sm text-amber-800">
|
||||||
|
This action will overwrite the status for all selected items. This cannot be undone automatically.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>New Status</Label>
|
||||||
|
<Select value={newStatus} onValueChange={setNewStatus}>
|
||||||
|
<SelectTrigger><SelectValue placeholder="Select new status..." /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{statusOptions.map(status => (
|
||||||
|
<SelectItem key={status} value={status}>{status}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs text-muted-foreground">Selected Items</Label>
|
||||||
|
<ScrollArea className="h-[150px] w-full border rounded-md p-2 bg-muted/50">
|
||||||
|
{selectedCollections.length === 0 ? (
|
||||||
|
<div className="text-xs text-muted-foreground p-2">No items selected</div>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{selectedCollections.map(c => (
|
||||||
|
<li key={c.id} className="text-xs flex items-center gap-2 py-1 border-b last:border-0">
|
||||||
|
<CheckCircle2 className="w-3 h-3 text-emerald-500" />
|
||||||
|
<span className="font-medium truncate max-w-[200px]">{c.address || c.id}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={loading}>Cancel</Button>
|
||||||
|
<Button onClick={handleBulkUpdate} disabled={!newStatus || loading || selectedCollections.length === 0}>
|
||||||
|
{loading ? (<><Loader2 className="w-4 h-4 mr-2 animate-spin" /> Updating...</>) : 'Update Status'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-[425px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Bulk Edit Category</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Update category for <span className="font-semibold text-foreground">{count}</span> selected expense{count !== 1 ? 's' : ''}.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4 py-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Select Category</Label>
|
||||||
|
<Select
|
||||||
|
value={isCustom ? "custom" : category}
|
||||||
|
onValueChange={(val) => {
|
||||||
|
if (val === "custom") { setIsCustom(true); setCategory(""); }
|
||||||
|
else { setIsCustom(false); setCategory(val); }
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger><SelectValue placeholder="Choose a category..." /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{existingCategories.map((cat) => (
|
||||||
|
<SelectItem key={cat} value={cat}>{cat}</SelectItem>
|
||||||
|
))}
|
||||||
|
<SelectItem value="custom">Create New Category...</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isCustom && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>New Category Name</Label>
|
||||||
|
<Input value={customCategory} onChange={(e) => setCustomCategory(e.target.value)} placeholder="Enter category name" autoFocus />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>Cancel</Button>
|
||||||
|
<Button type="submit" disabled={isLoading || (isCustom ? !customCategory : !category)}>
|
||||||
|
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
Update Expenses
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-3xl h-[85vh] flex flex-col p-0 gap-0 overflow-hidden">
|
||||||
|
<DialogHeader className="p-6 pb-2 shrink-0">
|
||||||
|
<DialogTitle>Bulk Tag Editor</DialogTitle>
|
||||||
|
<DialogDescription>Select updates to apply or remove tags in bulk.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||||
|
<div className="grid grid-cols-2 gap-4 p-4 mx-6 mt-2 mb-4 bg-muted/50 border rounded-lg shrink-0">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs font-semibold uppercase text-green-600">Add Tags</Label>
|
||||||
|
<div className="flex flex-wrap gap-2 max-h-24 overflow-y-auto pr-1">
|
||||||
|
{availableTags.map(tag => (
|
||||||
|
<Badge key={tag.id} variant={tagsToAdd.some(t => 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 className="w-3 h-3 mr-1" />}{tag.name}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
{availableTags.length === 0 && <span className="text-xs text-muted-foreground">No tags available.</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs font-semibold uppercase text-red-600">Remove Tags</Label>
|
||||||
|
<div className="flex flex-wrap gap-2 max-h-24 overflow-y-auto pr-1">
|
||||||
|
{availableTags.map(tag => (
|
||||||
|
<Badge key={tag.id} variant={tagsToRemove.some(t => 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) && <X className="w-3 h-3 mr-1" />}{tag.name}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
{availableTags.length === 0 && <span className="text-xs text-muted-foreground">No tags available.</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col flex-1 min-h-0 mx-6 mb-4 border rounded-md overflow-hidden">
|
||||||
|
<div className="flex items-center gap-2 p-2 border-b shrink-0">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input placeholder="Search updates..." className="pl-9 h-9" value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground font-medium px-2">{selectedUpdates.length} selected</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-muted/50 p-2 border-b flex items-center gap-3 text-xs font-semibold text-muted-foreground shrink-0">
|
||||||
|
<Checkbox checked={filteredUpdates.length > 0 && selectedUpdates.length === filteredUpdates.length} onCheckedChange={handleSelectAll} />
|
||||||
|
<span>Select All Visible</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ScrollArea className="flex-1 w-full">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex justify-center p-8"><Loader2 className="w-6 h-6 animate-spin text-muted-foreground" /></div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y">
|
||||||
|
{filteredUpdates.length === 0 ? (
|
||||||
|
<div className="p-8 text-center text-muted-foreground text-sm">No updates found.</div>
|
||||||
|
) : (
|
||||||
|
filteredUpdates.map(update => (
|
||||||
|
<div key={update.id} className={cn("flex items-start gap-3 p-3 hover:bg-muted/50 transition-colors cursor-pointer", selectedUpdates.includes(update.id) && "bg-primary/5")} onClick={() => handleSelectUpdate(update.id)}>
|
||||||
|
<Checkbox checked={selectedUpdates.includes(update.id)} onCheckedChange={() => handleSelectUpdate(update.id)} className="mt-1" />
|
||||||
|
<div className="flex-1 min-w-0 space-y-1">
|
||||||
|
<div className="text-xs text-muted-foreground line-clamp-2" dangerouslySetInnerHTML={{ __html: update.content }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="p-4 border-t shrink-0">
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)}>Cancel</Button>
|
||||||
|
<Button onClick={handleSubmit} disabled={processing || selectedUpdates.length === 0}>
|
||||||
|
{processing ? (<><Loader2 className="w-4 h-4 mr-2 animate-spin" />Updating...</>) : `Update ${selectedUpdates.length} Items`}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<Dialog open={open} onOpenChange={handleClose}>
|
||||||
|
<DialogContent className="sm:max-w-[425px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Manage Proxy Text</DialogTitle>
|
||||||
|
<DialogDescription>This feature is currently unavailable.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="py-4">
|
||||||
|
<p className="text-sm text-muted-foreground">The proxy text configuration is not supported in the current database schema.</p>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button onClick={handleClose}>Close</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-[500px] max-h-[85vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Bulk Update Violations</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Updating <span className="font-semibold text-primary">{selectedIds.length}</span> selected violation(s).
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="grid gap-4 py-4">
|
||||||
|
<div className="space-y-4 border rounded-md p-4 bg-muted/30">
|
||||||
|
<h4 className="font-medium text-sm flex items-center gap-2">
|
||||||
|
<CalendarIcon className="w-4 h-4" /> Update Dates
|
||||||
|
</h4>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs mb-1.5 block">New Violation Date</Label>
|
||||||
|
<Input type="date" value={updates.violation_date} onChange={(e) => setUpdates(prev => ({ ...prev, violation_date: e.target.value }))} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs mb-1.5 block">New Due Date</Label>
|
||||||
|
<Input type="date" value={updates.due_date} onChange={(e) => setUpdates(prev => ({ ...prev, due_date: e.target.value }))} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h4 className="font-medium text-sm border-t pt-2">Other Properties</h4>
|
||||||
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label className="text-right text-xs">Status</Label>
|
||||||
|
<Select value={updates.status || 'no_change'} onValueChange={(val) => setUpdates(prev => ({ ...prev, status: val }))}>
|
||||||
|
<SelectTrigger className="col-span-3 h-8 text-xs"><SelectValue placeholder="No Change" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="no_change">No Change</SelectItem>
|
||||||
|
<SelectItem value="open">Open</SelectItem>
|
||||||
|
<SelectItem value="resolved">Resolved</SelectItem>
|
||||||
|
<SelectItem value="recommended_for_fining">Recommended for Fining</SelectItem>
|
||||||
|
<SelectItem value="fined">Fined</SelectItem>
|
||||||
|
<SelectItem value="closed">Closed</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label className="text-right text-xs">Stage</Label>
|
||||||
|
<Select value={updates.stage || 'no_change'} onValueChange={(val) => setUpdates(prev => ({ ...prev, stage: val }))}>
|
||||||
|
<SelectTrigger className="col-span-3 h-8 text-xs"><SelectValue placeholder="No Change" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="no_change">No Change</SelectItem>
|
||||||
|
<SelectItem value="First Notice">First Notice</SelectItem>
|
||||||
|
<SelectItem value="Second Notice">Second Notice</SelectItem>
|
||||||
|
<SelectItem value="Third & Final Notice">Third & Final Notice</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label className="text-right text-xs">Priority</Label>
|
||||||
|
<Select value={updates.priority || 'no_change'} onValueChange={(val) => setUpdates(prev => ({ ...prev, priority: val }))}>
|
||||||
|
<SelectTrigger className="col-span-3 h-8 text-xs"><SelectValue placeholder="No Change" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="no_change">No Change</SelectItem>
|
||||||
|
<SelectItem value="low">Low</SelectItem>
|
||||||
|
<SelectItem value="medium">Medium</SelectItem>
|
||||||
|
<SelectItem value="high">High</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2 pt-2">
|
||||||
|
<Label className="text-xs flex items-center gap-2">
|
||||||
|
<StickyNote className="w-3 h-3" /> Optional Notes (Overwrites existing)
|
||||||
|
</Label>
|
||||||
|
<Textarea value={updates.notes} onChange={(e) => setUpdates(prev => ({ ...prev, notes: e.target.value }))} placeholder="Add a note to all selected violations..." className="h-20 text-xs" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="ghost" onClick={() => onOpenChange(false)}>Cancel</Button>
|
||||||
|
<Button onClick={handleSubmit} disabled={loading}>
|
||||||
|
{loading ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : null}
|
||||||
|
Confirm Update
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-[500px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2 text-destructive">
|
||||||
|
<Lock className="w-5 h-5" />
|
||||||
|
{eventToEdit ? 'Edit Blocked Date' : 'Block a Date'}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>Blocking a date prevents scheduling on this day.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4 py-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="date">Date to Block</Label>
|
||||||
|
<Input id="date" type="date" value={formData.date} onChange={(e) => setFormData({ ...formData, date: e.target.value })} required />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2 border p-3 rounded-md bg-muted/30">
|
||||||
|
<Switch id="all-day" checked={formData.allDay} onCheckedChange={(checked) => setFormData({ ...formData, allDay: checked })} />
|
||||||
|
<Label htmlFor="all-day" className="flex-1 cursor-pointer font-medium">Block Entire Day</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!formData.allDay && (
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="start-time" className="flex items-center gap-1.5"><Clock className="w-3.5 h-3.5" /> Start Time</Label>
|
||||||
|
<Input id="start-time" type="time" value={formData.startTime} onChange={(e) => setFormData({ ...formData, startTime: e.target.value })} required />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="end-time" className="flex items-center gap-1.5"><Clock className="w-3.5 h-3.5" /> End Time</Label>
|
||||||
|
<Input id="end-time" type="time" value={formData.endTime} onChange={(e) => setFormData({ ...formData, endTime: e.target.value })} required />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="title">Label</Label>
|
||||||
|
<Input id="title" value={formData.title} onChange={(e) => setFormData({ ...formData, title: e.target.value })} placeholder="e.g. Holiday, Closed" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="description">Internal Note (Optional)</Label>
|
||||||
|
<Textarea id="description" value={formData.description} onChange={(e) => setFormData({ ...formData, description: e.target.value })} placeholder="Why is this blocked?" rows={3} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="flex justify-between sm:justify-between pt-2 mt-4">
|
||||||
|
{eventToEdit ? (
|
||||||
|
<Button type="button" variant="outline" className="text-destructive border-destructive/20 hover:bg-destructive/5" onClick={handleUnblock} disabled={loading}>
|
||||||
|
<Trash2 className="w-4 h-4 mr-2" /> Unblock
|
||||||
|
</Button>
|
||||||
|
) : <div />}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>Cancel</Button>
|
||||||
|
<Button type="submit" variant="destructive" disabled={loading}>
|
||||||
|
{loading && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
|
||||||
|
{eventToEdit ? 'Update Block' : 'Block Date'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<>
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-[600px] max-h-[90vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
{activeEvent ? 'Edit Event' : 'New Event'}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4 py-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Association</Label>
|
||||||
|
<Select value={formData.associationId} onValueChange={(val) => setFormData({...formData, associationId: val})}>
|
||||||
|
<SelectTrigger><SelectValue placeholder="Select Association" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{associations.map(c => (
|
||||||
|
<SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="title">Event Title</Label>
|
||||||
|
<Input id="title" value={formData.title} onChange={(e) => setFormData({ ...formData, title: e.target.value })} placeholder="e.g. Annual Meeting" required />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="date">Date</Label>
|
||||||
|
<Input id="date" type="date" value={formData.date} onChange={(e) => setFormData({ ...formData, date: e.target.value })} required />
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="type">Type</Label>
|
||||||
|
<Select value={formData.type} onValueChange={(val) => setFormData({...formData, type: val})}>
|
||||||
|
<SelectTrigger><SelectValue placeholder="Select type" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="meeting">Meeting</SelectItem>
|
||||||
|
<SelectItem value="deadline">Deadline</SelectItem>
|
||||||
|
<SelectItem value="reminder">Reminder</SelectItem>
|
||||||
|
<SelectItem value="inspection">Inspection</SelectItem>
|
||||||
|
<SelectItem value="other">Other</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2 py-2">
|
||||||
|
<Switch id="all-day" checked={formData.allDay} onCheckedChange={(checked) => setFormData({ ...formData, allDay: checked })} />
|
||||||
|
<Label htmlFor="all-day" className="flex items-center gap-2 cursor-pointer"><Clock className="w-4 h-4" /> All Day Event</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!formData.allDay && (
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="startTime">Start Time</Label>
|
||||||
|
<Input id="startTime" type="time" value={formData.startTime} onChange={(e) => setFormData({ ...formData, startTime: e.target.value })} required />
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="endTime">End Time</Label>
|
||||||
|
<Input id="endTime" type="time" value={formData.endTime} onChange={(e) => setFormData({ ...formData, endTime: e.target.value })} required />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="description">Description</Label>
|
||||||
|
<Textarea id="description" value={formData.description} onChange={(e) => setFormData({ ...formData, description: e.target.value })} placeholder="Add details..." rows={3} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="gap-2 sm:gap-0 flex flex-col sm:flex-row sm:justify-between w-full mt-4">
|
||||||
|
<div className="flex gap-2 order-2 sm:order-1">
|
||||||
|
{activeEvent && (
|
||||||
|
<Button type="button" variant="destructive" onClick={() => setShowDeleteConfirm(true)} size="sm">
|
||||||
|
<Trash2 className="w-4 h-4 mr-2" /> Delete
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 justify-end order-1 sm:order-2">
|
||||||
|
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>Close</Button>
|
||||||
|
<Button type="submit" disabled={loading}>
|
||||||
|
{loading ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
|
||||||
|
{activeEvent ? 'Save Changes' : 'Create Event'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<AlertDialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Delete Event</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>Are you sure you want to delete this event? This action cannot be undone.</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<div className="flex gap-3 justify-end">
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={handleDelete} disabled={loading} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
|
||||||
|
{loading ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null} Delete
|
||||||
|
</AlertDialogAction>
|
||||||
|
</div>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{callLog ? 'Edit Communication Log' : 'Add New Communication Log'}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{callLog ? 'Update the details for this communication.' : 'Fill out the form to add a new communication record.'}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<Label>Client / Category <span className="text-destructive">*</span></Label>
|
||||||
|
<Select value={form.association_id} onValueChange={(v) => setForm({ ...form, association_id: v })}>
|
||||||
|
<SelectTrigger><SelectValue placeholder="Select a client or category" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{SPECIAL_OPTIONS.map((opt) => (
|
||||||
|
<SelectItem key={opt} value={opt}>{opt}</SelectItem>
|
||||||
|
))}
|
||||||
|
{associations.map(a => <SelectItem key={a.id} value={a.id}>{a.name}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<Label>Caller Name</Label>
|
||||||
|
<Input value={form.caller_name} onChange={(e) => setForm({ ...form, caller_name: e.target.value })} placeholder="Enter caller's name" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Caller Number</Label>
|
||||||
|
<Input value={form.caller_phone} onChange={(e) => setForm({ ...form, caller_phone: e.target.value })} placeholder="Enter phone number" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<div>
|
||||||
|
<Label>Type <span className="text-destructive">*</span></Label>
|
||||||
|
<Select value={form.call_type} onValueChange={(v) => setForm({ ...form, call_type: v })}>
|
||||||
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="inbound">Inbound</SelectItem>
|
||||||
|
<SelectItem value="outbound">Outbound</SelectItem>
|
||||||
|
<SelectItem value="email">Email</SelectItem>
|
||||||
|
<SelectItem value="voicemail">Voicemail</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Status</Label>
|
||||||
|
<Select value={form.status} onValueChange={(v) => setForm({ ...form, status: v })}>
|
||||||
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="pending">Pending</SelectItem>
|
||||||
|
<SelectItem value="responded">Responded</SelectItem>
|
||||||
|
<SelectItem value="resolved">Resolved</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Duration (mins)</Label>
|
||||||
|
<Input type="number" value={form.duration} onChange={(e) => setForm({ ...form, duration: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Notes</Label>
|
||||||
|
<Textarea value={form.notes} onChange={(e) => setForm({ ...form, notes: e.target.value })} placeholder="Enter call notes..." rows={4} />
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end space-x-2 pt-4">
|
||||||
|
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>Cancel</Button>
|
||||||
|
<Button type="submit" disabled={loading}>
|
||||||
|
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
{loading ? 'Saving...' : callLog ? 'Update' : 'Create'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CallLogDialog;
|
||||||
@@ -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 (
|
||||||
|
<Dialog open={open} onOpenChange={handleClose}>
|
||||||
|
<DialogContent className="sm:max-w-[600px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<Shield className="w-5 h-5 text-blue-600" />
|
||||||
|
Secure Call Log Import
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Import call logs from CSV, Excel, or JSON. Data is validated for security and integrity.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4 py-2">
|
||||||
|
{!file ? (
|
||||||
|
<div className="border-2 border-dashed border-slate-200 rounded-lg p-8 flex flex-col items-center justify-center text-center hover:bg-slate-50 transition-colors">
|
||||||
|
<Upload className="w-10 h-10 text-slate-400 mb-4" />
|
||||||
|
<h3 className="font-medium text-slate-900">Upload Log File</h3>
|
||||||
|
<p className="text-sm text-slate-500 mb-4">CSV, Excel, JSON (max 5MB)</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" onClick={() => fileInputRef.current?.click()}>
|
||||||
|
Select File
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="sm" onClick={downloadTemplate} title="Download Template">
|
||||||
|
<Download className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
ref={fileInputRef}
|
||||||
|
className="hidden"
|
||||||
|
accept=".csv, .xlsx, .xls, .json"
|
||||||
|
onChange={handleFileChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between p-3 bg-slate-50 rounded border">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<FileText className="w-5 h-5 text-blue-500" />
|
||||||
|
<span className="font-medium text-sm truncate max-w-[300px]">{file.name}</span>
|
||||||
|
</div>
|
||||||
|
<Button variant="ghost" size="icon" onClick={() => { setFile(null); resetValidation(); }} disabled={importing || analyzing}>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!validationResult && (
|
||||||
|
<Button onClick={handleAnalyze} disabled={analyzing} className="w-full">
|
||||||
|
{analyzing ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : "Validate & Preview"}
|
||||||
|
{analyzing ? "Analyzing..." : ""}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{validationResult && (
|
||||||
|
<div className="animate-in fade-in slide-in-from-top-2">
|
||||||
|
{validationResult.valid ? (
|
||||||
|
<Alert className="bg-green-50 border-green-200">
|
||||||
|
<CheckCircle className="h-4 w-4 text-green-600" />
|
||||||
|
<AlertTitle className="text-green-800">Validation Passed</AlertTitle>
|
||||||
|
<AlertDescription className="text-green-700">
|
||||||
|
Ready to import {validationResult.sanitizedRecords.length} records.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertTriangle className="h-4 w-4" />
|
||||||
|
<AlertTitle>Validation Failed</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
Found {validationResult.errors.length} issues. Import blocked.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!validationResult.valid && (
|
||||||
|
<ScrollArea className="h-[150px] w-full border rounded mt-2 p-2 bg-red-50 text-xs text-red-700">
|
||||||
|
{validationResult.errors.map((err, i) => (
|
||||||
|
<div key={i} className="mb-1 border-b border-red-100 pb-1 last:border-0">
|
||||||
|
<strong>Row {err.row}:</strong> {err.messages.join(', ')}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</ScrollArea>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{importing && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex justify-between text-xs text-slate-500">
|
||||||
|
<span>Importing...</span>
|
||||||
|
<span>{progress}%</span>
|
||||||
|
</div>
|
||||||
|
<Progress value={progress} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="ghost" onClick={handleClose} disabled={importing}>Close</Button>
|
||||||
|
{validationResult?.valid && (
|
||||||
|
<Button onClick={executeImport} disabled={importing} className="bg-green-600 hover:bg-green-700">
|
||||||
|
{importing ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : "Confirm Import"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<Dialog open={open} onOpenChange={handleClose}>
|
||||||
|
<DialogContent className="sm:max-w-[600px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<RotateCcw className="w-5 h-5 text-amber-600" />
|
||||||
|
Call Log Restoration
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Scan and verify integrity of recent call logs.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="py-4">
|
||||||
|
{step === 'analyze' && (
|
||||||
|
<div className="flex flex-col items-center justify-center py-8">
|
||||||
|
<Loader2 className="w-8 h-8 animate-spin text-blue-600 mb-4" />
|
||||||
|
<p className="text-sm text-slate-500">Scanning call logs...</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 'review' && analysis && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4 flex items-start gap-3">
|
||||||
|
<AlertTriangle className="w-5 h-5 text-amber-600 shrink-0 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<h4 className="font-medium text-amber-900 text-sm">Review Findings</h4>
|
||||||
|
<p className="text-sm text-amber-800 mt-1">
|
||||||
|
Found <strong>{analysis.count}</strong> recent call logs.
|
||||||
|
{analysis.issues.length > 0
|
||||||
|
? ` Detected ${analysis.issues.length} potential integrity issues.`
|
||||||
|
: " No critical issues detected."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-sm text-slate-600">
|
||||||
|
Proceeding will attempt to correct any metadata issues and verify record consistency.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 'restoring' && (
|
||||||
|
<div className="flex flex-col items-center justify-center py-8">
|
||||||
|
<Loader2 className="w-8 h-8 animate-spin text-green-600 mb-4" />
|
||||||
|
<p className="text-sm text-slate-500">Processing records...</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 'result' && result && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="bg-green-50 border border-green-200 rounded-lg p-4 flex items-start gap-3">
|
||||||
|
<CheckCircle className="w-5 h-5 text-green-600 shrink-0 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<h4 className="font-medium text-green-900 text-sm">Process Complete</h4>
|
||||||
|
<p className="text-sm text-green-800 mt-1">
|
||||||
|
Successfully processed {result.successCount} records.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{result.errors.length > 0 && (
|
||||||
|
<ScrollArea className="h-[100px] w-full border rounded-md p-2 bg-red-50 text-xs text-red-700">
|
||||||
|
{result.errors.map((e, i) => (
|
||||||
|
<div key={i} className="mb-1">Error with ID {e.id}: {e.message}</div>
|
||||||
|
))}
|
||||||
|
</ScrollArea>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
{step === 'review' && (
|
||||||
|
<>
|
||||||
|
<Button variant="outline" onClick={handleClose}>Cancel</Button>
|
||||||
|
<Button onClick={handleRestore} className="bg-amber-600 hover:bg-amber-700 text-white">
|
||||||
|
Proceed
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{step === 'result' && (
|
||||||
|
<Button onClick={handleClose}>Close</Button>
|
||||||
|
)}
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<Select value={value || undefined} onValueChange={(nextValue) => onChange(nextValue === NONE_VALUE ? '' : nextValue)} disabled={disabled}>
|
||||||
|
<SelectTrigger className={cn("text-xs", className)}>
|
||||||
|
<SelectValue placeholder={placeholder} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="max-h-[400px]">
|
||||||
|
<SelectItem value={NONE_VALUE}>None</SelectItem>
|
||||||
|
{orderedAccounts.map(a => (
|
||||||
|
<SelectItem key={a.id} value={a.id}>
|
||||||
|
<span style={{ paddingLeft: `${(a._depth || 0) * 12}px` }}>
|
||||||
|
{a._depth > 0 ? '↳ ' : ''}{a.account_number} - {a.account_name}
|
||||||
|
</span>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<string | null>(null);
|
||||||
|
const [layout, setLayout] = useState<CheckLayout>(DEFAULTS);
|
||||||
|
const [otherLayouts, setOtherLayouts] = useState<Array<{ id: string; association_id: string; association_name: string }>>([]);
|
||||||
|
const [copyFromId, setCopyFromId] = useState<string>("");
|
||||||
|
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 = <K extends keyof CheckLayout>(key: K, value: CheckLayout[K]) => {
|
||||||
|
setLayout((prev) => ({ ...prev, [key]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateField = (key: CheckFieldKey, patch: Partial<CheckFieldPosition>) => {
|
||||||
|
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 <div className="flex justify-center py-12"><Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /></div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between gap-3 flex-wrap">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold">Check Layout</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Customize how printed checks look for this association. Applied automatically when printing checks.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 items-center flex-wrap">
|
||||||
|
{otherLayouts.length > 0 && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Select value={copyFromId} onValueChange={setCopyFromId}>
|
||||||
|
<SelectTrigger className="w-[240px]">
|
||||||
|
<SelectValue placeholder="Copy settings from…" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{otherLayouts.map((l) => (
|
||||||
|
<SelectItem key={l.id} value={l.id}>{l.association_name}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
disabled={!copyFromId}
|
||||||
|
onClick={() => setConfirmCopyOpen(true)}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
<Copy className="h-4 w-4" /> Copy
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Button variant="outline" onClick={handlePreview} className="gap-2">
|
||||||
|
<Printer className="h-4 w-4" /> Preview PDF
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSave} disabled={saving} className="gap-2">
|
||||||
|
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
|
||||||
|
{saving ? "Saving…" : "Save Layout"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AlertDialog open={confirmCopyOpen} onOpenChange={setConfirmCopyOpen}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Copy check layout settings?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
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".
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel disabled={copying}>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={handleCopyFrom} disabled={copying}>
|
||||||
|
{copying ? "Copying…" : "Copy settings"}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader><CardTitle className="text-base">Page & Typography</CardTitle></CardHeader>
|
||||||
|
<CardContent className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||||
|
<div>
|
||||||
|
<Label>Check Position</Label>
|
||||||
|
<Select
|
||||||
|
value={layout.check_position || "top"}
|
||||||
|
onValueChange={(v) => update("check_position", v as CheckLayout["check_position"])}
|
||||||
|
>
|
||||||
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="top">Top of page</SelectItem>
|
||||||
|
<SelectItem value="middle">Middle of page</SelectItem>
|
||||||
|
<SelectItem value="bottom">Bottom of page</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">Match your check stock layout.</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Font Family</Label>
|
||||||
|
<Select
|
||||||
|
value={layout.font_family || "helvetica"}
|
||||||
|
onValueChange={(v) => update("font_family", v)}
|
||||||
|
>
|
||||||
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{AVAILABLE_FONTS.map(f => (
|
||||||
|
<SelectItem key={f.value} value={f.value}>{f.label}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-end">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Switch
|
||||||
|
id="show_labels"
|
||||||
|
checked={!!layout.show_field_labels}
|
||||||
|
onCheckedChange={(v) => update("show_field_labels", v)}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="show_labels" className="cursor-pointer">Print field labels (Payee, Memo, Date…)</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Global Horizontal offset (in)</Label>
|
||||||
|
<Input type="number" step="0.05" value={layout.offset_x ?? 0}
|
||||||
|
onChange={(e) => update("offset_x", Number(e.target.value))} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Global Vertical offset (in)</Label>
|
||||||
|
<Input type="number" step="0.05" value={layout.offset_y ?? 0}
|
||||||
|
onChange={(e) => update("offset_y", Number(e.target.value))} />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader><CardTitle className="text-base">Logo</CardTitle></CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Switch id="show_logo" checked={!!layout.show_logo}
|
||||||
|
onCheckedChange={(v) => update("show_logo", v)} />
|
||||||
|
<Label htmlFor="show_logo" className="cursor-pointer">Print logo on the check</Label>
|
||||||
|
</div>
|
||||||
|
{layout.logo_url ? (
|
||||||
|
<div className="flex items-center gap-3 p-3 border rounded-md bg-muted/30">
|
||||||
|
<img src={layout.logo_url} alt="logo" className="h-14 max-w-[160px] object-contain bg-background rounded border p-1" />
|
||||||
|
<Button type="button" variant="ghost" size="sm"
|
||||||
|
onClick={() => update("logo_url", "")} className="gap-1 text-destructive">
|
||||||
|
<Trash2 className="h-4 w-4" /> Remove
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<label className="inline-flex items-center gap-2 px-3 py-2 border rounded-md cursor-pointer hover:bg-muted text-sm">
|
||||||
|
{uploadingLogo ? <Loader2 className="h-4 w-4 animate-spin" /> : <Upload className="h-4 w-4" />}
|
||||||
|
{uploadingLogo ? "Uploading…" : "Upload logo (PNG)"}
|
||||||
|
<input type="file" accept="image/png,image/jpeg" className="hidden"
|
||||||
|
onChange={(e) => {
|
||||||
|
const f = e.target.files?.[0];
|
||||||
|
if (f) handleImageUpload(f, "check-logos", "logo_url");
|
||||||
|
}} />
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Adjust logo size and position in the per-field editor below (under "Logo").
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader><CardTitle className="text-base">Payer Block</CardTitle></CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Switch id="show_payer" checked={!!layout.show_payer_block}
|
||||||
|
onCheckedChange={(v) => update("show_payer_block", v)} />
|
||||||
|
<Label htmlFor="show_payer" className="cursor-pointer">Show payer name & address</Label>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Payer Name</Label>
|
||||||
|
<Input value={layout.payer_name || ""}
|
||||||
|
onChange={(e) => update("payer_name", e.target.value)}
|
||||||
|
placeholder={associationName || "Association Name"} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Payer Address</Label>
|
||||||
|
<Textarea rows={3} value={layout.payer_address || ""}
|
||||||
|
onChange={(e) => update("payer_address", e.target.value)}
|
||||||
|
placeholder={"123 Main Street\nCity, ST 12345"} />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader><CardTitle className="text-base">Signature</CardTitle></CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Switch id="show_sig" checked={!!layout.show_signature_line}
|
||||||
|
onCheckedChange={(v) => update("show_signature_line", v)} />
|
||||||
|
<Label htmlFor="show_sig" className="cursor-pointer">Show signature line</Label>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Signature Label</Label>
|
||||||
|
<Input value={layout.signature_label || ""}
|
||||||
|
onChange={(e) => update("signature_label", e.target.value)}
|
||||||
|
placeholder="Authorized Signature" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Signature Image (optional)</Label>
|
||||||
|
<p className="text-xs text-muted-foreground mb-2">
|
||||||
|
Upload a PNG of the signature. Adjust its position and size freely in the per-field editor below (under "Signature Image") — no constraints.
|
||||||
|
</p>
|
||||||
|
{layout.signature_image_url ? (
|
||||||
|
<div className="flex items-center gap-3 p-3 border rounded-md bg-muted/30">
|
||||||
|
<img src={layout.signature_image_url} alt="signature" className="h-12 max-w-[200px] object-contain" />
|
||||||
|
<Button type="button" variant="ghost" size="sm"
|
||||||
|
onClick={() => update("signature_image_url", "")} className="gap-1 text-destructive">
|
||||||
|
<Trash2 className="h-4 w-4" /> Remove
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<label className="inline-flex items-center gap-2 px-3 py-2 border rounded-md cursor-pointer hover:bg-muted text-sm">
|
||||||
|
{uploading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Upload className="h-4 w-4" />}
|
||||||
|
{uploading ? "Uploading…" : "Upload signature image"}
|
||||||
|
<input type="file" accept="image/png,image/jpeg" className="hidden"
|
||||||
|
onChange={(e) => {
|
||||||
|
const f = e.target.files?.[0];
|
||||||
|
if (f) handleImageUpload(f, "check-signatures", "signature_image_url");
|
||||||
|
}} />
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader><CardTitle className="text-base">Memo & Footer</CardTitle></CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<Label>Memo Prefix</Label>
|
||||||
|
<Input value={layout.memo_prefix || ""}
|
||||||
|
onChange={(e) => update("memo_prefix", e.target.value)}
|
||||||
|
placeholder="e.g. HOA Dues —" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Footer Text</Label>
|
||||||
|
<Input value={layout.footer_text || ""}
|
||||||
|
onChange={(e) => update("footer_text", e.target.value)}
|
||||||
|
placeholder="e.g. Void after 90 days" />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Per-Field Position, Size & Labels</CardTitle>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2">
|
||||||
|
<div className="grid grid-cols-12 gap-2 text-xs font-semibold text-muted-foreground px-2">
|
||||||
|
<div className="col-span-3">Field</div>
|
||||||
|
<div className="col-span-1 text-center">Show</div>
|
||||||
|
<div className="col-span-1">X (in)</div>
|
||||||
|
<div className="col-span-1">Y (in)</div>
|
||||||
|
<div className="col-span-1">Size</div>
|
||||||
|
<div className="col-span-4">Label</div>
|
||||||
|
<div className="col-span-1" />
|
||||||
|
</div>
|
||||||
|
{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 (
|
||||||
|
<div key={key} className="grid grid-cols-12 gap-2 items-center px-2 py-1.5 rounded-md hover:bg-muted/40 border border-transparent hover:border-border">
|
||||||
|
<div className="col-span-3 text-sm">{FIELD_LABELS[key]}</div>
|
||||||
|
<div className="col-span-1 flex justify-center">
|
||||||
|
<Switch
|
||||||
|
checked={(cur.visible ?? def.visible) !== false}
|
||||||
|
onCheckedChange={(v) => updateField(key, { visible: v })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-1">
|
||||||
|
<Input type="number" step="0.05" className="h-8"
|
||||||
|
value={cur.x ?? def.x ?? 0}
|
||||||
|
onChange={(e) => updateField(key, { x: Number(e.target.value) })} />
|
||||||
|
</div>
|
||||||
|
<div className="col-span-1">
|
||||||
|
<Input type="number" step="0.05" className="h-8"
|
||||||
|
value={cur.y ?? def.y ?? 0}
|
||||||
|
onChange={(e) => updateField(key, { y: Number(e.target.value) })} />
|
||||||
|
</div>
|
||||||
|
<div className="col-span-1">
|
||||||
|
{isImage ? (
|
||||||
|
<Input type="number" step="0.05" className="h-8"
|
||||||
|
placeholder="W"
|
||||||
|
value={cur.width ?? def.width ?? 0}
|
||||||
|
onChange={(e) => updateField(key, { width: Number(e.target.value) })} />
|
||||||
|
) : (
|
||||||
|
<Input type="number" step="1" className="h-8"
|
||||||
|
value={cur.font_size ?? def.font_size ?? 10}
|
||||||
|
onChange={(e) => updateField(key, { font_size: Number(e.target.value) })} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="col-span-4">
|
||||||
|
{isImage ? (
|
||||||
|
<Input type="number" step="0.05" className="h-8"
|
||||||
|
placeholder="Height (in)"
|
||||||
|
value={cur.height ?? def.height ?? 0}
|
||||||
|
onChange={(e) => updateField(key, { height: Number(e.target.value) })} />
|
||||||
|
) : (
|
||||||
|
<Input className="h-8"
|
||||||
|
placeholder={def.label || "(no label)"}
|
||||||
|
value={cur.label ?? def.label ?? ""}
|
||||||
|
onChange={(e) => updateField(key, { label: e.target.value })} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="col-span-1 text-right">
|
||||||
|
<Button type="button" variant="ghost" size="sm" className="h-8 text-xs"
|
||||||
|
onClick={() => resetField(key)} title="Reset to default">
|
||||||
|
Reset
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<Dialog open={open} onOpenChange={handleClose}>
|
||||||
|
<DialogContent className="sm:max-w-[800px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Import Checklists from CSV</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Upload a CSV file to bulk create checklist templates.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert variant="destructive" className="mb-4">
|
||||||
|
<AlertTriangle className="h-4 w-4" />
|
||||||
|
<AlertTitle>Error</AlertTitle>
|
||||||
|
<AlertDescription>{error}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 'upload' && (
|
||||||
|
<div className="py-8 space-y-6">
|
||||||
|
<div
|
||||||
|
className="border-2 border-dashed border-slate-200 rounded-lg p-10 text-center hover:bg-slate-50 transition-colors cursor-pointer"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
>
|
||||||
|
<Upload className="mx-auto h-12 w-12 text-slate-400 mb-3" />
|
||||||
|
<p className="text-sm font-medium text-slate-900">Click to upload CSV</p>
|
||||||
|
<p className="text-xs text-slate-500 mt-1">or drag and drop file here</p>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept=".csv"
|
||||||
|
ref={fileInputRef}
|
||||||
|
onChange={handleFileChange}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-blue-50 p-4 rounded-md flex items-start gap-3 text-sm text-blue-700">
|
||||||
|
<HelpCircle className="h-5 w-5 shrink-0 mt-0.5" />
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="font-semibold">CSV Format Requirements:</p>
|
||||||
|
<ul className="list-disc list-inside space-y-1 text-xs">
|
||||||
|
<li>Headers must be exactly: <strong>Checklist Name, Items, Description</strong></li>
|
||||||
|
<li><strong>Items</strong> should be separated by a pipe character (|) e.g. "Task 1|Task 2|Task 3"</li>
|
||||||
|
<li><strong>Description</strong> is optional.</li>
|
||||||
|
</ul>
|
||||||
|
<div className="mt-2 text-xs bg-white p-2 rounded border border-blue-200 font-mono">
|
||||||
|
Checklist Name,Items,Description<br/>
|
||||||
|
"Weekly Audit","Check Lights|Check Doors|Empty Trash","Weekly facility check"<br/>
|
||||||
|
"Safety Insp","Fire Extinguishers|Exits Clear","Monthly safety"
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 'preview' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h4 className="text-sm font-medium">Preview Data ({parsedData.length} rows)</h4>
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => setStep('upload')}>Upload Different File</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ScrollArea className="h-[300px] border rounded-md">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Checklist Name *</TableHead>
|
||||||
|
<TableHead>Items (Pipe Separated) *</TableHead>
|
||||||
|
<TableHead>Description</TableHead>
|
||||||
|
<TableHead className="w-[50px]"></TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{parsedData.map((row, idx) => (
|
||||||
|
<TableRow key={idx} className={!row.valid ? "bg-red-50" : ""}>
|
||||||
|
<TableCell>
|
||||||
|
<Input
|
||||||
|
value={row.title}
|
||||||
|
onChange={(e) => handleCellEdit(idx, 'title', e.target.value)}
|
||||||
|
className="h-8 text-xs"
|
||||||
|
placeholder="Required"
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Input
|
||||||
|
value={row.itemsRaw}
|
||||||
|
onChange={(e) => handleCellEdit(idx, 'itemsRaw', e.target.value)}
|
||||||
|
className="h-8 text-xs"
|
||||||
|
placeholder="Item 1|Item 2..."
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Input
|
||||||
|
value={row.description}
|
||||||
|
onChange={(e) => handleCellEdit(idx, 'description', e.target.value)}
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Button variant="ghost" size="icon" className="h-6 w-6 text-slate-400 hover:text-red-500" onClick={() => removeRow(idx)}>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</ScrollArea>
|
||||||
|
|
||||||
|
<div className="flex justify-between items-center text-xs text-slate-500">
|
||||||
|
<span>* Required fields</span>
|
||||||
|
<span>{parsedData.filter(r => !r.valid).length} invalid rows will be skipped</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={handleClose}>Cancel</Button>
|
||||||
|
{step === 'preview' && (
|
||||||
|
<Button onClick={handleImport} disabled={importing || parsedData.filter(r => r.valid).length === 0}>
|
||||||
|
{importing && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
Import {parsedData.filter(r => r.valid).length} Templates
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Import Checklist</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Upload a file (CSV, JSON, TXT) to import checklist items. First row/key will be used as headers.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4 py-4">
|
||||||
|
<div className="grid w-full max-w-sm items-center gap-1.5">
|
||||||
|
<Label htmlFor="checklist-file">Checklist File</Label>
|
||||||
|
<Input
|
||||||
|
ref={fileInputRef}
|
||||||
|
id="checklist-file"
|
||||||
|
type="file"
|
||||||
|
accept=".json,.csv,.txt"
|
||||||
|
onChange={handleFileChange}
|
||||||
|
className="cursor-pointer"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading && (
|
||||||
|
<div className="flex items-center justify-center py-8 text-slate-500">
|
||||||
|
<Loader2 className="w-6 h-6 animate-spin mr-3" />
|
||||||
|
Parsing file contents...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<AlertDescription>{error}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{items.length > 0 && !loading && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label>Preview ({items.length} items)</Label>
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => {
|
||||||
|
setFile(null);
|
||||||
|
setItems([]);
|
||||||
|
setHeaders([]);
|
||||||
|
if(fileInputRef.current) fileInputRef.current.value = '';
|
||||||
|
}}>Clear</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border rounded-md">
|
||||||
|
<ScrollArea className="h-[250px] w-full bg-slate-50/50">
|
||||||
|
<Table>
|
||||||
|
<TableHeader className="bg-slate-100 sticky top-0">
|
||||||
|
<TableRow>
|
||||||
|
{headers.map((h, i) => (
|
||||||
|
<TableHead key={i} className="text-xs font-semibold">{h}</TableHead>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{items.slice(0, 10).map((item, idx) => (
|
||||||
|
<TableRow key={idx} className="text-xs">
|
||||||
|
{headers.map((h, i) => (
|
||||||
|
<TableCell key={i} className="py-2">{item[h] || '-'}</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</ScrollArea>
|
||||||
|
{items.length > 10 && (
|
||||||
|
<div className="p-2 text-center text-xs text-slate-400 border-t bg-slate-50">
|
||||||
|
And {items.length - 10} more items...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)}>Cancel</Button>
|
||||||
|
<Button onClick={handleNext} disabled={items.length === 0 || loading}>
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-4xl bg-slate-50 text-slate-900 max-h-[90vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-2xl font-bold">{isEditMode ? 'Edit Association' : 'Add New Association'}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{isEditMode ? 'Update the association\'s details below.' : 'Fill in the details for the new association.'}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-6 py-4">
|
||||||
|
{/* Logo Upload Section */}
|
||||||
|
<div className="bg-white p-4 rounded-lg border shadow-sm">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="font-semibold text-sm">Association Logo</Label>
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{formData.logo_url ? (
|
||||||
|
<div className="relative w-full h-32 bg-slate-100 rounded border flex items-center justify-center overflow-hidden group">
|
||||||
|
<img src={formData.logo_url} alt="Logo" className="max-h-full max-w-full object-contain" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={removeLogo}
|
||||||
|
className="absolute top-2 right-2 bg-red-100 p-1 rounded-full text-red-600 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-32 bg-slate-100 rounded border border-dashed border-slate-300 flex flex-col items-center justify-center text-slate-400">
|
||||||
|
<ImageIcon className="w-8 h-8 mb-2" />
|
||||||
|
<span className="text-xs">No logo uploaded</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
onChange={handleFileUpload}
|
||||||
|
className="cursor-pointer text-xs"
|
||||||
|
disabled={uploadingLogo}
|
||||||
|
/>
|
||||||
|
{uploadingLogo && <Loader2 className="w-4 h-4 animate-spin" />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div className="flex flex-col space-y-2">
|
||||||
|
<Label htmlFor="name" className="font-semibold text-sm">Name *</Label>
|
||||||
|
<Input id="name" name="name" value={formData.name} onChange={handleChange} required className="bg-white" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col space-y-2">
|
||||||
|
<Label htmlFor="email" className="font-semibold text-sm">Email</Label>
|
||||||
|
<Input id="email" name="email" type="email" value={formData.email} onChange={handleChange} className="bg-white" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col space-y-2">
|
||||||
|
<Label htmlFor="phone" className="font-semibold text-sm">Phone</Label>
|
||||||
|
<Input id="phone" name="phone" value={formData.phone} onChange={handleChange} className="bg-white" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col space-y-2">
|
||||||
|
<Label htmlFor="address" className="font-semibold text-sm">Address</Label>
|
||||||
|
<Input id="address" name="address" value={formData.address} onChange={handleChange} className="bg-white" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col space-y-2">
|
||||||
|
<Label htmlFor="city" className="font-semibold text-sm">City</Label>
|
||||||
|
<Input id="city" name="city" value={formData.city} onChange={handleChange} className="bg-white" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col space-y-2">
|
||||||
|
<Label htmlFor="state" className="font-semibold text-sm">State</Label>
|
||||||
|
<Input id="state" name="state" value={formData.state} onChange={handleChange} className="bg-white" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col space-y-2">
|
||||||
|
<Label htmlFor="zip" className="font-semibold text-sm">Zip</Label>
|
||||||
|
<Input id="zip" name="zip" value={formData.zip} onChange={handleChange} className="bg-white" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={isSubmitting}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" className="bg-blue-900 hover:bg-blue-800" disabled={isSubmitting}>
|
||||||
|
{isSubmitting ? 'Saving...' : (isEditMode ? 'Save Changes' : 'Add Association')}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ClientDialog;
|
||||||
@@ -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 (
|
||||||
|
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
{trigger || (
|
||||||
|
<Button variant="outline" size="sm" className="gap-2">
|
||||||
|
<Mail className="h-4 w-4" />
|
||||||
|
Manage Inbound Email
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="sm:max-w-[500px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Inbound Email Configuration</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Configuration for <strong>{clientName}</strong>.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="py-4 space-y-6">
|
||||||
|
{loading && !emailData ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-8 gap-3">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-blue-600" />
|
||||||
|
<p className="text-sm text-slate-500">Communicating with server...</p>
|
||||||
|
</div>
|
||||||
|
) : emailData ? (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs font-semibold uppercase text-slate-500">Active Inbound Address</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Input readOnly value={emailData.email_address} className="pr-10 font-mono bg-slate-50 text-slate-700 border-slate-300" />
|
||||||
|
<ShieldCheck className="absolute right-3 top-3 h-4 w-4 text-green-500" />
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="icon" onClick={handleCopy} className="shrink-0">
|
||||||
|
{copied ? <Check className="h-4 w-4 text-green-600" /> : <Copy className="h-4 w-4" />}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-slate-400">
|
||||||
|
Created on {new Date(emailData.created_at).toLocaleDateString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Alert className="bg-blue-50 border-blue-100 text-blue-800">
|
||||||
|
<Mail className="h-4 w-4 text-blue-600" />
|
||||||
|
<AlertTitle className="text-blue-900 font-semibold ml-2">How it works</AlertTitle>
|
||||||
|
<AlertDescription className="text-blue-800/90 text-xs mt-1 ml-2 leading-relaxed">
|
||||||
|
Emails sent to this address are automatically processed.
|
||||||
|
The content is extracted and added as a <strong>Status Update</strong> for this association.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<div className="border-t pt-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-slate-900">Regenerate Address</p>
|
||||||
|
<p className="text-xs text-slate-500">Create a new random address if spam occurs.</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={generateEmail}
|
||||||
|
disabled={loading}
|
||||||
|
className="text-red-500 hover:text-red-600 hover:bg-red-50"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`h-3 w-3 mr-2 ${loading ? 'animate-spin' : ''}`} />
|
||||||
|
Regenerate
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-6 space-y-6">
|
||||||
|
<div className="mx-auto bg-slate-100 p-6 rounded-full w-20 h-20 flex items-center justify-center border border-slate-200">
|
||||||
|
<Mail className="h-10 w-10 text-slate-400" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2 max-w-xs mx-auto">
|
||||||
|
<h4 className="font-semibold text-slate-900">No Email Address Assigned</h4>
|
||||||
|
<p className="text-sm text-slate-500">Generate a unique inbound address for {clientName} to enable email-to-status features.</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={generateEmail} disabled={loading} className="w-full max-w-xs bg-blue-600 hover:bg-blue-700">
|
||||||
|
{loading ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : <RefreshCw className="h-4 w-4 mr-2" />}
|
||||||
|
Generate Email Address
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-[700px] max-h-[90vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<div className="flex justify-between items-start pr-4">
|
||||||
|
<div>
|
||||||
|
<DialogTitle className="text-2xl font-bold text-slate-900">Collection Details</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Review the complete information for this collection record.
|
||||||
|
</DialogDescription>
|
||||||
|
</div>
|
||||||
|
<span className={`inline-flex items-center px-3 py-1 rounded-full text-xs font-bold capitalize shadow-sm border ${getStatusColor(collection.status)}`}>
|
||||||
|
{collection.status || 'Pending'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="grid gap-6 py-2">
|
||||||
|
<div className="flex items-center justify-between p-5 bg-white shadow-sm rounded-lg border border-slate-200">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-xs font-bold text-slate-400 uppercase tracking-wider">Owner</p>
|
||||||
|
<p className="font-bold text-lg text-slate-900">{collection.owners?.first_name} {collection.owners?.last_name}</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right space-y-1">
|
||||||
|
<p className="text-xs font-bold text-slate-400 uppercase tracking-wider">Total Outstanding</p>
|
||||||
|
<p className="text-3xl font-bold text-emerald-600">{formatCurrency(collection.amount_owed)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div className="flex items-start space-x-3">
|
||||||
|
<div className="p-2 bg-blue-50 rounded-lg">
|
||||||
|
<User className="w-5 h-5 text-blue-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-slate-500">Association</p>
|
||||||
|
<p className="text-slate-900 font-semibold">{collection.associations?.name || 'N/A'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div className="flex items-start space-x-3">
|
||||||
|
<div className="p-2 bg-red-50 rounded-lg">
|
||||||
|
<Clock className="w-5 h-5 text-red-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-slate-500">Last Notice Date</p>
|
||||||
|
<p className="text-slate-900 font-semibold">{formatDate(collection.last_notice_date)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-start space-x-3">
|
||||||
|
<div className="p-2 bg-slate-100 rounded-lg">
|
||||||
|
<Calendar className="w-5 h-5 text-slate-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-slate-500">Date Created</p>
|
||||||
|
<p className="text-slate-900 font-medium">{formatDate(collection.created_at)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{collection.notes && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-sm font-bold text-slate-700">Notes & History</p>
|
||||||
|
<div className="p-4 bg-yellow-50/50 rounded-lg border border-yellow-100 text-slate-700 text-sm leading-relaxed">
|
||||||
|
{collection.notes}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="sm:justify-between items-center border-t pt-4">
|
||||||
|
<div className="text-xs text-slate-400 flex items-center mt-4 sm:mt-0">
|
||||||
|
Last updated: {formatDate(collection.updated_at)}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="lg"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
className="w-full sm:w-auto bg-slate-900 hover:bg-slate-800"
|
||||||
|
>
|
||||||
|
Close Details
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-[500px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{collection ? 'Edit Collection' : 'Add New Collection'}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="association_id">Association *</Label>
|
||||||
|
<select
|
||||||
|
id="association_id"
|
||||||
|
value={formData.association_id}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
>
|
||||||
|
<option value="">Select an association...</option>
|
||||||
|
{associations.map((a) => (
|
||||||
|
<option key={a.id} value={a.id}>{a.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="unit_id">Unit</Label>
|
||||||
|
<select
|
||||||
|
id="unit_id"
|
||||||
|
value={formData.unit_id}
|
||||||
|
onChange={(e) => 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}
|
||||||
|
>
|
||||||
|
<option value="">Select a unit...</option>
|
||||||
|
{units.map((u) => (
|
||||||
|
<option key={u.id} value={u.id}>{u.unit_number}{u.address ? ` - ${u.address}` : ''}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="owner_id">Owner</Label>
|
||||||
|
<select
|
||||||
|
id="owner_id"
|
||||||
|
value={formData.owner_id}
|
||||||
|
onChange={(e) => 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}
|
||||||
|
>
|
||||||
|
<option value="">Select an owner...</option>
|
||||||
|
{owners.map((o) => (
|
||||||
|
<option key={o.id} value={o.id}>{o.first_name} {o.last_name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="status">Status *</Label>
|
||||||
|
<select
|
||||||
|
id="status"
|
||||||
|
value={formData.status}
|
||||||
|
onChange={(e) => 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) => (
|
||||||
|
<option key={status} value={status}>{status}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="last_notice_date">Last Notice Date</Label>
|
||||||
|
<input
|
||||||
|
id="last_notice_date"
|
||||||
|
type="date"
|
||||||
|
value={formData.last_notice_date}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="amount_owed">Amount Owed ($)</Label>
|
||||||
|
<input
|
||||||
|
id="amount_owed"
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
placeholder="0.00"
|
||||||
|
value={formData.amount_owed}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="notes">Notes</Label>
|
||||||
|
<textarea
|
||||||
|
id="notes"
|
||||||
|
value={formData.notes}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end space-x-2 pt-4">
|
||||||
|
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>Cancel</Button>
|
||||||
|
<Button type="submit" disabled={loading}>
|
||||||
|
{loading ? 'Saving...' : collection ? 'Update' : 'Create'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CollectionDialog;
|
||||||
@@ -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 (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{collection ? 'Financial Record' : 'Add Financial Record'}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Update the financial breakdown and status for this collection case.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit(handleSave)} className="space-y-4 py-4">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="association_id">Association <span className="text-red-500">*</span></Label>
|
||||||
|
<Select
|
||||||
|
onValueChange={(val) => setValue('association_id', val)}
|
||||||
|
defaultValue={collection?.association_id}
|
||||||
|
disabled={!!collection}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="association_id" className={errors.association_id ? "border-red-500" : ""}>
|
||||||
|
<SelectValue placeholder="Select Association" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{associations?.map(a => (
|
||||||
|
<SelectItem key={a.id} value={a.id}>{a.name}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{errors.association_id && <p className="text-red-500 text-xs">{errors.association_id.message}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="status">Status <span className="text-red-500">*</span></Label>
|
||||||
|
<Select
|
||||||
|
onValueChange={(val) => setValue('status', val)}
|
||||||
|
defaultValue={collection?.status || 'open'}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="status" className={errors.status ? "border-red-500" : ""}>
|
||||||
|
<SelectValue placeholder="Select Status" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="open">Open</SelectItem>
|
||||||
|
<SelectItem value="in_progress">In Progress</SelectItem>
|
||||||
|
<SelectItem value="resolved">Resolved</SelectItem>
|
||||||
|
<SelectItem value="closed">Closed</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{errors.status && <p className="text-red-500 text-xs">{errors.status.message}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="amount_owed">Amount Owed ($) <span className="text-red-500">*</span></Label>
|
||||||
|
<Input
|
||||||
|
id="amount_owed"
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
{...register('amount_owed')}
|
||||||
|
className={errors.amount_owed ? "border-red-500" : ""}
|
||||||
|
/>
|
||||||
|
{errors.amount_owed && <p className="text-red-500 text-xs">{errors.amount_owed.message}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="last_notice_date">Last Notice Date</Label>
|
||||||
|
<Input
|
||||||
|
id="last_notice_date"
|
||||||
|
type="date"
|
||||||
|
{...register('last_notice_date')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="notes">Notes</Label>
|
||||||
|
<textarea
|
||||||
|
id="notes"
|
||||||
|
{...register('notes')}
|
||||||
|
rows={3}
|
||||||
|
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-teal-500 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="mt-6">
|
||||||
|
<Button variant="outline" type="button" onClick={() => onOpenChange(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={isSubmitting || !isValid}>
|
||||||
|
{isSubmitting ? 'Saving...' : (collection ? 'Update Record' : 'Create Record')}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-[425px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Export {getStatusLabel()} Financial Report</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Generate a detailed financial breakdown PDF for your {getStatusLabel().toLowerCase()} collections.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="py-4 space-y-2 text-sm text-slate-500">
|
||||||
|
<p>This report includes financial details for {collections?.length || 0} visible records.</p>
|
||||||
|
<div className="bg-slate-50 p-3 rounded border text-xs">
|
||||||
|
<strong>Included Columns:</strong>
|
||||||
|
<ul className="list-disc pl-4 mt-1 space-y-0.5">
|
||||||
|
<li>Owner Name</li>
|
||||||
|
<li>Total Amount Owed</li>
|
||||||
|
<li>Last Notice Date</li>
|
||||||
|
<li>Status</li>
|
||||||
|
</ul>
|
||||||
|
<p className="mt-2 text-slate-400 italic">Report Date: {todayEST} (EST)</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleExport} disabled={isGenerating}>
|
||||||
|
{isGenerating ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Generating...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<FileDown className="mr-2 h-4 w-4" />
|
||||||
|
Download Report
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<Popover open={open} onOpenChange={setOpen} modal>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={open}
|
||||||
|
disabled={disabled}
|
||||||
|
className={cn("justify-between font-normal", className)}
|
||||||
|
>
|
||||||
|
<span className="truncate">{selected?.label || placeholder}</span>
|
||||||
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-[--radix-popover-trigger-width] p-0 z-[9999]" align="start">
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder="Search..." />
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty>{emptyText}</CommandEmpty>
|
||||||
|
<CommandGroup className="max-h-64 overflow-y-auto">
|
||||||
|
{options.map(option => (
|
||||||
|
<CommandItem
|
||||||
|
key={option.value}
|
||||||
|
value={option.label}
|
||||||
|
onSelect={() => { onChange(option.value); setOpen(false); }}
|
||||||
|
>
|
||||||
|
<Check className={cn("mr-2 h-4 w-4", value === option.value ? "opacity-100" : "opacity-0")} />
|
||||||
|
<span className="truncate">{option.label}</span>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="p-4 text-sm text-muted-foreground">
|
||||||
|
No custom variables available for this association.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 (
|
||||||
|
<ScrollArea className="max-h-60">
|
||||||
|
<div className="p-2 space-y-2">
|
||||||
|
{Object.entries(grouped).map(([category, vars]) => (
|
||||||
|
<div key={category}>
|
||||||
|
<div className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground px-2 py-1">
|
||||||
|
{category}
|
||||||
|
</div>
|
||||||
|
{vars.map((v) => (
|
||||||
|
<Button
|
||||||
|
key={v.id}
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="w-full justify-start text-sm font-mono"
|
||||||
|
onClick={() => {
|
||||||
|
onSelect?.(v.variable_name);
|
||||||
|
onClose?.();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="text-primary">{`{{${v.variable_name}}}`}</span>
|
||||||
|
<span className="ml-2 text-muted-foreground font-sans text-xs truncate">{v.display_label}</span>
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<any[]>([]);
|
||||||
|
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<CustomVariable | null>(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<Record<string, CustomVariable[]>>((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 (
|
||||||
|
<div className="p-6 space-y-5 max-w-5xl mx-auto">
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-bold text-foreground flex items-center gap-2">
|
||||||
|
<Variable className="h-5 w-5 text-primary" /> Custom Variables
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-muted-foreground mt-0.5">
|
||||||
|
Create reusable variables for letters, forms, and notices. Use <code className="bg-muted px-1 rounded text-xs font-mono">{"{{variable_name}}"}</code> in your templates.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Select value={selectedAssocId} onValueChange={setSelectedAssocId}>
|
||||||
|
<SelectTrigger className="w-[220px] h-9 text-xs">
|
||||||
|
<SelectValue placeholder="Select Association" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{associations.map(a => <SelectItem key={a.id} value={a.id}>{a.name}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button size="sm" className="gap-1.5" onClick={openCreate} disabled={!selectedAssocId}>
|
||||||
|
<Plus className="h-4 w-4" /> Add Variable
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search */}
|
||||||
|
<div className="relative max-w-sm">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input placeholder="Search variables..." className="pl-10" value={search} onChange={e => setSearch(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Variables Table */}
|
||||||
|
{loading ? (
|
||||||
|
<div className="space-y-2">{Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-12 w-full" />)}</div>
|
||||||
|
) : !selectedAssocId ? (
|
||||||
|
<Card><CardContent className="py-12 text-center text-muted-foreground">Select an association to manage its custom variables.</CardContent></Card>
|
||||||
|
) : filtered.length === 0 ? (
|
||||||
|
<Card><CardContent className="py-12 text-center text-muted-foreground">
|
||||||
|
{search ? "No variables match your search." : "No custom variables yet. Click \"Add Variable\" to create one."}
|
||||||
|
</CardContent></Card>
|
||||||
|
) : (
|
||||||
|
Object.entries(grouped).map(([category, vars]) => (
|
||||||
|
<Card key={category} className="border shadow-sm">
|
||||||
|
<CardHeader className="py-3 px-4">
|
||||||
|
<CardTitle className="text-sm font-semibold capitalize">{category}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="text-xs">Variable Tag</TableHead>
|
||||||
|
<TableHead className="text-xs">Label</TableHead>
|
||||||
|
<TableHead className="text-xs">Default Value</TableHead>
|
||||||
|
<TableHead className="text-xs">Description</TableHead>
|
||||||
|
<TableHead className="text-xs text-right">Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{vars.map(v => (
|
||||||
|
<TableRow key={v.id}>
|
||||||
|
<TableCell>
|
||||||
|
<button onClick={() => copyTag(v.variable_name)} className="flex items-center gap-1.5 group" title="Click to copy">
|
||||||
|
<code className="font-mono text-xs font-semibold text-primary bg-primary/10 px-2 py-0.5 rounded">
|
||||||
|
{`{{${v.variable_name}}}`}
|
||||||
|
</code>
|
||||||
|
<Copy className="h-3 w-3 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||||
|
</button>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm font-medium">{v.display_label}</TableCell>
|
||||||
|
<TableCell className="text-sm text-muted-foreground">{v.default_value || "—"}</TableCell>
|
||||||
|
<TableCell className="text-sm text-muted-foreground max-w-[200px] truncate">{v.description || "—"}</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<div className="flex justify-end gap-1">
|
||||||
|
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => openEdit(v)}>
|
||||||
|
<Edit2 className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive" onClick={() => handleDelete(v)}>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</Card>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Create/Edit Dialog */}
|
||||||
|
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||||
|
<DialogContent className="max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{editing ? "Edit Variable" : "Create Variable"}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{editing ? "Update this custom variable." : "Define a new variable to use in your templates."}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Variable Name</Label>
|
||||||
|
<Input
|
||||||
|
value={form.variable_name}
|
||||||
|
onChange={e => setForm({ ...form, variable_name: e.target.value })}
|
||||||
|
placeholder="e.g. late_fee_amount"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Use in templates as: <code className="bg-muted px-1 rounded font-mono">{`{{${form.variable_name.replace(/[{}]/g, "").replace(/\s+/g, "_").toLowerCase() || "variable_name"}}}`}</code>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Display Label</Label>
|
||||||
|
<Input value={form.display_label} onChange={e => setForm({ ...form, display_label: e.target.value })} placeholder="e.g. Late Fee Amount" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Default Value</Label>
|
||||||
|
<Input value={form.default_value} onChange={e => setForm({ ...form, default_value: e.target.value })} placeholder="e.g. $25.00" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Category</Label>
|
||||||
|
<Select value={form.category} onValueChange={v => setForm({ ...form, category: v })}>
|
||||||
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{CATEGORIES.map(c => <SelectItem key={c} value={c} className="capitalize">{c}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Description</Label>
|
||||||
|
<Input value={form.description} onChange={e => setForm({ ...form, description: e.target.value })} placeholder="What this variable represents..." />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
||||||
|
<Button type="button" variant="outline" onClick={() => setDialogOpen(false)}>Cancel</Button>
|
||||||
|
<Button type="button" onClick={handleSave} disabled={saving}>
|
||||||
|
{saving ? "Saving..." : editing ? "Save Changes" : "Create Variable"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-[600px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2 text-xl">
|
||||||
|
Request Details
|
||||||
|
<Badge className={
|
||||||
|
request.status === 'approved' ? 'bg-green-100 text-green-800' :
|
||||||
|
request.status === 'rejected' ? 'bg-red-100 text-red-800' :
|
||||||
|
'bg-yellow-100 text-yellow-800'
|
||||||
|
}>
|
||||||
|
{request.status?.toUpperCase()}
|
||||||
|
</Badge>
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Review the details submitted by the client.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="grid gap-6 py-4">
|
||||||
|
<div className="flex items-start gap-4 p-4 bg-muted rounded-lg border">
|
||||||
|
<div className="bg-background p-2 rounded-full border shadow-sm">
|
||||||
|
<User className="w-5 h-5 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="font-semibold">{request.associations?.name || request.requester_name}</h4>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">Request ID: {request.id?.slice(0, 8)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wide flex items-center gap-1">
|
||||||
|
<Calendar className="w-3 h-3" /> Requested Date
|
||||||
|
</span>
|
||||||
|
<p className="font-medium text-lg">
|
||||||
|
{request.created_at ? format(parseISO(request.created_at), 'MMMM d, yyyy') : 'N/A'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wide flex items-center gap-1">
|
||||||
|
<Clock className="w-3 h-3" /> Priority
|
||||||
|
</span>
|
||||||
|
<p className="font-medium text-lg capitalize">
|
||||||
|
{request.priority || 'Medium'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wide block mb-1">
|
||||||
|
Title
|
||||||
|
</span>
|
||||||
|
<p className="text-sm capitalize bg-muted inline-block px-2 py-1 rounded border">
|
||||||
|
{request.title || 'General'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{request.description && (
|
||||||
|
<div>
|
||||||
|
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wide block mb-1">
|
||||||
|
Description
|
||||||
|
</span>
|
||||||
|
<p className="text-sm leading-relaxed bg-muted p-3 rounded-md border">
|
||||||
|
{request.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{request.status === 'pending' || request.status === 'open' ? (
|
||||||
|
<div className="border-t pt-4 mt-2">
|
||||||
|
{!showRejectInput ? (
|
||||||
|
<div className="flex gap-3 justify-end">
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => setShowRejectInput(true)}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
<XCircle className="w-4 h-4 mr-2" /> Reject Request
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleApprove}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : <CheckCircle className="w-4 h-4 mr-2" />}
|
||||||
|
Approve & Schedule
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3 bg-destructive/10 p-4 rounded-lg border border-destructive/20">
|
||||||
|
<div className="flex items-center gap-2 text-destructive font-semibold mb-2">
|
||||||
|
<AlertTriangle className="w-4 h-4" /> Reject Request
|
||||||
|
</div>
|
||||||
|
<Textarea
|
||||||
|
placeholder="Please provide a reason for rejection..."
|
||||||
|
value={rejectReason}
|
||||||
|
onChange={(e) => setRejectReason(e.target.value)}
|
||||||
|
/>
|
||||||
|
<div className="flex gap-2 justify-end">
|
||||||
|
<Button variant="ghost" onClick={() => setShowRejectInput(false)} disabled={loading}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button variant="destructive" onClick={handleReject} disabled={loading}>
|
||||||
|
{loading ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : "Confirm Rejection"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="sm:justify-start">
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)}>Close</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
|
||||||
|
<div className="flex flex-col lg:flex-row gap-6">
|
||||||
|
|
||||||
|
<div className="flex-shrink-0 w-full lg:w-auto flex flex-col items-center">
|
||||||
|
<h3 className="text-lg font-semibold mb-4 w-full text-center lg:text-left">Select a Date</h3>
|
||||||
|
<div className="border rounded-lg p-4 bg-background shadow-sm">
|
||||||
|
<Calendar
|
||||||
|
mode="single"
|
||||||
|
selected={selectedDate}
|
||||||
|
onSelect={setSelectedDate}
|
||||||
|
disabled={disabledDays}
|
||||||
|
className="rounded-md border-0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 grid grid-cols-2 gap-2 text-xs w-full max-w-[300px]">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-3 h-3 rounded bg-destructive/10 border border-destructive/20"></div>
|
||||||
|
<span>Unavailable</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-3 h-3 rounded bg-primary border border-primary/50"></div>
|
||||||
|
<span>Selected</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h3 className="text-lg font-semibold mb-4">Request Details</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Date request form placeholder. Selected date: {selectedDate?.toLocaleDateString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle className="flex items-center gap-2 text-destructive">
|
||||||
|
<Trash2 className="h-5 w-5" />
|
||||||
|
Delete Association
|
||||||
|
</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription className="space-y-4">
|
||||||
|
<div className="p-3 bg-destructive/10 border border-destructive/20 rounded-md text-sm flex gap-3">
|
||||||
|
<AlertTriangle className="h-5 w-5 shrink-0" />
|
||||||
|
<p>
|
||||||
|
You are about to delete <strong>{association.name}</strong>. This action is permanent and cannot be undone.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel disabled={isDeleting}>Cancel</AlertDialogCancel>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={isDeleting}
|
||||||
|
>
|
||||||
|
{isDeleting ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Deleting...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Delete Association'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<AlertDialogContent className="max-w-xl">
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle className="flex items-center gap-2 text-destructive">
|
||||||
|
<AlertTriangle className="w-5 h-5" />
|
||||||
|
Delete Account: {account.account_name}
|
||||||
|
</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription asChild>
|
||||||
|
<div className="space-y-4 pt-2">
|
||||||
|
<p className="text-foreground">
|
||||||
|
You are about to delete{" "}
|
||||||
|
<span className="font-bold">
|
||||||
|
{account.account_number} - {account.account_name}
|
||||||
|
</span>
|
||||||
|
. This action is permanent.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{checking ? (
|
||||||
|
<div className="flex items-center gap-2 p-4 bg-muted text-muted-foreground rounded-md">
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" /> Checking account dependencies...
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{journalCount > 0 && (
|
||||||
|
<div className="p-4 bg-destructive/10 border border-destructive/20 rounded-md space-y-2">
|
||||||
|
<div className="flex items-start gap-2 text-destructive">
|
||||||
|
<AlertTriangle className="w-5 h-5 shrink-0 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<strong className="block text-sm">Cannot Delete</strong>
|
||||||
|
<p className="text-xs mt-1">
|
||||||
|
This account is referenced by{" "}
|
||||||
|
<strong>{journalCount} journal entries</strong>. Remove those references
|
||||||
|
before deleting.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{journalCount === 0 && (
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
No dependencies found. This action cannot be undone.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter className="mt-6 border-t pt-4">
|
||||||
|
<AlertDialogCancel disabled={isDeleting}>Cancel</AlertDialogCancel>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={handleConfirm}
|
||||||
|
disabled={!canSubmit || checking}
|
||||||
|
>
|
||||||
|
{isDeleting && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
|
||||||
|
Confirm Delete
|
||||||
|
</Button>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<AlertDialogContent className="max-w-xl">
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle className="flex items-center gap-2 text-destructive">
|
||||||
|
<AlertTriangle className="w-5 h-5" />
|
||||||
|
Delete Account: {account.account_name}
|
||||||
|
</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription asChild>
|
||||||
|
<div className="space-y-4 pt-2">
|
||||||
|
<p className="text-foreground">
|
||||||
|
You are about to delete <span className="font-bold">{account.account_number} - {account.account_name}</span>.
|
||||||
|
This action is permanent.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{checking ? (
|
||||||
|
<div className="flex items-center gap-2 p-4 bg-muted text-muted-foreground rounded-md">
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" /> Checking account dependencies...
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{journalCount > 0 && (
|
||||||
|
<div className="p-4 bg-destructive/10 border border-destructive/20 rounded-md space-y-2">
|
||||||
|
<div className="flex items-start gap-2 text-destructive">
|
||||||
|
<AlertTriangle className="w-5 h-5 shrink-0 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<strong className="block text-sm">Cannot Delete</strong>
|
||||||
|
<p className="text-xs mt-1">
|
||||||
|
This account is referenced by <strong>{journalCount} journal entries</strong>. Remove those references before deleting.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasSubAccounts && journalCount === 0 && (
|
||||||
|
<div className="p-4 bg-yellow-50 border border-yellow-200 rounded-md text-sm">
|
||||||
|
<strong className="block mb-2">Warning: This account has nested sub-accounts.</strong>
|
||||||
|
<p className="mb-3 text-xs text-muted-foreground">Choose how to handle the child accounts:</p>
|
||||||
|
|
||||||
|
<div className="space-y-3 bg-background p-3 rounded border">
|
||||||
|
<div className="flex items-start space-x-2">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
id="del-all"
|
||||||
|
name="del-mode"
|
||||||
|
className="mt-1"
|
||||||
|
checked={deleteMode === 'delete-all'}
|
||||||
|
onChange={() => setDeleteMode('delete-all')}
|
||||||
|
/>
|
||||||
|
<label htmlFor="del-all" className="cursor-pointer">
|
||||||
|
<strong className="block">Delete All</strong>
|
||||||
|
<span className="text-xs text-muted-foreground">Remove this account AND all its sub-accounts permanently.</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-start space-x-2">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
id="del-move"
|
||||||
|
name="del-mode"
|
||||||
|
className="mt-1"
|
||||||
|
checked={deleteMode === 'move'}
|
||||||
|
onChange={() => setDeleteMode('move')}
|
||||||
|
/>
|
||||||
|
<div className="flex-1">
|
||||||
|
<label htmlFor="del-move" className="cursor-pointer">
|
||||||
|
<strong className="block">Move Sub-accounts</strong>
|
||||||
|
<span className="text-xs text-muted-foreground">Reassign sub-accounts to a new parent.</span>
|
||||||
|
</label>
|
||||||
|
{deleteMode === 'move' && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<Select value={newParentId} onValueChange={setNewParentId}>
|
||||||
|
<SelectTrigger className="h-9 w-full">
|
||||||
|
<SelectValue placeholder="Select New Parent (or None)" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="max-h-60">
|
||||||
|
<SelectItem value="none">-- Make Top Level (No Parent) --</SelectItem>
|
||||||
|
{allAccounts
|
||||||
|
.filter(a => a.id !== account.id && a.parent_account_id !== account.id)
|
||||||
|
.map(a => (
|
||||||
|
<SelectItem key={a.id} value={a.id}>
|
||||||
|
{a.account_name} {a.account_number ? `(${a.account_number})` : ''}
|
||||||
|
</SelectItem>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{journalCount === 0 && !hasSubAccounts && (
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
No dependencies found. This action is permanent and cannot be undone.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter className="mt-6 border-t pt-4">
|
||||||
|
<AlertDialogCancel disabled={isDeleting}>Cancel</AlertDialogCancel>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={handleConfirm}
|
||||||
|
disabled={!canSubmit || checking}
|
||||||
|
>
|
||||||
|
{isDeleting && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
|
||||||
|
Confirm Delete
|
||||||
|
</Button>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Are you sure?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
This will permanently delete the saved report "{report.report_name}". This action cannot be undone.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel disabled={isDeleting}>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onConfirm(report.id);
|
||||||
|
}}
|
||||||
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
|
disabled={isDeleting}
|
||||||
|
>
|
||||||
|
{isDeleting ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Deleting...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Delete'
|
||||||
|
)}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
This action cannot be undone. This will permanently delete the user account for{' '}
|
||||||
|
<span className="font-semibold">{targetUser?.email}</span>
|
||||||
|
{targetUser?.full_name && <span> ({targetUser.full_name})</span>}.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel disabled={loading}>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
handleDelete();
|
||||||
|
}}
|
||||||
|
disabled={loading}
|
||||||
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
|
>
|
||||||
|
{loading ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : null}
|
||||||
|
Delete User
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-[450px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-destructive flex items-center gap-2">
|
||||||
|
<AlertCircle className="w-5 h-5" /> Delete {entityName}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Are you sure you want to delete the {entityName} "{itemToDelete?.name}"?
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="py-4">
|
||||||
|
{checkingUsage ? (
|
||||||
|
<div className="flex items-center text-muted-foreground text-sm"><Loader2 className="w-4 h-4 animate-spin mr-2" /> Checking usage...</div>
|
||||||
|
) : usageCount > 0 ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="bg-yellow-50 text-yellow-800 p-3 rounded-md text-sm border border-yellow-200">
|
||||||
|
This {entityName} is currently used by <strong>{usageCount}</strong> account(s). You must select a replacement to reassign these accounts before deleting.
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium">Reassign to:</label>
|
||||||
|
<Select value={replacementName} onValueChange={setReplacementName}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder={`Select a replacement ${entityName}`} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{replacements.map(r => (
|
||||||
|
<SelectItem key={r.name} value={r.name}>{r.name}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-muted-foreground text-sm">
|
||||||
|
This {entityName} is not currently in use by any accounts. It is safe to delete.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={loading}>Cancel</Button>
|
||||||
|
<Button variant="destructive" onClick={handleDelete} disabled={loading || (usageCount > 0 && !replacementName)}>
|
||||||
|
{loading ? <Loader2 className="w-4 h-4 animate-spin mr-2" /> : null}
|
||||||
|
Confirm Delete
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<any[]>([]);
|
||||||
|
const [associationId, setAssociationId] = useState(initialAssocId || "");
|
||||||
|
const [documentName, setDocumentName] = useState(initialDocName || "");
|
||||||
|
const [emailSubject, setEmailSubject] = useState("");
|
||||||
|
const [emailBody, setEmailBody] = useState("");
|
||||||
|
const [recipients, setRecipients] = useState<Recipient[]>([{ name: "", email: "" }]);
|
||||||
|
const [file, setFile] = useState<File | null>(null);
|
||||||
|
const [consentUrl, setConsentUrl] = useState<string | null>(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 (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-lg max-h-[85vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<FileSignature className="h-5 w-5 text-primary" />
|
||||||
|
Send for Signature
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Send a document via DocuSign for electronic signature.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4 py-2">
|
||||||
|
{/* Association */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>Association</Label>
|
||||||
|
<Select value={associationId} onValueChange={setAssociationId}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select association" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{associations.map((a) => (
|
||||||
|
<SelectItem key={a.id} value={a.id}>{a.name}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Document Upload */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>Document</Label>
|
||||||
|
{documentUrl ? (
|
||||||
|
<div className="flex items-center gap-2 p-2 bg-muted rounded-md text-sm">
|
||||||
|
<FileSignature className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span className="truncate flex-1">{documentName || "Linked document"}</span>
|
||||||
|
<Badge variant="secondary" className="text-xs">From storage</Badge>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Input
|
||||||
|
type="file"
|
||||||
|
accept=".pdf,.doc,.docx"
|
||||||
|
onChange={(e) => {
|
||||||
|
const f = e.target.files?.[0] || null;
|
||||||
|
setFile(f);
|
||||||
|
if (f && !documentName) setDocumentName(f.name);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Document Name */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>Document Name</Label>
|
||||||
|
<Input
|
||||||
|
placeholder="e.g., Estoppel Certificate"
|
||||||
|
value={documentName}
|
||||||
|
onChange={(e) => setDocumentName(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Email Subject */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>Email Subject (optional)</Label>
|
||||||
|
<Input
|
||||||
|
placeholder={`Please sign: ${documentName || "Document"}`}
|
||||||
|
value={emailSubject}
|
||||||
|
onChange={(e) => setEmailSubject(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Email Body */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>Message (optional)</Label>
|
||||||
|
<Textarea
|
||||||
|
placeholder="Please review and sign the attached document."
|
||||||
|
value={emailBody}
|
||||||
|
onChange={(e) => setEmailBody(e.target.value)}
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Recipients */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label>Recipients</Label>
|
||||||
|
<Button type="button" variant="ghost" size="sm" onClick={addRecipient} className="gap-1 h-7 text-xs">
|
||||||
|
<UserPlus className="h-3 w-3" /> Add Signer
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{recipients.map((r, idx) => (
|
||||||
|
<div key={idx} className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
placeholder="Full name"
|
||||||
|
value={r.name}
|
||||||
|
onChange={(e) => updateRecipient(idx, "name", e.target.value)}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
placeholder="Email"
|
||||||
|
type="email"
|
||||||
|
value={r.email}
|
||||||
|
onChange={(e) => updateRecipient(idx, "email", e.target.value)}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
{recipients.length > 1 && (
|
||||||
|
<Button type="button" variant="ghost" size="icon" className="h-8 w-8 shrink-0" onClick={() => removeRecipient(idx)}>
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-muted/50 rounded-md p-3 text-xs text-muted-foreground space-y-1">
|
||||||
|
<p className="font-medium text-foreground">Signature placement tips:</p>
|
||||||
|
<p>• Add <code className="bg-muted px-1 rounded">/sig/</code> in your document where signatures should appear</p>
|
||||||
|
<p>• Add <code className="bg-muted px-1 rounded">/date/</code> where the signed date should appear</p>
|
||||||
|
<p>• DocuSign will automatically detect these anchors</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{consentUrl && (
|
||||||
|
<div className="rounded-md border border-border bg-muted/50 p-3 space-y-2">
|
||||||
|
<p className="text-sm font-medium text-foreground">DocuSign access needs one-time consent</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Open the consent page, approve access, then come back and resend the document.</p>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => window.open(consentUrl, "_blank", "noopener,noreferrer")}
|
||||||
|
>
|
||||||
|
Grant DocuSign Consent
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={sending}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSend} disabled={sending} className="gap-2">
|
||||||
|
{sending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
|
||||||
|
{sending ? "Sending..." : "Send for Signature"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-[700px] max-h-[90vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-xl">Upload Documents</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4 p-4 bg-muted rounded-lg border">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="association_id">Association *</Label>
|
||||||
|
<select
|
||||||
|
id="association_id"
|
||||||
|
value={commonData.association_id}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
>
|
||||||
|
<option value="">Select an association</option>
|
||||||
|
{associations.map((a) => (
|
||||||
|
<option key={a.id} value={a.id}>{a.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="category">Category (Optional)</Label>
|
||||||
|
<select
|
||||||
|
id="category"
|
||||||
|
value={commonData.category}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
>
|
||||||
|
<option value="">General</option>
|
||||||
|
<option value="financial">Financial</option>
|
||||||
|
<option value="legal">Legal</option>
|
||||||
|
<option value="insurance">Insurance</option>
|
||||||
|
<option value="meeting_minutes">Meeting Minutes</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"border-2 border-dashed rounded-xl p-8 transition-colors text-center cursor-pointer",
|
||||||
|
isDragging
|
||||||
|
? "border-primary bg-primary/5"
|
||||||
|
: "border-border hover:border-primary/50 bg-background"
|
||||||
|
)}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
ref={fileInputRef}
|
||||||
|
className="hidden"
|
||||||
|
multiple
|
||||||
|
onChange={handleFileInputChange}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col items-center gap-2">
|
||||||
|
<div className="p-3 bg-primary/10 text-primary rounded-full">
|
||||||
|
<UploadCloud className="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
<div className="text-sm font-medium">
|
||||||
|
Click to upload or drag and drop
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
PDF, DOCX, XLSX, Images (max 10MB)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-base font-semibold">Files to Upload ({files.length})</Label>
|
||||||
|
<Button type="button" variant="ghost" size="sm" onClick={handleAddEmptyRow} className="text-xs">
|
||||||
|
<Plus className="w-3 h-3 mr-1" /> Add Manual Link
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-h-[300px] overflow-y-auto space-y-3 pr-2">
|
||||||
|
{files.map((file) => (
|
||||||
|
<div key={file.id} className="relative p-4 bg-background border rounded-lg shadow-sm group">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => 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"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<div className="flex-shrink-0 mt-1">
|
||||||
|
<div className={cn("p-2 rounded-lg", file.fileObject ? "bg-primary/10 text-primary" : "bg-muted text-muted-foreground")}>
|
||||||
|
<FileText className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 grid grid-cols-1 gap-3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Document Title"
|
||||||
|
value={file.title}
|
||||||
|
onChange={(e) => handleFileChange(file.id, 'title', e.target.value)}
|
||||||
|
className="w-full px-0 py-1 text-sm font-medium border-0 border-b border-transparent hover:border-border focus:border-primary focus:ring-0 bg-transparent placeholder:text-muted-foreground transition-colors"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{file.fileObject ? (
|
||||||
|
<div className="text-xs text-muted-foreground flex items-center">
|
||||||
|
<span className="bg-muted px-2 py-0.5 rounded mr-2">
|
||||||
|
{(file.fileObject.size / 1024 / 1024).toFixed(2)} MB
|
||||||
|
</span>
|
||||||
|
Ready to upload
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
placeholder="External File URL (https://...)"
|
||||||
|
value={file.file_url}
|
||||||
|
onChange={(e) => handleFileChange(file.id, 'file_url', e.target.value)}
|
||||||
|
className="w-full px-2 py-1.5 text-xs border rounded bg-muted focus:bg-background focus:border-primary focus:outline-none"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Description (optional)"
|
||||||
|
value={file.description}
|
||||||
|
onChange={(e) => handleFileChange(file.id, 'description', e.target.value)}
|
||||||
|
className="w-full px-2 py-1.5 text-xs border rounded focus:border-primary focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end items-center gap-3 pt-4 border-t">
|
||||||
|
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={loading}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={loading} className="min-w-[140px]">
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
|
{uploadProgress > 0 ? `${uploadProgress}%` : 'Processing...'}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
`Upload ${files.length} File${files.length !== 1 ? 's' : ''}`
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DocumentDialog;
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
const DropdownElementDialog = () => {
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export { DropdownElementDialog };
|
||||||
|
export default DropdownElementDialog;
|
||||||
@@ -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 (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-[600px] max-h-[90vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-xl">Edit Bill {bill?.invoice_number ? `(${bill.invoice_number})` : ''}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Update bill details and ensure accounting records remain balanced.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{fetchError && (
|
||||||
|
<Alert variant="destructive" className="mt-2">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<AlertTitle>Error</AlertTitle>
|
||||||
|
<AlertDescription>{fetchError}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5 py-4">
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="association_id" className="font-semibold">Association <span className="text-destructive">*</span></Label>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="association_id"
|
||||||
|
render={({ field }) => (
|
||||||
|
<Select onValueChange={field.onChange} value={field.value} disabled={loadingAssociations || isSubmitting}>
|
||||||
|
<SelectTrigger className={errors.association_id ? 'border-destructive' : ''}>
|
||||||
|
<SelectValue placeholder={loadingAssociations ? "Loading..." : "Select Association"} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{associations.map(a => (
|
||||||
|
<SelectItem key={a.id} value={a.id}>{a.name}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{errors.association_id && <p className="text-xs text-destructive">{errors.association_id.message}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="expense_account_id" className="font-semibold">GL Account (Expense) <span className="text-destructive">*</span></Label>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="expense_account_id"
|
||||||
|
render={({ field }) => (
|
||||||
|
<Select onValueChange={field.onChange} value={field.value} disabled={isSubmitting || loadingCoas}>
|
||||||
|
<SelectTrigger className={errors.expense_account_id ? 'border-destructive' : ''}>
|
||||||
|
<SelectValue placeholder={loadingCoas ? "Loading..." : "Select Expense Account"} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{coas.map(acc => (
|
||||||
|
<SelectItem key={acc.id} value={acc.id}>
|
||||||
|
{acc.account_number} - {acc.account_name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
{coas.length === 0 && !loadingCoas && (
|
||||||
|
<SelectItem value="none" disabled>No expense accounts found.</SelectItem>
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{errors.expense_account_id && <p className="text-xs text-destructive">{errors.expense_account_id.message}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="invoice_number" className="font-semibold">Bill/Invoice Number <span className="text-destructive">*</span></Label>
|
||||||
|
<Input
|
||||||
|
id="invoice_number"
|
||||||
|
{...register('invoice_number')}
|
||||||
|
className={errors.invoice_number ? 'border-destructive' : ''}
|
||||||
|
/>
|
||||||
|
{errors.invoice_number && <p className="text-xs text-destructive">{errors.invoice_number.message}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="vendor_name" className="font-semibold">Vendor Name <span className="text-destructive">*</span></Label>
|
||||||
|
<Input
|
||||||
|
id="vendor_name"
|
||||||
|
{...register('vendor_name')}
|
||||||
|
className={errors.vendor_name ? 'border-destructive' : ''}
|
||||||
|
/>
|
||||||
|
{errors.vendor_name && <p className="text-xs text-destructive">{errors.vendor_name.message}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="bill_date" className="font-semibold">Bill Date <span className="text-destructive">*</span></Label>
|
||||||
|
<Input
|
||||||
|
id="bill_date"
|
||||||
|
type="date"
|
||||||
|
{...register('bill_date')}
|
||||||
|
className={errors.bill_date ? 'border-destructive' : ''}
|
||||||
|
/>
|
||||||
|
{errors.bill_date && <p className="text-xs text-destructive">{errors.bill_date.message}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="due_date" className="font-semibold">Due Date <span className="text-destructive">*</span></Label>
|
||||||
|
<Input
|
||||||
|
id="due_date"
|
||||||
|
type="date"
|
||||||
|
{...register('due_date')}
|
||||||
|
className={errors.due_date ? 'border-destructive' : ''}
|
||||||
|
/>
|
||||||
|
{errors.due_date && <p className="text-xs text-destructive">{errors.due_date.message}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="amount" className="font-semibold">Amount <span className="text-destructive">*</span></Label>
|
||||||
|
<div className="relative">
|
||||||
|
<DollarSign className="absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
id="amount"
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
className={`pl-9 ${errors.amount ? 'border-destructive' : ''}`}
|
||||||
|
placeholder="0.00"
|
||||||
|
{...register('amount')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{errors.amount && <p className="text-xs text-destructive">{errors.amount.message}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="description" className="font-semibold">Description / Notes</Label>
|
||||||
|
<Textarea
|
||||||
|
id="description"
|
||||||
|
placeholder="Optional notes about this bill..."
|
||||||
|
{...register('description')}
|
||||||
|
className="min-h-[80px]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="font-semibold">Supporting Document (PDF)</Label>
|
||||||
|
<div className="border-2 border-dashed rounded-lg p-4 hover:bg-muted/50 transition-colors text-center cursor-pointer relative">
|
||||||
|
<Input
|
||||||
|
type="file"
|
||||||
|
onChange={(e) => 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 ? (
|
||||||
|
<div className="flex flex-col items-center gap-2 text-primary">
|
||||||
|
<div className="font-medium truncate max-w-[250px]">{pdfFile.name}</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 text-destructive hover:text-destructive z-20 relative"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setPdfFile(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4 mr-1" /> Remove
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center gap-2 text-muted-foreground">
|
||||||
|
<div className="bg-primary/10 p-2 rounded-full">
|
||||||
|
<Upload className="w-5 h-5 text-primary" />
|
||||||
|
</div>
|
||||||
|
{bill?.attachment_url ? (
|
||||||
|
<span className="text-sm font-medium">Existing PDF attached. Click to replace.</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-sm font-medium">Click to upload or drag and drop</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-4 mt-6 border-t flex justify-end gap-3">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={isSubmitting || !isValid}
|
||||||
|
className="min-w-[120px]"
|
||||||
|
>
|
||||||
|
{isSubmitting ? (
|
||||||
|
<><Loader2 className="mr-2 h-4 w-4 animate-spin" /> Saving...</>
|
||||||
|
) : (
|
||||||
|
'Save Changes'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-[425px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Add Email Routing Rule</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Assign an email address to an association. All emails sent to this address will be routed to the selected association.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="association_id"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Association</FormLabel>
|
||||||
|
<Select
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
defaultValue={field.value}
|
||||||
|
value={field.value}
|
||||||
|
>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger disabled={loadingAssociations}>
|
||||||
|
<SelectValue placeholder={loadingAssociations ? "Loading..." : "Select an association"} />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
{associations.map((assoc) => (
|
||||||
|
<SelectItem key={assoc.id} value={assoc.id}>
|
||||||
|
{assoc.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="email_address"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Email Address</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="e.g. board@oceanview.example.com" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DialogFooter className="pt-4">
|
||||||
|
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||||
|
{form.formState.isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
Save Rule
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-[500px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{estoppel ? 'Edit Estoppel' : 'New Estoppel Request'}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{estoppel ? 'Update estoppel details and status.' : 'Create a new estoppel record.'}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="association_id">Association *</Label>
|
||||||
|
<select
|
||||||
|
id="association_id"
|
||||||
|
value={formData.association_id}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
>
|
||||||
|
<option value="">Select an association</option>
|
||||||
|
{associations.map((assoc) => (
|
||||||
|
<option key={assoc.id} value={assoc.id}>{assoc.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Request Type</Label>
|
||||||
|
<div className="flex space-x-6">
|
||||||
|
<label className="flex items-center space-x-2 cursor-pointer">
|
||||||
|
<div className="relative flex items-center">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="scopeType"
|
||||||
|
value="property"
|
||||||
|
checked={scopeType === 'property'}
|
||||||
|
onChange={() => setScopeType('property')}
|
||||||
|
className="peer h-4 w-4 border-border text-primary focus:ring-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-medium text-foreground">Property Address</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center space-x-2 cursor-pointer">
|
||||||
|
<div className="relative flex items-center">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="scopeType"
|
||||||
|
value="association"
|
||||||
|
checked={scopeType === 'association'}
|
||||||
|
onChange={() => setScopeType('association')}
|
||||||
|
className="peer h-4 w-4 border-border text-primary focus:ring-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-medium text-foreground">Association-level</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{scopeType === 'property' && (
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="address">Address *</Label>
|
||||||
|
<input
|
||||||
|
id="address"
|
||||||
|
type="text"
|
||||||
|
value={formData.address}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="status">Current Stage *</Label>
|
||||||
|
<select
|
||||||
|
id="status"
|
||||||
|
value={formData.status}
|
||||||
|
onChange={(e) => 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) => (
|
||||||
|
<option key={stage} value={stage}>{stage}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="notes">Notes</Label>
|
||||||
|
<textarea
|
||||||
|
id="notes"
|
||||||
|
value={formData.notes}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end space-x-2 pt-4">
|
||||||
|
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={loading}>
|
||||||
|
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
{loading ? (estoppel ? 'Updating...' : 'Creating...') : (estoppel ? 'Save Changes' : 'Create Record')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default EstoppelDialog;
|
||||||
@@ -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 (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="!flex !h-[calc(100vh-2rem)] !w-[calc(100vw-2rem)] !max-h-[calc(100vh-2rem)] !max-w-[1000px] !flex-col !gap-0 overflow-hidden p-0">
|
||||||
|
<DialogHeader className="p-6 pb-2 shrink-0">
|
||||||
|
<DialogTitle>{bundle ? 'Edit Expense Bundle' : 'Create Expense Bundle'}</DialogTitle>
|
||||||
|
<DialogDescription>Group related expenses together for organized billing.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex-1 min-h-0 overflow-y-auto px-6 py-4 space-y-6">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 p-4 bg-muted/50 border rounded-lg">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Association</Label>
|
||||||
|
<Select value={formData.association_id} onValueChange={handleAssociationChange} disabled={!!bundle}>
|
||||||
|
<SelectTrigger className="bg-background">
|
||||||
|
<SelectValue placeholder="Select Association" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{associations.map(a => (
|
||||||
|
<SelectItem key={a.id} value={a.id}>{a.name}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Bundle Name</Label>
|
||||||
|
<Input
|
||||||
|
value={formData.name}
|
||||||
|
onChange={e => setFormData(prev => ({ ...prev, name: e.target.value }))}
|
||||||
|
placeholder="e.g. July 2024 Legal Fees"
|
||||||
|
className="bg-background"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Description</Label>
|
||||||
|
<Textarea
|
||||||
|
value={formData.description}
|
||||||
|
onChange={e => setFormData(prev => ({ ...prev, description: e.target.value }))}
|
||||||
|
placeholder="Optional details about this bundle..."
|
||||||
|
className="h-[108px] bg-background resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-base font-semibold">Select Fees to Include</Label>
|
||||||
|
{formData.association_id ? (
|
||||||
|
<ExpenseBundleSelector
|
||||||
|
associationId={formData.association_id}
|
||||||
|
selectedIds={selectedExpenseIds}
|
||||||
|
onSelectionChange={setSelectedExpenseIds}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="p-12 text-center border-2 border-dashed rounded-lg text-muted-foreground bg-muted/30">
|
||||||
|
Please select an association to view existing billable expenses and universal fee templates.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="shrink-0 border-t bg-background p-6 pt-2">
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)}>Cancel</Button>
|
||||||
|
<Button onClick={handleSubmit} disabled={loading || !formData.association_id} className="min-w-[140px]">
|
||||||
|
{loading ? 'Saving...' : (bundle ? 'Update Bundle' : 'Create Bundle')}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="flex items-center justify-center py-12 text-muted-foreground gap-2">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" /> Loading expenses...
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!allRows.length) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-12 text-muted-foreground border-2 border-dashed rounded-lg bg-muted/30">
|
||||||
|
No billable expenses or fee templates found for this association.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 rounded-lg border bg-muted/20 p-4">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">Bundle fees</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Add rows and choose any existing or universal fee.</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 text-sm">
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{selectedIds.size} selected
|
||||||
|
</span>
|
||||||
|
<Badge variant={selectedTotal >= 0 ? "default" : "secondary"}>
|
||||||
|
Total: ${Math.abs(selectedTotal).toFixed(2)}{selectedTotal < 0 ? ' CR' : ''}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{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 (
|
||||||
|
<div key={`${rowId || 'empty'}-${index}`} className="rounded-md border bg-background p-3">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<Combobox
|
||||||
|
options={rowOptions}
|
||||||
|
value={rowId}
|
||||||
|
onChange={(value) => handleRowChange(index, value)}
|
||||||
|
placeholder="Select a fee"
|
||||||
|
emptyText="No fees available"
|
||||||
|
className="h-10 flex-1"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => handleRemoveRow(index)}
|
||||||
|
disabled={!rowId && rows.length === 1}
|
||||||
|
aria-label="Remove fee row"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedItem && (
|
||||||
|
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<Badge variant="outline">{selectedItem.source === 'fee_schedule' ? 'Universal Fee' : 'Existing Fee'}</Badge>
|
||||||
|
<span>{selectedItem.category || 'Uncategorized'}</span>
|
||||||
|
{selectedItem.vendor_name && <span>{selectedItem.vendor_name}</span>}
|
||||||
|
{selectedItem.date && <span>{format(new Date(`${selectedItem.date}T12:00:00`), 'MM/dd/yy')}</span>}
|
||||||
|
<span>
|
||||||
|
{selectedItem.is_credit ? '-' : ''}${Math.abs(Number(selectedItem.amount || 0)).toFixed(2)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleAddRow}
|
||||||
|
disabled={rows.filter(Boolean).length >= allRows.length}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Add row
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-[1000px] max-h-[90vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{expense ? 'Edit Expense' : 'Add Expenses'}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{expense ? 'Update the details for this expense record.' : 'Create one or more billable expense records.'}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-6 py-2">
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4 p-4 bg-muted/50 rounded-lg border">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="association">Association</Label>
|
||||||
|
<Select
|
||||||
|
value={headerData.association_id}
|
||||||
|
onValueChange={(val) => {
|
||||||
|
setHeaderData({ ...headerData, association_id: val, address: 'Association-level' });
|
||||||
|
}}
|
||||||
|
disabled={!!expense}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="bg-background">
|
||||||
|
<SelectValue placeholder="Select Association..." />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{(associations || []).map(a => (
|
||||||
|
<SelectItem key={a.id} value={a.id}>{a.name}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="date">Date</Label>
|
||||||
|
<Input
|
||||||
|
id="date"
|
||||||
|
type="date"
|
||||||
|
value={headerData.date}
|
||||||
|
onChange={(e) => setHeaderData({ ...headerData, date: e.target.value })}
|
||||||
|
required
|
||||||
|
className="bg-background"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-4 p-4 border rounded-lg bg-accent/20">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Switch
|
||||||
|
id="credit-mode"
|
||||||
|
checked={isCredit}
|
||||||
|
onCheckedChange={setIsCredit}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="credit-mode" className="flex items-center gap-2 font-medium cursor-pointer">
|
||||||
|
<RefreshCw className={`w-4 h-4 ${isCredit ? 'text-primary' : 'text-muted-foreground'}`} />
|
||||||
|
Mark as Credit/Refund
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
{isCredit && (
|
||||||
|
<Badge variant="outline">Amounts will be negative</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isCredit && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs text-muted-foreground">Credit Reason / Notes</Label>
|
||||||
|
<Textarea
|
||||||
|
value={creditReason}
|
||||||
|
onChange={(e) => setCreditReason(e.target.value)}
|
||||||
|
placeholder="Reason for refund..."
|
||||||
|
className="h-[38px] min-h-[38px] py-2 text-xs bg-background resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex justify-between items-center mb-1">
|
||||||
|
<Label>Line Items</Label>
|
||||||
|
{!expense && (
|
||||||
|
<Button type="button" size="sm" variant="ghost" onClick={addLineItem} className="h-7 text-xs">
|
||||||
|
<Plus className="w-3 h-3 mr-1" /> Add Row
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{(lineItems || []).map((item, index) => (
|
||||||
|
<div key={item.id} className="rounded-lg border bg-background p-4 space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">Item {index + 1}</span>
|
||||||
|
{!expense && (
|
||||||
|
<Button
|
||||||
|
type="button" variant="ghost" size="icon"
|
||||||
|
className="h-7 w-7 text-muted-foreground hover:text-destructive"
|
||||||
|
onClick={() => removeLineItem(item.id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs text-muted-foreground">Category</Label>
|
||||||
|
<Input
|
||||||
|
value={item.category}
|
||||||
|
onChange={(e) => updateLineItem(item.id, 'category', e.target.value)}
|
||||||
|
placeholder="Category..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs text-muted-foreground">Description</Label>
|
||||||
|
<Input
|
||||||
|
value={item.description}
|
||||||
|
onChange={(e) => updateLineItem(item.id, 'description', e.target.value)}
|
||||||
|
placeholder="Item description..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs text-muted-foreground">Qty</Label>
|
||||||
|
<Input
|
||||||
|
type="number" min="0" step="0.01"
|
||||||
|
value={item.quantity}
|
||||||
|
onChange={(e) => updateLineItem(item.id, 'quantity', e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs text-muted-foreground">Rate</Label>
|
||||||
|
<Input
|
||||||
|
type="number" min="0" step="0.01"
|
||||||
|
value={item.unit_price}
|
||||||
|
onChange={(e) => updateLineItem(item.id, 'unit_price', e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs text-muted-foreground">Amount</Label>
|
||||||
|
<div className={`h-9 flex items-center justify-end px-3 text-sm font-semibold rounded-md border ${isCredit ? 'bg-destructive/10 text-destructive' : 'bg-muted'}`}>
|
||||||
|
{isCredit && '-'}${parseFloat(item.amount || 0).toFixed(2)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2 border-t pt-4">
|
||||||
|
<Label>Receipt Attachment</Label>
|
||||||
|
{!headerData.receipt_url ? (
|
||||||
|
<div className="border-2 border-dashed rounded-lg p-4 flex flex-col items-center justify-center bg-muted/30 hover:bg-muted/50 transition-colors cursor-pointer relative">
|
||||||
|
<input
|
||||||
|
type="file" accept="image/*,application/pdf"
|
||||||
|
className="absolute inset-0 opacity-0 cursor-pointer"
|
||||||
|
onChange={handleFileUpload} disabled={uploading}
|
||||||
|
/>
|
||||||
|
<Upload className={`w-6 h-6 mb-2 ${uploading ? 'text-muted-foreground animate-pulse' : 'text-muted-foreground'}`} />
|
||||||
|
<span className="text-sm font-medium">{uploading ? 'Uploading...' : 'Click to upload receipt'}</span>
|
||||||
|
<span className="text-xs text-muted-foreground mt-1">PDF, PNG, JPG up to 5MB</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-between p-3 bg-accent/30 border rounded-lg">
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<div className="bg-background p-2 rounded-md border">
|
||||||
|
<FileText className="w-5 h-5 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-sm font-medium">Receipt Attached</span>
|
||||||
|
<a href={headerData.receipt_url} target="_blank" rel="noopener noreferrer" className="text-xs text-primary hover:underline truncate max-w-[200px]">
|
||||||
|
View File
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button type="button" variant="ghost" size="icon" onClick={handleRemoveReceipt}>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="flex items-center justify-between sm:justify-between w-full pt-4">
|
||||||
|
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>Cancel</Button>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{!expense && (
|
||||||
|
<Button
|
||||||
|
type="submit" variant="outline"
|
||||||
|
disabled={loading || uploading}
|
||||||
|
onClick={() => { saveAndAddRef.current = true; }}
|
||||||
|
>
|
||||||
|
<PlusCircle className="w-4 h-4 mr-2" />
|
||||||
|
Save & Add Another
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading || uploading}
|
||||||
|
onClick={() => { saveAndAddRef.current = false; }}
|
||||||
|
className="min-w-[120px]"
|
||||||
|
>
|
||||||
|
{loading ? 'Saving...' : (expense ? 'Update Expense' : `Save ${lineItems.length} Expenses`)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ExpenseDialog;
|
||||||
@@ -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 (
|
||||||
|
<Dialog open={open} onOpenChange={handleClose}>
|
||||||
|
<DialogContent className="sm:max-w-[600px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<RotateCcw className="w-5 h-5 text-amber-600" />
|
||||||
|
Expense Data Restoration
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Verify and restore integrity of billable expenses.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="py-4">
|
||||||
|
{step === 'analyze' && (
|
||||||
|
<div className="flex flex-col items-center justify-center py-8">
|
||||||
|
<Loader2 className="w-8 h-8 animate-spin text-primary mb-4" />
|
||||||
|
<p className="text-sm text-muted-foreground">Analyzing expense records...</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 'review' && analysis && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4 flex items-start gap-3">
|
||||||
|
<AlertTriangle className="w-5 h-5 text-amber-600 shrink-0 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<h4 className="font-medium text-amber-900 text-sm">Review Findings</h4>
|
||||||
|
<p className="text-sm text-amber-800 mt-1">
|
||||||
|
Found <strong>{analysis.totalFound}</strong> billable expenses in the database.
|
||||||
|
{analysis.potentialIssues.length > 0
|
||||||
|
? ` Detected ${analysis.potentialIssues.length} records with potential integrity issues.`
|
||||||
|
: " No critical integrity issues detected."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
Proceeding will re-verify these records and update their system status timestamps.
|
||||||
|
This action is logged for audit purposes.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 'restoring' && (
|
||||||
|
<div className="flex flex-col items-center justify-center py-8">
|
||||||
|
<Loader2 className="w-8 h-8 animate-spin text-primary mb-4" />
|
||||||
|
<p className="text-sm text-muted-foreground">Processing records...</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 'result' && result && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="bg-green-50 border border-green-200 rounded-lg p-4 flex items-start gap-3">
|
||||||
|
<CheckCircle className="w-5 h-5 text-green-600 shrink-0 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<h4 className="font-medium text-green-900 text-sm">Process Complete</h4>
|
||||||
|
<p className="text-sm text-green-800 mt-1">
|
||||||
|
Successfully processed {result.successCount} records.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{result.errors.length > 0 && (
|
||||||
|
<ScrollArea className="h-[100px] w-full border rounded-md p-2 bg-destructive/5 text-xs text-destructive">
|
||||||
|
{result.errors.map((e, i) => (
|
||||||
|
<div key={i} className="mb-1">Error with ID {e.id}: {e.message}</div>
|
||||||
|
))}
|
||||||
|
</ScrollArea>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
{step === 'review' && (
|
||||||
|
<>
|
||||||
|
<Button variant="outline" onClick={handleClose}>Cancel</Button>
|
||||||
|
<Button onClick={handleRestore}>
|
||||||
|
Proceed with Restoration
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{step === 'result' && (
|
||||||
|
<Button onClick={handleClose}>Close</Button>
|
||||||
|
)}
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<Category[]>([]);
|
||||||
|
const [subcategories, setSubcategories] = useState<Subcategory[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
// Category dialog
|
||||||
|
const [catDialogOpen, setCatDialogOpen] = useState(false);
|
||||||
|
const [editingCat, setEditingCat] = useState<Category | null>(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<Subcategory | null>(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 <div className="flex items-center justify-center py-12"><Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /></div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-bold">Expense Categories & Subcategories</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">Manage categories used across billable expenses and fee schedules.</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={openAddCat} className="gap-2"><Plus className="h-4 w-4" /> Add Category</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{categories.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="py-12 text-center text-muted-foreground">
|
||||||
|
<FolderTree className="h-8 w-8 mx-auto mb-2 opacity-50" />
|
||||||
|
<p>No categories yet. Click "Add Category" to get started.</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<Accordion type="multiple" defaultValue={categories.map(c => c.id)} className="space-y-3">
|
||||||
|
{categories.map(cat => {
|
||||||
|
const subs = getSubsForCategory(cat.id);
|
||||||
|
return (
|
||||||
|
<AccordionItem key={cat.id} value={cat.id} className="border rounded-lg bg-card overflow-hidden">
|
||||||
|
<AccordionTrigger className="px-4 py-3 hover:no-underline">
|
||||||
|
<div className="flex items-center gap-3 flex-1">
|
||||||
|
<div className="w-3 h-3 rounded-full shrink-0" style={{ backgroundColor: cat.color || "#6366f1" }} />
|
||||||
|
<span className="font-semibold text-sm">{cat.name}</span>
|
||||||
|
<Badge variant="secondary" className="text-[10px]">{subs.length} sub{subs.length !== 1 ? "s" : ""}</Badge>
|
||||||
|
{!cat.is_active && <Badge variant="outline" className="text-[10px] text-muted-foreground">Inactive</Badge>}
|
||||||
|
</div>
|
||||||
|
</AccordionTrigger>
|
||||||
|
<AccordionContent className="px-4 pb-4">
|
||||||
|
<div className="flex items-center justify-between mb-3 pt-1">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Switch checked={cat.is_active} onCheckedChange={() => toggleCatActive(cat)} />
|
||||||
|
<span className="text-xs text-muted-foreground">Active</span>
|
||||||
|
</div>
|
||||||
|
{cat.description && <span className="text-xs text-muted-foreground italic">{cat.description}</span>}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Button variant="ghost" size="sm" className="h-7 text-xs gap-1" onClick={() => openAddSub(cat.id)}>
|
||||||
|
<Plus className="h-3 w-3" /> Add Subcategory
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => openEditCat(cat)}>
|
||||||
|
<Edit className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive hover:text-destructive" onClick={() => setDeleteTarget({ type: "category", id: cat.id, name: cat.name })}>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{subs.length === 0 ? (
|
||||||
|
<div className="text-xs text-muted-foreground py-3 text-center border rounded-md bg-muted/30">
|
||||||
|
No subcategories. Click "+ Add Subcategory" to create one.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="border rounded-md overflow-hidden">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow className="bg-muted/50">
|
||||||
|
<TableHead className="text-xs h-8">Subcategory</TableHead>
|
||||||
|
<TableHead className="text-xs h-8">Description</TableHead>
|
||||||
|
<TableHead className="text-xs h-8 w-20 text-center">Active</TableHead>
|
||||||
|
<TableHead className="text-xs h-8 w-24 text-right">Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{subs.map(sub => (
|
||||||
|
<TableRow key={sub.id}>
|
||||||
|
<TableCell className="text-sm font-medium py-2">{sub.name}</TableCell>
|
||||||
|
<TableCell className="text-xs text-muted-foreground py-2">{sub.description || "—"}</TableCell>
|
||||||
|
<TableCell className="text-center py-2">
|
||||||
|
<Switch checked={sub.is_active} onCheckedChange={() => toggleSubActive(sub)} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right py-2">
|
||||||
|
<div className="flex justify-end gap-1">
|
||||||
|
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => openEditSub(sub)}>
|
||||||
|
<Edit className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive hover:text-destructive" onClick={() => setDeleteTarget({ type: "subcategory", id: sub.id, name: sub.name })}>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Accordion>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Category Dialog */}
|
||||||
|
<Dialog open={catDialogOpen} onOpenChange={setCatDialogOpen}>
|
||||||
|
<DialogContent className="sm:max-w-[425px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{editingCat ? "Edit Category" : "Add Category"}</DialogTitle>
|
||||||
|
<DialogDescription>Define a category for organizing expenses.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4 py-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Name *</Label>
|
||||||
|
<Input value={catForm.name} onChange={e => setCatForm(p => ({ ...p, name: e.target.value }))} placeholder="e.g. Maintenance" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Description</Label>
|
||||||
|
<Input value={catForm.description} onChange={e => setCatForm(p => ({ ...p, description: e.target.value }))} placeholder="Optional description" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Color</Label>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<input type="color" value={catForm.color} onChange={e => setCatForm(p => ({ ...p, color: e.target.value }))} className="w-10 h-10 rounded cursor-pointer border" />
|
||||||
|
<span className="text-sm text-muted-foreground">{catForm.color}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setCatDialogOpen(false)}>Cancel</Button>
|
||||||
|
<Button onClick={saveCat} disabled={catSaving}>
|
||||||
|
{catSaving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
{editingCat ? "Update" : "Create"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Subcategory Dialog */}
|
||||||
|
<Dialog open={subDialogOpen} onOpenChange={setSubDialogOpen}>
|
||||||
|
<DialogContent className="sm:max-w-[425px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{editingSub ? "Edit Subcategory" : "Add Subcategory"}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Under: {categories.find(c => c.id === subForm.category_id)?.name || "—"}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4 py-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Name *</Label>
|
||||||
|
<Input value={subForm.name} onChange={e => setSubForm(p => ({ ...p, name: e.target.value }))} placeholder="e.g. Plumbing" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Description</Label>
|
||||||
|
<Input value={subForm.description} onChange={e => setSubForm(p => ({ ...p, description: e.target.value }))} placeholder="Optional description" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setSubDialogOpen(false)}>Cancel</Button>
|
||||||
|
<Button onClick={saveSub} disabled={subSaving}>
|
||||||
|
{subSaving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
{editingSub ? "Update" : "Create"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Delete Confirmation */}
|
||||||
|
<AlertDialog open={!!deleteTarget} onOpenChange={open => { if (!open) setDeleteTarget(null); }}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Delete {deleteTarget?.type === "category" ? "Category" : "Subcategory"}</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
Are you sure you want to delete "{deleteTarget?.name}"?
|
||||||
|
{deleteTarget?.type === "category" && " This will also delete all its subcategories."}
|
||||||
|
{" "}This cannot be undone.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={confirmDelete} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">Delete</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<AlertDialogContent className="max-w-md">
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Confirm Sign-in Sheet Export</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
Review the roster details before generating the PDF.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
|
||||||
|
<div className="py-4 space-y-4">
|
||||||
|
<div className="grid grid-cols-3 gap-3 text-center">
|
||||||
|
<div className="bg-muted p-3 rounded-lg border">
|
||||||
|
<div className="text-2xl font-bold">{totalCount}</div>
|
||||||
|
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Total</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-destructive/10 p-3 rounded-lg border border-destructive/20">
|
||||||
|
<div className="text-2xl font-bold text-destructive">{excludedOwners.length}</div>
|
||||||
|
<div className="text-xs font-semibold text-destructive/70 uppercase tracking-wider">Excluded</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-green-50 p-3 rounded-lg border border-green-100">
|
||||||
|
<div className="text-2xl font-bold text-green-700">{includedCount}</div>
|
||||||
|
<div className="text-xs font-semibold text-green-600 uppercase tracking-wider">Included</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{excludedOwners.length > 0 ? (
|
||||||
|
<div className="border rounded-md">
|
||||||
|
<div className="bg-muted px-3 py-2 border-b text-xs font-medium text-muted-foreground flex justify-between items-center">
|
||||||
|
<span>Excluded Owners ({excludedOwners.length})</span>
|
||||||
|
</div>
|
||||||
|
<ScrollArea className="h-[140px] p-2">
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{excludedOwners.map(owner => (
|
||||||
|
<li key={owner.id} className="flex justify-between items-start text-sm p-1.5 hover:bg-muted/50 rounded">
|
||||||
|
<span className="font-medium">{owner.owner_name}</span>
|
||||||
|
<span className="text-xs text-muted-foreground max-w-[120px] truncate ml-2">{owner.property_address}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center p-4 text-sm text-muted-foreground bg-muted/50 rounded-md border border-dashed">
|
||||||
|
No owners excluded. All {totalCount} owners will be listed.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={onConfirm}
|
||||||
|
disabled={includedCount === 0}
|
||||||
|
>
|
||||||
|
{includedCount === 0 ? "No Owners to Export" : "Confirm Export"}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||