Add ACMCC app source, Supabase backend, and project config

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-01 20:19:26 -04:00
parent 313b51b412
commit 183fe0a93c
1422 changed files with 259271 additions and 0 deletions
+43
View File
@@ -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;
}
+568
View File
@@ -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;
Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 302 B

+369
View File
@@ -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;
+287
View File
@@ -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;
+68
View File
@@ -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>
);
}
+89
View File
@@ -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>
);
}
+136
View File
@@ -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;
+481
View File
@@ -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 &amp; 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>
);
}
+259
View File
@@ -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>
</>
);
}
+287
View File
@@ -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>
);
}
+36
View File
@@ -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>
);
}
+187
View File
@@ -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>
);
}
+71
View File
@@ -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>
);
}
+121
View File
@@ -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>
</>
);
}
+251
View File
@@ -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>
);
}
+256
View File
@@ -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>
);
}
+172
View File
@@ -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>
);
}
+163
View File
@@ -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>
);
}
+106
View File
@@ -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>
);
}
+137
View File
@@ -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>
);
}
+49
View File
@@ -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>
);
}
+166
View File
@@ -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>
);
}
+258
View File
@@ -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>
);
}
+153
View File
@@ -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>
);
}
+369
View File
@@ -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>
);
}
+76
View File
@@ -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>
);
}
+201
View File
@@ -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>
);
}
+24
View File
@@ -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>
);
}
+270
View File
@@ -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>
</>
);
}
+212
View File
@@ -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;
+193
View File
@@ -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>
);
}
+149
View File
@@ -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>
);
}
+118
View File
@@ -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>
);
}
+613
View File
@@ -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>
);
}
+277
View File
@@ -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>
);
}
+209
View File
@@ -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>
);
}
+222
View File
@@ -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;
+178
View File
@@ -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>
);
}
+131
View File
@@ -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>
);
}
+231
View File
@@ -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>
);
}
+89
View File
@@ -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>
);
}
+63
View File
@@ -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>
);
}
+264
View File
@@ -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>
);
}
+244
View File
@@ -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>
);
}
+52
View File
@@ -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>
);
}
+49
View File
@@ -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>
);
}
+82
View File
@@ -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>
);
}
+133
View File
@@ -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>
);
}
+332
View File
@@ -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>
);
}
+346
View File
@@ -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;
+8
View File
@@ -0,0 +1,8 @@
import React from 'react';
const DropdownElementDialog = () => {
return null;
};
export { DropdownElementDialog };
export default DropdownElementDialog;
+409
View File
@@ -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>
);
}
+189
View File
@@ -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>
);
}
+243
View File
@@ -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;
+208
View File
@@ -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>
);
}
+224
View File
@@ -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>
);
}
+500
View File
@@ -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;
+151
View File
@@ -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>
);
}
+350
View File
@@ -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>
);
}
+248
View File
@@ -0,0 +1,248 @@
import React, { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import * as z from 'zod';
import { supabase } from '@/integrations/supabase/client';
import {
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
} from "@/components/ui/dialog";
import {
Form, FormControl, FormField, FormItem, FormLabel, FormMessage,
} from "@/components/ui/form";
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from "@/components/ui/select";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Loader2 } from "lucide-react";
import { useToast } from "@/hooks/use-toast";
const formSchema = z.object({
description: z.string().min(1, "Description is required"),
fee: z.coerce.number().min(0, "Fee must be 0 or greater"),
category: z.string().min(1, "Category is required"),
subcategory: z.string().min(1, "Subcategory is required"),
account: z.string().optional(),
});
export function FeeScheduleDialog({ open, onOpenChange, onSuccess, item = null }) {
const { toast } = useToast();
const [subcategories, setSubcategories] = useState([]);
const [categories, setCategories] = useState([]);
const form = useForm({
resolver: zodResolver(formSchema),
defaultValues: {
description: '',
fee: '',
category: '',
subcategory: '',
account: '',
},
});
useEffect(() => {
if (open) {
fetchSubcategories();
fetchCategories();
if (item) {
form.reset({
description: item.description,
fee: item.fee,
category: item.category || '',
subcategory: item.subcategory || '',
account: item.account || '',
});
} else {
form.reset({
description: '',
fee: '',
category: '',
subcategory: '',
account: '',
});
}
}
}, [open, item, form]);
const fetchSubcategories = async () => {
const { data } = await supabase
.from('expense_subcategories')
.select('*')
.eq('is_active', true)
.order('name');
setSubcategories(data || []);
};
const fetchCategories = async () => {
const { data } = await supabase
.from('expense_categories')
.select('*')
.eq('is_active', true)
.order('name');
setCategories(data || []);
};
const onSubmit = async (values) => {
try {
const payload = {
description: values.description,
fee: values.fee,
category: values.category,
subcategory: values.subcategory,
account: values.account || null,
updated_at: new Date().toISOString(),
};
let error;
if (item) {
const result = await supabase
.from('fee_schedules')
.update(payload)
.eq('id', item.id);
error = result.error;
} else {
const result = await supabase
.from('fee_schedules')
.insert([payload]);
error = result.error;
}
if (error) throw error;
toast({
title: item ? "Updated" : "Created",
description: `Fee schedule item has been ${item ? "updated" : "created"} successfully.`,
});
window.dispatchEvent(new Event('feeScheduleUpdated'));
onSuccess();
onOpenChange(false);
} catch (error) {
console.error('Error saving fee schedule:', error);
toast({
variant: "destructive",
title: "Error",
description: error.message || "Failed to save fee schedule item.",
});
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>{item ? "Edit Fee Item" : "Add Fee Item"}</DialogTitle>
<DialogDescription>
{item ? "Edit the details of this fee schedule item." : "Add a new item to the fee schedule."}
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Service / Item Description <span className="text-destructive">*</span></FormLabel>
<FormControl>
<Input placeholder="e.g. Estoppel Fee" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="category"
render={({ field }) => (
<FormItem>
<FormLabel>Category <span className="text-destructive">*</span></FormLabel>
<Select onValueChange={field.onChange} value={field.value || ""}>
<FormControl>
<SelectTrigger><SelectValue placeholder="Select..." /></SelectTrigger>
</FormControl>
<SelectContent>
{categories.map((cat) => (
<SelectItem key={cat.id} value={cat.name}>{cat.name}</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="subcategory"
render={({ field }) => (
<FormItem>
<FormLabel>Subcategory <span className="text-destructive">*</span></FormLabel>
<Select onValueChange={field.onChange} value={field.value || ""}>
<FormControl>
<SelectTrigger><SelectValue placeholder="Select..." /></SelectTrigger>
</FormControl>
<SelectContent>
{subcategories.map((sub) => (
<SelectItem key={sub.id} value={sub.name}>{sub.name}</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="account"
render={({ field }) => (
<FormItem>
<FormLabel>Account</FormLabel>
<Select onValueChange={field.onChange} value={field.value || ""}>
<FormControl>
<SelectTrigger><SelectValue placeholder="Select account..." /></SelectTrigger>
</FormControl>
<SelectContent>
{categories.map((cat) => (
<SelectItem key={cat.id} value={cat.name}>{cat.name}</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="fee"
render={({ field }) => (
<FormItem>
<FormLabel>Fee Amount ($) <span className="text-destructive">*</span></FormLabel>
<FormControl>
<Input type="number" step="0.01" placeholder="0.00" {...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>
);
}
+123
View File
@@ -0,0 +1,123 @@
import React, { useCallback, useState } from 'react';
import { useDropzone } from 'react-dropzone';
import { supabase } from '@/integrations/supabase/client';
import { useToast } from '@/hooks/use-toast';
import { Upload, X, FileText, Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
interface UploadedFile {
file_name: string;
file_url: string;
file_size: number;
}
interface FileUploadDropzoneProps {
bucketName: string;
onFilesUploaded: (files: UploadedFile[]) => void;
maxFiles?: number;
className?: string;
}
export function FileUploadDropzone({ bucketName, onFilesUploaded, maxFiles = 5, className }: FileUploadDropzoneProps) {
const { toast } = useToast();
const [uploading, setUploading] = useState(false);
const [uploadedFiles, setUploadedFiles] = useState<UploadedFile[]>([]);
const onDrop = useCallback(async (acceptedFiles: File[]) => {
if (acceptedFiles.length === 0) return;
setUploading(true);
const newFiles: UploadedFile[] = [];
try {
for (const file of acceptedFiles) {
const fileExt = file.name.split('.').pop();
const filePath = `${Date.now()}-${Math.random().toString(36).substring(7)}.${fileExt}`;
const { error: uploadError } = await supabase.storage
.from(bucketName)
.upload(filePath, file);
if (uploadError) throw uploadError;
const { data: { publicUrl } } = supabase.storage
.from(bucketName)
.getPublicUrl(filePath);
newFiles.push({
file_name: file.name,
file_url: publicUrl,
file_size: file.size,
});
}
const allFiles = [...uploadedFiles, ...newFiles];
setUploadedFiles(allFiles);
onFilesUploaded(allFiles);
} catch (err: any) {
console.error('Upload error:', err);
toast({ variant: 'destructive', title: 'Upload failed', description: err.message });
} finally {
setUploading(false);
}
}, [bucketName, uploadedFiles, onFilesUploaded, toast]);
const removeFile = (index: number) => {
const updated = uploadedFiles.filter((_, i) => i !== index);
setUploadedFiles(updated);
onFilesUploaded(updated);
};
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
maxFiles: maxFiles - uploadedFiles.length,
disabled: uploading || uploadedFiles.length >= maxFiles,
});
return (
<div className={cn("space-y-3", className)}>
<div
{...getRootProps()}
className={cn(
"border-2 border-dashed rounded-lg p-6 text-center cursor-pointer transition-colors",
isDragActive ? "border-primary bg-primary/5" : "border-muted-foreground/25 hover:border-primary/50",
(uploading || uploadedFiles.length >= maxFiles) && "opacity-50 cursor-not-allowed"
)}
>
<input {...getInputProps()} />
{uploading ? (
<div className="flex flex-col items-center gap-2">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
<p className="text-sm text-muted-foreground">Uploading...</p>
</div>
) : (
<div className="flex flex-col items-center gap-2">
<Upload className="h-8 w-8 text-muted-foreground" />
<p className="text-sm text-muted-foreground">
{isDragActive ? "Drop files here..." : "Drag & drop files, or click to browse"}
</p>
<p className="text-xs text-muted-foreground/70">
Max {maxFiles} files {uploadedFiles.length}/{maxFiles} uploaded
</p>
</div>
)}
</div>
{uploadedFiles.length > 0 && (
<div className="space-y-2">
{uploadedFiles.map((file, idx) => (
<div key={idx} className="flex items-center gap-2 p-2 rounded-md border bg-muted/30">
<FileText className="h-4 w-4 text-primary shrink-0" />
<span className="text-sm truncate flex-1">{file.file_name}</span>
<span className="text-xs text-muted-foreground">{(file.file_size / 1024).toFixed(1)} KB</span>
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => removeFile(idx)}>
<X className="h-3 w-3" />
</Button>
</div>
))}
</div>
)}
</div>
);
}
+274
View File
@@ -0,0 +1,274 @@
import React, { useState, useEffect } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Plus, Trash2, Loader2, PlayCircle } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
export default function FinancialRuleDialog({ isOpen, onClose, onSave, initialData }) {
const [loading, setLoading] = useState(false);
const [formData, setFormData] = useState({
name: '', description: '', rule_type: 'Charge',
parameters: [], conditions: [], actions: [],
});
const [errors, setErrors] = useState({});
useEffect(() => {
if (isOpen) {
if (initialData) {
setFormData({
name: initialData.name || '',
description: initialData.description || '',
rule_type: initialData.rule_type || 'Charge',
parameters: initialData.parameters || [],
conditions: initialData.conditions || [],
actions: initialData.actions || [],
});
} else {
setFormData({ name: '', description: '', rule_type: 'Charge', parameters: [], conditions: [], actions: [] });
}
setErrors({});
}
}, [isOpen, initialData]);
const handleAddParam = () => {
setFormData(prev => ({
...prev,
parameters: [...prev.parameters, { id: crypto.randomUUID(), name: '', type: 'Text', defaultValue: '' }]
}));
};
const handleUpdateParam = (id, field, value) => {
setFormData(prev => ({
...prev,
parameters: prev.parameters.map(p => p.id === id ? { ...p, [field]: value } : p)
}));
};
const handleRemoveParam = (id) => {
setFormData(prev => ({ ...prev, parameters: prev.parameters.filter(p => p.id !== id) }));
};
const handleAddCondition = () => {
setFormData(prev => ({
...prev,
conditions: [...prev.conditions, { id: crypto.randomUUID(), field: '', operator: 'equals', value: '', logic: 'AND' }]
}));
};
const handleUpdateCondition = (id, field, value) => {
setFormData(prev => ({
...prev,
conditions: prev.conditions.map(c => c.id === id ? { ...c, [field]: value } : c)
}));
};
const handleRemoveCondition = (id) => {
setFormData(prev => ({ ...prev, conditions: prev.conditions.filter(c => c.id !== id) }));
};
const handleAddAction = () => {
setFormData(prev => ({
...prev,
actions: [...prev.actions, { id: crypto.randomUUID(), type: 'Apply Charge', target: '', amount: '' }]
}));
};
const handleUpdateAction = (id, field, value) => {
setFormData(prev => ({
...prev,
actions: prev.actions.map(a => a.id === id ? { ...a, [field]: value } : a)
}));
};
const handleRemoveAction = (id) => {
setFormData(prev => ({ ...prev, actions: prev.actions.filter(a => a.id !== id) }));
};
const validate = () => {
const newErrors = {};
if (!formData.name.trim()) newErrors.name = 'Rule name is required';
if (!formData.rule_type) newErrors.rule_type = 'Rule type is required';
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = async (e) => {
e.preventDefault();
if (!validate()) return;
setLoading(true);
try {
await onSave(formData);
onClose();
} finally {
setLoading(false);
}
};
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="sm:max-w-[700px] max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-2xl font-bold">
{initialData ? 'Edit Financial Rule' : 'Create New Rule'}
</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-6 py-4">
<div className="space-y-4">
<div>
<Label className="font-semibold">Rule Name *</Label>
<Input
value={formData.name}
onChange={e => setFormData({...formData, name: e.target.value})}
placeholder="e.g., Late Fee on Overdue Balances"
className={`mt-1 ${errors.name ? 'border-destructive' : ''}`}
/>
{errors.name && <p className="text-destructive text-xs mt-1">{errors.name}</p>}
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label className="font-semibold">Rule Type *</Label>
<Select value={formData.rule_type} onValueChange={v => setFormData({...formData, rule_type: v})}>
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="Charge">Charge Rule</SelectItem>
<SelectItem value="Payment">Payment Rule</SelectItem>
<SelectItem value="Adjustment">Adjustment</SelectItem>
<SelectItem value="Fee Rule">Fee Rule</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div>
<Label className="font-semibold">Description</Label>
<Textarea
value={formData.description}
onChange={e => setFormData({...formData, description: e.target.value})}
placeholder="Optional description of what this rule does..."
className="mt-1" rows={2}
/>
</div>
</div>
{/* Parameters Section */}
<div className="p-4 rounded-xl bg-muted/50 border space-y-4">
<div className="flex justify-between items-center">
<Label className="font-bold text-base">Custom Parameters</Label>
<Button type="button" variant="outline" size="sm" onClick={handleAddParam} className="h-8 gap-1 rounded-full">
<Plus className="w-3 h-3" /> Add Parameter
</Button>
</div>
<AnimatePresence>
{formData.parameters.map((param) => (
<motion.div key={param.id} initial={{ opacity: 0, y: -10 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, height: 0 }} className="flex gap-2 items-center bg-background p-2 rounded-lg border shadow-sm">
<Input placeholder="Param Name" value={param.name} onChange={e => handleUpdateParam(param.id, 'name', e.target.value)} className="flex-1" />
<Select value={param.type} onValueChange={v => handleUpdateParam(param.id, 'type', v)}>
<SelectTrigger className="w-[120px]"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="Text">Text</SelectItem>
<SelectItem value="Number">Number</SelectItem>
<SelectItem value="Date">Date</SelectItem>
<SelectItem value="Percentage">Percentage</SelectItem>
</SelectContent>
</Select>
<Input placeholder="Default" value={param.defaultValue} onChange={e => handleUpdateParam(param.id, 'defaultValue', e.target.value)} className="w-[120px]" />
<Button type="button" variant="ghost" size="icon" onClick={() => handleRemoveParam(param.id)} className="text-muted-foreground hover:text-destructive"><Trash2 className="w-4 h-4" /></Button>
</motion.div>
))}
</AnimatePresence>
{formData.parameters.length === 0 && <p className="text-sm text-muted-foreground italic">No custom parameters defined.</p>}
</div>
{/* Conditions Section */}
<div className="p-4 rounded-xl bg-accent/30 border space-y-4">
<div className="flex justify-between items-center">
<Label className="font-bold text-base">Conditions</Label>
<Button type="button" variant="outline" size="sm" onClick={handleAddCondition} className="h-8 gap-1 rounded-full">
<Plus className="w-3 h-3" /> Add Condition
</Button>
</div>
<AnimatePresence>
{formData.conditions.map((cond, idx) => (
<motion.div key={cond.id} initial={{ opacity: 0, y: -10 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, height: 0 }} className="flex gap-2 items-center bg-background p-2 rounded-lg border shadow-sm">
{idx > 0 && (
<Select value={cond.logic} onValueChange={v => handleUpdateCondition(cond.id, 'logic', v)}>
<SelectTrigger className="w-[80px] bg-muted"><SelectValue /></SelectTrigger>
<SelectContent><SelectItem value="AND">AND</SelectItem><SelectItem value="OR">OR</SelectItem></SelectContent>
</Select>
)}
<Input placeholder="Field (e.g. balance)" value={cond.field} onChange={e => handleUpdateCondition(cond.id, 'field', e.target.value)} className="flex-1" />
<Select value={cond.operator} onValueChange={v => handleUpdateCondition(cond.id, 'operator', v)}>
<SelectTrigger className="w-[120px]"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="equals">Equals</SelectItem>
<SelectItem value=">">{'>'}</SelectItem>
<SelectItem value="<">{'<'}</SelectItem>
<SelectItem value="contains">Contains</SelectItem>
</SelectContent>
</Select>
<Input placeholder="Value" value={cond.value} onChange={e => handleUpdateCondition(cond.id, 'value', e.target.value)} className="flex-1" />
<Button type="button" variant="ghost" size="icon" onClick={() => handleRemoveCondition(cond.id)} className="text-muted-foreground hover:text-destructive"><Trash2 className="w-4 h-4" /></Button>
</motion.div>
))}
</AnimatePresence>
{formData.conditions.length === 0 && <p className="text-sm text-muted-foreground italic">Rule applies unconditionally if empty.</p>}
</div>
{/* Actions Section */}
<div className="p-4 rounded-xl bg-green-50/50 border space-y-4">
<div className="flex justify-between items-center">
<Label className="font-bold text-base">Actions</Label>
<Button type="button" variant="outline" size="sm" onClick={handleAddAction} className="h-8 gap-1 rounded-full">
<Plus className="w-3 h-3" /> Add Action
</Button>
</div>
<AnimatePresence>
{formData.actions.map((act) => (
<motion.div key={act.id} initial={{ opacity: 0, y: -10 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, height: 0 }} className="flex gap-2 items-center bg-background p-2 rounded-lg border shadow-sm">
<Select value={act.type} onValueChange={v => handleUpdateAction(act.id, 'type', v)}>
<SelectTrigger className="w-[160px]"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="Apply Charge">Apply Charge</SelectItem>
<SelectItem value="Waive Fee">Waive Fee</SelectItem>
<SelectItem value="Send Notice">Send Notice</SelectItem>
</SelectContent>
</Select>
<Input placeholder="Target (e.g. Account)" value={act.target} onChange={e => handleUpdateAction(act.id, 'target', e.target.value)} className="flex-1" />
<Input placeholder="Amount / Value" value={act.amount} onChange={e => handleUpdateAction(act.id, 'amount', e.target.value)} className="w-[120px]" />
<Button type="button" variant="ghost" size="icon" onClick={() => handleRemoveAction(act.id)} className="text-muted-foreground hover:text-destructive"><Trash2 className="w-4 h-4" /></Button>
</motion.div>
))}
</AnimatePresence>
{formData.actions.length === 0 && <p className="text-sm text-muted-foreground italic">Add actions to execute when conditions are met.</p>}
</div>
{/* Preview */}
<div className="p-4 rounded-xl bg-card border space-y-2 relative overflow-hidden">
<div className="absolute top-0 right-0 p-4 opacity-10"><PlayCircle className="w-24 h-24" /></div>
<Label className="text-muted-foreground font-bold uppercase text-xs tracking-wider">Preview Summary</Label>
<p className="text-sm font-medium">
{formData.name ? `Rule: ${formData.name}` : 'Unnamed Rule'} ({formData.rule_type})
</p>
<p className="text-xs text-muted-foreground">
If {formData.conditions.length} condition(s) met, execute {formData.actions.length} action(s).
</p>
</div>
<DialogFooter className="pt-4 border-t">
<Button type="button" variant="outline" onClick={onClose} disabled={loading} className="rounded-full px-6">Cancel</Button>
<Button type="submit" disabled={loading} className="rounded-full px-6">
{loading && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
Save Rule
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
+198
View File
@@ -0,0 +1,198 @@
import React, { useState, useEffect } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { supabase } from '@/integrations/supabase/client';
import { useToast } from '@/hooks/use-toast';
import { Copy, RefreshCw, Globe, Lock, ExternalLink, ShieldAlert } from 'lucide-react';
import { v4 as uuidv4 } from 'uuid';
export function FolderShareDialog({ open, onOpenChange, folder, onSuccess }) {
const { toast } = useToast();
const [loading, setLoading] = useState(false);
const [isShared, setIsShared] = useState(false);
const [password, setPassword] = useState('');
const [shareToken, setShareToken] = useState('');
useEffect(() => {
if (folder) {
setIsShared(folder.is_shared || false);
setPassword(folder.share_password || '');
setShareToken(folder.share_token || '');
}
}, [folder]);
const handleShareToggle = (checked) => {
setIsShared(checked);
if (checked && !shareToken) {
setShareToken(uuidv4());
}
if (checked && !password) {
generateRandomPassword();
}
};
const generateRandomPassword = () => {
const chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
let pass = "";
for(let i=0; i<8; i++) {
pass += chars.charAt(Math.floor(Math.random() * chars.length));
}
setPassword(pass);
};
const regenerateToken = () => {
if (window.confirm("Are you sure? This will invalidate any existing links sent to users.")) {
setShareToken(uuidv4());
}
};
const handleSave = async () => {
setLoading(true);
try {
if (isShared && !password.trim()) {
throw new Error("An access code is required to enable secure sharing.");
}
const updates = {
is_shared: isShared,
share_password: isShared ? password : null,
share_token: isShared ? shareToken : folder.share_token
};
const { error } = await supabase
.from('document_categories')
.update(updates)
.eq('id', folder.id);
if (error) throw error;
toast({ title: "Settings Saved", description: isShared ? "Folder is now publicly accessible via the link." : "Folder sharing disabled." });
if (onSuccess) onSuccess();
onOpenChange(false);
} catch (err) {
toast({ variant: "destructive", title: "Error", description: err.message });
} finally {
setLoading(false);
}
};
const shareLink = shareToken ? `${window.location.origin}/shared/${shareToken}` : '';
const copyLink = () => {
navigator.clipboard.writeText(shareLink);
toast({ title: "Link Copied!", description: "Share link copied to clipboard." });
};
const copyPassword = () => {
navigator.clipboard.writeText(password);
toast({ title: "Code Copied!", description: "Access code copied to clipboard." });
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Globe className="w-5 h-5 text-primary" />
Share Folder: {folder?.name}
</DialogTitle>
<DialogDescription>
Generate a secure link for external users.
</DialogDescription>
</DialogHeader>
<div className="space-y-6 py-4">
<div className="flex items-center justify-between space-x-2 bg-muted p-4 rounded-lg border">
<div className="space-y-0.5">
<Label className="text-base font-semibold">Public Sharing</Label>
<p className="text-xs text-muted-foreground">
{isShared ? 'Enabled - Folder is accessible with link & code' : 'Disabled - Folder is private'}
</p>
</div>
<Switch checked={isShared} onCheckedChange={handleShareToggle} />
</div>
{isShared && (
<div className="space-y-5 animate-in fade-in slide-in-from-top-2 duration-300">
<div className="space-y-2">
<Label className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">1. Share Link</Label>
<div className="flex space-x-2">
<div className="relative flex-1">
<Input
readOnly
value={shareLink}
className="bg-muted font-mono text-xs pr-10"
/>
<div className="absolute inset-y-0 right-0 flex items-center pr-2">
<a href={shareLink} target="_blank" rel="noopener noreferrer" className="text-muted-foreground hover:text-primary" title="Open Link">
<ExternalLink className="w-3 h-3" />
</a>
</div>
</div>
<Button size="icon" variant="outline" onClick={copyLink} className="shrink-0">
<Copy className="w-4 h-4" />
</Button>
</div>
<div className="flex justify-end">
<button onClick={regenerateToken} className="text-[10px] text-muted-foreground hover:text-destructive underline">
Regenerate Link
</button>
</div>
</div>
<div className="space-y-2">
<div className="flex justify-between items-center">
<Label className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">2. Access Code</Label>
<Button
variant="ghost"
size="sm"
onClick={generateRandomPassword}
className="h-6 text-[10px] text-primary px-2"
>
<RefreshCw className="w-3 h-3 mr-1" /> New Code
</Button>
</div>
<div className="bg-amber-50 border border-amber-200 rounded-md p-4 relative overflow-hidden">
<div className="absolute top-0 right-0 p-2 opacity-10">
<ShieldAlert className="w-24 h-24 text-amber-600" />
</div>
<div className="relative z-10">
<div className="flex items-center space-x-2 mb-3">
<Lock className="w-4 h-4 text-amber-600" />
<span className="text-xs text-amber-800 font-bold uppercase tracking-wide">Authentication Required</span>
</div>
<div className="flex space-x-2 mb-2">
<Input
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Enter secure code"
className="font-mono text-xl font-bold tracking-widest text-center bg-background border-amber-300"
/>
<Button size="icon" variant="outline" onClick={copyPassword} className="shrink-0 border-amber-300 text-amber-700 hover:bg-amber-100">
<Copy className="w-4 h-4" />
</Button>
</div>
<p className="text-[11px] text-amber-700/80 leading-relaxed text-center font-medium">
External users <strong>must</strong> enter this code to view files.
</p>
</div>
</div>
</div>
</div>
)}
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button variant="outline" onClick={() => onOpenChange(false)}>Cancel</Button>
<Button onClick={handleSave} disabled={loading}>
{loading ? 'Saving...' : 'Save Settings'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+60
View File
@@ -0,0 +1,60 @@
import React, { useState } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
function GenerateInvoiceDialog({ open, onOpenChange, onGenerate, count }) {
const [invoiceNumber, setInvoiceNumber] = useState('');
const [loading, setLoading] = useState(false);
const handleSubmit = async (e) => {
e.preventDefault();
if (!invoiceNumber || invoiceNumber.trim() === '') return;
setLoading(true);
await onGenerate(invoiceNumber);
setLoading(false);
setInvoiceNumber('');
onOpenChange(false);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Generate Invoice</DialogTitle>
<DialogDescription>
Generate a PDF invoice for {count} selected item{count !== 1 ? 's' : ''}.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="invoice-number">Invoice Number</Label>
<Input
id="invoice-number"
value={invoiceNumber}
onChange={(e) => setInvoiceNumber(e.target.value)}
placeholder="e.g. INV-2024-001"
required
autoFocus
/>
<p className="text-xs text-muted-foreground">
This number will be used for the invoice and set as the tracking ID.
</p>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit" disabled={!invoiceNumber || loading}>
{loading ? 'Generating...' : 'Generate PDF'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
export default GenerateInvoiceDialog;
@@ -0,0 +1,154 @@
import React, { useState, useEffect } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator';
import { Badge } from '@/components/ui/badge';
import { Calendar, Trash2, Edit, Mail, CheckCircle2 } from 'lucide-react';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { useAuth } from '@/contexts/AuthContext';
import { supabase } from '@/integrations/supabase/client';
import { useToast } from '@/hooks/use-toast';
// NOTE: These sub-components are referenced but may need to be created separately:
// HomeownerRequestCommentsSection, HomeownerRequestVotingPanel,
// HomeownerRequestSummarySection, HomeownerRequestNotificationHistory, HomeownerRequestNotifyDialog
export default function HomeownerRequestDetailsDialog({ open, onOpenChange, request, onRefresh, onEdit }) {
const { userRole } = useAuth();
const { toast } = useToast();
const [refreshTrigger, setRefreshTrigger] = useState(0);
const [notifyDialogOpen, setNotifyDialogOpen] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [localRequest, setLocalRequest] = useState(request);
useEffect(() => {
setLocalRequest(request);
}, [request]);
const fetchDetails = async () => {
if (!request?.id) return;
const { data: requestData, error: requestError } = await supabase
.from('homeowner_requests')
.select('*')
.eq('id', request.id)
.single();
if (requestError) {
console.error("Error fetching request details:", requestError);
return;
}
if (requestData) {
setLocalRequest(prev => ({
...prev,
...requestData,
}));
}
};
useEffect(() => {
if (open) {
fetchDetails();
}
}, [open, request?.id, refreshTrigger]);
if (!localRequest) return null;
const isAdmin = userRole?.role === 'admin' || userRole?.role === 'manager';
const handleDelete = async () => {
try {
const { error } = await supabase.from('homeowner_requests').delete().eq('id', localRequest.id);
if (error) throw error;
toast({ title: 'Deleted', description: 'Homeowner request deleted successfully.' });
setDeleteDialogOpen(false);
onOpenChange(false);
if (onRefresh) onRefresh();
} catch (error) {
console.error("Delete failed:", error);
toast({ variant: 'destructive', title: 'Error', description: error.message || "Failed to delete request." });
}
};
const handleRefresh = () => {
setRefreshTrigger(prev => prev + 1);
fetchDetails();
if (onRefresh) onRefresh();
};
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[900px] h-[90vh] sm:h-[850px] flex flex-col p-0 gap-0">
<DialogHeader className="p-6 pb-4 border-b">
<div className="flex justify-between items-start gap-4">
<div className="space-y-2 flex-1">
<div className="flex items-center gap-2 mb-1">
<Badge variant={localRequest.status === 'open' ? 'default' : 'secondary'} className="capitalize px-2 py-0.5 text-xs font-normal">{localRequest.status}</Badge>
<span className="text-xs text-muted-foreground flex items-center gap-1"><Calendar className="w-3 h-3" /> {new Date(localRequest.created_at).toLocaleDateString()}</span>
</div>
<DialogTitle className="text-2xl font-bold leading-tight">{localRequest.title}</DialogTitle>
</div>
{isAdmin && (
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={() => onEdit(localRequest)}><Edit className="w-3.5 h-3.5 mr-2" /> Edit</Button>
<Button
variant="outline"
size="icon"
className="text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={() => setDeleteDialogOpen(true)}
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
)}
</div>
</DialogHeader>
<ScrollArea className="flex-1 px-6">
<div className="py-6 space-y-8">
<div className="flex-1 space-y-6 min-w-0 w-full bg-card p-5 rounded-lg border shadow-sm">
{localRequest.description && (
<div className="text-lg text-foreground font-medium leading-relaxed">
{localRequest.description}
</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 the homeowner request
and all associated data.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleDelete} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
Delete Request
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
+210
View File
@@ -0,0 +1,210 @@
import React, { useState, useEffect } from 'react';
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Checkbox } from '@/components/ui/checkbox';
import { ScrollArea } from '@/components/ui/scroll-area';
import { useToast } from '@/hooks/use-toast';
import { supabase } from '@/integrations/supabase/client';
import { useAuth } from '@/contexts/AuthContext';
import { Loader2, CheckCircle2 } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
export default function HomeownerRequestDialog({ open, onOpenChange, request, onSuccess, isReadOnly = false }) {
const { user } = useAuth();
const { toast } = useToast();
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [status, setStatus] = useState('open');
const [category, setCategory] = useState('general');
const [priority, setPriority] = useState('medium');
const [selectedAssociations, setSelectedAssociations] = useState([]);
const [associations, setAssociations] = useState([]);
const [loading, setLoading] = useState(false);
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
const fetchAssociations = async () => {
setLoading(true);
const { data } = await supabase.from('associations').select('id, name').eq('status', 'active').order('name');
setAssociations(data || []);
setLoading(false);
};
if (open) fetchAssociations();
}, [open]);
useEffect(() => {
if (request) {
setTitle(request.title || '');
setDescription(request.description || '');
setStatus(request.status || 'open');
setCategory(request.category || 'general');
setPriority(request.priority || 'medium');
} else {
setTitle('');
setDescription('');
setStatus('open');
setCategory('general');
setPriority('medium');
setSelectedAssociations([]);
}
}, [request, open]);
const handleSubmit = async (e) => {
e.preventDefault();
if (!title.trim()) return toast({ variant: 'destructive', title: 'Title required' });
if (selectedAssociations.length === 0) return toast({ variant: 'destructive', title: 'Select at least one association' });
setSubmitting(true);
try {
const payload = {
title,
description,
status,
category,
priority,
updated_at: new Date().toISOString()
};
if (request) {
const { error } = await supabase.from('homeowner_requests').update(payload).eq('id', request.id);
if (error) throw error;
} else {
const { error } = await supabase.from('homeowner_requests')
.insert({ ...payload, association_id: selectedAssociations[0] });
if (error) throw error;
}
toast({ title: 'Success', description: 'Request saved successfully.' });
if (onSuccess) onSuccess();
onOpenChange(false);
} catch (error) {
console.error(error);
toast({ variant: 'destructive', title: 'Error', description: error.message });
} finally {
setSubmitting(false);
}
};
const toggleAssociation = (id) => {
if (isReadOnly) return;
setSelectedAssociations(prev =>
prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id]
);
};
const handleSelectAll = () => {
if (isReadOnly) return;
if (selectedAssociations.length === associations.length) {
setSelectedAssociations([]);
} else {
setSelectedAssociations(associations.map(c => c.id));
}
};
if (isReadOnly) {
return null;
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[800px] h-[90vh] flex flex-col p-0 gap-0">
<DialogHeader className="p-6 pb-2">
<div className="flex justify-between items-start">
<div>
<DialogTitle>{request ? 'Edit Homeowner Request' : 'New Homeowner Request'}</DialogTitle>
<DialogDescription>Create or edit a homeowner request.</DialogDescription>
</div>
</div>
</DialogHeader>
<ScrollArea className="flex-1 px-6">
<div className="py-4 space-y-6">
<div className="space-y-2">
<Label>Title <span className="text-destructive">*</span></Label>
<Input value={title} onChange={e => setTitle(e.target.value)} placeholder="e.g. Maintenance Request" />
</div>
<div className="space-y-2">
<Label>Description</Label>
<Textarea value={description} onChange={e => setDescription(e.target.value)} placeholder="Brief summary..." rows={3} />
</div>
<div className="grid grid-cols-3 gap-4">
<div className="space-y-2">
<Label>Status</Label>
<Select value={status} onValueChange={setStatus}>
<SelectTrigger><SelectValue /></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>
</div>
<div className="space-y-2">
<Label>Category</Label>
<Select value={category} onValueChange={setCategory}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="general">General</SelectItem>
<SelectItem value="maintenance">Maintenance</SelectItem>
<SelectItem value="complaint">Complaint</SelectItem>
<SelectItem value="architectural">Architectural</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Priority</Label>
<Select value={priority} onValueChange={setPriority}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="low">Low</SelectItem>
<SelectItem value="medium">Medium</SelectItem>
<SelectItem value="high">High</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-3">
<div className="flex justify-between items-center">
<Label>Assigned Associations <span className="text-destructive">*</span></Label>
<Button variant="link" size="sm" className="h-auto p-0 text-xs" onClick={handleSelectAll}>
{selectedAssociations.length === associations.length ? 'Deselect All' : 'Select All'}
</Button>
</div>
<div className="border rounded-md p-4 max-h-48 overflow-y-auto grid grid-cols-1 sm:grid-cols-2 gap-2 bg-muted">
{loading ? <Loader2 className="animate-spin mx-auto" /> : associations.map(assoc => (
<div key={assoc.id} className="flex items-center space-x-2">
<Checkbox id={assoc.id} checked={selectedAssociations.includes(assoc.id)} onCheckedChange={() => toggleAssociation(assoc.id)} />
<label htmlFor={assoc.id} className="text-sm font-medium leading-none cursor-pointer">
{assoc.name}
</label>
</div>
))}
</div>
</div>
</div>
</ScrollArea>
<DialogFooter className="p-6 pt-2 border-t">
<Button variant="outline" onClick={() => onOpenChange(false)}>Cancel</Button>
<Button onClick={handleSubmit} disabled={submitting}>
{submitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Save Request
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,161 @@
import React, { useState } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Loader2, FileDown } from 'lucide-react';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Label } from '@/components/ui/label';
import { format } from 'date-fns';
export default function HomeownerRequestExportDialog({
open,
onOpenChange,
requests = [],
singleRequest = null,
associations = []
}) {
const [isExporting, setIsExporting] = useState(false);
const [selectedAssociationFilter, setSelectedAssociationFilter] = useState('all');
const handleExport = async () => {
setIsExporting(true);
try {
let dataToExport = singleRequest ? [singleRequest] : requests;
if (!singleRequest && selectedAssociationFilter !== 'all') {
dataToExport = dataToExport.filter(req => req.association_id === selectedAssociationFilter);
}
if (dataToExport.length === 0) {
alert("No data to export with current filters.");
setIsExporting(false);
return;
}
// Dynamic import for jsPDF to avoid bundle bloat
const jsPDF = (await import('jspdf')).default;
await import('jspdf-autotable');
const doc = new jsPDF();
let yPos = 20;
doc.setFontSize(22);
doc.setTextColor(44, 62, 80);
doc.text("Homeowner Requests Report", 14, 20);
doc.setDrawColor(200);
doc.setLineWidth(0.5);
doc.line(14, 25, 196, 25);
yPos = 35;
doc.setFontSize(10);
doc.setTextColor(100);
doc.text(`Generated: ${format(new Date(), 'MMM dd, yyyy HH:mm')}`, 14, yPos);
yPos += 5;
if (singleRequest) {
doc.text(`Type: Detail Report`, 14, yPos);
} else {
const assocName = associations.find(c => c.id === selectedAssociationFilter)?.name || "All Associations";
doc.text(`Association Filter: ${assocName}`, 14, yPos);
}
yPos += 15;
for (const req of dataToExport) {
if (yPos > 240) {
doc.addPage();
yPos = 20;
}
doc.setFillColor(240, 242, 245);
doc.setDrawColor(210);
doc.rect(14, yPos - 6, 182, 12, 'FD');
doc.setFontSize(14);
doc.setTextColor(0);
doc.setFont("helvetica", "bold");
const title = req.title || "Untitled Request";
const displayTitle = title.length > 50 ? title.substring(0, 50) + "..." : title;
doc.text(displayTitle, 17, yPos + 2);
doc.setFontSize(10);
const statusText = (req.status || 'open').toUpperCase();
const statusWidth = doc.getTextWidth(statusText);
doc.setTextColor(100);
doc.text(statusText, 190 - statusWidth, yPos + 2);
yPos += 15;
doc.setTextColor(0);
doc.setFontSize(10);
doc.setFont("helvetica", "normal");
const createdDate = format(new Date(req.created_at), 'MMM dd, yyyy');
doc.text(`Submitted: ${createdDate}`, 17, yPos);
yPos += 8;
if (req.description) {
doc.setFont("helvetica", "bold");
doc.text("Description:", 17, yPos);
yPos += 5;
doc.setFont("helvetica", "normal");
const splitDesc = doc.splitTextToSize(req.description, 175);
doc.text(splitDesc, 17, yPos);
yPos += (splitDesc.length * 5) + 10;
}
}
const filename = singleRequest
? `Request_${singleRequest.title.substring(0, 10).replace(/[^a-zA-Z0-9]/g, '_')}.pdf`
: `Requests_Report_${format(new Date(), 'yyyy-MM-dd')}.pdf`;
doc.save(filename);
onOpenChange(false);
} catch (error) {
console.error("Export failed:", error);
alert("Export failed. See console for details.");
} finally {
setIsExporting(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>{singleRequest ? 'Export Request Details' : 'Export Requests Report'}</DialogTitle>
<DialogDescription>
Generate a detailed PDF report.
</DialogDescription>
</DialogHeader>
{!singleRequest && (
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="assoc-filter">Filter by Association</Label>
<Select value={selectedAssociationFilter} onValueChange={setSelectedAssociationFilter}>
<SelectTrigger id="assoc-filter">
<SelectValue placeholder="All Associations" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Associations</SelectItem>
{associations.map(assoc => (
<SelectItem key={assoc.id} value={assoc.id}>{assoc.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>Cancel</Button>
<Button onClick={handleExport} disabled={isExporting}>
{isExporting ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <FileDown className="mr-2 h-4 w-4" />}
Download PDF
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,134 @@
import React, { useState, useEffect } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Loader2, Mail, AlertTriangle, CheckCircle2 } from 'lucide-react';
import { useToast } from '@/hooks/use-toast';
import { supabase } from '@/integrations/supabase/client';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { ScrollArea } from '@/components/ui/scroll-area';
import { useAuth } from '@/contexts/AuthContext';
export default function HomeownerRequestNotifyDialog({ open, onOpenChange, requestId, onSuccess }) {
const { toast } = useToast();
const { user } = useAuth();
const [loading, setLoading] = useState(false);
const [sending, setSending] = useState(false);
const [request, setRequest] = useState(null);
const [previewTab, setPreviewTab] = useState('recipients');
const [sendResults, setSendResults] = useState(null);
useEffect(() => {
if (open && requestId) {
loadData();
} else {
resetState();
}
}, [open, requestId]);
const resetState = () => {
setRequest(null);
setSendResults(null);
};
const loadData = async () => {
setLoading(true);
try {
const { data: reqData, error: reqError } = await supabase
.from('homeowner_requests')
.select('*')
.eq('id', requestId)
.single();
if (reqError) throw reqError;
setRequest(reqData);
} catch (error) {
console.error("Error loading notify dialog data:", error);
toast({ variant: 'destructive', title: 'Data access error', description: 'Failed to retrieve homeowner request.' });
} finally {
setLoading(false);
}
};
const handleSend = async () => {
setSending(true);
setSendResults(null);
try {
// Placeholder: In production, this would call an edge function or email service
toast({ title: 'Notification', description: 'Email notification feature requires email service configuration.' });
setSendResults({ success: true });
if (onSuccess) onSuccess();
} catch (error) {
console.error("Send failed:", error);
toast({ variant: 'destructive', title: 'Failed to send', description: error.message });
setSendResults({ success: false, error: error.message });
} finally {
setSending(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[800px] h-[85vh] flex flex-col p-0 gap-0">
<DialogHeader className="p-6 pb-2 border-b shrink-0">
<DialogTitle>Notify Homeowners</DialogTitle>
<DialogDescription>Send email notification for "{request?.title}"</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-hidden flex flex-col min-h-0">
{sendResults ? (
<div className="flex-1 p-6 overflow-y-auto">
<div className="text-center mb-6">
{sendResults.success ? (
<div className="inline-flex items-center justify-center p-3 bg-emerald-100 rounded-full mb-3">
<CheckCircle2 className="w-8 h-8 text-emerald-600" />
</div>
) : (
<div className="inline-flex items-center justify-center p-3 bg-destructive/10 rounded-full mb-3">
<AlertTriangle className="w-8 h-8 text-destructive" />
</div>
)}
<h3 className="text-lg font-semibold">
{sendResults.success ? 'Notifications Sent' : 'Sending Failed'}
</h3>
</div>
</div>
) : (
<div className="flex-1 p-6">
{loading ? (
<div className="flex flex-col items-center justify-center py-12 gap-4">
<Loader2 className="animate-spin text-muted-foreground w-8 h-8" />
<p className="text-muted-foreground text-sm">Loading request details...</p>
</div>
) : (
<div className="text-center text-muted-foreground py-12">
<Mail className="w-12 h-12 mx-auto mb-4 opacity-50" />
<p>Email notification functionality requires email service configuration.</p>
</div>
)}
</div>
)}
</div>
<DialogFooter className="p-4 border-t shrink-0">
{sendResults ? (
<Button onClick={() => onOpenChange(false)}>Close</Button>
) : (
<>
<Button variant="outline" onClick={() => onOpenChange(false)}>Cancel</Button>
<Button
onClick={handleSend}
disabled={sending || loading}
>
{sending ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Mail className="mr-2 h-4 w-4" />}
{sending ? 'Sending...' : 'Send Notifications'}
</Button>
</>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+131
View File
@@ -0,0 +1,131 @@
import React, { useState, useCallback } from 'react';
import { useDropzone } from 'react-dropzone';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Loader2, Upload, FileText, CheckCircle, AlertTriangle } from 'lucide-react';
import { useToast } from '@/hooks/use-toast';
export function IcsImportDialog({ open, onOpenChange, onImport }) {
const [file, setFile] = useState(null);
const [parsedEvents, setParsedEvents] = useState([]);
const [loading, setLoading] = useState(false);
const { toast } = useToast();
const onDrop = useCallback((acceptedFiles) => {
const uploadedFile = acceptedFiles[0];
if (uploadedFile) {
setFile(uploadedFile);
parseIcs(uploadedFile);
}
}, []);
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
accept: { 'text/calendar': ['.ics'] },
maxFiles: 1
});
const parseIcs = async (file) => {
const reader = new FileReader();
reader.onload = async () => {
try {
// Dynamic import for ical.js
const ICAL = (await import('ical.js')).default;
const jcalData = ICAL.parse(reader.result);
const comp = new ICAL.Component(jcalData);
const vevents = comp.getAllSubcomponents('vevent');
const events = vevents.map(vevent => {
const event = new ICAL.Event(vevent);
return {
title: event.summary,
description: event.description,
start_time: event.startDate.toJSDate().toISOString(),
end_time: event.endDate.toJSDate().toISOString(),
location: event.location,
source: 'ics_import'
};
});
setParsedEvents(events);
} catch (err) {
console.error(err);
toast({ variant: "destructive", title: "Parse Error", description: "Invalid ICS file format." });
setFile(null);
}
};
reader.readAsText(file);
};
const handleImport = async () => {
setLoading(true);
try {
await onImport(parsedEvents);
toast({ title: "Import Successful", description: `Imported ${parsedEvents.length} events.` });
onOpenChange(false);
setFile(null);
setParsedEvents([]);
} catch (err) {
toast({ variant: "destructive", title: "Import Failed", description: err.message });
} finally {
setLoading(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[600px]">
<DialogHeader>
<DialogTitle>Import Calendar (.ics)</DialogTitle>
<DialogDescription>Import events from external calendar files.</DialogDescription>
</DialogHeader>
{!file ? (
<div
{...getRootProps()}
className={`border-2 border-dashed rounded-lg p-10 text-center cursor-pointer transition-colors ${isDragActive ? 'border-primary bg-primary/5' : 'border-border hover:bg-muted'}`}
>
<input {...getInputProps()} />
<Upload className="w-10 h-10 mx-auto text-muted-foreground mb-4" />
<p className="text-foreground font-medium">Drag & drop .ics file here, or click to select</p>
<p className="text-xs text-muted-foreground mt-2">Maximum file size 5MB</p>
</div>
) : (
<div className="space-y-4">
<div className="flex items-center justify-between p-3 bg-muted rounded border">
<div className="flex items-center gap-3">
<FileText className="w-5 h-5 text-primary" />
<span className="font-medium text-sm">{file.name}</span>
</div>
<Button variant="ghost" size="sm" onClick={() => { setFile(null); setParsedEvents([]); }}>Change</Button>
</div>
<div className="bg-primary/5 border border-primary/20 rounded-md p-3 flex items-start gap-3">
<CheckCircle className="w-5 h-5 text-primary mt-0.5" />
<div>
<p className="text-sm font-medium">Ready to Import</p>
<p className="text-xs text-muted-foreground">Found {parsedEvents.length} events in file.</p>
</div>
</div>
<ScrollArea className="h-40 border rounded p-2">
{parsedEvents.map((ev, i) => (
<div key={i} className="text-xs py-1 border-b last:border-0">
<strong>{ev.title}</strong> - {new Date(ev.start_time).toLocaleDateString()}
</div>
))}
</ScrollArea>
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>Cancel</Button>
<Button onClick={handleImport} disabled={!file || parsedEvents.length === 0 || loading}>
{loading && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
Import Events
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+215
View File
@@ -0,0 +1,215 @@
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Trash2, GripVertical, Image as ImageIcon, Upload,
AlignLeft, AlignCenter, AlignRight,
} from "lucide-react";
import { supabase } from "@/integrations/supabase/client";
import { useToast } from "@/components/ui/use-toast";
interface ImageStyles {
width?: string;
widthUnit?: string;
height?: string;
alignment?: string;
}
interface ImageContent {
url?: string;
altText?: string;
}
interface ImageBlock {
styles?: ImageStyles;
content?: ImageContent;
}
interface ImageElementProps {
block: ImageBlock;
onChange: (updates: Partial<ImageBlock>) => void;
onDelete: () => void;
dragHandleProps?: Record<string, any>;
}
export default function ImageElement({ block, onChange, onDelete, dragHandleProps }: ImageElementProps) {
const { toast } = useToast();
const [uploading, setUploading] = useState(false);
const styles = block.styles || {};
const content = block.content || {};
const width = styles.width || "100";
const widthUnit = styles.widthUnit || "%";
const height = styles.height || "auto";
const alignment = styles.alignment || "center";
const imageUrl = content.url || "";
const altText = content.altText || "";
const updateStyle = (key: string, value: string) => {
onChange({ styles: { ...styles, [key]: value } });
};
const updateContent = (key: string, value: string) => {
onChange({ content: { ...content, [key]: value } });
};
const handleFileUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
try {
setUploading(true);
const file = event.target.files?.[0];
if (!file) return;
const fileExt = file.name.split(".").pop();
const fileName = `${Math.random().toString(36).substring(2)}.${fileExt}`;
const filePath = `form-assets/${fileName}`;
const { error: uploadError } = await supabase.storage
.from("company-assets")
.upload(filePath, file);
if (uploadError) throw uploadError;
const {
data: { publicUrl },
} = supabase.storage.from("company-assets").getPublicUrl(filePath);
updateContent("url", publicUrl);
toast({ title: "Success", description: "Image uploaded successfully" });
} catch (error) {
console.error("Error uploading image:", error);
toast({ variant: "destructive", title: "Error", description: "Failed to upload image." });
} finally {
setUploading(false);
}
};
return (
<div className="group relative bg-card rounded-xl shadow-sm border transition-all duration-200 mb-4 overflow-hidden hover:shadow-md">
<div className="flex items-center gap-2 p-2 bg-muted/50 border-b">
<div {...dragHandleProps} className="cursor-move text-muted-foreground hover:text-foreground p-1 rounded hover:bg-muted">
<GripVertical className="w-4 h-4" />
</div>
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wide flex items-center gap-1">
<ImageIcon className="w-3 h-3" /> Image
</span>
<div className="flex-1" />
<Button variant="ghost" size="icon" className="h-7 w-7 text-muted-foreground hover:text-destructive" onClick={onDelete}>
<Trash2 className="w-3.5 h-3.5" />
</Button>
</div>
<div className="p-4 space-y-4">
{!imageUrl ? (
<div className="border-2 border-dashed rounded-lg p-6 flex flex-col items-center justify-center bg-muted/30 hover:bg-muted/50 transition-colors">
<ImageIcon className="w-10 h-10 text-muted-foreground/40 mb-3" />
<div className="text-center space-y-2">
<p className="text-sm text-muted-foreground">Upload an image or enter URL</p>
<div className="flex gap-2 justify-center">
<div className="relative">
<input
type="file"
accept="image/*"
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
onChange={handleFileUpload}
disabled={uploading}
/>
<Button size="sm" variant="secondary" disabled={uploading}>
{uploading ? "Uploading..." : <><Upload className="w-3.5 h-3.5 mr-2" /> Choose File</>}
</Button>
</div>
</div>
<div className="relative max-w-xs mx-auto mt-2">
<Input
placeholder="Or paste image URL..."
className="text-xs h-8"
value={imageUrl}
onChange={(e) => updateContent("url", e.target.value)}
/>
</div>
</div>
</div>
) : (
<div className="space-y-4">
<div className="flex gap-4 items-start">
<div className="w-32 h-32 bg-muted rounded border flex items-center justify-center overflow-hidden shrink-0 relative group/img">
<img src={imageUrl} alt={altText} className="w-full h-full object-cover" />
<div className="absolute inset-0 bg-foreground/40 opacity-0 group-hover/img:opacity-100 transition-opacity flex items-center justify-center">
<Button variant="secondary" size="sm" className="h-7 text-xs" onClick={() => updateContent("url", "")}>
Change
</Button>
</div>
</div>
<div className="flex-1 space-y-3">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label className="text-xs">Width</Label>
<div className="flex gap-2">
<Input type="number" className="h-8 text-xs" value={width} onChange={(e) => updateStyle("width", e.target.value)} />
<select
className="h-8 text-xs border rounded bg-transparent px-2"
value={widthUnit}
onChange={(e) => updateStyle("widthUnit", e.target.value)}
>
<option value="%">%</option>
<option value="px">px</option>
</select>
</div>
</div>
<div className="space-y-1">
<Label className="text-xs">Height (px)</Label>
<Input type="text" className="h-8 text-xs" value={height} onChange={(e) => updateStyle("height", e.target.value)} placeholder="auto" />
</div>
</div>
<div className="space-y-1">
<Label className="text-xs">Alt Text</Label>
<Input className="h-8 text-xs" value={altText} onChange={(e) => updateContent("altText", e.target.value)} placeholder="Description for accessibility" />
</div>
<div className="space-y-1">
<Label className="text-xs">Alignment</Label>
<div className="flex bg-muted rounded p-1 w-fit">
{(["left", "center", "right"] as const).map((align) => (
<Button
key={align}
variant={alignment === align ? "default" : "ghost"}
size="sm"
className="h-7 w-8 px-0"
onClick={() => updateStyle("alignment", align)}
>
{align === "left" && <AlignLeft className="w-3.5 h-3.5" />}
{align === "center" && <AlignCenter className="w-3.5 h-3.5" />}
{align === "right" && <AlignRight className="w-3.5 h-3.5" />}
</Button>
))}
</div>
</div>
</div>
</div>
{/* Preview */}
<div className="mt-4 pt-4 border-t">
<Label className="text-[10px] text-muted-foreground uppercase tracking-wider mb-2 block">Preview</Label>
<div className="bg-muted/50 p-4 rounded border border-dashed">
<div style={{ textAlign: alignment as any }}>
<img
src={imageUrl}
alt={altText}
style={{
width: widthUnit === "%" ? `${width}%` : `${width}px`,
height: height === "auto" ? "auto" : `${height}px`,
maxWidth: "100%",
display: "inline-block",
}}
/>
</div>
</div>
</div>
</div>
)}
</div>
</div>
);
}
+8
View File
@@ -0,0 +1,8 @@
import React from 'react';
const ImageElementDialog = () => {
return null;
};
export { ImageElementDialog };
export default ImageElementDialog;
+145
View File
@@ -0,0 +1,145 @@
import { useState, useCallback } from "react";
import { useDropzone } from "react-dropzone";
import { Upload, X, Loader2 } from "lucide-react";
import { supabase } from "@/integrations/supabase/client";
import { cn } from "@/lib/utils";
import { useToast } from "@/components/ui/use-toast";
interface ImageUploadFieldProps {
images?: string[];
onChange: (images: string[]) => void;
disabled?: boolean;
bucket?: string;
}
export function ImageUploadField({
images = [],
onChange,
disabled = false,
bucket = "status-update-images",
}: ImageUploadFieldProps) {
const [uploading, setUploading] = useState(false);
const { toast } = useToast();
const safeImages = Array.isArray(images) ? images : [];
const onDrop = useCallback(
async (acceptedFiles: File[]) => {
if (disabled) return;
setUploading(true);
const newImages: string[] = [];
const errors: string[] = [];
for (const file of acceptedFiles) {
try {
const fileExt = file.name.split(".").pop();
const fileName = `${Math.random().toString(36).substring(2)}_${Date.now()}.${fileExt}`;
const { error: uploadError } = await supabase.storage
.from(bucket)
.upload(fileName, file);
if (uploadError) throw uploadError;
const {
data: { publicUrl },
} = supabase.storage.from(bucket).getPublicUrl(fileName);
newImages.push(publicUrl);
} catch (error) {
console.error("Error uploading image:", error);
errors.push(file.name);
}
}
if (errors.length > 0) {
toast({
variant: "destructive",
title: "Upload Failed",
description: `Failed to upload: ${errors.join(", ")}`,
});
}
if (newImages.length > 0) {
onChange([...safeImages, ...newImages]);
}
setUploading(false);
},
[safeImages, onChange, disabled, toast, bucket]
);
const removeImage = (indexToRemove: number) => {
if (disabled) return;
onChange(safeImages.filter((_, index) => index !== indexToRemove));
};
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
accept: { "image/*": [".png", ".jpg", ".jpeg", ".gif", ".webp"] },
disabled: disabled || uploading,
maxSize: 5242880,
});
return (
<div className="space-y-4">
<div
{...getRootProps()}
className={cn(
"border-2 border-dashed rounded-lg p-6 transition-colors text-center cursor-pointer",
isDragActive
? "border-primary bg-primary/5"
: "border-border hover:bg-muted/50",
disabled && "opacity-50 cursor-not-allowed hover:bg-transparent"
)}
>
<input {...getInputProps()} />
<div className="flex flex-col items-center justify-center space-y-2 text-muted-foreground">
{uploading ? (
<Loader2 className="w-8 h-8 animate-spin text-primary" />
) : (
<Upload className="w-8 h-8" />
)}
<div className="text-sm">
{uploading ? (
<p>Uploading images...</p>
) : isDragActive ? (
<p className="text-primary font-medium">Drop images here</p>
) : (
<p>
<span className="font-semibold text-primary">Click to upload</span>{" "}
or drag and drop
</p>
)}
</div>
<p className="text-xs text-muted-foreground/70">PNG, JPG, GIF up to 5MB</p>
</div>
</div>
{safeImages.length > 0 && (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-4">
{safeImages.map((url, index) => (
<div
key={url}
className="group relative aspect-square rounded-md overflow-hidden border bg-muted"
>
<img
src={url}
alt={`Uploaded ${index + 1}`}
className="w-full h-full object-cover"
/>
{!disabled && (
<button
type="button"
onClick={() => removeImage(index)}
className="absolute top-1 right-1 p-1 bg-background/90 rounded-full shadow-sm opacity-0 group-hover:opacity-100 transition-opacity hover:bg-destructive/10 text-muted-foreground hover:text-destructive"
>
<X className="w-4 h-4" />
</button>
)}
</div>
))}
</div>
)}
</div>
);
}
+208
View File
@@ -0,0 +1,208 @@
import React, { useState, useCallback } from 'react';
import { useDropzone } from 'react-dropzone';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Alert, AlertTitle, AlertDescription } from '@/components/ui/alert';
import { useToast } from '@/hooks/use-toast';
import { Upload, FileText, AlertTriangle, CheckCircle, XCircle, Loader2 } from 'lucide-react';
import { validateCSVHeaders, validateRow, parseCSVFile, consolidateRows } from '@/lib/delinquencyImportUtils';
import { supabase } from '@/integrations/supabase/client';
export default function ImportDelinquencyDialog({ open, onOpenChange, associationId, onSuccess }) {
const [file, setFile] = useState(null);
const [analyzing, setAnalyzing] = useState(false);
const [uploading, setUploading] = useState(false);
const [validationResult, setValidationResult] = useState(null);
const [parsedRows, setParsedRows] = useState([]);
const { toast } = useToast();
const onDrop = useCallback(async (acceptedFiles) => {
const selectedFile = acceptedFiles[0];
if (!selectedFile) return;
setFile(selectedFile);
setAnalyzing(true);
setValidationResult(null);
try {
const rows = await parseCSVFile(selectedFile);
if (rows.length === 0) throw new Error("File is empty");
const headers = Object.keys(rows[0]);
const headerCheck = validateCSVHeaders(headers);
if (!headerCheck.valid) {
setValidationResult({ valid: false, error: headerCheck.error });
setAnalyzing(false);
return;
}
const errors = [];
let validRows = 0;
rows.forEach((row, index) => {
const rowErrors = validateRow(row, index);
if (rowErrors.length > 0) {
errors.push(...rowErrors);
} else {
validRows++;
}
});
setParsedRows(rows);
setValidationResult({
valid: errors.length === 0,
totalRows: rows.length,
validRows,
errors
});
} catch (error) {
setValidationResult({ valid: false, error: error.message });
} finally {
setAnalyzing(false);
}
}, []);
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
accept: { 'text/csv': ['.csv'] },
maxFiles: 1
});
const handleImport = async () => {
if (!parsedRows.length || !associationId) return;
setUploading(true);
try {
const consolidated = consolidateRows(parsedRows);
const { data, error } = await supabase.functions.invoke('process-delinquency-import', {
body: { rows: consolidated, association_id: associationId }
});
if (error) throw new Error(error.message);
if (!data.success) throw new Error(data.error);
toast({
title: "Import Successful",
description: `Processed ${data.processed} records with ${data.errors} errors.`
});
if (onSuccess) onSuccess();
onOpenChange(false);
setFile(null);
setValidationResult(null);
} catch (err) {
toast({
variant: "destructive",
title: "Import Failed",
description: err.message
});
} finally {
setUploading(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[600px] max-h-[85vh] flex flex-col">
<DialogHeader>
<DialogTitle>Import Delinquency Records</DialogTitle>
<DialogDescription>
Upload a CSV file with columns: <code>unit_id, account_number, amount, fee_type</code>.
</DialogDescription>
</DialogHeader>
{!file ? (
<div
{...getRootProps()}
className={`border-2 border-dashed rounded-lg p-10 text-center cursor-pointer transition-colors
${isDragActive ? 'border-primary bg-primary/5' : 'border-border hover:bg-muted'}`}
>
<input {...getInputProps()} />
<Upload className="mx-auto h-12 w-12 text-muted-foreground mb-4" />
<p className="text-sm font-medium">
Drag & drop CSV file here, or click to select
</p>
<p className="text-xs text-muted-foreground mt-2">
Valid fee types: Assessments, Late Fees, Interest, Legal Fees, Admin Fees
</p>
</div>
) : (
<div className="flex flex-col gap-4 flex-1 min-h-0">
<div className="flex items-center justify-between bg-muted p-3 rounded-md border">
<div className="flex items-center gap-3">
<FileText className="w-8 h-8 text-primary" />
<div>
<p className="text-sm font-medium truncate max-w-[200px]">{file.name}</p>
<p className="text-xs text-muted-foreground">{(file.size / 1024).toFixed(1)} KB</p>
</div>
</div>
<Button variant="ghost" size="sm" onClick={() => { setFile(null); setValidationResult(null); }}>Change</Button>
</div>
{analyzing && (
<div className="py-8 text-center text-muted-foreground">
<Loader2 className="w-6 h-6 animate-spin mx-auto mb-2" />
Validating file...
</div>
)}
{!analyzing && validationResult && (
<div className="flex-1 min-h-0 flex flex-col gap-4">
{validationResult.valid ? (
<Alert className="bg-emerald-50 border-emerald-200">
<CheckCircle className="h-4 w-4 text-emerald-600" />
<AlertTitle className="text-emerald-800">Validation Successful</AlertTitle>
<AlertDescription className="text-emerald-700">
Ready to import {validationResult.totalRows} records.
</AlertDescription>
</Alert>
) : (
<Alert variant="destructive">
<XCircle className="h-4 w-4" />
<AlertTitle>Validation Errors</AlertTitle>
<AlertDescription>
Found {validationResult.errors?.length || 1} issues in the file.
</AlertDescription>
</Alert>
)}
{validationResult.errors && validationResult.errors.length > 0 && (
<div className="flex-1 border rounded-md overflow-hidden flex flex-col">
<div className="bg-muted px-4 py-2 text-xs font-semibold text-muted-foreground border-b">
Error Log
</div>
<ScrollArea className="flex-1 p-4">
<ul className="space-y-2 text-sm text-destructive">
{validationResult.errors.map((err, i) => (
<li key={i} className="flex items-start gap-2">
<AlertTriangle className="w-4 h-4 shrink-0 mt-0.5" />
<span>{err}</span>
</li>
))}
</ul>
</ScrollArea>
</div>
)}
</div>
)}
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>Cancel</Button>
<Button
onClick={handleImport}
disabled={!file || analyzing || uploading || (validationResult && !validationResult.valid)}
>
{uploading ? <><Loader2 className="w-4 h-4 mr-2 animate-spin" /> Importing...</> : 'Import Records'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+771
View File
@@ -0,0 +1,771 @@
import React, { useState, useEffect } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { useToast } from '@/hooks/use-toast';
import { supabase } from '@/integrations/supabase/client';
import * as XLSX from 'xlsx';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Input } from '@/components/ui/input';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { AlertCircle, CheckCircle2, AlertTriangle, ArrowRight, HelpCircle } from 'lucide-react';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
/**
* ImportDialog handles the parsing and mapping of bank/lockbox files.
* Maps imported data to associations, GL accounts, units, and vendors.
*/
const ImportDialog = ({ open, onOpenChange, requiredFields = [], onSuccess, additionalData = {}, defaultFileType = 'bank' }) => {
const { toast } = useToast();
const LOCKBOX_ACCEPT = '.txt,text/plain,text/*,.asc,.dat';
const [step, setStep] = useState(1);
const [isLoading, setIsLoading] = useState(false);
const [fileType, setFileType] = useState(defaultFileType);
const [file, setFile] = useState(null);
const [fileHeaders, setFileHeaders] = useState([]);
const [mapping, setMapping] = useState({});
const [bankData, setBankData] = useState([]);
const [bankPreviewData, setBankPreviewData] = useState([]);
const [lockboxRows, setLockboxRows] = useState([]);
const [importSummary, setImportSummary] = useState(null);
// Reference Data
const [associations, setAssociations] = useState([]);
const [chartOfAccounts, setChartOfAccounts] = useState([]);
const [vendors, setVendors] = useState([]);
const [units, setUnits] = useState([]);
useEffect(() => {
if (!open) {
setStep(1);
setIsLoading(false);
setFileType(defaultFileType);
setFile(null);
setFileHeaders([]);
setMapping({});
setBankData([]);
setBankPreviewData([]);
setLockboxRows([]);
setImportSummary(null);
} else {
fetchReferenceData();
}
}, [open, defaultFileType]);
const fetchReferenceData = async () => {
setIsLoading(true);
const [assocRes, coaRes, vendorsRes, unitsRes] = await Promise.all([
supabase.from('associations').select('id, name').eq('status', 'active'),
supabase.from('chart_of_accounts').select('id, account_number, account_name, association_id'),
supabase.from('vendors').select('id, vendor_name, association_id'),
supabase.from('units').select('id, unit_number, account_number, association_id')
]);
if (assocRes.data) setAssociations(assocRes.data);
if (coaRes.data) setChartOfAccounts(coaRes.data);
if (vendorsRes.data) setVendors(vendorsRes.data);
if (unitsRes.data) setUnits(unitsRes.data);
setIsLoading(false);
};
const handleFileChange = (e) => {
const selectedFile = e.target.files[0];
if (!selectedFile) return;
if (fileType === 'lockbox') {
const fileName = selectedFile.name.toLowerCase();
const isAsciiLockboxFile = fileName.endsWith('.txt') || fileName.endsWith('.asc') || fileName.endsWith('.dat') || selectedFile.type === 'text/plain' || selectedFile.type.startsWith('text/') || selectedFile.type === '' || selectedFile.type === 'application/octet-stream';
if (isAsciiLockboxFile) {
setFile(selectedFile);
} else {
toast({ variant: 'destructive', title: 'Invalid File Type', description: 'Please upload a .txt ASCII lockbox file.' });
}
} else {
if (selectedFile.type === 'text/csv' || selectedFile.name.endsWith('.csv') || selectedFile.name.endsWith('.xlsx') || selectedFile.name.endsWith('.xls')) {
setFile(selectedFile);
} else {
toast({ variant: 'destructive', title: 'Invalid File Type', description: 'Please upload a CSV or Excel file.' });
}
}
};
const handleParseFile = () => {
if (!file) {
toast({ variant: 'destructive', title: 'No file selected' });
return;
}
setIsLoading(true);
if (fileType === 'lockbox') {
parseLockboxFile();
} else {
parseBankFile();
}
};
const parseBankFile = () => {
const reader = new FileReader();
reader.onload = (e) => {
try {
const data = new Uint8Array(e.target.result);
const workbook = XLSX.read(data, { type: 'array', cellDates: true, dateNF: 'yyyy-mm-dd' });
const sheetName = workbook.SheetNames[0];
const worksheet = workbook.Sheets[sheetName];
const jsonData = XLSX.utils.sheet_to_json(worksheet, { header: 1 });
if (jsonData.length < 2) {
toast({ variant: 'destructive', title: 'Empty File', description: 'The file appears to be empty or has only headers.' });
setIsLoading(false);
return;
}
const headers = jsonData[0].map(h => String(h).trim());
const rows = jsonData.slice(1).map(row => {
let rowData = {};
headers.forEach((header, index) => {
rowData[header] = row[index];
});
return rowData;
});
setFileHeaders(headers);
setBankData(rows);
const initialMapping = {};
requiredFields.forEach(field => {
const matchedHeader = headers.find(h =>
h.toLowerCase().replace(/[\s_]/g, '') === field.label.toLowerCase().replace(/[\s_]/g, '') ||
h.toLowerCase().replace(/[\s_]/g, '') === field.key.toLowerCase().replace(/[\s_]/g, '')
);
if(matchedHeader) initialMapping[field.key] = matchedHeader;
});
setMapping(initialMapping);
setStep(2);
} catch (error) {
toast({ variant: 'destructive', title: 'File Parsing Error', description: 'Could not read the file.' });
} finally {
setIsLoading(false);
}
};
reader.readAsArrayBuffer(file);
};
const parseLockboxFile = () => {
const reader = new FileReader();
reader.onload = (e) => {
try {
const text = e.target.result;
const lines = text.split(/\r?\n/);
const parsedRows = [];
lines.forEach((line, index) => {
const cleanLine = line.trim();
if (!cleanLine) return;
const cols = cleanLine.replace(/^"|"$/g, '').split('","');
const [acctRaw, amtRaw, dateRaw, txnRaw, memoRaw] = cols;
const acct = acctRaw?.trim() || '';
const amt = parseFloat(amtRaw) || 0;
let isoDate = dateRaw || '';
if (dateRaw && dateRaw.length === 8) {
isoDate = `${dateRaw.substring(4,8)}-${dateRaw.substring(0,2)}-${dateRaw.substring(2,4)}`;
}
const normalizedAcct = acct.replace(/^0+/, '').trim();
const matchedUnit = units.find(u => {
const unitAccount = String(u.account_number || '').trim();
return unitAccount === acct || unitAccount.replace(/^0+/, '') === normalizedAcct;
});
parsedRows.push({
_id: index,
accountNumber: acct,
unitId: matchedUnit ? matchedUnit.id : '',
amount: amt,
date: isoDate,
transactionNumber: txnRaw || '',
memo: memoRaw || ''
});
});
setLockboxRows(parsedRows);
setStep(3);
} catch (error) {
toast({ variant: 'destructive', title: 'Parse Error', description: 'Error parsing Lockbox file.' });
} finally {
setIsLoading(false);
}
};
reader.readAsText(file);
};
const handleMappingChange = (fieldKey, header) => {
setMapping(prev => ({ ...prev, [fieldKey]: header }));
};
const handleGenerateBankPreview = () => {
const requiredUnmapped = requiredFields.filter(f => f.required && !mapping[f.key]);
if (requiredUnmapped.length > 0) {
toast({ variant: 'destructive', title: 'Mapping Incomplete', description: `Please map required fields: ${requiredUnmapped.map(f => f.label).join(', ')}` });
return;
}
const preview = bankData.map((row, index) => {
const getVal = (key) => row[mapping[key]];
const assocRaw = getVal('association');
const glRaw = getVal('gl_account');
const vendorRaw = getVal('vendor');
const unitRaw = getVal('unit');
const paymentRaw = getVal('payment');
const creditRaw = getVal('credit');
const amountRaw = getVal('amount');
const dateRaw = getVal('transaction_date');
const errors = [];
const warnings = [];
let parsedDate = new Date();
if (dateRaw) {
parsedDate = new Date(dateRaw);
if (isNaN(parsedDate)) {
parsedDate = new Date();
warnings.push('Invalid Date, using today');
}
} else {
warnings.push('Missing Date, using today');
}
let matchedAssoc = null;
if (assocRaw) {
matchedAssoc = associations.find(c => c.name.toLowerCase() === String(assocRaw).trim().toLowerCase());
if (!matchedAssoc) errors.push('Association not found');
} else {
errors.push('Association required');
}
let matchedCoa = null;
if (glRaw && matchedAssoc) {
matchedCoa = chartOfAccounts.find(c =>
c.association_id === matchedAssoc.id &&
(c.account_number === String(glRaw).trim() || c.account_name.toLowerCase() === String(glRaw).trim().toLowerCase())
);
if (!matchedCoa) errors.push('GL Account not found for this association');
} else if (!glRaw) {
errors.push('GL Account required');
}
let matchedVendor = null;
if (vendorRaw && matchedAssoc) {
matchedVendor = vendors.find(v => v.association_id === matchedAssoc.id && v.vendor_name.toLowerCase() === String(vendorRaw).trim().toLowerCase());
if (!matchedVendor) warnings.push('Vendor unmapped (will import as text)');
}
let matchedUnit = null;
if (unitRaw && matchedAssoc) {
matchedUnit = units.find(u =>
u.association_id === matchedAssoc.id &&
u.unit_number === String(unitRaw).trim()
);
if (!matchedUnit) warnings.push('Unit unmapped');
}
if (!paymentRaw) errors.push('Payment required');
if (!creditRaw) errors.push('Credit indicator required');
const parsedAmount = parseFloat(amountRaw);
if (isNaN(parsedAmount)) errors.push('Invalid Amount');
return {
_id: index,
raw: row,
association: assocRaw || '',
gl_account: glRaw || '',
vendor: vendorRaw || '',
unit: unitRaw || '',
payment: paymentRaw || '',
credit: creditRaw || '',
amount: isNaN(parsedAmount) ? amountRaw : parsedAmount,
transaction_date: parsedDate.toISOString().split('T')[0],
matchedAssoc,
matchedCoa,
matchedVendor,
matchedUnit,
errors,
warnings,
isValid: errors.length === 0
};
});
setBankPreviewData(preview);
setStep(3);
};
const handleImport = async () => {
setIsLoading(true);
if (fileType === 'lockbox') {
await importLockboxData();
} else {
await importBankData();
}
};
const importBankData = async () => {
const validRows = bankPreviewData.filter(r => r.isValid);
if (validRows.length === 0) {
toast({ variant: 'destructive', title: 'Import Failed', description: 'No valid rows to import.' });
setIsLoading(false);
return;
}
const processedBankData = [];
validRows.forEach(r => {
const isDebit = String(r.credit).toLowerCase().includes('debit');
const txType = isDebit ? 'debit' : 'credit';
const desc = r.matchedVendor ? `Vendor: ${r.matchedVendor.vendor_name}` : (r.vendor ? `Vendor: ${r.vendor}` : 'Imported Bank Transaction');
processedBankData.push({
association_id: r.matchedAssoc.id,
description: desc,
[isDebit ? 'debit' : 'credit']: r.amount,
date: r.transaction_date,
transaction_type: txType === 'debit' ? 'payment' : 'deposit',
...additionalData
});
});
try {
const { error: bankErr } = await supabase.from('bank_transactions').insert(processedBankData);
if (bankErr) throw bankErr;
setImportSummary({
total: bankPreviewData.length,
success: validRows.length,
failed: bankPreviewData.length - validRows.length
});
setStep(4);
if(onSuccess) onSuccess();
} catch (error) {
toast({ variant: 'destructive', title: 'Import Failed', description: error.message });
}
setIsLoading(false);
};
const importLockboxData = async () => {
const validRows = lockboxRows.filter(r => r.unitId && !isNaN(r.amount) && r.amount > 0 && r.date);
if (validRows.length === 0) {
toast({ variant: 'destructive', title: 'Import Failed', description: 'No valid rows to import. Each row needs a matched unit, positive amount, and date.' });
setIsLoading(false);
return;
}
try {
// Resolve owner_id + association_id for each unit
const unitIds = [...new Set(validRows.map(r => r.unitId))];
const { data: ownerRows, error: ownerErr } = await supabase
.from('owners')
.select('id, unit_id, association_id, status')
.in('unit_id', unitIds)
.neq('status', 'archived');
if (ownerErr) throw ownerErr;
const ownerByUnit = {};
(ownerRows || []).forEach(o => {
const existing = ownerByUnit[o.unit_id];
// Prefer active owners
if (!existing || (existing.status !== 'active' && o.status === 'active')) {
ownerByUnit[o.unit_id] = o;
}
});
const entries = [];
const skipped = [];
validRows.forEach(r => {
const owner = ownerByUnit[r.unitId];
if (!owner) { skipped.push(r); return; }
entries.push({
association_id: owner.association_id,
owner_id: owner.id,
unit_id: r.unitId,
date: r.date,
transaction_type: 'payment',
description: `Lockbox payment${r.transactionNumber ? ` #${r.transactionNumber}` : ''}${r.memo ? ` - ${r.memo}` : ''}`,
debit: 0,
credit: r.amount,
reference_id: r.transactionNumber || `lockbox-${r.date}-${r.accountNumber}-${r.amount}`,
reference_type: 'lockbox',
});
});
if (entries.length === 0) {
toast({ variant: 'destructive', title: 'Import Failed', description: 'No matching owners found for the imported units.' });
setIsLoading(false);
return;
}
// Batch insert (500 at a time)
const BATCH = 500;
let inserted = 0;
for (let i = 0; i < entries.length; i += BATCH) {
const batch = entries.slice(i, i + BATCH);
const { error } = await supabase.from('owner_ledger_entries').insert(batch);
if (error) throw error;
inserted += batch.length;
}
toast({ title: 'Import Complete', description: `${inserted} payment(s) posted to the ledger.` });
setImportSummary({
total: lockboxRows.length,
success: inserted,
failed: lockboxRows.length - inserted,
});
setStep(4);
if (onSuccess) onSuccess();
} catch (error) {
console.error('Lockbox import error:', error);
toast({ variant: 'destructive', title: 'Import Failed', description: error.message });
} finally {
setIsLoading(false);
}
};
const handleLockboxRowChange = (index, field, value) => {
const newRows = [...lockboxRows];
newRows[index][field] = value;
if (field === 'accountNumber') {
const normalizedAcct = String(value || '').replace(/^0+/, '').trim();
const matchedUnit = units.find(u => {
const unitAccount = String(u.account_number || '').trim();
return unitAccount === String(value || '').trim() || unitAccount.replace(/^0+/, '') === normalizedAcct;
});
newRows[index].unitId = matchedUnit ? matchedUnit.id : '';
}
setLockboxRows(newRows);
};
return (
<Dialog open={open} onOpenChange={(val) => { if (!isLoading) onOpenChange(val); }}>
<DialogContent className="sm:max-w-4xl max-h-[90vh] flex flex-col">
<DialogHeader className="shrink-0">
<DialogTitle className="text-xl font-bold">
{step === 1 && "Import Transactions"}
{step === 2 && "Map Fields"}
{step === 3 && "Preview Data"}
{step === 4 && "Import Complete"}
</DialogTitle>
<DialogDescription>
{step === 1 && 'Select the file type and upload your document to begin.'}
{step === 2 && 'Map your file columns to the database fields.'}
{step === 3 && 'Review parsed data and correct any errors before importing.'}
{step === 4 && 'Review the results of your import operation.'}
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-y-auto py-4 min-h-[300px]">
{step === 1 && (
<div className="space-y-6">
<div className="space-y-3">
<Label className="text-sm font-semibold">File Type</Label>
<Select value={fileType} onValueChange={setFileType}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select import type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="bank">Bank Transactions (CSV/Excel)</SelectItem>
<SelectItem value="lockbox">Lockbox Payments (.txt)</SelectItem>
</SelectContent>
</Select>
</div>
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertTitle className="font-semibold">
{fileType === 'lockbox' ? 'Lockbox Format Requirements' : 'Bank Transaction Format'}
</AlertTitle>
<AlertDescription className="mt-2 text-sm leading-relaxed">
{fileType === 'lockbox'
? 'Upload a .txt ASCII lockbox file with quoted, comma-separated values.'
: 'Upload a CSV or Excel file containing the required fields. You will map the columns in the next step.'}
</AlertDescription>
</Alert>
<div className="space-y-3 pt-2">
<Label htmlFor="import-file" className="text-sm font-semibold">Select File</Label>
<Input
id="import-file"
type="file"
onChange={handleFileChange}
accept={fileType === 'lockbox' ? LOCKBOX_ACCEPT : ".csv, application/vnd.openxmlformats-officedocument.spreadsheetml.sheet, application/vnd.ms-excel"}
className="w-full cursor-pointer"
/>
</div>
</div>
)}
{step === 2 && fileType === 'bank' && (
<div className="space-y-4">
<p className="text-sm text-muted-foreground mb-4">Map columns from your file to the required transaction fields.</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{requiredFields.map(field => (
<div key={field.key} className="space-y-1.5 p-4 border rounded-lg bg-muted">
<div className="flex items-center justify-between">
<Label className="font-semibold flex items-center">
{field.label}
{field.required && <span className="text-destructive ml-1">*</span>}
<TooltipProvider>
<Tooltip>
<TooltipTrigger type="button" className="ml-2 cursor-help"><HelpCircle className="w-4 h-4 text-muted-foreground" /></TooltipTrigger>
<TooltipContent><p>{field.description}</p></TooltipContent>
</Tooltip>
</TooltipProvider>
</Label>
</div>
<Select value={mapping[field.key] || ''} onValueChange={(val) => handleMappingChange(field.key, val)}>
<SelectTrigger className={`bg-background ${field.required && !mapping[field.key] ? 'border-amber-300 ring-1 ring-amber-100' : ''}`}>
<SelectValue placeholder="Select a column..." />
</SelectTrigger>
<SelectContent>
{fileHeaders.map(header => (
<SelectItem key={header} value={header}>{header}</SelectItem>
))}
</SelectContent>
</Select>
</div>
))}
</div>
</div>
)}
{step === 3 && fileType === 'bank' && (
<div className="space-y-4">
<Alert variant={bankPreviewData.some(r => !r.isValid) ? "destructive" : "default"} className="mb-4">
{bankPreviewData.some(r => !r.isValid) ? <AlertCircle className="h-4 w-4" /> : <CheckCircle2 className="h-4 w-4 text-emerald-600" />}
<AlertTitle>{bankPreviewData.some(r => !r.isValid) ? 'Errors Found' : 'Ready to Import'}</AlertTitle>
<AlertDescription>
{bankPreviewData.some(r => !r.isValid)
? `${bankPreviewData.filter(r => !r.isValid).length} row(s) have errors and will be skipped.`
: `All ${bankPreviewData.length} rows are valid and ready to be imported.`}
</AlertDescription>
</Alert>
<div className="rounded-md border overflow-x-auto shadow-sm">
<Table className="min-w-[800px]">
<TableHeader>
<TableRow>
<TableHead>Date</TableHead>
<TableHead>Association</TableHead>
<TableHead>GL Account</TableHead>
<TableHead>Vendor</TableHead>
<TableHead>Unit</TableHead>
<TableHead>Payment</TableHead>
<TableHead>Credit</TableHead>
<TableHead className="text-right">Amount</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{bankPreviewData.map((row) => (
<TableRow key={row._id} className={!row.isValid ? "bg-destructive/5" : (row.warnings.length > 0 ? "bg-amber-50/30" : "")}>
<TableCell className="text-xs">{row.transaction_date}</TableCell>
<TableCell className="text-xs truncate max-w-[120px]">{row.matchedAssoc ? row.matchedAssoc.name : <span className="text-destructive font-medium">{row.association || 'Missing'}</span>}</TableCell>
<TableCell className="text-xs truncate max-w-[120px]">{row.matchedCoa ? row.matchedCoa.account_number : <span className="text-destructive font-medium">{row.gl_account || 'Missing'}</span>}</TableCell>
<TableCell className="text-xs truncate max-w-[100px]">{row.matchedVendor ? row.matchedVendor.vendor_name : <span className="text-muted-foreground">{row.vendor || '-'}</span>}</TableCell>
<TableCell className="text-xs truncate max-w-[100px]">{row.matchedUnit ? row.matchedUnit.unit_number : <span className="text-muted-foreground">{row.unit || '-'}</span>}</TableCell>
<TableCell className="text-xs">{row.payment || <span className="text-destructive">Missing</span>}</TableCell>
<TableCell className="text-xs">{row.credit || <span className="text-destructive">Missing</span>}</TableCell>
<TableCell className="text-xs text-right font-medium">${!isNaN(row.amount) ? Number(row.amount).toFixed(2) : <span className="text-destructive">Invalid</span>}</TableCell>
<TableCell className="text-xs">
{row.isValid ? (
row.warnings.length > 0 ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger><AlertTriangle className="w-4 h-4 text-amber-500" /></TooltipTrigger>
<TooltipContent><p>{row.warnings.join(', ')}</p></TooltipContent>
</Tooltip>
</TooltipProvider>
) : <CheckCircle2 className="w-4 h-4 text-emerald-500" />
) : (
<TooltipProvider>
<Tooltip>
<TooltipTrigger><AlertCircle className="w-4 h-4 text-destructive" /></TooltipTrigger>
<TooltipContent><p>{row.errors.join(', ')}</p></TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
)}
{step === 3 && fileType === 'lockbox' && (
<div className="space-y-4">
<Alert className="bg-amber-50 border-amber-200">
<AlertTriangle className="h-4 w-4 text-amber-600" />
<AlertTitle>Review Data</AlertTitle>
<AlertDescription>
Verify the mapped units and amounts. Rows with missing units or invalid amounts will not be imported.
</AlertDescription>
</Alert>
<div className="rounded-md border overflow-hidden shadow-sm">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-24">Acct #</TableHead>
<TableHead className="min-w-[200px]">Matched Unit</TableHead>
<TableHead className="w-32">Amount ($)</TableHead>
<TableHead className="w-40">Date</TableHead>
<TableHead className="min-w-[150px]">Memo</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{lockboxRows.map((row, i) => {
const hasError = !row.unitId || isNaN(row.amount) || row.amount <= 0 || !row.date;
return (
<TableRow key={row._id} className={hasError ? "bg-destructive/5" : ""}>
<TableCell>
<Input
value={row.accountNumber}
onChange={(e) => handleLockboxRowChange(i, 'accountNumber', e.target.value)}
className="h-8 text-xs"
/>
</TableCell>
<TableCell>
<Select value={row.unitId} onValueChange={(val) => handleLockboxRowChange(i, 'unitId', val)}>
<SelectTrigger className={`h-8 text-xs ${!row.unitId ? 'border-destructive' : ''}`}>
<SelectValue placeholder="Select unit..." />
</SelectTrigger>
<SelectContent>
{units.map(u => (
<SelectItem key={u.id} value={u.id} className="text-xs">
{u.account_number ? `${u.account_number} - ${u.unit_number}` : u.unit_number}
</SelectItem>
))}
</SelectContent>
</Select>
</TableCell>
<TableCell>
<Input
type="number" step="0.01"
value={row.amount}
onChange={(e) => handleLockboxRowChange(i, 'amount', parseFloat(e.target.value))}
className={`h-8 text-xs ${isNaN(row.amount) || row.amount <= 0 ? 'border-destructive' : ''}`}
/>
</TableCell>
<TableCell>
<Input
type="date"
value={row.date}
onChange={(e) => handleLockboxRowChange(i, 'date', e.target.value)}
className={`h-8 text-xs ${!row.date ? 'border-destructive' : ''}`}
/>
</TableCell>
<TableCell>
<Input
value={row.memo}
onChange={(e) => handleLockboxRowChange(i, 'memo', e.target.value)}
className="h-8 text-xs"
/>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</div>
</div>
)}
{step === 4 && importSummary && (
<div className="flex flex-col items-center justify-center py-8 space-y-6">
<div className="w-16 h-16 rounded-full bg-emerald-100 flex items-center justify-center text-emerald-600 mb-2 shadow-sm">
<CheckCircle2 className="w-8 h-8" />
</div>
<div className="text-center space-y-2">
<h3 className="text-2xl font-bold">Import Complete</h3>
<p className="text-muted-foreground">Your file has been processed successfully.</p>
</div>
<div className="grid grid-cols-3 gap-4 w-full max-w-md">
<div className="bg-muted border rounded-xl p-4 text-center shadow-sm">
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-1">Total</p>
<p className="text-2xl font-bold">{importSummary.total}</p>
</div>
<div className="bg-emerald-50 border border-emerald-100 rounded-xl p-4 text-center shadow-sm">
<p className="text-xs font-semibold text-emerald-600 uppercase tracking-wider mb-1">Imported</p>
<p className="text-2xl font-bold text-emerald-700">{importSummary.success}</p>
</div>
<div className="bg-destructive/5 border border-destructive/20 rounded-xl p-4 text-center shadow-sm">
<p className="text-xs font-semibold text-destructive uppercase tracking-wider mb-1">Skipped</p>
<p className="text-2xl font-bold text-destructive">{importSummary.failed}</p>
</div>
</div>
{importSummary.failed > 0 && (
<Alert variant="destructive" className="max-w-md w-full shadow-sm mt-4">
<AlertTriangle className="h-4 w-4" />
<AlertTitle>Some rows skipped</AlertTitle>
<AlertDescription>
Rows with missing required data or validation errors were not imported.
</AlertDescription>
</Alert>
)}
</div>
)}
</div>
<DialogFooter className="shrink-0 pt-4 border-t">
{step < 4 ? (
<>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isLoading}>Cancel</Button>
{step === 1 && (
<Button onClick={handleParseFile} disabled={!file || isLoading}>
{isLoading ? 'Parsing...' : 'Continue'}
</Button>
)}
{step === 2 && fileType === 'bank' && (
<>
<Button variant="outline" onClick={() => setStep(1)} disabled={isLoading}>Back</Button>
<Button onClick={handleGenerateBankPreview} disabled={isLoading}>
Generate Preview
</Button>
</>
)}
{step === 3 && (
<>
<Button variant="outline" onClick={() => setStep(fileType === 'bank' ? 2 : 1)} disabled={isLoading}>Back</Button>
<Button onClick={handleImport} disabled={isLoading || (fileType === 'bank' && bankPreviewData.filter(r => r.isValid).length === 0) || (fileType === 'lockbox' && lockboxRows.filter(r => r.unitId && r.amount > 0 && r.date).length === 0)}>
{isLoading ? 'Importing...' : 'Run Import'}
</Button>
</>
)}
</>
) : (
<div className="w-full flex justify-between items-center">
<Button variant="outline" onClick={() => onOpenChange(false)}>Close</Button>
<Button onClick={() => onOpenChange(false)}>
Done <ArrowRight className="w-4 h-4 ml-2" />
</Button>
</div>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
};
export default ImportDialog;
+356
View File
@@ -0,0 +1,356 @@
import { useState, useRef } from "react";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Upload, FileSpreadsheet, AlertCircle, CheckCircle2, ArrowRight } from "lucide-react";
import * as XLSX from "xlsx";
interface ExpectedColumn {
key: string;
label: string;
required?: boolean;
aliases?: string[];
}
interface ImportSpreadsheetDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
title: string;
description: string;
expectedColumns: ExpectedColumn[];
onImport: (rows: Record<string, string>[]) => Promise<void>;
templateFileName?: string;
}
const normalizeHeader = (value: unknown) =>
String(value ?? "")
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "_")
.replace(/^_+|_+$/g, "");
export default function ImportSpreadsheetDialog({
open, onOpenChange, title, description, expectedColumns, onImport, templateFileName,
}: ImportSpreadsheetDialogProps) {
const [rawRows, setRawRows] = useState<unknown[][]>([]);
const [rawHeaders, setRawHeaders] = useState<string[]>([]);
const [columnMapping, setColumnMapping] = useState<Record<string, string>>({});
const [step, setStep] = useState<"upload" | "map" | "preview">("upload");
const [parsedRows, setParsedRows] = useState<Record<string, string>[]>([]);
const [importing, setImporting] = useState(false);
const [error, setError] = useState("");
const [fileName, setFileName] = useState("");
const fileInputRef = useRef<HTMLInputElement>(null);
const reset = () => {
setRawRows([]);
setRawHeaders([]);
setColumnMapping({});
setStep("upload");
setParsedRows([]);
setError("");
setFileName("");
if (fileInputRef.current) fileInputRef.current.value = "";
};
const handleFile = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setError("");
setFileName(file.name);
try {
const data = await file.arrayBuffer();
const workbook = XLSX.read(data, { type: "array", cellDates: true });
const sheet = workbook.Sheets[workbook.SheetNames[0]];
const json: unknown[][] = XLSX.utils.sheet_to_json(sheet, { header: 1, defval: "", rawNumbers: false });
if (json.length < 2) {
setError("File must have a header row and at least one data row.");
return;
}
const headers = json[0].map((h) => String(h ?? "").trim()).filter(Boolean);
setRawHeaders(headers);
setRawRows(json.slice(1).filter((row) => (row as unknown[]).some((cell: unknown) => String(cell ?? "").trim())));
// Auto-suggest mapping with aliases and partial matching
const autoMap: Record<string, string> = {};
for (const col of expectedColumns) {
const candidates = [col.key, col.label, ...(col.aliases || [])];
const normCandidates = candidates.map(normalizeHeader);
let matched = false;
for (const h of headers) {
const normH = normalizeHeader(h);
if (normCandidates.some(nc => nc === normH)) {
autoMap[col.key] = h;
matched = true;
break;
}
}
// Partial match fallback: check if header contains key or key contains header
if (!matched) {
for (const h of headers) {
const normH = normalizeHeader(h);
if (normCandidates.some(nc => (normH.length > 2 && nc.includes(normH)) || (nc.length > 2 && normH.includes(nc)))) {
autoMap[col.key] = h;
break;
}
}
}
}
setColumnMapping(autoMap);
setStep("map");
} catch {
setError("Could not read file. Please use a valid CSV or Excel (.xlsx/.xls) file.");
}
};
const proceedToPreview = () => {
const missingRequired = expectedColumns
.filter((c) => c.required)
.filter((c) => !columnMapping[c.key]);
if (missingRequired.length > 0) {
setError(`Please map required fields: ${missingRequired.map((c) => c.label).join(", ")}`);
return;
}
setError("");
const mappedHeaders = Object.keys(columnMapping).filter((k) => columnMapping[k]);
const rows = rawRows.map((row) => {
const obj: Record<string, string> = {};
for (const [dbKey, csvHeader] of Object.entries(columnMapping)) {
if (!csvHeader) continue;
const colIndex = rawHeaders.indexOf(csvHeader);
if (colIndex === -1) continue;
const cell = (row as unknown[])[colIndex];
if (cell instanceof Date && !isNaN(cell.getTime())) {
const yyyy = cell.getFullYear();
const mm = String(cell.getMonth() + 1).padStart(2, "0");
const dd = String(cell.getDate()).padStart(2, "0");
obj[dbKey] = `${yyyy}-${mm}-${dd}`;
} else {
obj[dbKey] = String(cell ?? "").trim();
}
}
return obj;
}).filter((row) => Object.values(row).some((v) => v));
if (rows.length === 0) {
setError("No data rows found after mapping.");
return;
}
setParsedRows(rows);
setStep("preview");
};
const handleImport = async () => {
setImporting(true);
try {
await onImport(parsedRows);
reset();
onOpenChange(false);
} catch (err: any) {
setError(err.message || "Import failed");
} finally {
setImporting(false);
}
};
const downloadTemplate = () => {
const ws = XLSX.utils.aoa_to_sheet([expectedColumns.map((column) => column.label)]);
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, "Template");
XLSX.writeFile(wb, templateFileName || "import_template.xlsx");
};
const updateMapping = (dbKey: string, csvHeader: string) => {
setColumnMapping((prev) => ({
...prev,
[dbKey]: csvHeader === "__none__" ? "" : csvHeader,
}));
};
const mappedDbKeys = Object.keys(columnMapping).filter((k) => columnMapping[k]);
const previewHeaders = step === "preview" ? mappedDbKeys : [];
return (
<Dialog open={open} onOpenChange={(nextOpen) => {
if (!nextOpen) reset();
onOpenChange(nextOpen);
}}>
<DialogContent className="max-w-4xl w-[95vw] max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<FileSpreadsheet className="h-5 w-5" /> {title}
</DialogTitle>
<p className="text-sm text-muted-foreground">{description}</p>
</DialogHeader>
{/* Step 1: Upload */}
{step === "upload" && (
<div className="space-y-4 py-4">
<div className="flex flex-col items-center gap-4 py-8 border-2 border-dashed rounded-lg">
<Upload className="h-10 w-10 text-muted-foreground" />
<p className="text-sm text-muted-foreground">Drop a CSV or Excel file, or click to browse</p>
<input ref={fileInputRef} type="file" accept=".csv,.xlsx,.xls" className="hidden" onChange={handleFile} />
<Button variant="outline" onClick={() => fileInputRef.current?.click()}>Select File</Button>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Need a template?</span>
<Button variant="link" size="sm" onClick={downloadTemplate}>Download Template (.xlsx)</Button>
</div>
{error && (
<div className="flex items-center gap-2 text-destructive text-sm">
<AlertCircle className="h-4 w-4" /> {error}
</div>
)}
</div>
)}
{/* Step 2: Map Fields */}
{step === "map" && (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-sm">
<CheckCircle2 className="h-4 w-4 text-emerald-600" />
<span className="font-medium">{fileName}</span> {rawRows.length} rows, {rawHeaders.length} columns
</div>
<Button variant="ghost" size="sm" onClick={reset}>Choose Different File</Button>
</div>
<div className="bg-muted/50 rounded-lg p-4">
<p className="text-sm font-medium mb-4">Map your file columns to fields</p>
<div className="space-y-3">
{expectedColumns.map((col) => (
<div key={col.key} className="flex items-center gap-4">
<div className="w-[45%] text-sm flex items-center gap-1.5">
<span className={col.required ? "font-medium" : "text-muted-foreground"}>
{col.label}
</span>
{col.required && <span className="text-destructive text-xs">*</span>}
</div>
<ArrowRight className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
<Select
value={columnMapping[col.key] || "__none__"}
onValueChange={(v) => updateMapping(col.key, v)}
>
<SelectTrigger className="h-9 text-sm flex-1">
<SelectValue placeholder="— skip —" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__"> skip </SelectItem>
{rawHeaders.map((h) => (
<SelectItem key={h} value={h}>{h}</SelectItem>
))}
</SelectContent>
</Select>
</div>
))}
</div>
</div>
{/* Sample data preview */}
{rawRows.length > 0 && (
<div className="rounded-lg border overflow-x-auto max-h-36">
<Table>
<TableHeader>
<TableRow>
{rawHeaders.map((h) => <TableHead key={h} className="text-xs font-mono whitespace-nowrap">{h}</TableHead>)}
</TableRow>
</TableHeader>
<TableBody>
{rawRows.slice(0, 3).map((row, i) => (
<TableRow key={i}>
{rawHeaders.map((h, ci) => (
<TableCell key={ci} className="text-xs whitespace-nowrap">
{String((row as unknown[])[ci] ?? "").slice(0, 50) || "—"}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
{error && (
<div className="flex items-center gap-2 text-destructive text-sm">
<AlertCircle className="h-4 w-4" /> {error}
</div>
)}
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => { reset(); onOpenChange(false); }}>Cancel</Button>
<Button onClick={proceedToPreview}>
Continue <ArrowRight className="h-3.5 w-3.5 ml-1" />
</Button>
</div>
</div>
)}
{/* Step 3: Preview & Import */}
{step === "preview" && (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-sm">
<CheckCircle2 className="h-4 w-4 text-emerald-600" />
<span className="font-medium">{parsedRows.length} rows</span> ready to import
</div>
<Button variant="ghost" size="sm" onClick={() => setStep("map")}>Back to Mapping</Button>
</div>
<div className="rounded-lg border overflow-x-auto max-h-64">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-10 text-xs">#</TableHead>
{previewHeaders.map((key) => {
const col = expectedColumns.find((c) => c.key === key);
return <TableHead key={key} className="text-xs font-mono">{col?.label || key}</TableHead>;
})}
</TableRow>
</TableHeader>
<TableBody>
{parsedRows.slice(0, 10).map((row, index) => (
<TableRow key={index}>
<TableCell className="text-xs text-muted-foreground">{index + 1}</TableCell>
{previewHeaders.map((key) => (
<TableCell key={key} className="text-xs">{row[key] || "—"}</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
{parsedRows.length > 10 && (
<p className="text-xs text-muted-foreground text-center py-2">
...and {parsedRows.length - 10} more rows
</p>
)}
</div>
{error && (
<div className="flex items-center gap-2 text-destructive text-sm">
<AlertCircle className="h-4 w-4" /> {error}
</div>
)}
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => { reset(); onOpenChange(false); }}>Cancel</Button>
<Button onClick={handleImport} disabled={importing}>
{importing ? "Importing..." : `Import ${parsedRows.length} rows`}
</Button>
</div>
</div>
)}
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,346 @@
import { useEffect, useState } from "react";
import { supabase } from "@/integrations/supabase/client";
import { useToast } from "@/hooks/use-toast";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Loader2, Download, Landmark } from "lucide-react";
interface Association {
id: string;
name: string;
zoho_organization_id: string | null;
}
interface ZohoBankAccount {
account_id: string;
account_name: string;
account_type?: string;
account_number?: string;
routing_number?: string | null;
bank_name?: string | null;
currency_code?: string;
balance?: number;
uncategorized_transactions?: number;
is_active?: boolean;
}
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
associations: Association[];
defaultAssociationId?: string;
onImported: () => void;
}
/**
* Lets users browse the bank accounts available in an association's linked Zoho
* Books organization and import the selected ones into local `bank_accounts`.
*/
export default function ImportZohoBankAccountsDialog({
open,
onOpenChange,
associations,
defaultAssociationId,
onImported,
}: Props) {
const { toast } = useToast();
const eligible = associations.filter((a) => !!a.zoho_organization_id);
const [associationId, setAssociationId] = useState<string>("");
const [zohoAccounts, setZohoAccounts] = useState<ZohoBankAccount[]>([]);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [existing, setExisting] = useState<Set<string>>(new Set()); // account numbers already imported
const [loading, setLoading] = useState(false);
const [importing, setImporting] = useState(false);
// Reset on open
useEffect(() => {
if (!open) return;
setZohoAccounts([]);
setSelected(new Set());
setExisting(new Set());
const initial =
(defaultAssociationId && eligible.find((a) => a.id === defaultAssociationId)?.id) ||
eligible[0]?.id ||
"";
setAssociationId(initial);
}, [open, defaultAssociationId]); // eslint-disable-line react-hooks/exhaustive-deps
// Fetch when association changes
useEffect(() => {
if (!open || !associationId) return;
let cancelled = false;
(async () => {
setLoading(true);
try {
const [zohoRes, localRes] = await Promise.all([
supabase.functions.invoke("zoho-books", {
body: { action: "list_bank_accounts", params: { association_id: associationId } },
}),
supabase
.from("bank_accounts")
.select("account_number, account_name")
.eq("association_id", associationId),
]);
if (cancelled) return;
if (zohoRes.error) throw zohoRes.error;
const list: ZohoBankAccount[] = Array.isArray(zohoRes.data?.data)
? zohoRes.data.data
: zohoRes.data?.data?.bankaccounts || [];
setZohoAccounts(list);
const existingSet = new Set<string>();
(localRes.data || []).forEach((a) => {
if (a.account_number) existingSet.add(a.account_number);
if (a.account_name) existingSet.add(`name:${a.account_name.toLowerCase()}`);
});
setExisting(existingSet);
// Pre-select accounts not already imported
setSelected(
new Set(
list
.filter(
(a) =>
!(a.account_number && existingSet.has(a.account_number)) &&
!existingSet.has(`name:${(a.account_name || "").toLowerCase()}`)
)
.map((a) => a.account_id)
)
);
} catch (e: any) {
toast({
variant: "destructive",
title: "Could not load Zoho bank accounts",
description: e?.message || "Check that this association is linked to a Zoho org.",
});
setZohoAccounts([]);
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [open, associationId, toast]);
const toggle = (id: string) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const toggleAll = (checked: boolean) => {
if (!checked) {
setSelected(new Set());
return;
}
setSelected(
new Set(
zohoAccounts
.filter(
(a) =>
!(a.account_number && existing.has(a.account_number)) &&
!existing.has(`name:${(a.account_name || "").toLowerCase()}`)
)
.map((a) => a.account_id)
)
);
};
const mapZohoTypeToLocal = (t?: string): string => {
const v = (t || "").toLowerCase();
if (v.includes("credit")) return "credit_card";
if (v.includes("saving")) return "savings";
if (v.includes("paypal") || v.includes("other")) return "other";
return "checking";
};
const handleImport = async () => {
if (!associationId || selected.size === 0) return;
setImporting(true);
try {
const toImport = zohoAccounts.filter((a) => selected.has(a.account_id));
const payload = toImport.map((a) => ({
association_id: associationId,
account_name: a.account_name || "Zoho Bank Account",
account_number: a.account_number || null,
routing_number: a.routing_number || null,
bank_name: a.bank_name || null,
account_type: mapZohoTypeToLocal(a.account_type),
account_category: "operating",
current_balance: Number(a.balance || 0),
status: a.is_active === false ? "inactive" : "active",
}));
const { error } = await supabase.from("bank_accounts").insert(payload);
if (error) throw error;
toast({ title: `Imported ${payload.length} bank account${payload.length === 1 ? "" : "s"} from Zoho` });
onImported();
onOpenChange(false);
} catch (e: any) {
toast({
variant: "destructive",
title: "Import failed",
description: e?.message || "Could not import bank accounts.",
});
} finally {
setImporting(false);
}
};
const allSelectableSelected =
zohoAccounts.length > 0 &&
zohoAccounts
.filter(
(a) =>
!(a.account_number && existing.has(a.account_number)) &&
!existing.has(`name:${(a.account_name || "").toLowerCase()}`)
)
.every((a) => selected.has(a.account_id));
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-2xl max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Landmark className="h-5 w-5 text-primary" />
Import Bank Accounts from Zoho Books
</DialogTitle>
<DialogDescription>
Select an association linked to Zoho Books, then choose which bank accounts to import.
</DialogDescription>
</DialogHeader>
{eligible.length === 0 ? (
<div className="rounded-md border bg-muted/30 p-4 text-sm text-muted-foreground">
No associations are linked to a Zoho Books organization yet. Open an association and set
its <span className="font-mono">Zoho Organization ID</span> first.
</div>
) : (
<div className="space-y-4">
<div>
<Label>Association (Zoho-linked)</Label>
<Select value={associationId} onValueChange={setAssociationId}>
<SelectTrigger>
<SelectValue placeholder="Select association" />
</SelectTrigger>
<SelectContent>
{eligible.map((a) => (
<SelectItem key={a.id} value={a.id}>
{a.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="rounded-md border">
<div className="flex items-center justify-between px-3 py-2 border-b bg-muted/30">
<div className="flex items-center gap-2">
<Checkbox
checked={allSelectableSelected}
onCheckedChange={(v) => toggleAll(!!v)}
disabled={loading || zohoAccounts.length === 0}
/>
<span className="text-sm font-medium">
{loading
? "Loading…"
: `${selected.size} selected of ${zohoAccounts.length}`}
</span>
</div>
</div>
<div className="divide-y max-h-80 overflow-y-auto">
{loading ? (
<div className="flex items-center justify-center py-10 text-muted-foreground">
<Loader2 className="h-5 w-5 animate-spin mr-2" /> Fetching from Zoho
</div>
) : zohoAccounts.length === 0 ? (
<div className="px-3 py-8 text-center text-sm text-muted-foreground">
No bank accounts returned by Zoho for this organization.
</div>
) : (
zohoAccounts.map((a) => {
const dup =
(a.account_number && existing.has(a.account_number)) ||
existing.has(`name:${(a.account_name || "").toLowerCase()}`);
return (
<label
key={a.account_id}
className={`flex items-center gap-3 px-3 py-2.5 text-sm cursor-pointer hover:bg-muted/50 ${
dup ? "opacity-60" : ""
}`}
>
<Checkbox
checked={selected.has(a.account_id)}
onCheckedChange={() => toggle(a.account_id)}
disabled={dup}
/>
<div className="flex-1 min-w-0">
<div className="font-medium text-foreground truncate">
{a.account_name}
{dup && (
<span className="ml-2 text-xs text-muted-foreground">
(already imported)
</span>
)}
</div>
<div className="text-xs text-muted-foreground">
{[a.bank_name, a.account_type, a.account_number ? `•••• ${a.account_number.slice(-4)}` : null]
.filter(Boolean)
.join(" · ")}
</div>
</div>
<div className="text-right text-sm font-mono">
${Number(a.balance || 0).toLocaleString("en-US", { minimumFractionDigits: 2 })}
</div>
</label>
);
})
)}
</div>
</div>
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={importing}>
Cancel
</Button>
<Button
onClick={handleImport}
disabled={importing || loading || selected.size === 0 || eligible.length === 0}
className="gap-2"
>
{importing ? <Loader2 className="h-4 w-4 animate-spin" /> : <Download className="h-4 w-4" />}
Import {selected.size > 0 ? `(${selected.size})` : ""}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,134 @@
import React, { useState, useEffect } from 'react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useToast } from "@/hooks/use-toast";
import { supabase } from '@/integrations/supabase/client';
import { Loader2 } from 'lucide-react';
export default function IndividualOwnerEditDialog({ open, onOpenChange, owner, onSuccess }) {
const { toast } = useToast();
const [loading, setLoading] = useState(false);
const [formData, setFormData] = useState({
owner_name: '',
phone_number: '',
unit_id: ''
});
useEffect(() => {
if (owner) {
setFormData({
owner_name: owner.owner_name || '',
phone_number: owner.phone_number || '',
unit_id: owner.unit_id || ''
});
}
}, [owner]);
const handleChange = (e) => {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
};
const handleSubmit = async (e) => {
e.preventDefault();
if (!formData.owner_name.trim()) {
toast({
variant: "destructive",
title: "Validation Error",
description: "Owner name is required."
});
return;
}
setLoading(true);
try {
const { error } = await supabase
.from('owners')
.update({
first_name: formData.owner_name.split(' ')[0] || '',
last_name: formData.owner_name.split(' ').slice(1).join(' ') || '',
phone: formData.phone_number,
unit_id: formData.unit_id || null,
updated_at: new Date().toISOString()
})
.eq('id', owner.id);
if (error) throw error;
toast({
title: "Success",
description: "Owner details updated successfully."
});
if (onSuccess) onSuccess();
onOpenChange(false);
} catch (error) {
toast({
variant: "destructive",
title: "Error",
description: "Failed to update owner details."
});
} finally {
setLoading(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Edit Individual Owner</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="unit_id">Unit ID</Label>
<Input
id="unit_id"
name="unit_id"
value={formData.unit_id}
onChange={handleChange}
placeholder="Unit 101"
/>
</div>
<div className="space-y-2">
<Label htmlFor="owner_name">Owner Name</Label>
<Input
id="owner_name"
name="owner_name"
value={formData.owner_name}
onChange={handleChange}
placeholder="Full Name"
/>
</div>
<div className="space-y-2">
<Label htmlFor="phone_number">Phone Number</Label>
<Input
id="phone_number"
name="phone_number"
value={formData.phone_number}
onChange={handleChange}
placeholder="(555) 123-4567"
/>
</div>
<DialogFooter>
<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" />}
Save Changes
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

Some files were not shown because too many files have changed in this diff Show More