From a7e16e7bf668ff06158f95e13843413d43a55cd4 Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Tue, 26 May 2026 18:19:32 +0200 Subject: [PATCH] =?UTF-8?q?feat(crm):=20frontend=20code=20=E2=80=94=20page?= =?UTF-8?q?s=20+=20services=20+=20components?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brings in the full frontend CRM stack: admin authoring pages, customer-portal surfaces, public response flows, typed services, and the supporting component library. i18n locale JSON is the next commit (kept separate so reviewers can read it as data). Pages - Quotes: list / editor / detail / public accept-decline - Invoices: list / editor / detail / public payment-check - Contracts: list / editor / detail / block library / public sign - Calendar (FullCalendar — admin-only v1) - Tax report (period picker + CSV/PDF export) - Hours (logged time entries, per-customer) - Deals lineage (DocumentLineageCard surfaces) - CRM Development (admin dev tools, gated by crmDevelopment flag) - Customer-portal pages for quotes / invoices / contracts - Settings reorg: CRM-Settings group + dedicated tabs for Business Profile, CRM behaviour, Contracts block library, Reminder emails - BrandingPage typography (PDF font picker) - EventDetailsPage / CustomerDetailPage / CreateEventPage extensions (event-time fields, hours toggle, per-event reminder override) Services (typed) - quotes.service, bills.service, contracts.service - customerAdmin.service, deals.service, calendar.service, taxReport.service, contracts-blocks.service - businessProfile.service (timezone, font picker, bank accounts) - useInstallmentDefaults hook, useLocalizedDate dateInputLang extension Components (admin) - CustomerPicker (shared across quote/invoice/contract editors) - LineItemsTable (hierarchy + details_text, memoised pricing) - InstallmentsPanel (simple + advanced toggle, fixed-date vs trigger) - DocumentLineageCard (deal_uuid grouped view) - EditInstallmentPlanModal (atomic post-spawn plan reshape) - EventReminderOverrideCard, EmailTemplateEditor (tiptap), PdfFontPicker, IntegrityCheckCard - Feature-flag context + RequireFeature wrapper + AdminSidebar featureFlagsAny derivation + UI-hiding sweep Build infra - vite.config: fullcalendar chunk carved off (~200 KB lazy-loaded) - frontend/package.json: tiptap, fullcalendar, signature_pad, react-international-phone, et al. - tailwind + prose styles updated for editor surfaces 3-way merge note: 1 conflict (CustomerDetailPage.tsx) hand-resolved to keep upstream's SUPPORTED_LANGUAGES.map() data-driven pattern over feat/crm's hardcoded option list; feat/crm's DecimalInput import preserved alongside. --- frontend/package-lock.json | 121 +- frontend/package.json | 6 + frontend/src/App.tsx | 105 +- .../src/components/admin/AdminGuestDetail.tsx | 4 +- .../src/components/admin/AdminGuestsList.tsx | 4 +- frontend/src/components/admin/AdminHeader.tsx | 109 +- frontend/src/components/admin/AdminLayout.tsx | 52 +- .../src/components/admin/AdminSidebar.tsx | 160 ++- .../src/components/admin/ClientsLayout.tsx | 75 +- .../components/admin/CrmOverviewSection.tsx | 272 +++++ .../components/admin/CssTemplateEditor.tsx | 4 +- .../components/admin/CustomerCrmPanels.tsx | 221 ++++ .../src/components/admin/CustomerPicker.tsx | 214 ++++ .../components/admin/DocumentLineageCard.tsx | 313 +++++ .../admin/EditInstallmentPlanModal.tsx | 209 ++++ .../admin/EventReminderOverrideCard.tsx | 177 +++ .../src/components/admin/HoursSection.tsx | 407 +++++++ .../components/admin/InlineCustomerCreate.tsx | 360 ++++++ .../components/admin/InstallmentsPanel.tsx | 320 +++++ .../src/components/admin/LineItemsTable.tsx | 481 ++++++++ .../components/admin/PdfTypographyCard.tsx | 102 ++ .../admin/ThemeCustomizerEnhanced.tsx | 14 +- .../src/components/common/DecimalInput.tsx | 108 ++ .../src/components/gallery/GalleryLayout.tsx | 10 +- frontend/src/contexts/CustomerAuthContext.tsx | 29 +- frontend/src/contexts/FeatureFlagsContext.tsx | 30 +- .../settings/hooks/useSettingsState.ts | 11 +- .../features/settings/tabs/ApiTokensTab.tsx | 6 +- .../features/settings/tabs/FeaturesTab.tsx | 126 +- .../src/features/settings/tabs/GeneralTab.tsx | 21 + .../src/features/settings/tabs/StatusTab.tsx | 4 +- .../features/settings/tabs/WebhooksTab.tsx | 6 +- frontend/src/hooks/useInstallmentDefaults.ts | 51 + frontend/src/hooks/useLocalizedDate.ts | 87 +- frontend/src/hooks/usePublicDarkMode.ts | 43 + frontend/src/index.css | 2 +- frontend/src/pages/admin/AdminDashboard.tsx | 22 +- frontend/src/pages/admin/BrandingPage.tsx | 87 +- frontend/src/pages/admin/CMSPage.tsx | 4 +- frontend/src/pages/admin/CreateEventPage.tsx | 52 + .../src/pages/admin/CustomerDetailPage.tsx | 528 +++++++- .../pages/admin/CustomerManagementPage.tsx | 383 ++---- frontend/src/pages/admin/EmailConfigPage.tsx | 7 + frontend/src/pages/admin/EventDetailsPage.tsx | 20 +- frontend/src/pages/admin/SettingsPage.tsx | 55 +- .../src/pages/admin/WebhookDeliveriesPage.tsx | 4 +- .../src/pages/admin/bills/BillDetailPage.tsx | 529 ++++++++ .../src/pages/admin/bills/BillEditorPage.tsx | 690 +++++++++++ .../src/pages/admin/bills/BillsListPage.tsx | 504 ++++++++ .../src/pages/admin/clients/CalendarPage.tsx | 737 +++++++++++ .../admin/clients/CrmDevelopmentPage.tsx | 319 +++++ .../clients/HourEntryDragCreateModal.tsx | 346 ++++++ .../admin/clients/HourEntryInlinePopover.tsx | 243 ++++ .../pages/admin/clients/HoursLoggingPage.tsx | 130 ++ .../src/pages/admin/clients/TaxReportPage.tsx | 449 +++++++ .../admin/contracts/BlockLibraryPage.tsx | 535 ++++++++ .../admin/contracts/ContractDetailPage.tsx | 1074 +++++++++++++++++ .../admin/contracts/ContractEditorPage.tsx | 535 ++++++++ .../admin/contracts/ContractsListPage.tsx | 202 ++++ frontend/src/pages/admin/index.ts | 12 +- .../pages/admin/quotes/QuoteDetailPage.tsx | 267 ++++ .../pages/admin/quotes/QuoteEditorPage.tsx | 650 ++++++++++ .../src/pages/admin/quotes/QuotesListPage.tsx | 171 +++ .../pages/admin/settings/CrmSettingsPage.tsx | 433 +++++++ .../admin/settings/ReminderTemplatesPage.tsx | 471 ++++++++ .../settings/SettingsBusinessProfilePage.tsx | 529 ++++++++ .../src/pages/customer/CustomerBillsPage.tsx | 342 +++++- .../pages/customer/CustomerContractsPage.tsx | 243 ++++ .../src/pages/customer/CustomerLayout.tsx | 47 +- .../src/pages/customer/CustomerQuotesPage.tsx | 288 ++++- frontend/src/pages/customer/index.ts | 1 + .../src/pages/public/AcceptInvitePage.tsx | 25 +- .../src/pages/public/ContractResponsePage.tsx | 496 ++++++++ .../src/pages/public/PaymentCheckPage.tsx | 405 +++++++ .../src/pages/public/QuoteResponsePage.tsx | 380 ++++++ frontend/src/services/bills.service.ts | 388 ++++++ .../src/services/businessProfile.service.ts | 157 +++ frontend/src/services/calendar.service.ts | 99 ++ frontend/src/services/contracts.service.ts | 455 +++++++ frontend/src/services/customer.service.ts | 127 ++ .../src/services/customerAdmin.service.ts | 220 ++++ frontend/src/services/deals.service.ts | 38 + frontend/src/services/devTools.service.ts | 46 + frontend/src/services/email.service.ts | 16 + frontend/src/services/featureFlags.service.ts | 24 +- frontend/src/services/paymentCheck.service.ts | 55 + .../src/services/publicSettings.service.ts | 16 +- frontend/src/services/quotes.service.ts | 396 ++++++ frontend/src/services/settings.service.ts | 24 +- frontend/src/services/taxReport.service.ts | 107 ++ frontend/src/types/index.ts | 5 + frontend/src/utils/calendarPrefs.ts | 52 + frontend/src/utils/dateShort.ts | 20 + frontend/src/utils/money.ts | 106 ++ frontend/src/utils/parsers.ts | 101 ++ frontend/vite.config.ts | 11 + 96 files changed, 18370 insertions(+), 572 deletions(-) create mode 100644 frontend/src/components/admin/CrmOverviewSection.tsx create mode 100644 frontend/src/components/admin/CustomerCrmPanels.tsx create mode 100644 frontend/src/components/admin/CustomerPicker.tsx create mode 100644 frontend/src/components/admin/DocumentLineageCard.tsx create mode 100644 frontend/src/components/admin/EditInstallmentPlanModal.tsx create mode 100644 frontend/src/components/admin/EventReminderOverrideCard.tsx create mode 100644 frontend/src/components/admin/HoursSection.tsx create mode 100644 frontend/src/components/admin/InlineCustomerCreate.tsx create mode 100644 frontend/src/components/admin/InstallmentsPanel.tsx create mode 100644 frontend/src/components/admin/LineItemsTable.tsx create mode 100644 frontend/src/components/admin/PdfTypographyCard.tsx create mode 100644 frontend/src/components/common/DecimalInput.tsx create mode 100644 frontend/src/hooks/useInstallmentDefaults.ts create mode 100644 frontend/src/hooks/usePublicDarkMode.ts create mode 100644 frontend/src/pages/admin/bills/BillDetailPage.tsx create mode 100644 frontend/src/pages/admin/bills/BillEditorPage.tsx create mode 100644 frontend/src/pages/admin/bills/BillsListPage.tsx create mode 100644 frontend/src/pages/admin/clients/CalendarPage.tsx create mode 100644 frontend/src/pages/admin/clients/CrmDevelopmentPage.tsx create mode 100644 frontend/src/pages/admin/clients/HourEntryDragCreateModal.tsx create mode 100644 frontend/src/pages/admin/clients/HourEntryInlinePopover.tsx create mode 100644 frontend/src/pages/admin/clients/HoursLoggingPage.tsx create mode 100644 frontend/src/pages/admin/clients/TaxReportPage.tsx create mode 100644 frontend/src/pages/admin/contracts/BlockLibraryPage.tsx create mode 100644 frontend/src/pages/admin/contracts/ContractDetailPage.tsx create mode 100644 frontend/src/pages/admin/contracts/ContractEditorPage.tsx create mode 100644 frontend/src/pages/admin/contracts/ContractsListPage.tsx create mode 100644 frontend/src/pages/admin/quotes/QuoteDetailPage.tsx create mode 100644 frontend/src/pages/admin/quotes/QuoteEditorPage.tsx create mode 100644 frontend/src/pages/admin/quotes/QuotesListPage.tsx create mode 100644 frontend/src/pages/admin/settings/CrmSettingsPage.tsx create mode 100644 frontend/src/pages/admin/settings/ReminderTemplatesPage.tsx create mode 100644 frontend/src/pages/admin/settings/SettingsBusinessProfilePage.tsx create mode 100644 frontend/src/pages/customer/CustomerContractsPage.tsx create mode 100644 frontend/src/pages/public/ContractResponsePage.tsx create mode 100644 frontend/src/pages/public/PaymentCheckPage.tsx create mode 100644 frontend/src/pages/public/QuoteResponsePage.tsx create mode 100644 frontend/src/services/bills.service.ts create mode 100644 frontend/src/services/businessProfile.service.ts create mode 100644 frontend/src/services/calendar.service.ts create mode 100644 frontend/src/services/contracts.service.ts create mode 100644 frontend/src/services/deals.service.ts create mode 100644 frontend/src/services/devTools.service.ts create mode 100644 frontend/src/services/paymentCheck.service.ts create mode 100644 frontend/src/services/quotes.service.ts create mode 100644 frontend/src/services/taxReport.service.ts create mode 100644 frontend/src/utils/calendarPrefs.ts create mode 100644 frontend/src/utils/dateShort.ts create mode 100644 frontend/src/utils/money.ts diff --git a/frontend/package-lock.json b/frontend/package-lock.json index ce58fe8c..9aac8719 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,13 +1,18 @@ { "name": "picpeak-frontend", - "version": "3.44.1-beta.0", + "version": "3.47.2-beta.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "picpeak-frontend", - "version": "3.44.1-beta.0", + "version": "3.47.2-beta.0", "dependencies": { + "@fullcalendar/core": "^6.1.20", + "@fullcalendar/daygrid": "^6.1.20", + "@fullcalendar/interaction": "^6.1.20", + "@fullcalendar/react": "^6.1.20", + "@fullcalendar/timegrid": "^6.1.20", "@tanstack/react-query": "^5.0.0", "@tiptap/extension-character-count": "^2.26.1", "@tiptap/extension-code-block-lowlight": "^2.26.1", @@ -45,6 +50,7 @@ "react-photo-album": "^3.4.0", "react-router-dom": "^6.8.0", "react-toastify": "11.0.5", + "signature_pad": "^5.1.3", "swiper": "^12.1.0", "tailwind-merge": "^3.3.1", "yet-another-react-lightbox": "^3.28.0" @@ -150,7 +156,6 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -516,7 +521,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -540,7 +544,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -1151,6 +1154,56 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@fullcalendar/core": { + "version": "6.1.20", + "resolved": "https://registry.npmjs.org/@fullcalendar/core/-/core-6.1.20.tgz", + "integrity": "sha512-1cukXLlePFiJ8YKXn/4tMKsy0etxYLCkXk8nUCFi11nRONF2Ba2CD5b21/ovtOO2tL6afTJfwmc1ed3HG7eB1g==", + "license": "MIT", + "dependencies": { + "preact": "~10.12.1" + } + }, + "node_modules/@fullcalendar/daygrid": { + "version": "6.1.20", + "resolved": "https://registry.npmjs.org/@fullcalendar/daygrid/-/daygrid-6.1.20.tgz", + "integrity": "sha512-AO9vqhkLP77EesmJzuU+IGXgxNulsA8mgQHynclJ8U70vSwAVnbcLG9qftiTAFSlZjiY/NvhE7sflve6cJelyQ==", + "license": "MIT", + "peerDependencies": { + "@fullcalendar/core": "~6.1.20" + } + }, + "node_modules/@fullcalendar/interaction": { + "version": "6.1.20", + "resolved": "https://registry.npmjs.org/@fullcalendar/interaction/-/interaction-6.1.20.tgz", + "integrity": "sha512-p6txmc5txL0bMiPaJxe2ip6o0T384TyoD2KGdsU6UjZ5yoBlaY+dg7kxfnYKpYMzEJLG58n+URrHr2PgNL2fyA==", + "license": "MIT", + "peerDependencies": { + "@fullcalendar/core": "~6.1.20" + } + }, + "node_modules/@fullcalendar/react": { + "version": "6.1.20", + "resolved": "https://registry.npmjs.org/@fullcalendar/react/-/react-6.1.20.tgz", + "integrity": "sha512-1w0pZtceaUdfAnxMSCGHCQalhi+mR1jOe76sXzyAXpcPz/Lf0zHSdcGK/U2XpZlnQgQtBZW+d+QBnnzVQKCxAA==", + "license": "MIT", + "peerDependencies": { + "@fullcalendar/core": "~6.1.20", + "react": "^16.7.0 || ^17 || ^18 || ^19", + "react-dom": "^16.7.0 || ^17 || ^18 || ^19" + } + }, + "node_modules/@fullcalendar/timegrid": { + "version": "6.1.20", + "resolved": "https://registry.npmjs.org/@fullcalendar/timegrid/-/timegrid-6.1.20.tgz", + "integrity": "sha512-4H+/MWbz3ntA50lrPif+7TsvMeX3R1GSYjiLULz0+zEJ7/Yfd9pupZmAwUs/PBpA6aAcFmeRr0laWfcz1a9V1A==", + "license": "MIT", + "dependencies": { + "@fullcalendar/daygrid": "~6.1.20" + }, + "peerDependencies": { + "@fullcalendar/core": "~6.1.20" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -2808,6 +2861,7 @@ "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "dequal": "^2.0.3" } @@ -2817,7 +2871,8 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@testing-library/jest-dom": { "version": "6.9.1", @@ -2886,7 +2941,6 @@ "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.27.1.tgz", "integrity": "sha512-nkerkl8syHj44ZzAB7oA2GPmmZINKBKCa79FuNvmGJrJ4qyZwlkDzszud23YteFZEytbc87kVd/fP76ROS6sLg==", "license": "MIT", - "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -2983,7 +3037,6 @@ "resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-2.27.1.tgz", "integrity": "sha512-wCI5VIOfSAdkenCWFvh4m8FFCJ51EOK+CUmOC/PWUjyo2Dgn8QC8HMi015q8XF7886T0KvYVVoqxmxJSUDAYNg==", "license": "MIT", - "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -3262,7 +3315,6 @@ "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-2.27.1.tgz", "integrity": "sha512-ijKo3+kIjALthYsnBmkRXAuw2Tswd9gd7BUR5OMfIcjGp8v576vKxOxrRfuYiUM78GPt//P0sVc1WV82H5N0PQ==", "license": "MIT", - "peer": true, "dependencies": { "prosemirror-changeset": "^2.3.0", "prosemirror-collab": "^1.3.1", @@ -3349,7 +3401,8 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -3480,7 +3533,6 @@ "integrity": "sha512-sokuT28dxf9JT5Kady1fsXOvI4HVpjZa95NKT5y9PNTIrs2AsobR4GFAA90ZG8M+nxVRLysCXsVj6eGC7Vbrlw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.19.0" } @@ -3496,7 +3548,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz", "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", "license": "MIT", - "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -3508,7 +3559,6 @@ "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "devOptional": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^18.0.0" } @@ -3586,7 +3636,6 @@ "integrity": "sha512-jCzKdm/QK0Kg4V4IK/oMlRZlY+QOcdjv89U2NgKHZk1CYTj82/RVSx1mV/0gqCVMJ/DA+Zf/S4NBWNF8GQ+eqQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.48.0", "@typescript-eslint/types": "8.48.0", @@ -3953,7 +4002,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4004,6 +4052,7 @@ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=8" } @@ -4219,7 +4268,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -4704,6 +4752,7 @@ "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=6" } @@ -4893,7 +4942,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -5693,7 +5741,6 @@ "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.8.0.tgz", "integrity": "sha512-MedQhoqVdr0U6SSnWPzfiadUcDHfN/Wzq25AkXiQv9oiOO/sG0S7XkvpFIqWBl9Yq1UYyYOOVORs5UW2XlPyzg==", "license": "BSD-3-Clause", - "peer": true, "engines": { "node": ">=12.0.0" } @@ -5796,7 +5843,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.28.4" }, @@ -5960,7 +6006,6 @@ "integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -6299,7 +6344,6 @@ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true, "license": "MIT", - "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -6590,7 +6634,6 @@ "resolved": "https://registry.npmjs.org/lowlight/-/lowlight-2.9.0.tgz", "integrity": "sha512-OpcaUTCLmHuVuBcyNckKfH5B0oA4JUavb/M/8n9iAvanJYNQkrVm4pvyX0SUaqkBG4dnWHKt7p50B3ngAG2Rfw==", "license": "MIT", - "peer": true, "dependencies": { "@types/hast": "^2.0.0", "fault": "^2.0.0", @@ -6626,6 +6669,7 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -7241,7 +7285,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -7289,7 +7332,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -7433,6 +7475,16 @@ "dev": true, "license": "MIT" }, + "node_modules/preact": { + "version": "10.12.1", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.12.1.tgz", + "integrity": "sha512-l8386ixSsBdbreOAkqtrwqHwdvR35ID8c3rKPa8lCWuO86dBi32QWHV4vfsZK1utLLFMvw+Z5Ad4XLkZzchscg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -7449,6 +7501,7 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -7464,6 +7517,7 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10" }, @@ -7476,7 +7530,8 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/pretty-ms": { "version": "9.3.0", @@ -7617,7 +7672,6 @@ "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz", "integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==", "license": "MIT", - "peer": true, "dependencies": { "orderedmap": "^2.0.0" } @@ -7647,7 +7701,6 @@ "resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz", "integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==", "license": "MIT", - "peer": true, "dependencies": { "prosemirror-model": "^1.0.0", "prosemirror-transform": "^1.0.0", @@ -7696,7 +7749,6 @@ "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.3.tgz", "integrity": "sha512-SqMiYMUQNNBP9kfPhLO8WXEk/fon47vc52FQsUiJzTBuyjKgEcoAwMyF04eQ4WZ2ArMn7+ReypYL60aKngbACQ==", "license": "MIT", - "peer": true, "dependencies": { "prosemirror-model": "^1.20.0", "prosemirror-state": "^1.0.0", @@ -7757,7 +7809,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -7796,7 +7847,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -8243,6 +8293,12 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/signature_pad": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/signature_pad/-/signature_pad-5.1.3.tgz", + "integrity": "sha512-zyxW5vuJVnQdGcU+kAj9FYl7WaAunY3kA5S7mPg0xJiujL9+sPAWfSQHS5tXaJXDUa4FuZeKhfdCDQ6K3wfkpQ==", + "license": "MIT" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -8711,8 +8767,7 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD", - "peer": true + "license": "0BSD" }, "node_modules/type-check": { "version": "0.4.0", @@ -8733,7 +8788,6 @@ "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -8855,7 +8909,6 @@ "integrity": "sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", diff --git a/frontend/package.json b/frontend/package.json index ce08812e..5f5a143c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -15,6 +15,11 @@ "i18n:ci": "i18next-cli extract --ci --dry-run" }, "dependencies": { + "@fullcalendar/core": "^6.1.20", + "@fullcalendar/daygrid": "^6.1.20", + "@fullcalendar/interaction": "^6.1.20", + "@fullcalendar/react": "^6.1.20", + "@fullcalendar/timegrid": "^6.1.20", "@tanstack/react-query": "^5.0.0", "@tiptap/extension-character-count": "^2.26.1", "@tiptap/extension-code-block-lowlight": "^2.26.1", @@ -52,6 +57,7 @@ "react-photo-album": "^3.4.0", "react-router-dom": "^6.8.0", "react-toastify": "11.0.5", + "signature_pad": "^5.1.3", "swiper": "^12.1.0", "tailwind-merge": "^3.3.1", "yet-another-react-lightbox": "^3.28.0" diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8b96d4b1..d6bd48d8 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { lazy, Suspense, useEffect, useState } from 'react'; import { BrowserRouter as Router, Routes, Route, Navigate, useParams } from 'react-router-dom'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { ToastContainer } from 'react-toastify'; @@ -24,8 +24,29 @@ import { UserManagementPage, CustomerManagementPage, CustomerDetailPage, - WebhookDeliveriesPage + WebhookDeliveriesPage, + // CRM routes (#TBD) — feature-flagged at the route layer via RequireFeature. + QuotesListPage, + QuoteEditorPage, + QuoteDetailPage, + BillsListPage, + BillEditorPage, + BillDetailPage, } from './pages/admin'; +import { CrmDevelopmentPage } from './pages/admin/clients/CrmDevelopmentPage'; +import { TaxReportPage } from './pages/admin/clients/TaxReportPage'; +import { HoursLoggingPage } from './pages/admin/clients/HoursLoggingPage'; +// E.6 — Calendar page lazy-loaded so the ~200 KB FullCalendar bundle +// (carved into its own chunk in vite.config.ts) doesn't ship with the +// main app. Only pages that visit /admin/clients/calendar fetch it. +const CalendarPage = lazy(() => import('./pages/admin/clients/CalendarPage').then((m) => ({ default: m.CalendarPage }))); +import { QuoteResponsePage } from './pages/public/QuoteResponsePage'; +import { ContractResponsePage } from './pages/public/ContractResponsePage'; +import { ContractsListPage } from './pages/admin/contracts/ContractsListPage'; +import { ContractEditorPage } from './pages/admin/contracts/ContractEditorPage'; +import { ContractDetailPage } from './pages/admin/contracts/ContractDetailPage'; +import { BlockLibraryPage } from './pages/admin/contracts/BlockLibraryPage'; +import { PaymentCheckPage } from './pages/public/PaymentCheckPage'; import { AcceptInvitePage } from './pages/public/AcceptInvitePage'; import { CustomerLoginPage, @@ -36,13 +57,14 @@ import { CustomerCalendarPage, CustomerQuotesPage, CustomerBillsPage, + CustomerContractsPage, CustomerResetPasswordPage, } from './pages/customer'; import { CustomerAuthProvider } from './contexts/CustomerAuthContext'; import { AdminLayout, AdminAuthWrapper } from './components/admin'; import { ClientsLayout } from './components/admin/ClientsLayout'; import { RequireFeature } from './components/admin/RequireFeature'; -import { PageErrorBoundary, OfflineIndicator, SkipLink, DynamicFavicon, RobotsMetaTags, CMSContentBlock } from './components/common'; +import { PageErrorBoundary, OfflineIndicator, SkipLink, DynamicFavicon, RobotsMetaTags, CMSContentBlock, Loading } from './components/common'; import { MaintenanceWrapper } from './components/MaintenanceWrapper'; import { GlobalThemeProvider } from './components/GlobalThemeProvider'; import { usePublicSettings } from './hooks/usePublicSettings'; @@ -172,12 +194,67 @@ function App() { } /> } /> + {/* Quotes (CRM) — gated by `quotes`. */} + }> + } /> + } /> + } /> + } /> + + {/* Bills / invoices (CRM) — gated by `bills`. */} + }> + } /> + } /> + } /> + } /> + + + {/* Contracts (CRM) — gated by `contracts`. Independent + of quotes/bills; a free-standing legal document + type composed from a library of reusable blocks. */} + }> + } /> + } /> + } /> + } /> + } /> + + + {/* Hour logging (standalone surface) — gated by + `hoursLogging`. Independent of `bills` so admin + can log hours before the full billing surface + is enabled. */} + }> + } /> + + + {/* Admin calendar (migration 137) — gated by + `calendar`. Lazy-loaded so the FullCalendar + bundle stays out of the main chunk. */} + }> + }> + + + } + /> + + + {/* Tax / Steuer report — gated by `taxReport`. */} + }> + } /> + + {/* Developer tools — gated by `crmDevelopment`. */} + }> + } /> + {/* Default: send /admin/clients (no sub-path) to - the first available sub-feature. Today that's - always accounts; when calendar/quotes ship they - get their own routes here and the empty-state - in ClientsLayout handles the rare "parent on, - all children off" case. */} + the first enabled sub-feature. accounts comes + first because it predates the others. The empty + state inside ClientsLayout handles "parent on, + all children off". */} } /> @@ -209,6 +286,17 @@ function App() { {/* Public invitation acceptance page */} } /> + {/* Public quote accept/decline page (CRM). Token-only, + no auth required. */} + } /> + } /> + + {/* Admin payment-check page (CRM) — token only, + no auth. Reached from the "Paid in full / + Partial / Not paid" buttons in the payment- + check email. */} + } /> + {/* Customer surface (#354). Strictly separate provider / cookie / API surface from /admin/*. The customerPortal feature flag hides the *admin-side* surfaces (sidebar @@ -237,6 +325,7 @@ function App() { } /> } /> } /> + } /> } /> } /> diff --git a/frontend/src/components/admin/AdminGuestDetail.tsx b/frontend/src/components/admin/AdminGuestDetail.tsx index 0053e28e..b1a1c60f 100644 --- a/frontend/src/components/admin/AdminGuestDetail.tsx +++ b/frontend/src/components/admin/AdminGuestDetail.tsx @@ -6,6 +6,7 @@ import { Loading } from '../common'; import { guestsService, AdminGuest } from '../../services/guests.service'; import { AuthenticatedImage } from '../common/AuthenticatedImage'; import { buildResourceUrl } from '../../utils/url'; +import { useLocalizedDate } from '../../hooks/useLocalizedDate'; interface AdminGuestDetailProps { eventId: number; @@ -17,6 +18,7 @@ type Tab = 'all' | 'liked' | 'favorited' | 'rated' | 'commented'; export const AdminGuestDetail: React.FC = ({ eventId, guest, onClose }) => { const { t } = useTranslation(); + const { formatDateTime: fmtDateTime } = useLocalizedDate(); const [tab, setTab] = useState('all'); const { data, isLoading } = useQuery({ @@ -159,7 +161,7 @@ export const AdminGuestDetail: React.FC = ({ eventId, gue />
- {c.photo.filename} · {new Date(c.created_at).toLocaleString()} + {c.photo.filename} · {fmtDateTime(c.created_at)}

{c.comment}

diff --git a/frontend/src/components/admin/AdminGuestsList.tsx b/frontend/src/components/admin/AdminGuestsList.tsx index 1f7201ca..53344802 100644 --- a/frontend/src/components/admin/AdminGuestsList.tsx +++ b/frontend/src/components/admin/AdminGuestsList.tsx @@ -8,6 +8,7 @@ import { AdminGuestDetail } from './AdminGuestDetail'; import { GuestSelectionsAggregate } from './GuestSelectionsAggregate'; import { GuestInviteDialog } from './GuestInviteDialog'; import { toast } from 'react-toastify'; +import { useLocalizedDate } from '../../hooks/useLocalizedDate'; interface AdminGuestsListProps { eventId: number; @@ -18,6 +19,7 @@ type View = 'list' | 'aggregate'; export const AdminGuestsList: React.FC = ({ eventId, eventName }) => { const { t } = useTranslation(); + const { format: fmtDate } = useLocalizedDate(); const queryClient = useQueryClient(); const [view, setView] = useState('list'); const [selectedGuest, setSelectedGuest] = useState(null); @@ -271,7 +273,7 @@ export const AdminGuestsList: React.FC = ({ eventId, event {guest.stats.ratings} - {new Date(guest.last_seen_at).toLocaleDateString()} + {fmtDate(guest.last_seen_at)}
diff --git a/frontend/src/components/admin/AdminHeader.tsx b/frontend/src/components/admin/AdminHeader.tsx index 5b7e3794..7217f825 100644 --- a/frontend/src/components/admin/AdminHeader.tsx +++ b/frontend/src/components/admin/AdminHeader.tsx @@ -35,10 +35,30 @@ export const AdminHeader: React.FC = ({ onMenuClick }) => { const companyName = brandingSettings?.branding_company_name?.trim() || 'PicPeak'; const logoUrl = brandingSettings?.branding_logo_url?.trim(); const logoDisplayMode = brandingSettings?.branding_logo_display_mode || 'logo_and_text'; + // Logo placement honours the same Branding > Logo Position setting + // the gallery does. 'sidepanel' moves the logo into the AdminSidebar + // brand row — suppress it here so it doesn't double up. left / + // center / right reposition the logo block within this header bar. + const logoPosition = brandingSettings?.branding_logo_position || 'left'; + const logoInSidebar = logoPosition === 'sidepanel'; const resolvedLogoUrl = logoUrl ? (logoUrl.startsWith('http') ? logoUrl : buildResourceUrl(logoUrl)) : '/picpeak-kamera-transparent.png'; + // Renders the logo + wordmark block per the current logo_display_mode. + // Re-used in left / center / right slots below so all three positions + // produce visually identical brand chrome. + const renderBrandBlock = () => ( +
+ {!logoInSidebar && (logoDisplayMode === 'logo_only' || logoDisplayMode === 'logo_and_text') && ( + {companyName} + )} + {(logoDisplayMode === 'text_only' || logoDisplayMode === 'logo_and_text') && ( + {companyName} + )} +
+ ); + const userMenuRef = useRef(null); const notificationRef = useRef(null); @@ -79,10 +99,25 @@ export const AdminHeader: React.FC = ({ onMenuClick }) => { const unreadCount = notificationsData?.unreadCount || 0; return ( -
-
-
- {/* Left side - Menu button, Logo, and Date */} + // border-b is on the OUTER
so it spans the full header + // width — putting it on the inner row instead left a 32px gap on + // the left (the px-4/sm:px-6/lg:px-8 padding) before the divider + // started, visible as a missing segment between the sidebar's + // brand-row bottom border and the header's bottom border. + // + // The outer header is explicitly h-16 with the default border-box, + // so the 1px border is painted INSIDE the 64px height (y=63..64) + // — same model as the sidebar's brand row (also h-16 border-b + // border-box). Both bottom borders meet at the exact same + // y-coordinate. +
+
+
+ {/* Left side - Menu button, optional left-positioned logo, Date. + Logo block appears here when logo_position = 'left' (the + default). For 'center' it's absolutely positioned across + the whole header; for 'right' it sits in the right-side + cluster just before the action widgets. */}
- {/* Logo - sticky to the left on all sizes */} -
- {(logoDisplayMode === 'logo_only' || logoDisplayMode === 'logo_and_text') && ( - {companyName} - )} - {(logoDisplayMode === 'text_only' || logoDisplayMode === 'logo_and_text') && ( - {companyName} - )} -
+ {!logoInSidebar && logoPosition === 'left' && renderBrandBlock()} + {/* Narrow-viewport fallback for center / right positions. + On screens where the centered (lg+) or right-anchored + (md+) brand block is hidden, render it on the left so + the admin chrome doesn't go logo-less on phones. */} + {!logoInSidebar && logoPosition === 'center' && ( +
{renderBrandBlock()}
+ )} + {!logoInSidebar && logoPosition === 'right' && ( +
{renderBrandBlock()}
+ )} - {/* Date display - hidden on smaller screens */} -
-

+ {/* Date display - hidden on smaller screens. + The vertical divider + left padding only render when + the logo sits on the left of the date (logo_position + = 'left'). For 'center' / 'right' / 'sidepanel' the + left cluster has only the mobile menu (which is + hidden on xl+), so a divider would be floating on + its own with nothing to separate. */} + {/* Explicit `flex items-center` (not just block) + the + self-stretch on the border-l variant so the divider + covers the full header row, AND the text baseline sits + exactly on the same y-axis as the brand-block / sidebar + logo to its left. The previous `hidden xl:block` left + the

inheriting its block-level vertical position, + which read as slightly off-centre next to the larger + logo image. */} +

+

{format(new Date(), 'PPPP')}

- {/* Right side actions */} + {/* Centered logo. Absolutely positioned so the existing + left/right clusters keep their natural sizing; hidden on + sub-lg widths to avoid colliding with the right-side + action cluster on narrow screens. pointer-events-none on + the wrapper passes hover/click through (the logo itself + has no interactive children today). */} + {!logoInSidebar && logoPosition === 'center' && ( +
+ {renderBrandBlock()} +
+ )} + + {/* Right side actions (preceded by the brand block when + logo_position = 'right'). The right-anchored logo sits + before the language / dark-mode / notifications / user + cluster so the widgets stay where admins expect them. */}
+ {!logoInSidebar && logoPosition === 'right' && ( +
+ {renderBrandBlock()} +
+ )} {/* Language Selector */} diff --git a/frontend/src/components/admin/AdminLayout.tsx b/frontend/src/components/admin/AdminLayout.tsx index 25d6850b..7eef8a12 100644 --- a/frontend/src/components/admin/AdminLayout.tsx +++ b/frontend/src/components/admin/AdminLayout.tsx @@ -9,10 +9,23 @@ import { AdminHeader } from './AdminHeader'; import { MaintenanceBanner } from './MaintenanceBanner'; import { MandatoryPasswordChangeModal } from './MandatoryPasswordChangeModal'; +const SIDEBAR_COLLAPSED_KEY = 'admin-sidebar-collapsed'; + export const AdminLayout: React.FC = () => { const { isAuthenticated, isLoading, mustChangePassword } = useAdminAuth(); const [sidebarOpen, setSidebarOpen] = useState(false); - + const [sidebarCollapsed, setSidebarCollapsedState] = useState(() => { + if (typeof window === 'undefined') return false; + return window.localStorage.getItem(SIDEBAR_COLLAPSED_KEY) === '1'; + }); + + const setSidebarCollapsed = (v: boolean) => { + setSidebarCollapsedState(v); + if (typeof window !== 'undefined') { + window.localStorage.setItem(SIDEBAR_COLLAPSED_KEY, v ? '1' : '0'); + } + }; + // Handle session timeout useSessionTimeout(); @@ -40,6 +53,8 @@ export const AdminLayout: React.FC = () => { @@ -49,10 +64,12 @@ export const AdminLayout: React.FC = () => { interface AdminLayoutInnerProps { sidebarOpen: boolean; setSidebarOpen: (v: boolean) => void; + sidebarCollapsed: boolean; + setSidebarCollapsed: (v: boolean) => void; mustChangePassword: boolean; } -const AdminLayoutInner: React.FC = ({ sidebarOpen, setSidebarOpen, mustChangePassword }) => { +const AdminLayoutInner: React.FC = ({ sidebarOpen, setSidebarOpen, sidebarCollapsed, setSidebarCollapsed, mustChangePassword }) => { return (
{/* Mandatory Password Change Modal */} @@ -68,21 +85,40 @@ const AdminLayoutInner: React.FC = ({ sidebarOpen, setSid {/* Sidebar - disabled when password change required */}
- setSidebarOpen(false)} /> + setSidebarOpen(false)} + collapsed={sidebarCollapsed} + onToggleCollapse={() => setSidebarCollapsed(!sidebarCollapsed)} + />
- {/* Main content */} -
+ {/* Main content. `scrollbar-gutter: stable` on the column itself + (via the inline style) reserves the scrollbar gutter once at + the column level — so the header sits in the full column + width AND lines up with the sidebar's right edge, while +
's scroll content honors the same gutter and never + shifts when content overflows. Without this, the header and + main each made their own decisions about the gutter, leaving + a visible ~15px notch on the right edge of the header's + border between the column's content area and the scrollbar. */} +
{/* Header - disabled when password change required */}
setSidebarOpen(true)} />
- + {/* Maintenance mode banner */} - {/* Page content - disabled when password change required */} -
+ {/* Page content - disabled when password change required. + overflow moved up to the column so the scrollbar gutter is + reserved once at the column level (see above). main now + just contributes its content + padding. */} +
diff --git a/frontend/src/components/admin/AdminSidebar.tsx b/frontend/src/components/admin/AdminSidebar.tsx index ffff92df..1f81e94b 100644 --- a/frontend/src/components/admin/AdminSidebar.tsx +++ b/frontend/src/components/admin/AdminSidebar.tsx @@ -9,6 +9,8 @@ import { X, Users, Briefcase, + PanelLeftClose, + PanelLeftOpen, } from 'lucide-react'; import { useQuery } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; @@ -16,10 +18,16 @@ import { settingsService } from '../../services/settings.service'; import { VersionInfo } from './VersionInfo'; import { usePermissions } from '../../contexts/PermissionsContext'; import { useFeatureFlags, type FeatureKey } from '../../contexts/FeatureFlagsContext'; +import { usePublicSettings } from '../../hooks/usePublicSettings'; +import { buildResourceUrl } from '../../utils/url'; interface AdminSidebarProps { isOpen: boolean; onClose: () => void; + /** Desktop-only: collapse to icon-rail when true. Persisted by parent. */ + collapsed?: boolean; + /** Desktop-only: toggle for the collapse button rendered in the title bar. */ + onToggleCollapse?: () => void; } interface NavItem { @@ -74,18 +82,45 @@ const navigation: NavItem[] = [ permission: 'customers.view', featureFlag: 'clients', // Hide the entry when the parent is on but no sub-feature is — - // there's nothing inside ClientsLayout to link to. Add future - // sub-flags (calendar, quotes, bills, messaging) here as they - // ship; the entry reappears the moment any of them is enabled. - featureFlagsAny: ['customerPortal'], + // there's nothing inside ClientsLayout to link to. Mirror the same + // set used to derive the parent `clients` flag in + // FeatureFlagsContext (see clientsDependsOn) so the two checks + // can't disagree: any sub-feature on lights up the entry, all off + // hides it. Future siblings (e.g. `messaging`) get appended here + // AND in the context derivation. + featureFlagsAny: [ + 'customerPortal', 'crmDevelopment', 'quotes', 'bills', + 'taxReport', 'hoursLogging', 'contracts', 'calendar', + ], }, ]; -export const AdminSidebar: React.FC = ({ isOpen, onClose }) => { +export const AdminSidebar: React.FC = ({ isOpen, onClose, collapsed = false, onToggleCollapse }) => { const location = useLocation(); const { t } = useTranslation(); const { hasPermission } = usePermissions(); const { flags } = useFeatureFlags(); + // Branding lookup for the "logo_position = sidepanel" mode — when + // chosen, the logo replaces the "PicPeak Admin" text in the brand + // row, and the favicon takes over in the collapsed icon rail. + const { data: publicSettings } = usePublicSettings(); + const logoInSidebar = publicSettings?.branding_logo_position === 'sidepanel'; + const rawLogoUrl = publicSettings?.branding_logo_url?.trim(); + const rawFaviconUrl = publicSettings?.branding_favicon_url?.trim(); + const resolvedLogoUrl = rawLogoUrl + ? (rawLogoUrl.startsWith('http') ? rawLogoUrl : buildResourceUrl(rawLogoUrl)) + : null; + const resolvedFaviconUrl = rawFaviconUrl + ? (rawFaviconUrl.startsWith('http') ? rawFaviconUrl : buildResourceUrl(rawFaviconUrl)) + : null; + // In collapsed rail, prefer the favicon (it's already a square, + // tight crop). Fall back to the logo when no favicon is set, then + // to nothing — better an empty rail than a stretched logo. + const sidebarBrandImageUrl = collapsed + ? (resolvedFaviconUrl || resolvedLogoUrl) + : (resolvedLogoUrl || resolvedFaviconUrl); + const showLogoBrand = logoInSidebar && !!sidebarBrandImageUrl; + const brandAlt = publicSettings?.branding_company_name?.trim() || t('admin.title'); const filteredNavigation = navigation.filter((item) => { if (item.permission && !hasPermission(item.permission as string)) return false; @@ -101,38 +136,99 @@ export const AdminSidebar: React.FC = ({ isOpen, onClose }) = return true; }); + // Desktop width: full nav (w-64) vs icon rail (w-16). Mobile is always + // w-64 since the collapse affordance only applies on lg+ viewports. + const widthClasses = collapsed ? 'w-64 lg:w-16' : 'w-64'; + const showLabels = !collapsed; + return (
- {/* Brand */} -
-
- {t('admin.title')} + {/* Brand row: title on the left, mobile close (X) on the + right. The desktop collapse toggle used to live here but + was moved down next to the version / storage widgets so + it sits in admins' muscle-memory zone for chrome controls. + When collapsed on desktop the title hides and the row + becomes an empty spacer (no rail-width fight). */} +
+
+ {showLogoBrand ? ( + <> + {/* Logo brand variant — fed by Branding > Logo + Position = "Sidebar". On the collapsed rail, only + the favicon (or logo as fallback) is shown — sized + to fit the 64px-wide rail. Expanded shows the full + logo at the same h-8 the admin header uses for + visual continuity. */} + {brandAlt} + {/* On mobile the rail-narrow style only applies at + lg+, so when collapsed=true the mobile view still + has the regular w-64 width — show the company name + next to the logo so the brand row doesn't feel + empty there. */} + {collapsed && ( + + {brandAlt} + + )} + + ) : ( + <> + {showLabels && ( + {t('admin.title')} + )} + {/* When collapsed on desktop the title is hidden; on mobile we + always show it because the rail-narrow style only applies at lg+ */} + {collapsed && ( + {t('admin.title')} + )} + + )}
{/* Navigation */} -